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}
-
+
);
}
-class ComposerPastedTextNode extends DecoratorNode {
+export class ComposerPastedTextNode extends DecoratorNode {
__text: string;
__ordinal: number;
@@ -518,6 +565,15 @@ class ComposerPastedTextNode extends DecoratorNode {
return serializePastedText(this.__text);
}
+ setText(text: string): void {
+ if (text.length === 0) {
+ this.remove();
+ return;
+ }
+ const writable = this.getWritable();
+ writable.__text = text;
+ }
+
override isInline(): true {
return true;
}
@@ -528,11 +584,17 @@ class ComposerPastedTextNode extends DecoratorNode {
}
override decorate(): React.ReactElement {
- return ;
+ return (
+
+ );
}
}
-function $createComposerPastedTextNode(text: string, ordinal = 1): ComposerPastedTextNode {
+export function $createComposerPastedTextNode(text: string, ordinal = 1): ComposerPastedTextNode {
return $applyNodeReplacement(new ComposerPastedTextNode(text, ordinal));
}