From bb7e4ad339320a396f93f08090f9f030b65e7e60 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Fri, 4 Sep 2026 14:55:41 -0500 Subject: [PATCH] fix(web): make pasted text blocks editable - Sync expanded pasted-text edits back to the composer - Remove pasted-text blocks when cleared - Add coverage for editing and deletion --- .../components/ComposerPromptEditor.test.ts | 75 +++++++++++++++++- .../src/components/ComposerPromptEditor.tsx | 76 +++++++++++++++++-- 2 files changed, 143 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 413e93f2e21..ea40d2aa07b 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { $createParagraphNode, $createTextNode, + $getNodeByKey, $getRoot, $getSelection, $isRangeSelection, @@ -9,9 +10,14 @@ import { createEditor, PASTE_COMMAND, } from "lexical"; +import { serializePastedText } from "@t3tools/shared/pastedText"; import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; -import { isComposerPromptEditorBeyondMinimumHeight } from "./ComposerPromptEditor"; +import { + $createComposerPastedTextNode, + ComposerPastedTextNode, + isComposerPromptEditorBeyondMinimumHeight, +} from "./ComposerPromptEditor"; describe("isComposerPromptEditorBeyondMinimumHeight", () => { it("reports the first physical height increase beyond the editor minimum", () => { @@ -21,6 +27,73 @@ describe("isComposerPromptEditorBeyondMinimumHeight", () => { }); }); +describe("ComposerPastedTextNode", () => { + it("updates the serialized prompt when its expanded editor changes", () => { + const editor = createEditor({ nodes: [ComposerPastedTextNode] }); + let nodeKey = ""; + + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + const pastedText = $createComposerPastedTextNode("original pasted text"); + paragraph.append(pastedText); + nodeKey = pastedText.getKey(); + }, + { discrete: true }, + ); + editor.update( + () => { + const pastedText = $getNodeByKey(nodeKey); + expect(pastedText).toBeInstanceOf(ComposerPastedTextNode); + if (pastedText instanceof ComposerPastedTextNode) { + pastedText.setText("edited pasted text"); + } + }, + { discrete: true }, + ); + + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( + serializePastedText("edited pasted text"), + ); + }); + + it("removes the pasted-text block when its expanded editor is cleared", () => { + const editor = createEditor({ nodes: [ComposerPastedTextNode] }); + let nodeKey = ""; + let retainedNodeKey = ""; + + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + const pastedText = $createComposerPastedTextNode("delete all of this"); + const retainedPastedText = $createComposerPastedTextNode("keep this block"); + paragraph.append(pastedText, retainedPastedText); + nodeKey = pastedText.getKey(); + retainedNodeKey = retainedPastedText.getKey(); + }, + { discrete: true }, + ); + editor.update( + () => { + const pastedText = $getNodeByKey(nodeKey); + expect(pastedText).toBeInstanceOf(ComposerPastedTextNode); + if (pastedText instanceof ComposerPastedTextNode) { + pastedText.setText(""); + } + }, + { discrete: true }, + ); + + editor.getEditorState().read(() => { + expect($getNodeByKey(nodeKey)).toBeNull(); + expect($getNodeByKey(retainedNodeKey)).toBeInstanceOf(ComposerPastedTextNode); + expect($getRoot().getTextContent()).toBe(serializePastedText("keep this block")); + }); + }); +}); + class TestClipboardEvent extends Event { readonly clipboardData: DataTransfer; diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 8f5655065fa..c0ad9dadc73 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -5,6 +5,7 @@ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; +import { useLexicalEditable } from "@lexical/react/useLexicalEditable"; import { type ServerProviderSkill } from "@t3tools/contracts"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { serializePastedText } from "@t3tools/shared/pastedText"; @@ -13,6 +14,7 @@ import { $createRangeSelectionFromDom, $createRangeSelection, $getSelection, + $getNodeByKey, $setSelection, $isElementNode, $isLineBreakNode, @@ -437,10 +439,36 @@ function $createComposerTerminalContextNode( return $applyNodeReplacement(new ComposerTerminalContextNode(context)); } -function ComposerPastedTextDecorator(props: { text: string; ordinal: number }) { +function ComposerPastedTextDecorator(props: { text: string; ordinal: number; nodeKey: NodeKey }) { + const [editor] = useLexicalComposerContext(); + const isEditable = useLexicalEditable(); const [expanded, setExpanded] = useState(false); + const [draft, setDraft] = useState(props.text); const label = `Pasted text #${props.ordinal}`; + useEffect(() => { + setDraft(props.text); + }, [props.text]); + + const updateText = useCallback( + (text: string) => { + setDraft(text); + editor.update(() => { + const node = $getNodeByKey(props.nodeKey); + if (node instanceof ComposerPastedTextNode) { + node.setText(text); + } + }); + }, + [editor, props.nodeKey], + ); + const stopEditorPropagation = useCallback((event: React.SyntheticEvent) => { + event.stopPropagation(); + }, []); + // Input is committed during capture so it cannot also reach Lexical's outer + // editor. Keep an onChange handler so React recognizes the controlled field. + const acknowledgeControlledInput = useCallback(() => undefined, []); + return ( {expanded ? ( - - {props.text} - +