From 55ea8098f7a552914ffe0dbff155b1e4acdda177 Mon Sep 17 00:00:00 2001 From: "Gustavo A." Date: Sun, 18 Oct 2026 20:00:37 -0300 Subject: [PATCH 1/4] fix(web): surround selected text with dead-key quotes --- ...rPromptEditorTiptap.input.browser.test.tsx | 205 +++++++++++++++++- .../components/ComposerPromptEditorTiptap.tsx | 157 +++++++++++--- 2 files changed, 334 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 8af19e152c76d..1ad6286f69a2d 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -17,6 +17,7 @@ beforeEach(() => { }); afterEach(async () => { + vi.useRealTimers(); await act(async () => root.unmount()); container.remove(); vi.unstubAllGlobals(); @@ -64,7 +65,10 @@ async function replaceDomText(editor: Editor, text: string, isComposing = false) } function typeText(editor: Editor, from: number, to: number, text: string) { - const insert = () => editor.state.tr.insertText(text, from, to); + const insert = () => { + const tr = editor.state.tr.insertText(text, from, to); + return editor.view.composing ? tr.setMeta("composition", 1) : tr; + }; if ( !editor.view.someProp("handleTextInput", (handler) => handler(editor.view, from, to, text, insert), @@ -135,3 +139,202 @@ it.each([false, true])( expect(serializeEditorDoc(editor.state.doc).value).toBe('"word"'); }, ); + +it.each([ + { quote: "'", richTextEnabled: true, key: "Dead" }, + { quote: '"', richTextEnabled: true, key: "Dead" }, + { quote: "'", richTextEnabled: false, key: "Dead" }, + { quote: "'", richTextEnabled: true, key: "Process" }, + { quote: '"', richTextEnabled: true, key: "Process" }, +])( + "surrounds the original selection after dead-key composition: %j", + async ({ quote, richTextEnabled, key }) => { + const editor = await mountEditor("before word after", richTextEnabled); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await act(async () => { + editor.commands.setTextSelection({ from: 8, to: 12 }); + editor.view.dom.dispatchEvent( + new KeyboardEvent("keydown", { + key, + code: "Quote", + keyCode: key === "Process" ? 229 : 222, + bubbles: true, + }), + ); + expect(editor.state.selection.empty).toBe(true); + editor.view.dom.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true, data: "word" }), + ); + editor.view.dispatch( + editor.state.tr.insertText(quote === "'" ? "´" : "¨").setMeta("composition", 1), + ); + expect(editor.state.doc.textContent).toBe(`before ${quote === "'" ? "´" : "¨"}word after`); + typeText(editor, 8, 9, quote); + expect(editor.state.doc.textContent).toBe(`before ${quote}word after`); + editor.view.dom.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: quote }), + ); + await vi.advanceTimersByTimeAsync(0); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`before ${quote}word${quote} after`); + expect( + editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to), + ).toBe("word"); + await act(async () => { + editor.commands.undo(); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe("before word after"); + await act(async () => { + editor.commands.redo(); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`before ${quote}word${quote} after`); + }, +); + +it("lets an accented letter replace the selection", async () => { + const editor = await mountEditor("word", true); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await act(async () => { + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); + editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + editor.view.dispatch(editor.state.tr.insertText("´").setMeta("composition", 1)); + typeText(editor, 1, 2, "é"); + editor.view.dom.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "é" }), + ); + await vi.advanceTimersByTimeAsync(0); + typeText(editor, 2, 2, "'"); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe("é'"); + expect(editor.state.selection.empty).toBe(true); +}); + +it.each([ + { text: "'", expected: "'word'", selected: "word" }, + { text: '"', expected: '"word"', selected: "word" }, + { text: "é", expected: "é", selected: "" }, +])("resolves dead keys without composition events: %j", async ({ text, expected, selected }) => { + const editor = await mountEditor("word", true); + await act(async () => { + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.view.dom.dispatchEvent( + new KeyboardEvent("keydown", { key: "Dead", code: "Quote", bubbles: true }), + ); + typeText(editor, 1, 1, text); + }); + expect(editor.state.doc.textContent).toBe(expected); + expect(editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)).toBe( + selected, + ); + await act(async () => { + editor.commands.undo(); + }); + expect(editor.state.doc.textContent).toBe("word"); +}); + +it("does not restore a composition selection after the draft is replaced", async () => { + const editor = await mountEditor("word", true); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await act(async () => { + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); + editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + editor.view.dispatch(editor.state.tr.insertText("'").setMeta("composition", 1)); + editor.view.dom.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "'" }), + ); + editor.commands.setContent("new draft"); + await vi.advanceTimersByTimeAsync(0); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe("new draft"); +}); + +it("keeps the original selection when dead-key composition is cancelled", async () => { + const editor = await mountEditor("word", true); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await act(async () => { + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); + editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + editor.view.dispatch(editor.state.tr.insertText("´").setMeta("composition", 1)); + typeText(editor, 1, 2, ""); + editor.view.dom.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "" }), + ); + await vi.advanceTimersByTimeAsync(0); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe("word"); + expect(editor.state.selection.from).toBe(1); + expect(editor.state.selection.to).toBe(5); +}); + +it("leaves other input methods free to replace selected text during composition", async () => { + const editor = await mountEditor("word", true); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await act(async () => { + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + editor.view.dispatch(editor.state.tr.insertText("字").setMeta("composition", 1)); + expect(editor.state.doc.textContent).toBe("字"); + editor.view.dom.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "字" }), + ); + await vi.advanceTimersByTimeAsync(0); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe("字"); +}); + +it("keeps select-all text in place while a dead key resolves", async () => { + const editor = await mountEditor("word", true); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await act(async () => { + editor.commands.selectAll(); + editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); + editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + expect(editor.state.selection.from).toBe(1); + editor.view.dispatch(editor.state.tr.insertText("´").setMeta("composition", 1)); + expect(editor.state.doc.textContent).toBe("´word"); + typeText(editor, 1, 2, "'"); + editor.view.dom.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "'" }), + ); + await vi.advanceTimersByTimeAsync(0); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe("'word'"); +}); + +it("does not reuse a dead-key selection after the caret moves away and back", async () => { + const editor = await mountEditor("word"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await act(async () => { + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); + editor.commands.setTextSelection(5); + editor.commands.setTextSelection(1); + editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + typeText(editor, 1, 1, "'"); + editor.view.dom.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "'" }), + ); + await vi.advanceTimersByTimeAsync(0); + }); + expect(editor.state.doc.textContent).toBe("'word"); + expect(editor.state.selection.empty).toBe(true); +}); + +it("lets a dead key replace a selected node", async () => { + const editor = await mountEditor("word"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await act(async () => { + editor.commands.setNodeSelection(0); + editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); + editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + typeText(editor, editor.state.selection.from, editor.state.selection.to, "'"); + editor.view.dom.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "'" }), + ); + await vi.advanceTimersByTimeAsync(0); + }); + expect(editor.state.doc.textContent).toBe("'"); +}); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 14562f73cd473..2aec0a72b2f48 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -13,6 +13,7 @@ import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands"; import { type EditorState, + AllSelection, NodeSelection, Plugin, PluginKey, @@ -224,6 +225,24 @@ const SURROUND_CLOSE: Record = { _: "_", }; +function canSurroundComposerRange(doc: ProseMirrorNode, from: number, to: number) { + if (from === to) return false; + // Wrapping styled text or inline chips would discard their formatting or context. + let touchesSpecial = false; + doc.nodesBetween(from, to, (node) => { + if ((node.isAtom && node.isInline && !node.isText) || (node.isText && node.marks.length > 0)) { + touchesSpecial = true; + return false; + } + return true; + }); + if (touchesSpecial) return false; + const map = serializeEditorDoc(doc); + const startMd = flatToMarkdown(map, pmToFlat(map, from)); + const endMd = flatToMarkdown(map, pmToFlat(map, to)); + return !selectionTouchesMentionBoundary(map.value, startMd, endMd); +} + function resolvedThemeFromDocument(): "light" | "dark" { return document.documentElement.classList.contains("dark") ? "dark" : "light"; } @@ -928,6 +947,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const importFragmentRef = useRef(importContextFragment); const latestValueRef = useRef(value); const editorHolder = useRef(null); + const deadKeySelectionRef = useRef<{ + selection: TextSelection; + inputState: EditorState; + compositionId: number | null; + } | null>(null); useEffect(() => { onChangeRef.current = onChange; @@ -1105,7 +1129,81 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { editable: !disabled, editorProps: { attributes: editorAttributes, + handleDOMEvents: { + compositionstart: (view) => { + const pending = deadKeySelectionRef.current; + if (pending?.inputState !== view.state) { + deadKeySelectionRef.current = null; + } + return false; + }, + compositionend: (view, event) => { + const previous = deadKeySelectionRef.current; + const text = event.data; + if (!previous) return false; + const closer = text.length === 1 ? SURROUND_CLOSE[text] : undefined; + // Like Tiptap's input rules, wait for ProseMirror to commit the composition. + setTimeout(() => { + if (deadKeySelectionRef.current !== previous) return; + deadKeySelectionRef.current = null; + if (view.isDestroyed || view.composing) return; + const { from, to } = previous.selection; + const committed = previous.inputState.tr.insertText(text, from, from); + if ( + !view.state.doc.eq(committed.doc) || + !view.state.selection.eq(committed.selection) + ) + return; + const tr = view.state.tr; + const start = from + text.length; + const end = to + text.length; + if (closer) { + tr.insertText(closer, end); + tr.setSelection(TextSelection.create(tr.doc, start, end)); + } else if (text.length > 0) { + tr.delete(start, end); + tr.setSelection(TextSelection.create(tr.doc, start)); + } else { + tr.setSelection(TextSelection.create(tr.doc, from, to)); + } + if (previous.compositionId !== null) + tr.setMeta("composition", previous.compositionId); + view.dispatch(tr); + }); + return false; + }, + }, handleKeyDown: (view, event) => { + if ( + (event.key === "Dead" || + (event.key === "Process" && + (event.code === "Quote" || event.code === "Backquote"))) && + !literalText && + !view.composing + ) { + const { state } = view; + const selection = + state.selection instanceof AllSelection + ? TextSelection.between(state.selection.$from, state.selection.$to) + : state.selection; + if ( + selection instanceof TextSelection && + canSurroundComposerRange(state.doc, selection.from, selection.to) + ) { + // Wayland reports Process on keydown and Dead only on keyup. Collapse + // before composition starts so native input leaves the selected text in place. + view.dispatch( + view.state.tr.setSelection(TextSelection.create(view.state.doc, selection.from)), + ); + deadKeySelectionRef.current = { + selection, + inputState: view.state, + compositionId: null, + }; + } else { + deadKeySelectionRef.current = null; + } + } if ( isMacPlatform(navigator.platform) && (event.key === "Home" || event.key === "End") && @@ -1296,39 +1394,39 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return handled; }, handleTextInput: (view, from, to, text) => { - if (literalText || view.composing || text.length !== 1) return false; - const closer = SURROUND_CLOSE[text]; - const { selection } = view.state; - // Compose preedit updates replace a range even without a user selection. + if (literalText || view.composing) return false; + const pending = deadKeySelectionRef.current; + // Windows dead keys commit text without starting an IME composition. + const preserved = + pending && + view.state === pending.inputState && + from === to && + from === pending.selection.from + ? pending.selection + : null; + if (preserved) deadKeySelectionRef.current = null; + const closer = text.length === 1 ? SURROUND_CLOSE[text] : undefined; + if (!closer) { + if (!preserved) return false; + view.dispatch(view.state.tr.insertText(text, preserved.from, preserved.to)); + return true; + } + const selection = preserved ?? view.state.selection; + // A preedit replacement range is not necessarily the user's selection. if ( - !closer || !(selection instanceof TextSelection) || selection.empty || - selection.from !== from || - selection.to !== to + (!preserved && (selection.from !== from || selection.to !== to)) || + !canSurroundComposerRange(view.state.doc, selection.from, selection.to) ) { return false; } - // Never wrap chips or other atoms, and never wrap styled text: the - // default replace keeps marks intact, wrapping would drop them. - let touchesSpecial = false; - view.state.doc.nodesBetween(from, to, (node) => { - if ( - (node.isAtom && node.isInline && !node.isText) || - (node.isText && node.marks.length > 0) - ) { - touchesSpecial = true; - return false; - } - return true; - }); - if (touchesSpecial) return false; - const map = serializeEditorDoc(view.state.doc); - const startMd = flatToMarkdown(map, pmToFlat(map, from)); - const endMd = flatToMarkdown(map, pmToFlat(map, to)); - if (selectionTouchesMentionBoundary(map.value, startMd, endMd)) return false; - const tr = view.state.tr.insertText(closer, to).insertText(text, from); - tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length)); + const tr = view.state.tr + .insertText(closer, selection.to) + .insertText(text, selection.from); + tr.setSelection( + TextSelection.create(tr.doc, selection.from + text.length, selection.to + text.length), + ); view.dispatch(tr); return true; }, @@ -1417,6 +1515,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return true; }, }, + onTransaction: ({ transaction }) => { + const pending = deadKeySelectionRef.current; + const compositionId = transaction.getMeta("composition"); + if (pending && typeof compositionId === "number") pending.compositionId = compositionId; + }, onUpdate: ({ editor: updated }) => { handleEditorChange(updated); }, From 18e33fb223ed2994ec0a5a5001e240155f46a958 Mon Sep 17 00:00:00 2001 From: "Gustavo A." Date: Sun, 18 Oct 2026 21:54:23 -0300 Subject: [PATCH 2/4] refactor(web): simplify dead-key selection handling --- ...rPromptEditorTiptap.input.browser.test.tsx | 70 ++++++++----------- .../components/ComposerPromptEditorTiptap.tsx | 14 ++-- 2 files changed, 33 insertions(+), 51 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 1ad6286f69a2d..ae8eede1b8f79 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -78,6 +78,19 @@ function typeText(editor: Editor, from: number, to: number, text: string) { } } +function dispatchComposition( + editor: Editor, + type: "compositionstart" | "compositionend", + data = "", +) { + editor.view.dom.dispatchEvent(new CompositionEvent(type, { bubbles: true, data })); +} + +async function finishComposition(editor: Editor, text: string) { + dispatchComposition(editor, "compositionend", text); + await vi.advanceTimersByTimeAsync(0); +} + it.each([false, true])( "composes an umlaut without wrapping the preedit text (rich text: %s)", async (richTextEnabled) => { @@ -162,19 +175,14 @@ it.each([ }), ); expect(editor.state.selection.empty).toBe(true); - editor.view.dom.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true, data: "word" }), - ); + dispatchComposition(editor, "compositionstart", "word"); editor.view.dispatch( editor.state.tr.insertText(quote === "'" ? "´" : "¨").setMeta("composition", 1), ); expect(editor.state.doc.textContent).toBe(`before ${quote === "'" ? "´" : "¨"}word after`); typeText(editor, 8, 9, quote); expect(editor.state.doc.textContent).toBe(`before ${quote}word after`); - editor.view.dom.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: quote }), - ); - await vi.advanceTimersByTimeAsync(0); + await finishComposition(editor, quote); }); expect(serializeEditorDoc(editor.state.doc).value).toBe(`before ${quote}word${quote} after`); expect( @@ -197,13 +205,10 @@ it("lets an accented letter replace the selection", async () => { await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); - editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + dispatchComposition(editor, "compositionstart"); editor.view.dispatch(editor.state.tr.insertText("´").setMeta("composition", 1)); typeText(editor, 1, 2, "é"); - editor.view.dom.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "é" }), - ); - await vi.advanceTimersByTimeAsync(0); + await finishComposition(editor, "é"); typeText(editor, 2, 2, "'"); }); expect(serializeEditorDoc(editor.state.doc).value).toBe("é'"); @@ -239,11 +244,9 @@ it("does not restore a composition selection after the draft is replaced", async await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); - editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + dispatchComposition(editor, "compositionstart"); editor.view.dispatch(editor.state.tr.insertText("'").setMeta("composition", 1)); - editor.view.dom.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "'" }), - ); + dispatchComposition(editor, "compositionend", "'"); editor.commands.setContent("new draft"); await vi.advanceTimersByTimeAsync(0); }); @@ -256,13 +259,10 @@ it("keeps the original selection when dead-key composition is cancelled", async await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); - editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + dispatchComposition(editor, "compositionstart"); editor.view.dispatch(editor.state.tr.insertText("´").setMeta("composition", 1)); typeText(editor, 1, 2, ""); - editor.view.dom.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "" }), - ); - await vi.advanceTimersByTimeAsync(0); + await finishComposition(editor, ""); }); expect(serializeEditorDoc(editor.state.doc).value).toBe("word"); expect(editor.state.selection.from).toBe(1); @@ -274,13 +274,10 @@ it("leaves other input methods free to replace selected text during composition" vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); - editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + dispatchComposition(editor, "compositionstart"); editor.view.dispatch(editor.state.tr.insertText("字").setMeta("composition", 1)); expect(editor.state.doc.textContent).toBe("字"); - editor.view.dom.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "字" }), - ); - await vi.advanceTimersByTimeAsync(0); + await finishComposition(editor, "字"); }); expect(serializeEditorDoc(editor.state.doc).value).toBe("字"); }); @@ -291,15 +288,12 @@ it("keeps select-all text in place while a dead key resolves", async () => { await act(async () => { editor.commands.selectAll(); editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); - editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + dispatchComposition(editor, "compositionstart"); expect(editor.state.selection.from).toBe(1); editor.view.dispatch(editor.state.tr.insertText("´").setMeta("composition", 1)); expect(editor.state.doc.textContent).toBe("´word"); typeText(editor, 1, 2, "'"); - editor.view.dom.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "'" }), - ); - await vi.advanceTimersByTimeAsync(0); + await finishComposition(editor, "'"); }); expect(serializeEditorDoc(editor.state.doc).value).toBe("'word'"); }); @@ -312,12 +306,9 @@ it("does not reuse a dead-key selection after the caret moves away and back", as editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); editor.commands.setTextSelection(5); editor.commands.setTextSelection(1); - editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + dispatchComposition(editor, "compositionstart"); typeText(editor, 1, 1, "'"); - editor.view.dom.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "'" }), - ); - await vi.advanceTimersByTimeAsync(0); + await finishComposition(editor, "'"); }); expect(editor.state.doc.textContent).toBe("'word"); expect(editor.state.selection.empty).toBe(true); @@ -329,12 +320,9 @@ it("lets a dead key replace a selected node", async () => { await act(async () => { editor.commands.setNodeSelection(0); editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); - editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + dispatchComposition(editor, "compositionstart"); typeText(editor, editor.state.selection.from, editor.state.selection.to, "'"); - editor.view.dom.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "'" }), - ); - await vi.advanceTimersByTimeAsync(0); + await finishComposition(editor, "'"); }); expect(editor.state.doc.textContent).toBe("'"); }); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 2aec0a72b2f48..a10c21092f004 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1157,15 +1157,10 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const tr = view.state.tr; const start = from + text.length; const end = to + text.length; - if (closer) { - tr.insertText(closer, end); - tr.setSelection(TextSelection.create(tr.doc, start, end)); - } else if (text.length > 0) { - tr.delete(start, end); - tr.setSelection(TextSelection.create(tr.doc, start)); - } else { - tr.setSelection(TextSelection.create(tr.doc, from, to)); - } + const replaceSelection = text.length > 0 && !closer; + if (closer) tr.insertText(closer, end); + else if (replaceSelection) tr.delete(start, end); + tr.setSelection(TextSelection.create(tr.doc, start, replaceSelection ? start : end)); if (previous.compositionId !== null) tr.setMeta("composition", previous.compositionId); view.dispatch(tr); @@ -1415,7 +1410,6 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { // A preedit replacement range is not necessarily the user's selection. if ( !(selection instanceof TextSelection) || - selection.empty || (!preserved && (selection.from !== from || selection.to !== to)) || !canSurroundComposerRange(view.state.doc, selection.from, selection.to) ) { From 23f609877fa5eebe5cb88c9614c9da2f65bc9284 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 15:28:46 +0000 Subject: [PATCH 3/4] refactor(web): align dead-key tests with editor lifecycle --- ...erPromptEditorTiptap.input.browser.test.tsx | 18 +++++++++--------- .../components/ComposerPromptEditorTiptap.tsx | 1 - 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index dbc2b96417f6d..a3a8c64fcdd35 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -465,7 +465,7 @@ it.each([ async ({ quote, richTextEnabled, key }) => { await using screen = await mountEditor("before word after", richTextEnabled); const { editor } = screen; - using _timers = fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + using _timers = fakeTimers(); await act(async () => { editor.commands.setTextSelection({ from: 8, to: 12 }); editor.view.dom.dispatchEvent( @@ -504,7 +504,7 @@ it.each([ it("lets an accented letter replace the selection", async () => { await using screen = await mountEditor("word", true); const { editor } = screen; - using _timers = fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + using _timers = fakeTimers(); await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); @@ -545,7 +545,7 @@ it.each([ it("does not restore a composition selection after the draft is replaced", async () => { await using screen = await mountEditor("word", true); const { editor } = screen; - using _timers = fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + using _timers = fakeTimers(); await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); @@ -561,7 +561,7 @@ it("does not restore a composition selection after the draft is replaced", async it("keeps the original selection when dead-key composition is cancelled", async () => { await using screen = await mountEditor("word", true); const { editor } = screen; - using _timers = fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + using _timers = fakeTimers(); await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); @@ -578,7 +578,7 @@ it("keeps the original selection when dead-key composition is cancelled", async it("leaves other input methods free to replace selected text during composition", async () => { await using screen = await mountEditor("word", true); const { editor } = screen; - using _timers = fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + using _timers = fakeTimers(); await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); dispatchComposition(editor, "compositionstart"); @@ -592,7 +592,7 @@ it("leaves other input methods free to replace selected text during composition" it("keeps select-all text in place while a dead key resolves", async () => { await using screen = await mountEditor("word", true); const { editor } = screen; - using _timers = fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + using _timers = fakeTimers(); await act(async () => { editor.commands.selectAll(); editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); @@ -609,7 +609,7 @@ it("keeps select-all text in place while a dead key resolves", async () => { it("does not reuse a dead-key selection after the caret moves away and back", async () => { await using screen = await mountEditor("word"); const { editor } = screen; - using _timers = fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + using _timers = fakeTimers(); await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); @@ -626,7 +626,7 @@ it("does not reuse a dead-key selection after the caret moves away and back", as it("lets a dead key replace a selected node", async () => { await using screen = await mountEditor("word"); const { editor } = screen; - using _timers = fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + using _timers = fakeTimers(); await act(async () => { editor.commands.setNodeSelection(0); editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); @@ -640,7 +640,7 @@ it("lets a dead key replace a selected node", async () => { it("surrounds a selection when a dead-key quote commits", async () => { await using screen = await mountEditor("word"); const { editor } = screen; - using _timers = fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + using _timers = fakeTimers(); await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 901a6d175a4c2..087d1ec0382fc 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1001,7 +1001,6 @@ function ComposerPromptEditorTiptapInner( literalText ? position : expandCollapsedComposerCursor(text, position); const latestValueRef = useRef(value); - const editorHolder = useRef(null); const deadKeySelectionRef = useRef<{ selection: TextSelection; inputState: EditorState; From 8a7e7103138d2435417d8bee8d05f4ddf73d28bd Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 08:11:35 +0000 Subject: [PATCH 4/4] fix(web): escape clears a pending dead-key selection --- ...rPromptEditorTiptap.input.browser.test.tsx | 47 +++++++++++-------- .../components/ComposerPromptEditorTiptap.tsx | 3 ++ 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index a029c964d8f9d..2fe391bbfe751 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -876,25 +876,34 @@ it.each([ { text: "'", expected: "'word'", selected: "word" }, { text: '"', expected: '"word"', selected: "word" }, { text: "é", expected: "é", selected: "" }, -])("resolves dead keys without composition events: %j", async ({ text, expected, selected }) => { - await using screen = await mountEditor("word", true); - const { editor } = screen; - await act(async () => { - editor.commands.setTextSelection({ from: 1, to: 5 }); - editor.view.dom.dispatchEvent( - new KeyboardEvent("keydown", { key: "Dead", code: "Quote", bubbles: true }), - ); - typeText(editor, 1, 1, text); - }); - expect(editor.state.doc.textContent).toBe(expected); - expect(editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)).toBe( - selected, - ); - await act(async () => { - editor.commands.undo(); - }); - expect(editor.state.doc.textContent).toBe("word"); -}); + { text: "a", expected: "aword", selected: "", cancel: true }, +])( + "resolves dead keys without composition events: %j", + async ({ text, expected, selected, cancel }) => { + await using screen = await mountEditor("word", true); + const { editor } = screen; + await act(async () => { + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.view.dom.dispatchEvent( + new KeyboardEvent("keydown", { key: "Dead", code: "Quote", bubbles: true }), + ); + if (cancel) { + editor.view.dom.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + } + typeText(editor, 1, 1, text); + }); + expect(editor.state.doc.textContent).toBe(expected); + expect( + editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to), + ).toBe(selected); + await act(async () => { + editor.commands.undo(); + }); + expect(editor.state.doc.textContent).toBe("word"); + }, +); it("does not restore a composition selection after the draft is replaced", async () => { await using screen = await mountEditor("word", true); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 4554c5b3f9a2e..1e197b53f12c5 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1123,6 +1123,9 @@ function ComposerPromptEditorTiptapInner( : event.key === "Home" || event.key === "End" ? "lineboundary" : null; + if (event.key === "Escape" && !view.composing) { + deadKeySelectionRef.current = null; + } if ( (event.key === "Dead" || (event.key === "Process" &&