From 2318e00270203780b72efbbcffce92e907312027 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 18 Jul 2026 13:03:37 +0200 Subject: [PATCH] fix(web): avoid duplicate mention text on paste Co-authored-by: codex --- .../components/ComposerPromptEditor.test.ts | 73 ++++++++++++ .../src/components/ComposerPromptEditor.tsx | 97 ++-------------- .../components/composerInlineTokenPaste.ts | 104 ++++++++++++++++++ 3 files changed, 186 insertions(+), 88 deletions(-) create mode 100644 apps/web/src/components/ComposerPromptEditor.test.ts create mode 100644 apps/web/src/components/composerInlineTokenPaste.ts diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..45a2db4731cecbda6142e6cb03b1ce5c39ac06ca --- /dev/null +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_EDITOR, + createEditor, + PASTE_COMMAND, +} from "lexical"; + +import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; + +class TestClipboardEvent extends Event { + readonly clipboardData: DataTransfer; + + constructor(text: string) { + super("paste", { cancelable: true }); + this.clipboardData = { + files: [], + getData: (type: string) => (type === "text/plain" ? text : ""), + } as unknown as DataTransfer; + } +} + +describe("registerComposerInlineTokenPaste", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("handles a copied mention without also running the plain-text paste fallback", () => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor(); + const mention = "[improve-deploy-error-logging.md](.changeset/improve-deploy-error-logging.md)"; + const plainTextFallback = vi.fn(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return false; + selection.insertText(mention); + return true; + }); + + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 0, + }); + editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); + + const event = new TestClipboardEvent(mention); + let handled = false; + editor.update( + () => { + handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, + ); + + expect(handled).toBe(true); + expect(plainTextFallback).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( + " ", + ); + }); +}); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 8652bfdf5ba7ee49be44fedf5c10788a0a07472e..67c3505d3b5cd7107e32f9cfba3af9abcc095d62 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -27,7 +27,6 @@ import { KEY_TAB_COMMAND, COMMAND_PRIORITY_HIGH, KEY_BACKSPACE_COMMAND, - PASTE_COMMAND, $getRoot, HISTORY_MERGE_TAG, DecoratorNode, @@ -56,7 +55,6 @@ import { expandCollapsedComposerCursor, isCollapsedCursorAdjacentToInlineToken, } from "~/composer-logic"; -import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { selectionTouchesMentionBoundary, splitPromptIntoComposerSegments, @@ -77,6 +75,7 @@ import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; const SURROUND_SYMBOLS: [string, string][] = [ @@ -1122,92 +1121,14 @@ function ComposerInlineTokenBackspacePlugin() { function ComposerInlineTokenPastePlugin() { const [editor] = useLexicalComposerContext(); - useEffect(() => { - return editor.registerCommand( - PASTE_COMMAND, - (event) => { - if (!(event instanceof ClipboardEvent) || event.clipboardData === null) { - return false; - } - if (event.clipboardData.files.length > 0) { - return false; - } - const text = event.clipboardData.getData("text/plain"); - if (text.length === 0) { - return false; - } - // Token grammar requires trailing whitespace; a virtual newline lets a - // mention at the very end of the pasted text still parse. - const mentions = collectComposerInlineTokens(`${text}\n`).filter( - (token) => token.type === "mention" && token.end <= text.length, - ); - if (mentions.length === 0) { - return false; - } - let inserted = false; - editor.update(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return; - } - const nodes: LexicalNode[] = []; - const appendText = (value: string) => { - const lines = value.split("\n"); - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] ?? ""; - if (line.length > 0) { - nodes.push($createTextNode(line)); - } - if (index < lines.length - 1) { - nodes.push($createLineBreakNode()); - } - } - }; - const firstMention = mentions[0]; - if (firstMention && firstMention.start === 0) { - const startPoint = selection.isBackward() ? selection.focus : selection.anchor; - const insertionOffset = getExpandedAbsoluteOffsetForPoint( - startPoint.getNode(), - startPoint.offset, - ); - const precedingChar = $getRoot() - .getTextContent() - .slice(insertionOffset - 1, insertionOffset); - if (precedingChar.length > 0 && !/\s/.test(precedingChar)) { - nodes.push($createTextNode(" ")); - } - } - let cursor = 0; - for (const mention of mentions) { - if (mention.start < cursor) { - continue; - } - if (mention.start > cursor) { - appendText(text.slice(cursor, mention.start)); - } - nodes.push($createComposerMentionNode(mention.value)); - cursor = mention.end; - } - if (cursor < text.length) { - appendText(text.slice(cursor)); - } else { - // Keep the serialized prompt valid: mention tokens need trailing - // whitespace, so a paste ending in a mention gets the same - // trailing space the autocomplete inserts. - nodes.push($createTextNode(" ")); - } - selection.insertNodes(nodes); - inserted = true; - }); - if (!inserted) { - return false; - } - event.preventDefault(); - return true; - }, - COMMAND_PRIORITY_HIGH, - ); - }, [editor]); + useEffect( + () => + registerComposerInlineTokenPaste(editor, { + createMentionNode: $createComposerMentionNode, + getExpandedAbsoluteOffsetForPoint, + }), + [editor], + ); return null; } diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts new file mode 100644 index 0000000000000000000000000000000000000000..f43091f09cb8e08b153decca802610bbf9a6fc8c --- /dev/null +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -0,0 +1,104 @@ +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import { + $createLineBreakNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_HIGH, + PASTE_COMMAND, + type LexicalEditor, + type LexicalNode, +} from "lexical"; + +interface ComposerInlineTokenPasteOptions { + createMentionNode: (path: string) => LexicalNode; + getExpandedAbsoluteOffsetForPoint: (node: LexicalNode, pointOffset: number) => number; +} + +export function registerComposerInlineTokenPaste( + editor: LexicalEditor, + options: ComposerInlineTokenPasteOptions, +): () => void { + return editor.registerCommand( + PASTE_COMMAND, + (event) => { + if (!(event instanceof ClipboardEvent) || event.clipboardData === null) { + return false; + } + if (event.clipboardData.files.length > 0) { + return false; + } + const text = event.clipboardData.getData("text/plain"); + if (text.length === 0) { + return false; + } + // Token grammar requires trailing whitespace; a virtual newline lets a + // mention at the very end of the pasted text still parse. + const mentions = collectComposerInlineTokens(`${text}\n`).filter( + (token) => token.type === "mention" && token.end <= text.length, + ); + if (mentions.length === 0) { + return false; + } + + // Lexical command listeners already run inside an editor update. Starting + // a nested update here queues the mention insertion until after this + // listener returns, which lets the plain-text paste handler run as well. + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + const nodes: LexicalNode[] = []; + const appendText = (value: string) => { + const lines = value.split("\n"); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (line.length > 0) { + nodes.push($createTextNode(line)); + } + if (index < lines.length - 1) { + nodes.push($createLineBreakNode()); + } + } + }; + const firstMention = mentions[0]; + if (firstMention && firstMention.start === 0) { + const startPoint = selection.isBackward() ? selection.focus : selection.anchor; + const insertionOffset = options.getExpandedAbsoluteOffsetForPoint( + startPoint.getNode(), + startPoint.offset, + ); + const precedingChar = $getRoot() + .getTextContent() + .slice(insertionOffset - 1, insertionOffset); + if (precedingChar.length > 0 && !/\s/.test(precedingChar)) { + nodes.push($createTextNode(" ")); + } + } + let cursor = 0; + for (const mention of mentions) { + if (mention.start < cursor) { + continue; + } + if (mention.start > cursor) { + appendText(text.slice(cursor, mention.start)); + } + nodes.push(options.createMentionNode(mention.value)); + cursor = mention.end; + } + if (cursor < text.length) { + appendText(text.slice(cursor)); + } else { + // Keep the serialized prompt valid: mention tokens need trailing + // whitespace, so a paste ending in a mention gets the same + // trailing space the autocomplete inserts. + nodes.push($createTextNode(" ")); + } + selection.insertNodes(nodes); + event.preventDefault(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); +} -- 2.54.0