diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 73c96c94b1b..20bd75cd321 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -164,6 +164,17 @@ vi.mock("@mdxeditor/editor", async () => { className={className} contentEditable suppressContentEditableWarning + onKeyDown={(event) => { + if (event.key === "Delete" || event.key === "Backspace") { + setContent(""); + onChange?.(""); + } + }} + onInput={(event) => { + const next = event.currentTarget.textContent ?? ""; + setContent(next); + onChange?.(next); + }} > {/* The real editor paints resolved text, never the escapes that carried it in. */} {content.replace(/\\ { }); }); + it("allows a user to clear loaded text as the first editor change", async () => { + const handleChange = vi.fn(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + await flush(); + expect(handleChange).not.toHaveBeenCalled(); + + const editor = container.querySelector('[contenteditable="true"]')!; + await act(async () => { + editor.textContent = ""; + editor.dispatchEvent(new InputEvent("input", { + bubbles: true, + inputType: "deleteContentBackward", + })); + }); + expect(handleChange).toHaveBeenCalledExactlyOnceWith(""); + + await act(async () => root.unmount()); + }); + + it.each(["Delete", "Backspace"])("allows first-change clearing when Lexical handles %s without an input event", async (key) => { + const handleChange = vi.fn(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + await flush(); + const editor = container.querySelector('[contenteditable="true"]')!; + await act(async () => { + editor.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key })); + }); + expect(handleChange).toHaveBeenCalledExactlyOnceWith(""); + await act(async () => root.unmount()); + }); + it("does not recreate the mention decoration observer when the external value changes", async () => { const originalMutationObserver = globalThis.MutationObserver; diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 1b1190939ec..0fdee647e5b 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -1366,8 +1366,27 @@ export const MarkdownEditor = forwardRef isDragOver && "ring-1 ring-primary/60 bg-accent/20", className, )} + onInputCapture={(event) => { + if ( + !readOnly + && event.target instanceof HTMLElement + && event.target.closest('[contenteditable="true"]') + ) { + // Actual input may be the editor's first change. An intentional clear + // must not be mistaken for its programmatic empty mount reset. + initialChildOnChangeRef.current = false; + } + }} onKeyDownCapture={(e) => { if (readOnly) return; + if ( + (e.key === "Backspace" || e.key === "Delete") + && e.target instanceof HTMLElement + && e.target.closest('[contenteditable="true"]') + ) { + // Lexical handles deletion on keydown and may suppress DOM input. + initialChildOnChangeRef.current = false; + } // Cmd/Ctrl+Enter to submit if (onSubmit && e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault();