From b9d236044672a55e7fc4eac0f2e50ce357e37822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 20 Sep 2026 14:33:54 -0300 Subject: [PATCH] Flatten pasted table cell spans --- CHANGELOG.md | 1 + docs/specification.md | 2 +- .../editor/plugins/tableShape.test.ts | 24 ++++++++++++++ src/features/editor/plugins/tableShape.ts | 33 +++++++++++++++++-- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae15e788..7105a500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Fixed +- Flatten pasted HTML table cells that span columns into ordinary GFM cells immediately, so saving and reopening keeps their content in the column where it was pasted instead of carrying an unrepresentable table span. - Keep a character reference in a link or image title, a definition's title, or an image description as it was written, such as the `©` in `[l](d.md "t ©")` or `![a ©](d.png)`. Saving wrote it as the character it names, so a file kept to plain ASCII did not stay that way. Once the title is edited, the reference is saved as its character, the same as anywhere else. An image description edited through the image's Markdown now reads a reference typed there as the character it names, the way the file does, where it used to keep the reference's characters as literal text. A definition title holding an escaped reference, such as `\©`, lost its backslash on save and reopened as `©`; it is now saved as written. - Keep a character reference on a line that ends in a space or a tab, such as ` ` in ` a ` followed by a second line, as it was written. Saving wrote every reference on such a line as the character it names, so a space a reference named at the start of a paragraph or list item was gone once the file reopened. The reference is now written back as it was; the space or tab ending the line is still left out. diff --git a/docs/specification.md b/docs/specification.md index 73a6a4a1..526b463f 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -139,7 +139,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - List items and blockquotes may contain other block-level elements. - Ordered lists render with visual continuation. - Clicking a task-list checkbox toggles it checked or unchecked. -- Tables render as editable table blocks. Basic table editing uses visual table interaction; pipe-delimited Markdown is not exposed in the editor surface. A row holding more or fewer cells than the header is read as the columns the header declares, which is what a Markdown reader shows; cells beyond the header are dropped and missing cells are filled at the end of the row. A table written with a header and delimiter row and no body rows is kept and rendered as a header-only table. +- Tables render as editable table blocks. Basic table editing uses visual table interaction; pipe-delimited Markdown is not exposed in the editor surface. A row holding more or fewer cells than the header is read as the columns the header declares, which is what a Markdown reader shows; cells beyond the header are dropped and missing cells are filled at the end of the row. A pasted HTML body cell spanning columns is flattened into its starting column and ordinary empty cells for the columns it covered, because GFM cannot represent the span. A table written with a header and delimiter row and no body rows is kept and rendered as a header-only table. - Code blocks render as styled monospace blocks with syntax highlighting when available. Focused code blocks edit code content directly. Language metadata controls are deferred. - Footnote definitions render as editable definition blocks that always show their source: `[^` and `]:` as muted monospace marker runs, the label between them in bold, and the definition body in muted text, with a small gap separating the label from each marker run. The presentation does not depend on the caret: it neither appears when a caret arrives nor resolves when one leaves. The label is document text a caret and a selection reach and edit; the marker runs are chrome that hold no document position, so a caret aimed at one resolves inside the definition. A backspace at the start of the definition's body moves the caret to the end of the label rather than merging the body into it. - Editing a footnote definition's label renames the definition and every reference that named it, so the resolution key moves on both sides together and no reference is left naming a label the file no longer defines. The label commits when the caret leaves it, and a file written while the caret is still in it is written with the label the author typed. An empty label, a label holding a bracket or a line ending, and a label another definition already answers to do not commit; the label the definition was read with stands. `Undo` restores the label the typing began from and returns the caret to it, which reopens the edit, so the rename it reverses settles when the caret next leaves or when the file is written. Editing a reference label still does not create, rename, delete, or modify any definition. diff --git a/src/features/editor/plugins/tableShape.test.ts b/src/features/editor/plugins/tableShape.test.ts index 4ea38c7a..7d643387 100644 --- a/src/features/editor/plugins/tableShape.test.ts +++ b/src/features/editor/plugins/tableShape.test.ts @@ -19,6 +19,8 @@ const HEADER_ONLY_MARKDOWN = "| Header only | No body rows |\n| --- | --- |\n"; const RAGGED_HTML = "" + "
AB
one
twothreeignored
"; +const COLSPAN_HTML = + '
AB
merged
'; const markdownCell = (value: string): MarkdownNode => ({ type: "tableCell", @@ -151,6 +153,28 @@ describe("table shape plugin", () => { ]); }); + it("flattens a pasted colspan cell before it is saved", async () => { + const mounted = await mountEditor(""); + + dispatchClipboardEvent(mounted.view.dom, "paste", { + [TEXT_HTML_MIME_TYPE]: COLSPAN_HTML, + [TEXT_PLAIN_MIME_TYPE]: "merged", + }); + + const table = mounted.view.state.doc.firstChild; + + expect(getTableCellTexts(mounted)).toEqual([ + ["A", "B"], + ["merged", ""], + ]); + expect(table?.child(1)?.child(0)?.attrs.colspan).toBe(1); + + const beforeDoc: unknown = mounted.view.state.doc.toJSON(); + const reopened = await mountEditor(mounted.getMarkdown()); + + expect(reopened.view.state.doc.toJSON()).toEqual(beforeDoc); + }); + it("matches every ragged table of one paste", async () => { const mounted = await mountEditor(""); diff --git a/src/features/editor/plugins/tableShape.ts b/src/features/editor/plugins/tableShape.ts index 0b1b1a87..615a0db8 100644 --- a/src/features/editor/plugins/tableShape.ts +++ b/src/features/editor/plugins/tableShape.ts @@ -83,6 +83,31 @@ const repairRow = ( return tr; }; +const flattenRowColspans = ( + state: EditorState, + row: ProseMirrorNode, + rowPos: number, + transaction: Transaction | null, +) => { + let tr = transaction; + let cellPos = rowPos + 1; + + row.forEach((cell) => { + if (cell.attrs.colspan > 1 && cell.attrs.rowspan === 1) { + tr ??= state.tr; + tr.setNodeMarkup(tr.mapping.map(cellPos), undefined, { + ...cell.attrs, + colspan: 1, + colwidth: null, + }); + } + + cellPos += cell.nodeSize; + }); + + return tr; +}; + const repairTableRows = ( state: EditorState, table: ProseMirrorNode, @@ -101,8 +126,12 @@ const repairTableRows = ( for (let index = 0; index < table.childCount; index += 1) { const row = table.child(index); - if (index > 0 && row.childCount !== headerRow.childCount) { - tr = repairRow(state, headerRow, row, rowPos, tr); + if (index > 0) { + tr = flattenRowColspans(state, row, rowPos, tr); + + if (row.childCount !== headerRow.childCount) { + tr = repairRow(state, headerRow, row, rowPos, tr); + } } rowPos += row.nodeSize;