From bd0b76072bbeee0aa059c3d4f847c59d3815fd16 Mon Sep 17 00:00:00 2001 From: dotta Date: Sun, 22 Mar 2026 06:34:15 -0500 Subject: [PATCH] Fix atomic markdown mention deletion Co-Authored-By: Paperclip --- ui/package.json | 1 + ui/src/components/MarkdownEditor.tsx | 2 + ui/src/lib/mention-deletion.test.ts | 86 ++++++++++++++++ ui/src/lib/mention-deletion.ts | 143 +++++++++++++++++++++++++++ ui/tsconfig.json | 3 +- ui/vite.config.ts | 1 + ui/vitest.config.ts | 7 ++ 7 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 ui/src/lib/mention-deletion.test.ts create mode 100644 ui/src/lib/mention-deletion.ts diff --git a/ui/package.json b/ui/package.json index 112fa86e..a02ddb12 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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:*", diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 84218a09..4ff06da2 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -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 tablePlugin(), linkPlugin({ validateUrl: isSafeMarkdownLinkUrl }), linkDialogPlugin(), + mentionDeletionPlugin(), thematicBreakPlugin(), codeBlockPlugin({ defaultCodeBlockLanguage: "txt", diff --git a/ui/src/lib/mention-deletion.test.ts b/ui/src/lib/mention-deletion.test.ts new file mode 100644 index 00000000..7e9ab864 --- /dev/null +++ b/ui/src/lib/mention-deletion.test.ts @@ -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); + }); + }); +}); diff --git a/ui/src/lib/mention-deletion.ts b/ui/src/lib/mention-deletion.ts new file mode 100644 index 00000000..fe87c9ed --- /dev/null +++ b/ui/src/lib/mention-deletion.ts @@ -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, + ), + ]); + }, +}); diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 26e34f48..32b378d2 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -13,7 +13,8 @@ "noEmit": true, "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "lexical": ["./node_modules/lexical/index.d.ts"] } }, "include": ["src"] diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 22d0b012..56d8a4db 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"), }, }, server: { diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index f624398e..6fadfc9d 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -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", },