mirror of
https://github.com/paperclipai/paperclip
synced 2026-03-25 11:21:48 +00:00
Fix atomic markdown mention deletion
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lexical/link": "0.35.0",
|
||||
"lexical": "0.35.0",
|
||||
"@mdxeditor/editor": "^3.52.4",
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
|
||||
@@ -30,6 +30,7 @@ import { LinkNode } from "@lexical/link";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
||||
import { mentionDeletionPlugin } from "../lib/mention-deletion";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
/* ---- Allow custom mention URL schemes in Lexical's LinkNode ---- */
|
||||
@@ -288,6 +289,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
tablePlugin(),
|
||||
linkPlugin({ validateUrl: isSafeMarkdownLinkUrl }),
|
||||
linkDialogPlugin(),
|
||||
mentionDeletionPlugin(),
|
||||
thematicBreakPlugin(),
|
||||
codeBlockPlugin({
|
||||
defaultCodeBlockLanguage: "txt",
|
||||
|
||||
86
ui/src/lib/mention-deletion.test.ts
Normal file
86
ui/src/lib/mention-deletion.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { $createLinkNode, LinkNode } from "@lexical/link";
|
||||
import { buildAgentMentionHref } from "@paperclipai/shared";
|
||||
import {
|
||||
createEditor,
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
} from "lexical";
|
||||
import { deleteSelectedMentionChip } from "./mention-deletion";
|
||||
|
||||
function createTestEditor() {
|
||||
return createEditor({
|
||||
namespace: "mention-deletion-test",
|
||||
nodes: [LinkNode],
|
||||
onError(error: Error) {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("mention deletion", () => {
|
||||
it("removes the full mention when backspacing from inside the chip", () => {
|
||||
const editor = createTestEditor();
|
||||
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
const before = $createTextNode("Hello ");
|
||||
const mention = $createLinkNode(buildAgentMentionHref("agent-123", "code"));
|
||||
const mentionText = $createTextNode("@QA");
|
||||
const after = $createTextNode(" world");
|
||||
|
||||
mention.append(mentionText);
|
||||
paragraph.append(before, mention, after);
|
||||
root.append(paragraph);
|
||||
|
||||
mentionText.selectEnd();
|
||||
|
||||
expect(deleteSelectedMentionChip("backward")).toBe(true);
|
||||
expect(root.getTextContent()).toBe("Hello world");
|
||||
|
||||
const selection = $getSelection();
|
||||
expect($isRangeSelection(selection)).toBe(true);
|
||||
if (!$isRangeSelection(selection)) {
|
||||
throw new Error("Expected range selection after backward mention deletion");
|
||||
}
|
||||
expect(selection.isCollapsed()).toBe(true);
|
||||
expect(selection.anchor.getNode().is(before)).toBe(true);
|
||||
expect(selection.anchor.offset).toBe(before.getTextContentSize());
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the full mention when deleting forward from adjacent text", () => {
|
||||
const editor = createTestEditor();
|
||||
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
const before = $createTextNode("Hello ");
|
||||
const mention = $createLinkNode(buildAgentMentionHref("agent-123", "code"));
|
||||
const mentionText = $createTextNode("@QA");
|
||||
const after = $createTextNode(" world");
|
||||
|
||||
mention.append(mentionText);
|
||||
paragraph.append(before, mention, after);
|
||||
root.append(paragraph);
|
||||
|
||||
before.selectEnd();
|
||||
|
||||
expect(deleteSelectedMentionChip("forward")).toBe(true);
|
||||
expect(root.getTextContent()).toBe("Hello world");
|
||||
|
||||
const selection = $getSelection();
|
||||
expect($isRangeSelection(selection)).toBe(true);
|
||||
if (!$isRangeSelection(selection)) {
|
||||
throw new Error("Expected range selection after forward mention deletion");
|
||||
}
|
||||
expect(selection.isCollapsed()).toBe(true);
|
||||
expect(selection.anchor.getNode().is(after)).toBe(true);
|
||||
expect(selection.anchor.offset).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
143
ui/src/lib/mention-deletion.ts
Normal file
143
ui/src/lib/mention-deletion.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { createRootEditorSubscription$, realmPlugin } from "@mdxeditor/editor";
|
||||
import { $isLinkNode, type LinkNode } from "@lexical/link";
|
||||
import {
|
||||
$getSelection,
|
||||
$isElementNode,
|
||||
$isNodeSelection,
|
||||
$isRangeSelection,
|
||||
$isTextNode,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
KEY_DELETE_COMMAND,
|
||||
type LexicalNode,
|
||||
type PointType,
|
||||
} from "lexical";
|
||||
import { parseMentionChipHref } from "./mention-chips";
|
||||
|
||||
export type MentionDeletionDirection = "backward" | "forward";
|
||||
|
||||
function isMentionLinkNode(node: LexicalNode | null | undefined): node is LinkNode {
|
||||
return Boolean(node && $isLinkNode(node) && parseMentionChipHref(node.getURL()));
|
||||
}
|
||||
|
||||
function findMentionLinkNode(node: LexicalNode | null | undefined): LinkNode | null {
|
||||
if (!node) return null;
|
||||
if (isMentionLinkNode(node)) return node;
|
||||
|
||||
let parent = node.getParent();
|
||||
while (parent) {
|
||||
if (isMentionLinkNode(parent)) return parent;
|
||||
parent = parent.getParent();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMentionLinkNodeAtPoint(point: PointType, direction: MentionDeletionDirection): LinkNode | null {
|
||||
const node = point.getNode();
|
||||
const directMention = findMentionLinkNode(node);
|
||||
if (directMention) return directMention;
|
||||
|
||||
if (point.type === "element" && $isElementNode(node)) {
|
||||
const childIndex = direction === "backward" ? point.offset - 1 : point.offset;
|
||||
if (childIndex < 0) return null;
|
||||
return findMentionLinkNode(node.getChildAtIndex(childIndex));
|
||||
}
|
||||
|
||||
if (point.type === "text" && $isTextNode(node)) {
|
||||
if (direction === "backward" && point.offset === 0) {
|
||||
return findMentionLinkNode(node.getPreviousSibling());
|
||||
}
|
||||
|
||||
if (direction === "forward" && point.offset === node.getTextContentSize()) {
|
||||
return findMentionLinkNode(node.getNextSibling());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findMentionLinkForDeletion(direction: MentionDeletionDirection): LinkNode | null {
|
||||
const selection = $getSelection();
|
||||
if (!selection) return null;
|
||||
|
||||
if ($isNodeSelection(selection)) {
|
||||
const [selectedNode] = selection.getNodes();
|
||||
return selectedNode ? findMentionLinkNode(selectedNode) : null;
|
||||
}
|
||||
|
||||
if (!$isRangeSelection(selection)) return null;
|
||||
|
||||
const anchorMention = findMentionLinkNode(selection.anchor.getNode());
|
||||
const focusMention = findMentionLinkNode(selection.focus.getNode());
|
||||
if (anchorMention && focusMention && anchorMention.is(focusMention)) {
|
||||
return anchorMention;
|
||||
}
|
||||
|
||||
if (!selection.isCollapsed()) return null;
|
||||
|
||||
return findMentionLinkNodeAtPoint(selection.anchor, direction);
|
||||
}
|
||||
|
||||
export function deleteSelectedMentionChip(direction: MentionDeletionDirection): boolean {
|
||||
const mentionNode = findMentionLinkForDeletion(direction);
|
||||
if (!mentionNode) return false;
|
||||
|
||||
const previousSibling = mentionNode.getPreviousSibling();
|
||||
const nextSibling = mentionNode.getNextSibling();
|
||||
const parent = mentionNode.getParentOrThrow();
|
||||
|
||||
mentionNode.remove();
|
||||
|
||||
if (direction === "backward") {
|
||||
if (previousSibling) {
|
||||
previousSibling.selectEnd();
|
||||
return true;
|
||||
}
|
||||
if (nextSibling) {
|
||||
nextSibling.selectStart();
|
||||
return true;
|
||||
}
|
||||
parent.selectStart();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (nextSibling) {
|
||||
nextSibling.selectStart();
|
||||
return true;
|
||||
}
|
||||
if (previousSibling) {
|
||||
previousSibling.selectEnd();
|
||||
return true;
|
||||
}
|
||||
parent.selectEnd();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleMentionDelete(direction: MentionDeletionDirection, event: KeyboardEvent | null): boolean {
|
||||
const didDelete = deleteSelectedMentionChip(direction);
|
||||
if (!didDelete) return false;
|
||||
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
|
||||
export const mentionDeletionPlugin = realmPlugin({
|
||||
init(realm) {
|
||||
realm.pub(createRootEditorSubscription$, [
|
||||
(editor) =>
|
||||
editor.registerCommand(
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
(event) => handleMentionDelete("backward", event as KeyboardEvent | null),
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
(editor) =>
|
||||
editor.registerCommand(
|
||||
KEY_DELETE_COMMAND,
|
||||
(event) => handleMentionDelete("forward", event as KeyboardEvent | null),
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
]);
|
||||
},
|
||||
});
|
||||
@@ -13,7 +13,8 @@
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"lexical": ["./node_modules/lexical/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import path from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user