Fix atomic markdown mention deletion

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-22 06:34:15 -05:00
parent db42adf1bf
commit bd0b76072b
7 changed files with 242 additions and 1 deletions

View File

@@ -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:*",

View File

@@ -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",

View 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);
});
});
});

View 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,
),
]);
},
});

View File

@@ -13,7 +13,8 @@
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"lexical": ["./node_modules/lexical/index.d.ts"]
}
},
"include": ["src"]

View File

@@ -8,6 +8,7 @@ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
},
},
server: {

View File

@@ -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",
},