From 52dab938cba4368b45d7cf75f0e834bbf8f80f31 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 20:41:50 -0500 Subject: [PATCH] Extract mention-aware link node helper and add tests Co-Authored-By: Paperclip --- ui/src/components/MarkdownEditor.tsx | 44 +------------- ui/src/lib/mention-aware-link-node.test.ts | 50 ++++++++++++++++ ui/src/lib/mention-aware-link-node.ts | 67 ++++++++++++++++++++++ 3 files changed, 119 insertions(+), 42 deletions(-) create mode 100644 ui/src/lib/mention-aware-link-node.test.ts create mode 100644 ui/src/lib/mention-aware-link-node.ts diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index b30c3edf..342a74de 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -26,53 +26,13 @@ import { thematicBreakPlugin, type RealmPlugin, } from "@mdxeditor/editor"; -import { LinkNode, type LinkAttributes } from "@lexical/link"; import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; import { AgentIcon } from "./AgentIconPicker"; import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips"; +import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node"; import { mentionDeletionPlugin } from "../lib/mention-deletion"; import { cn } from "../lib/utils"; -const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//; - -class MentionAwareLinkNode extends LinkNode { - static clone(node: MentionAwareLinkNode): MentionAwareLinkNode { - return new MentionAwareLinkNode( - node.getURL(), - { - rel: node.getRel(), - target: node.getTarget(), - title: node.getTitle(), - }, - node.getKey(), - ); - } - - constructor(url?: string, attributes?: LinkAttributes, key?: string) { - super(url, attributes, key); - } - - sanitizeUrl(url: string): string { - if (CUSTOM_MENTION_URL_RE.test(url)) return url; - return super.sanitizeUrl(url); - } -} - -const mentionAwareLinkNodeReplacement = { - replace: LinkNode, - with: (node: LinkNode) => - new MentionAwareLinkNode( - node.getURL(), - { - rel: node.getRel(), - target: node.getTarget(), - title: node.getTitle(), - }, - node.getKey(), - ), - withKlass: MentionAwareLinkNode, -} as const; - /* ---- Mention types ---- */ export interface MentionOption { @@ -590,7 +550,7 @@ export const MarkdownEditor = forwardRef "paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item", contentClassName, )} - additionalLexicalNodes={[mentionAwareLinkNodeReplacement]} + additionalLexicalNodes={[MentionAwareLinkNode, mentionAwareLinkNodeReplacement]} plugins={plugins} /> diff --git a/ui/src/lib/mention-aware-link-node.test.ts b/ui/src/lib/mention-aware-link-node.test.ts new file mode 100644 index 00000000..314b59eb --- /dev/null +++ b/ui/src/lib/mention-aware-link-node.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { $createLinkNode } from "@lexical/link"; +import { createEditor } from "lexical"; +import { + MentionAwareLinkNode, + getMentionAwareLinkNodeInit, + mentionAwareLinkNodeReplacement, +} from "./mention-aware-link-node"; + +function createTestEditor() { + return createEditor({ + namespace: "mention-aware-link-node-test", + nodes: [MentionAwareLinkNode, mentionAwareLinkNodeReplacement], + onError(error: Error) { + throw error; + }, + }); +} + +describe("getMentionAwareLinkNodeInit", () => { + it("copies link attributes without carrying over a node key", () => { + const init = getMentionAwareLinkNodeInit({ + getURL: () => "agent://agent-123", + getRel: () => "noreferrer", + getTarget: () => "_blank", + getTitle: () => "Agent mention", + }); + + expect(Object.keys(init)).toEqual(["url", "attributes"]); + expect(init).toEqual({ + url: "agent://agent-123", + attributes: { + rel: "noreferrer", + target: "_blank", + title: "Agent mention", + }, + }); + }); + + it("replaces LinkNode creation with MentionAwareLinkNode without throwing", () => { + const editor = createTestEditor(); + let created: unknown; + + editor.update(() => { + created = $createLinkNode("agent://agent-123"); + }); + + expect(created).toBeInstanceOf(MentionAwareLinkNode); + }); +}); diff --git a/ui/src/lib/mention-aware-link-node.ts b/ui/src/lib/mention-aware-link-node.ts new file mode 100644 index 00000000..c35ba050 --- /dev/null +++ b/ui/src/lib/mention-aware-link-node.ts @@ -0,0 +1,67 @@ +import { + LinkNode, + type LinkAttributes, + type SerializedLinkNode, +} from "@lexical/link"; + +const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//; + +export class MentionAwareLinkNode extends LinkNode { + static getType(): string { + return "mention-aware-link"; + } + + static clone(node: MentionAwareLinkNode): MentionAwareLinkNode { + return new MentionAwareLinkNode( + node.getURL(), + { + rel: node.getRel(), + target: node.getTarget(), + title: node.getTitle(), + }, + node.getKey(), + ); + } + + static importJSON(serializedNode: SerializedLinkNode): MentionAwareLinkNode { + return new MentionAwareLinkNode( + serializedNode.url ?? "", + { + rel: serializedNode.rel ?? null, + target: serializedNode.target ?? null, + title: serializedNode.title ?? null, + }, + ); + } + + constructor(url?: string, attributes?: LinkAttributes, key?: string) { + super(url, attributes, key); + } + + sanitizeUrl(url: string): string { + if (CUSTOM_MENTION_URL_RE.test(url)) return url; + return super.sanitizeUrl(url); + } +} + +type MentionAwareLinkSource = Pick; + +export function getMentionAwareLinkNodeInit(node: MentionAwareLinkSource) { + return { + url: node.getURL(), + attributes: { + rel: node.getRel(), + target: node.getTarget(), + title: node.getTitle(), + }, + }; +} + +export const mentionAwareLinkNodeReplacement = { + replace: LinkNode, + with: (node: LinkNode) => { + const { url, attributes } = getMentionAwareLinkNodeInit(node); + return new MentionAwareLinkNode(url, attributes); + }, + withKlass: MentionAwareLinkNode, +} as const;