diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 925319680d40d..2fe391bbfe751 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -115,7 +115,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), @@ -125,6 +128,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); +} + function textWithMark(editor: Editor, name: string) { const text: string[] = []; editor.state.doc.descendants((node) => { @@ -791,3 +807,211 @@ describe("an Arabic line", () => { ); }); }); + +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 }) => { + await using screen = await mountEditor("before word after", richTextEnabled); + const { editor } = screen; + using _timers = fakeTimers(); + 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); + 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`); + await finishComposition(editor, quote); + }); + 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 () => { + await using screen = await mountEditor("word", true); + const { editor } = screen; + using _timers = fakeTimers(); + await act(async () => { + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); + dispatchComposition(editor, "compositionstart"); + editor.view.dispatch(editor.state.tr.insertText("´").setMeta("composition", 1)); + typeText(editor, 1, 2, "é"); + await finishComposition(editor, "é"); + 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: "" }, + { 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); + const { editor } = screen; + using _timers = fakeTimers(); + await act(async () => { + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); + dispatchComposition(editor, "compositionstart"); + editor.view.dispatch(editor.state.tr.insertText("'").setMeta("composition", 1)); + dispatchComposition(editor, "compositionend", "'"); + 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 () => { + await using screen = await mountEditor("word", true); + const { editor } = screen; + using _timers = fakeTimers(); + await act(async () => { + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); + dispatchComposition(editor, "compositionstart"); + editor.view.dispatch(editor.state.tr.insertText("´").setMeta("composition", 1)); + typeText(editor, 1, 2, ""); + await finishComposition(editor, ""); + }); + 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 () => { + await using screen = await mountEditor("word", true); + const { editor } = screen; + using _timers = fakeTimers(); + await act(async () => { + editor.commands.setTextSelection({ from: 1, to: 5 }); + dispatchComposition(editor, "compositionstart"); + editor.view.dispatch(editor.state.tr.insertText("字").setMeta("composition", 1)); + expect(editor.state.doc.textContent).toBe("字"); + await finishComposition(editor, "字"); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe("字"); +}); + +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(); + await act(async () => { + editor.commands.selectAll(); + editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", 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, "'"); + await finishComposition(editor, "'"); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe("'word'"); +}); + +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(); + 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); + dispatchComposition(editor, "compositionstart"); + typeText(editor, 1, 1, "'"); + await finishComposition(editor, "'"); + }); + expect(editor.state.doc.textContent).toBe("'word"); + expect(editor.state.selection.empty).toBe(true); +}); + +it("lets a dead key replace a selected node", async () => { + await using screen = await mountEditor("word"); + const { editor } = screen; + using _timers = fakeTimers(); + await act(async () => { + editor.commands.setNodeSelection(0); + editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true })); + dispatchComposition(editor, "compositionstart"); + typeText(editor, editor.state.selection.from, editor.state.selection.to, "'"); + await finishComposition(editor, "'"); + }); + expect(editor.state.doc.textContent).toBe("'"); +}); + +it("surrounds a selection when a dead-key quote commits", async () => { + await using screen = await mountEditor("word"); + const { editor } = screen; + using _timers = fakeTimers(); + 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")); + const { from, to } = editor.state.selection; + editor.view.dispatch(editor.state.tr.insertText("'", from, to).setMeta("composition", 1)); + editor.view.dom.dispatchEvent(new CompositionEvent("compositionend", { data: "'" })); + await vi.advanceTimersByTimeAsync(0); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe("'word'"); +}); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 8c4698210b1d5..1e197b53f12c5 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -16,13 +16,14 @@ import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands"; import { isHistoryTransaction } from "@tiptap/pm/history"; import { type EditorState, + AllSelection, NodeSelection, Plugin, PluginKey, TextSelection, } from "@tiptap/pm/state"; import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; -import type { NodeType, ResolvedPos } from "@tiptap/pm/model"; +import type { Node as ProseMirrorNode, NodeType, ResolvedPos } from "@tiptap/pm/model"; import type { AssistantCitation, ComposerContextClipboardFragment, @@ -231,6 +232,28 @@ const SURROUND_CLOSE: Record = { _: "_", }; +function canSurroundComposerRange(doc: ProseMirrorNode, from: number, to: number) { + if (from === to) return false; + // Do not wrap chips, atoms, or styled text; default replacement preserves marks. + // Shown Markdown source is plain text, so typed delimiters wrap it. + let touchesSpecial = false; + doc.nodesBetween(from, to, (node) => { + if ( + (node.isAtom && node.isInline && !node.isText) || + (node.isText && node.marks.some((mark) => mark.type.name !== MARKDOWN_SOURCE_MARK)) + ) { + 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"; } @@ -900,6 +923,12 @@ function ComposerPromptEditorTiptapInner( literalText ? position : expandCollapsedComposerCursor(text, position); const latestValueRef = useRef(value); + const deadKeySelectionRef = useRef<{ + selection: TextSelection; + inputState: EditorState; + compositionId: number | null; + } | null>(null); + // The editor config is built once, so its handlers reach the current callbacks through here. const readCallbacks = useStableCallback(() => ({ onChange, @@ -1043,6 +1072,45 @@ function ComposerPromptEditorTiptapInner( 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; + 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); + }); + return false; + }, + }, handleKeyDown: (view, event) => { // In a right-to-left line the start is on the right. const backArrow = caretLineDirection(view.state) === "rtl" ? "ArrowRight" : "ArrowLeft"; @@ -1055,6 +1123,39 @@ 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" && + (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) && boundary && @@ -1259,39 +1360,38 @@ function ComposerPromptEditorTiptapInner( 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; } - // Do not wrap chips, atoms, or styled text; default replacement preserves marks. - // Shown Markdown source is plain text, so typed delimiters wrap it. - let touchesSpecial = false; - view.state.doc.nodesBetween(from, to, (node) => { - if ( - (node.isAtom && node.isInline && !node.isText) || - (node.isText && node.marks.some((mark) => mark.type.name !== MARKDOWN_SOURCE_MARK)) - ) { - 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; }, @@ -1392,6 +1492,11 @@ function ComposerPromptEditorTiptapInner( return true; }, }, + onTransaction: ({ transaction }) => { + const pending = deadKeySelectionRef.current; + const compositionId = transaction.getMeta("composition"); + if (pending && typeof compositionId === "number") pending.compositionId = compositionId; + }, onUpdate: ({ editor: updated, transaction }) => { // Undo can restore a chip for a skill the current provider no longer offers. if (!literalText && isHistoryTransaction(transaction)) {