Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions packages/app/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLTableCellElement>(
".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<HTMLTableCellElement>(
".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({
Expand Down
26 changes: 26 additions & 0 deletions packages/editor/src/codemirror/table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLTableElement>(".cm-markra-table");
const cell = table?.querySelector<HTMLTableCellElement>("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<HTMLTableCellElement>(
".cm-markra-table tbody td",
)?.textContent,
).toBe("Updated");
});

it("commits a visual table cell after IME composition finishes", async () => {
const doc = [
"| Name | Value |",
Expand Down
105 changes: 67 additions & 38 deletions packages/editor/src/codemirror/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLTableCellElement>("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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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";
Expand All @@ -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";
Expand Down
Loading