fix(ui): allow clearing preloaded markdown on the first edit

Preserve the programmatic empty mount reset guard while recognizing input and Lexical deletion intent before change callbacks.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-29 12:07:19 -05:00
1 parent 36f7a89b37
commit 8e8cdd7aaa
2 files changed
+67

No files matched your search

+48
View File
@@ -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(/\\</g, "<") || placeholder || ""}
@@ -376,6 +387,43 @@ describe("MarkdownEditor", () => {
});
});
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(<MarkdownEditor value="Loaded draft" onChange={handleChange} />);
});
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(<MarkdownEditor value="Loaded draft" onChange={handleChange} />);
});
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;
+19
View File
@@ -1366,8 +1366,27 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
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();