| author | |
| committer | |
| log | 2318e00270203780b72efbbcffce92e907312027 |
| tree | 4eef7fd30115699eca8986bdc4672784a1243376 |
| parent | 4cfec8c128084a0abde67853d10e996f5b2aac05 |
Co-authored-by: codex <codex@users.noreply.github.com>3 files changed, 186 insertions(+), 88 deletions(-)
apps/web/src/components/ComposerPromptEditor.test.ts created+73| ... | @@ -0,0 +1,73 @@ | ||
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | import { | ||
| 3 | $createParagraphNode, | ||
| 4 | $createTextNode, | ||
| 5 | $getRoot, | ||
| 6 | $getSelection, | ||
| 7 | $isRangeSelection, | ||
| 8 | COMMAND_PRIORITY_EDITOR, | ||
| 9 | createEditor, | ||
| 10 | PASTE_COMMAND, | ||
| 11 | } from "lexical"; | ||
| 12 | |||
| 13 | import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; | ||
| 14 | |||
| 15 | class TestClipboardEvent extends Event { | ||
| 16 | readonly clipboardData: DataTransfer; | ||
| 17 | |||
| 18 | constructor(text: string) { | ||
| 19 | super("paste", { cancelable: true }); | ||
| 20 | this.clipboardData = { | ||
| 21 | files: [], | ||
| 22 | getData: (type: string) => (type === "text/plain" ? text : ""), | ||
| 23 | } as unknown as DataTransfer; | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | describe("registerComposerInlineTokenPaste", () => { | ||
| 28 | afterEach(() => { | ||
| 29 | vi.unstubAllGlobals(); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("handles a copied mention without also running the plain-text paste fallback", () => { | ||
| 33 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 34 | const editor = createEditor(); | ||
| 35 | const mention = "[improve-deploy-error-logging.md](.changeset/improve-deploy-error-logging.md)"; | ||
| 36 | const plainTextFallback = vi.fn(() => { | ||
| 37 | const selection = $getSelection(); | ||
| 38 | if (!$isRangeSelection(selection)) return false; | ||
| 39 | selection.insertText(mention); | ||
| 40 | return true; | ||
| 41 | }); | ||
| 42 | |||
| 43 | editor.update( | ||
| 44 | () => { | ||
| 45 | const paragraph = $createParagraphNode(); | ||
| 46 | $getRoot().append(paragraph); | ||
| 47 | paragraph.selectEnd(); | ||
| 48 | }, | ||
| 49 | { discrete: true }, | ||
| 50 | ); | ||
| 51 | registerComposerInlineTokenPaste(editor, { | ||
| 52 | createMentionNode: (path) => $createTextNode(`<mention:${path}>`), | ||
| 53 | getExpandedAbsoluteOffsetForPoint: () => 0, | ||
| 54 | }); | ||
| 55 | editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); | ||
| 56 | |||
| 57 | const event = new TestClipboardEvent(mention); | ||
| 58 | let handled = false; | ||
| 59 | editor.update( | ||
| 60 | () => { | ||
| 61 | handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); | ||
| 62 | }, | ||
| 63 | { discrete: true }, | ||
| 64 | ); | ||
| 65 | |||
| 66 | expect(handled).toBe(true); | ||
| 67 | expect(plainTextFallback).not.toHaveBeenCalled(); | ||
| 68 | expect(event.defaultPrevented).toBe(true); | ||
| 69 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( | ||
| 70 | "<mention:.changeset/improve-deploy-error-logging.md> ", | ||
| 71 | ); | ||
| 72 | }); | ||
| 73 | }); | ||
apps/web/src/components/ComposerPromptEditor.tsx+9-88| ... | @@ -27,7 +27,6 @@ import { | ... | @@ -27,7 +27,6 @@ import { |
| 27 | KEY_TAB_COMMAND, | 27 | KEY_TAB_COMMAND, |
| 28 | COMMAND_PRIORITY_HIGH, | 28 | COMMAND_PRIORITY_HIGH, |
| 29 | KEY_BACKSPACE_COMMAND, | 29 | KEY_BACKSPACE_COMMAND, |
| 30 | PASTE_COMMAND, | ||
| 31 | $getRoot, | 30 | $getRoot, |
| 32 | HISTORY_MERGE_TAG, | 31 | HISTORY_MERGE_TAG, |
| 33 | DecoratorNode, | 32 | DecoratorNode, |
| ... | @@ -56,7 +55,6 @@ import { | ... | @@ -56,7 +55,6 @@ import { |
| 56 | expandCollapsedComposerCursor, | 55 | expandCollapsedComposerCursor, |
| 57 | isCollapsedCursorAdjacentToInlineToken, | 56 | isCollapsedCursorAdjacentToInlineToken, |
| 58 | } from "~/composer-logic"; | 57 | } from "~/composer-logic"; |
| 59 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; | ||
| 60 | import { | 58 | import { |
| 61 | selectionTouchesMentionBoundary, | 59 | selectionTouchesMentionBoundary, |
| 62 | splitPromptIntoComposerSegments, | 60 | splitPromptIntoComposerSegments, |
| ... | @@ -77,6 +75,7 @@ import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip | ... | @@ -77,6 +75,7 @@ import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip |
| 77 | import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; | 75 | import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; |
| 78 | import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; | 76 | import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; |
| 79 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 77 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 78 | import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; | ||
| 80 | 79 | ||
| 81 | const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; | 80 | const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; |
| 82 | const SURROUND_SYMBOLS: [string, string][] = [ | 81 | const SURROUND_SYMBOLS: [string, string][] = [ |
| ... | @@ -1122,92 +1121,14 @@ function ComposerInlineTokenBackspacePlugin() { | ... | @@ -1122,92 +1121,14 @@ function ComposerInlineTokenBackspacePlugin() { |
| 1122 | function ComposerInlineTokenPastePlugin() { | 1121 | function ComposerInlineTokenPastePlugin() { |
| 1123 | const [editor] = useLexicalComposerContext(); | 1122 | const [editor] = useLexicalComposerContext(); |
| 1124 | 1123 | ||
| 1125 | useEffect(() => { | 1124 | useEffect( |
| 1126 | return editor.registerCommand( | 1125 | () => |
| 1127 | PASTE_COMMAND, | 1126 | registerComposerInlineTokenPaste(editor, { |
| 1128 | (event) => { | 1127 | createMentionNode: $createComposerMentionNode, |
| 1129 | if (!(event instanceof ClipboardEvent) || event.clipboardData === null) { | 1128 | getExpandedAbsoluteOffsetForPoint, |
| 1130 | return false; | 1129 | }), |
| 1131 | } | 1130 | [editor], |
| 1132 | if (event.clipboardData.files.length > 0) { | 1131 | ); |
| 1133 | return false; | ||
| 1134 | } | ||
| 1135 | const text = event.clipboardData.getData("text/plain"); | ||
| 1136 | if (text.length === 0) { | ||
| 1137 | return false; | ||
| 1138 | } | ||
| 1139 | // Token grammar requires trailing whitespace; a virtual newline lets a | ||
| 1140 | // mention at the very end of the pasted text still parse. | ||
| 1141 | const mentions = collectComposerInlineTokens(`${text}\n`).filter( | ||
| 1142 | (token) => token.type === "mention" && token.end <= text.length, | ||
| 1143 | ); | ||
| 1144 | if (mentions.length === 0) { | ||
| 1145 | return false; | ||
| 1146 | } | ||
| 1147 | let inserted = false; | ||
| 1148 | editor.update(() => { | ||
| 1149 | const selection = $getSelection(); | ||
| 1150 | if (!$isRangeSelection(selection)) { | ||
| 1151 | return; | ||
| 1152 | } | ||
| 1153 | const nodes: LexicalNode[] = []; | ||
| 1154 | const appendText = (value: string) => { | ||
| 1155 | const lines = value.split("\n"); | ||
| 1156 | for (let index = 0; index < lines.length; index += 1) { | ||
| 1157 | const line = lines[index] ?? ""; | ||
| 1158 | if (line.length > 0) { | ||
| 1159 | nodes.push($createTextNode(line)); | ||
| 1160 | } | ||
| 1161 | if (index < lines.length - 1) { | ||
| 1162 | nodes.push($createLineBreakNode()); | ||
| 1163 | } | ||
| 1164 | } | ||
| 1165 | }; | ||
| 1166 | const firstMention = mentions[0]; | ||
| 1167 | if (firstMention && firstMention.start === 0) { | ||
| 1168 | const startPoint = selection.isBackward() ? selection.focus : selection.anchor; | ||
| 1169 | const insertionOffset = getExpandedAbsoluteOffsetForPoint( | ||
| 1170 | startPoint.getNode(), | ||
| 1171 | startPoint.offset, | ||
| 1172 | ); | ||
| 1173 | const precedingChar = $getRoot() | ||
| 1174 | .getTextContent() | ||
| 1175 | .slice(insertionOffset - 1, insertionOffset); | ||
| 1176 | if (precedingChar.length > 0 && !/\s/.test(precedingChar)) { | ||
| 1177 | nodes.push($createTextNode(" ")); | ||
| 1178 | } | ||
| 1179 | } | ||
| 1180 | let cursor = 0; | ||
| 1181 | for (const mention of mentions) { | ||
| 1182 | if (mention.start < cursor) { | ||
| 1183 | continue; | ||
| 1184 | } | ||
| 1185 | if (mention.start > cursor) { | ||
| 1186 | appendText(text.slice(cursor, mention.start)); | ||
| 1187 | } | ||
| 1188 | nodes.push($createComposerMentionNode(mention.value)); | ||
| 1189 | cursor = mention.end; | ||
| 1190 | } | ||
| 1191 | if (cursor < text.length) { | ||
| 1192 | appendText(text.slice(cursor)); | ||
| 1193 | } else { | ||
| 1194 | // Keep the serialized prompt valid: mention tokens need trailing | ||
| 1195 | // whitespace, so a paste ending in a mention gets the same | ||
| 1196 | // trailing space the autocomplete inserts. | ||
| 1197 | nodes.push($createTextNode(" ")); | ||
| 1198 | } | ||
| 1199 | selection.insertNodes(nodes); | ||
| 1200 | inserted = true; | ||
| 1201 | }); | ||
| 1202 | if (!inserted) { | ||
| 1203 | return false; | ||
| 1204 | } | ||
| 1205 | event.preventDefault(); | ||
| 1206 | return true; | ||
| 1207 | }, | ||
| 1208 | COMMAND_PRIORITY_HIGH, | ||
| 1209 | ); | ||
| 1210 | }, [editor]); | ||
| 1211 | 1132 | ||
| 1212 | return null; | 1133 | return null; |
| 1213 | } | 1134 | } |
apps/web/src/components/composerInlineTokenPaste.ts created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; | ||
| 2 | import { | ||
| 3 | $createLineBreakNode, | ||
| 4 | $createTextNode, | ||
| 5 | $getRoot, | ||
| 6 | $getSelection, | ||
| 7 | $isRangeSelection, | ||
| 8 | COMMAND_PRIORITY_HIGH, | ||
| 9 | PASTE_COMMAND, | ||
| 10 | type LexicalEditor, | ||
| 11 | type LexicalNode, | ||
| 12 | } from "lexical"; | ||
| 13 | |||
| 14 | interface ComposerInlineTokenPasteOptions { | ||
| 15 | createMentionNode: (path: string) => LexicalNode; | ||
| 16 | getExpandedAbsoluteOffsetForPoint: (node: LexicalNode, pointOffset: number) => number; | ||
| 17 | } | ||
| 18 | |||
| 19 | export function registerComposerInlineTokenPaste( | ||
| 20 | editor: LexicalEditor, | ||
| 21 | options: ComposerInlineTokenPasteOptions, | ||
| 22 | ): () => void { | ||
| 23 | return editor.registerCommand( | ||
| 24 | PASTE_COMMAND, | ||
| 25 | (event) => { | ||
| 26 | if (!(event instanceof ClipboardEvent) || event.clipboardData === null) { | ||
| 27 | return false; | ||
| 28 | } | ||
| 29 | if (event.clipboardData.files.length > 0) { | ||
| 30 | return false; | ||
| 31 | } | ||
| 32 | const text = event.clipboardData.getData("text/plain"); | ||
| 33 | if (text.length === 0) { | ||
| 34 | return false; | ||
| 35 | } | ||
| 36 | // Token grammar requires trailing whitespace; a virtual newline lets a | ||
| 37 | // mention at the very end of the pasted text still parse. | ||
| 38 | const mentions = collectComposerInlineTokens(`${text}\n`).filter( | ||
| 39 | (token) => token.type === "mention" && token.end <= text.length, | ||
| 40 | ); | ||
| 41 | if (mentions.length === 0) { | ||
| 42 | return false; | ||
| 43 | } | ||
| 44 | |||
| 45 | // Lexical command listeners already run inside an editor update. Starting | ||
| 46 | // a nested update here queues the mention insertion until after this | ||
| 47 | // listener returns, which lets the plain-text paste handler run as well. | ||
| 48 | const selection = $getSelection(); | ||
| 49 | if (!$isRangeSelection(selection)) { | ||
| 50 | return false; | ||
| 51 | } | ||
| 52 | const nodes: LexicalNode[] = []; | ||
| 53 | const appendText = (value: string) => { | ||
| 54 | const lines = value.split("\n"); | ||
| 55 | for (let index = 0; index < lines.length; index += 1) { | ||
| 56 | const line = lines[index] ?? ""; | ||
| 57 | if (line.length > 0) { | ||
| 58 | nodes.push($createTextNode(line)); | ||
| 59 | } | ||
| 60 | if (index < lines.length - 1) { | ||
| 61 | nodes.push($createLineBreakNode()); | ||
| 62 | } | ||
| 63 | } | ||
| 64 | }; | ||
| 65 | const firstMention = mentions[0]; | ||
| 66 | if (firstMention && firstMention.start === 0) { | ||
| 67 | const startPoint = selection.isBackward() ? selection.focus : selection.anchor; | ||
| 68 | const insertionOffset = options.getExpandedAbsoluteOffsetForPoint( | ||
| 69 | startPoint.getNode(), | ||
| 70 | startPoint.offset, | ||
| 71 | ); | ||
| 72 | const precedingChar = $getRoot() | ||
| 73 | .getTextContent() | ||
| 74 | .slice(insertionOffset - 1, insertionOffset); | ||
| 75 | if (precedingChar.length > 0 && !/\s/.test(precedingChar)) { | ||
| 76 | nodes.push($createTextNode(" ")); | ||
| 77 | } | ||
| 78 | } | ||
| 79 | let cursor = 0; | ||
| 80 | for (const mention of mentions) { | ||
| 81 | if (mention.start < cursor) { | ||
| 82 | continue; | ||
| 83 | } | ||
| 84 | if (mention.start > cursor) { | ||
| 85 | appendText(text.slice(cursor, mention.start)); | ||
| 86 | } | ||
| 87 | nodes.push(options.createMentionNode(mention.value)); | ||
| 88 | cursor = mention.end; | ||
| 89 | } | ||
| 90 | if (cursor < text.length) { | ||
| 91 | appendText(text.slice(cursor)); | ||
| 92 | } else { | ||
| 93 | // Keep the serialized prompt valid: mention tokens need trailing | ||
| 94 | // whitespace, so a paste ending in a mention gets the same | ||
| 95 | // trailing space the autocomplete inserts. | ||
| 96 | nodes.push($createTextNode(" ")); | ||
| 97 | } | ||
| 98 | selection.insertNodes(nodes); | ||
| 99 | event.preventDefault(); | ||
| 100 | return true; | ||
| 101 | }, | ||
| 102 | COMMAND_PRIORITY_HIGH, | ||
| 103 | ); | ||
| 104 | } | ||