From 84f669fc107c42d94d5b0bd45c0db180bb7a8304 Mon Sep 17 00:00:00 2001 From: MuRong Date: Tue, 28 Jul 2026 22:13:56 +0800 Subject: [PATCH] fix(editor): sync visual table edits to markdown --- packages/app/src/App.test.tsx | 50 +++++++++ packages/editor/src/codemirror/table.test.ts | 26 +++++ packages/editor/src/codemirror/table.ts | 105 ++++++++++++------- 3 files changed, 143 insertions(+), 38 deletions(-) diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index 97d7854b..7daab612 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -8112,6 +8112,56 @@ describe("Markra workspace", () => { expect(screen.queryByLabelText("Unsaved changes")).not.toBeInTheDocument(); }); + it("syncs visual table cell edits to source in split mode and saves them", async () => { + mockOpenMarkdownFile({ + content: ["| Field | Value |", "| --- | --- |", "| Name | Before |"].join("\n"), + name: "table.md", + path: mockNativePath + }); + mockedSaveNativeMarkdownFile.mockResolvedValue({ + name: "table.md", + path: mockNativePath + }); + const { container } = renderApp(); + + fireEvent.keyDown(window, { key: "o", metaKey: true }); + await selectEditorViewMode("Preview + Source"); + const sourceEditor = await screen.findByRole("textbox", { name: "Markdown source" }); + const cell = await waitFor(() => { + const nextCell = container.querySelector( + ".cm-markra-table tbody td:nth-child(2)" + ); + expect(nextCell).toBeInTheDocument(); + return nextCell!; + }); + + cell.focus(); + cell.textContent = "After"; + fireEvent.input(cell.closest("table")!); + const editedCell = await waitFor(() => { + const nextCell = container.querySelector( + ".cm-markra-table tbody td:nth-child(2)" + ); + expect(nextCell).toHaveTextContent("After"); + expect(nextCell).toHaveFocus(); + return nextCell!; + }); + await waitFor(() => + expect(readMarkdownSource(sourceEditor)).toContain("| Name | After |") + ); + fireEvent.keyDown(editedCell, { key: "s", metaKey: true }); + + await waitFor(() => + expect(mockedSaveNativeMarkdownFile).toHaveBeenCalledWith( + expect.objectContaining({ + contents: ["| Field | Value |", "| --- | --- |", "| Name | After |"].join("\n"), + path: mockNativePath, + suggestedName: "table.md" + }) + ) + ); + }); + it("keeps a clean file unmodified when toggling markdown source mode without edits", async () => { const originalContent = "Native file\n===========\n\nOpened from disk."; mockOpenMarkdownFile({ diff --git a/packages/editor/src/codemirror/table.test.ts b/packages/editor/src/codemirror/table.test.ts index c9de503a..ff796eec 100644 --- a/packages/editor/src/codemirror/table.test.ts +++ b/packages/editor/src/codemirror/table.test.ts @@ -675,6 +675,32 @@ describe("tablePreviewPlugin", () => { ); }); + it("updates Markdown when input targets the shared table editing host", 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("tbody td"); + + cell?.focus(); + if (cell) cell.textContent = "Updated"; + table?.dispatchEvent(new InputEvent("input", { bubbles: true })); + + await Promise.resolve(); + + expect(view.state.doc.toString()).toContain("| Updated | 1 |"); + expect( + view.dom.querySelector( + ".cm-markra-table tbody td", + )?.textContent, + ).toBe("Updated"); + }); + 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 ba651047..6d007640 100644 --- a/packages/editor/src/codemirror/table.ts +++ b/packages/editor/src/codemirror/table.ts @@ -625,6 +625,20 @@ function tableCellCaretOffset(cell: HTMLTableCellElement) { return range.toString().length; } +function activeVisualTableCell(table: HTMLTableElement) { + const selectionNode = table.ownerDocument.getSelection()?.anchorNode; + const selectionElement = + selectionNode instanceof Element ? selectionNode : selectionNode?.parentElement; + const selectedCell = selectionElement?.closest("th, td"); + if (selectedCell && table.contains(selectedCell)) return selectedCell; + + const activeElement = table.ownerDocument.activeElement; + return activeElement instanceof HTMLTableCellElement && + table.contains(activeElement) + ? activeElement + : null; +} + function focusVisualTableCell( view: CodeMirrorView, tableFrom: number, @@ -670,6 +684,39 @@ function focusVisualTableCell( }); } +function syncVisualTableCell( + view: CodeMirrorView, + preview: TablePreview, + cell: HTMLTableCellElement, +) { + if (view.state.readOnly) return; + + const rowIndex = Number(cell.dataset.tableRow); + const columnIndex = Number(cell.dataset.tableColumn); + const header = cell.dataset.tableHeader === "true"; + if (!Number.isInteger(rowIndex) || !Number.isInteger(columnIndex)) return; + + const caretOffset = tableCellCaretOffset(cell); + const changed = replaceVisualTableCell( + view, + preview, + rowIndex, + columnIndex, + header, + visualTableCellSource(cell), + ); + if (changed) { + focusVisualTableCell( + view, + preview.from, + rowIndex, + columnIndex, + header, + caretOffset, + ); + } +} + function revealVisualTableInlineSource( cell: HTMLTableCellElement, element: HTMLElement, @@ -730,7 +777,6 @@ function appendCell( links: LinksPluginOptions | undefined, ) { const cell = row.ownerDocument.createElement(header ? "th" : "td"); - let composing = false; const currentSession = tableEditingSessions.get(view); const keepInlineSourceVisible = currentSession?.tableFrom === preview.from && @@ -847,43 +893,6 @@ function appendCell( } }, 0); }); - const syncCellSource = () => { - if (view.state.readOnly) return; - const caretOffset = tableCellCaretOffset(cell); - const changed = replaceVisualTableCell( - view, - preview, - rowIndex, - columnIndex, - header, - visualTableCellSource(cell), - ); - if (changed) { - focusVisualTableCell( - view, - preview.from, - rowIndex, - columnIndex, - header, - caretOffset, - ); - } - }; - cell.addEventListener("compositionstart", (event) => { - event.stopPropagation(); - composing = true; - }); - cell.addEventListener("compositionend", (event) => { - event.stopPropagation(); - composing = false; - syncCellSource(); - }); - cell.addEventListener("input", (event) => { - event.stopPropagation(); - // Replacing the widget mid-composition cancels native CJK input, so commit only after compositionend. - if (composing || (event instanceof InputEvent && event.isComposing)) return; - syncCellSource(); - }); cell.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); @@ -1134,6 +1143,7 @@ class TableWidget extends WidgetType { ); let hoveredColumn = 0; let hoveredRow = 0; + let composing = false; wrapper.className = "cm-markra-table-wrap tableWrapper markra-table-controls-wrapper"; @@ -1151,6 +1161,25 @@ class TableWidget extends WidgetType { table.dataset.tableAlignment = tableAlignment; table.dataset.widthMode = widthMode; table.classList.toggle("markra-table-width-auto", widthMode === "auto"); + // Browsers target editing events at the shared contenteditable table host, + // so cell-level listeners miss real input even though the cell DOM changes. + table.addEventListener("compositionstart", (event) => { + event.stopPropagation(); + composing = true; + }); + table.addEventListener("compositionend", (event) => { + event.stopPropagation(); + composing = false; + const cell = activeVisualTableCell(table); + if (cell) syncVisualTableCell(view, this.preview, cell); + }); + table.addEventListener("input", (event) => { + event.stopPropagation(); + // Replacing the widget mid-composition cancels native CJK input, so commit only after compositionend. + if (composing || (event instanceof InputEvent && event.isComposing)) return; + const cell = activeVisualTableCell(table); + if (cell) syncVisualTableCell(view, this.preview, cell); + }); const sizeButton = document.createElement("button"); sizeButton.type = "button";