From ea6f8cb164e6307cf190a678786708d29854d37e Mon Sep 17 00:00:00 2001 From: MuRong Date: Fri, 4 Sep 2026 21:25:33 +0800 Subject: [PATCH] fix(editor): keep IME input inside table headers --- packages/editor/src/codemirror/table.test.ts | 112 +++++++++++++++++++ packages/editor/src/codemirror/table.ts | 26 ++++- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/codemirror/table.test.ts b/packages/editor/src/codemirror/table.test.ts index b4f2e157..96d7f52f 100644 --- a/packages/editor/src/codemirror/table.test.ts +++ b/packages/editor/src/codemirror/table.test.ts @@ -1591,6 +1591,118 @@ describe("tablePreviewPlugin", () => { expect(view.dom.querySelector(".cm-markra-table")).not.toBeNull(); }); + it("keeps IME composition inside a visual table header when WebKit lifts the selection", async () => { + const doc = [ + "| Name | Value |", + "| --- | --- |", + "| Alpha | 1 |", + "", + "Edit", + ].join("\n"); + const view = createView(doc); + const table = view.dom.querySelector(".cm-markra-table"); + const cell = table?.querySelector( + "thead th:nth-child(2)", + ); + const row = cell?.parentElement; + + cell?.focus(); + table?.dispatchEvent(new CompositionEvent("compositionstart", { + bubbles: true, + })); + table?.focus(); + if (row) { + const selection = document.getSelection(); + const range = document.createRange(); + range.setStart(row, 1); + range.collapse(true); + selection?.removeAllRanges(); + selection?.addRange(range); + } + table?.dispatchEvent(new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + data: "中文", + inputType: "insertCompositionText", + isComposing: true, + })); + + expect(document.activeElement).toBe(cell); + expect(cell?.contains(document.getSelection()?.anchorNode ?? null)).toBe(true); + + const selection = document.getSelection(); + const text = selection?.anchorNode; + if (selection && text instanceof Text) { + const offset = selection.anchorOffset; + text.insertData(offset, "中文"); + selection.collapse(text, offset + 2); + } + table?.dispatchEvent(new InputEvent("input", { + bubbles: true, + data: "中文", + inputType: "insertCompositionText", + isComposing: true, + })); + table?.dispatchEvent(new CompositionEvent("compositionend", { + bubbles: true, + data: "中文", + })); + await Promise.resolve(); + + expect(view.state.doc.toString()).toContain("| Name | Value中文 |"); + expect(view.dom.querySelectorAll(".cm-markra-table thead th")).toHaveLength(2); + expect(view.dom.querySelectorAll(".cm-markra-table tbody td")).toHaveLength(2); + }); + + it("stabilizes the caret host before composing into an empty visual table header", async () => { + const doc = [ + "| 1 | |", + "| --- | --- |", + "| 2 | 2 |", + "", + "Edit", + ].join("\n"); + const view = createView(doc); + + focusVisualTableCell(view, 0, -1, 1, true, 0); + await Promise.resolve(); + + const cell = view.dom.querySelector( + ".cm-markra-table thead th:nth-child(2)", + ); + const table = cell?.closest("table"); + expect(document.getSelection()?.anchorNode?.parentNode).toBe(cell); + expect(document.getSelection()?.anchorNode?.textContent).toBe(""); + + table?.dispatchEvent(new CompositionEvent("compositionstart", { + bubbles: true, + })); + + const compositionText = document.getSelection()?.anchorNode; + const compositionHost = compositionText?.parentElement; + expect(compositionText?.textContent).toBe("\u200b"); + expect(compositionHost?.dataset.markraTableCaretHost).toBe("true"); + + if (compositionText) compositionText.textContent = "\u200b苏打水"; + table?.dispatchEvent(new InputEvent("input", { + bubbles: true, + data: "苏打水", + inputType: "insertCompositionText", + isComposing: true, + })); + expect(view.state.doc.toString()).toBe(doc); + + table?.dispatchEvent(new CompositionEvent("compositionend", { + bubbles: true, + data: "苏打水", + })); + await Promise.resolve(); + + expect(view.state.doc.toString()).toContain("| 1 | 苏打水 |"); + expect(view.dom.querySelectorAll(".cm-markra-table thead th")).toHaveLength(2); + expect(view.dom.querySelectorAll(".cm-markra-table tbody td")).toHaveLength(2); + }); + it("commits a visual table cell after IME composition finishes", async () => { const doc = [ "| Name | Value |", diff --git a/packages/editor/src/codemirror/table.ts b/packages/editor/src/codemirror/table.ts index 6e945fa9..7bbd473d 100644 --- a/packages/editor/src/codemirror/table.ts +++ b/packages/editor/src/codemirror/table.ts @@ -756,6 +756,28 @@ function repairVisualTableCellSelection( } } +function stabilizeVisualTableCompositionCaret( + view: CodeMirrorView, + table: HTMLTableElement, +) { + const cell = activeVisualTableCell(view, table); + if (!cell || visualTableCellSource(cell) !== "") return; + + // An empty Text node is a valid DOM range but WebKit may move IME text to + // the surrounding table row. A zero-width span keeps the native insertion + // point inside the empty cell and is stripped when Markdown is serialized. + const caretHost = createVisualTableCaretHost(cell.ownerDocument); + cell.replaceChildren(caretHost.host); + cell.focus(); + const selection = cell.ownerDocument.getSelection(); + if (!selection) return; + const range = cell.ownerDocument.createRange(); + range.setStart(caretHost.text, TABLE_CARET_PLACEHOLDER.length); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); +} + function moveVisualTableCellFocus( view: CodeMirrorView, table: HTMLTableElement, @@ -1793,12 +1815,14 @@ class TableWidget extends WidgetType { event.stopPropagation(); return; } - if (event instanceof InputEvent && event.isComposing) return; + // WebKit can lift the range from a header cell after compositionstart. + // Repair only escaped ranges so the IME never inserts outside the cell. repairVisualTableCellSelection(view, table); }); table.addEventListener("compositionstart", (event) => { event.stopPropagation(); repairVisualTableCellSelection(view, table); + stabilizeVisualTableCompositionCaret(view, table); composing = true; }); table.addEventListener("compositionend", (event) => {