Fix atomic markdown mention deletion
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
db42adf1bf
commit
bd0b76072b
7 changed files with 242 additions and 1 deletions
|
|
@ -14,6 +14,7 @@
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@lexical/link": "0.35.0",
|
"@lexical/link": "0.35.0",
|
||||||
|
"lexical": "0.35.0",
|
||||||
"@mdxeditor/editor": "^3.52.4",
|
"@mdxeditor/editor": "^3.52.4",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { LinkNode } from "@lexical/link";
|
||||||
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
|
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
||||||
|
import { mentionDeletionPlugin } from "../lib/mention-deletion";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
/* ---- Allow custom mention URL schemes in Lexical's LinkNode ---- */
|
/* ---- Allow custom mention URL schemes in Lexical's LinkNode ---- */
|
||||||
|
|
@ -288,6 +289,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
tablePlugin(),
|
tablePlugin(),
|
||||||
linkPlugin({ validateUrl: isSafeMarkdownLinkUrl }),
|
linkPlugin({ validateUrl: isSafeMarkdownLinkUrl }),
|
||||||
linkDialogPlugin(),
|
linkDialogPlugin(),
|
||||||
|
mentionDeletionPlugin(),
|
||||||
thematicBreakPlugin(),
|
thematicBreakPlugin(),
|
||||||
codeBlockPlugin({
|
codeBlockPlugin({
|
||||||
defaultCodeBlockLanguage: "txt",
|
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,
|
"noEmit": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
|
"lexical": ["./node_modules/lexical/index.d.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
|
import path from "path";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue