From c204af03e30ff0cc69b745bf1899f12eba7d1456 Mon Sep 17 00:00:00 2001 From: feruzm Date: Thu, 13 Aug 2026 20:08:29 +0000 Subject: [PATCH 1/2] fix(editor): paste markdown tables that contain blank cells Pasting a markdown table with any blank cell inserted nothing at all. The table did not appear, and no error surfaced to the user. A blank cell renders as . The ProseMirror tableCell schema requires at least one block child, so it rejects the document with "Invalid content for node tableCell: <>" and insertContent throws. The throw aborts the whole paste, which is why a single blank cell anywhere loses the entire table rather than leaving a gap in it. parse-all-extensions-to-doc already does exactly this for blockquotes, which fail the same schema check for the same reason. Table cells needed the same treatment: give an empty cell one empty paragraph. Verified against a real 29-row table with 16 blank cells. On develop the paste throws and nothing is inserted; with this change all 29 rows land, the blank cells render as empty cells, and the surrounding content is unchanged. Regression specs cover blank cells leading, trailing, in the middle, a fully blank row, a blank header cell and several blank rows, plus the unaffected all-filled case. --- .../functions/parse-all-extensions-to-doc.ts | 12 +++ .../empty-table-cell-paste.spec.ts | 83 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 apps/web/src/specs/features/tiptap-editor/empty-table-cell-paste.spec.ts diff --git a/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts b/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts index 88ed857eb3..d161a47f9d 100644 --- a/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts +++ b/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts @@ -174,5 +174,17 @@ export function parseAllExtensionsToDoc(value?: string) { } }); + // Same problem, same fix, for table cells. A markdown table may legitimately + // leave a cell blank, which renders as , but the ProseMirror + // tableCell schema requires at least one block child and rejects the whole + // insert with "Invalid content for node tableCell: <>". Because insertContent + // throws, NOTHING is pasted: the table does not appear at all, rather than + // appearing with a gap. One blank cell anywhere is enough to lose the table. + (Array.from(tree.querySelectorAll("td, th")) as HTMLElement[]).forEach((cell) => { + if (!cell.firstElementChild && !cell.textContent?.trim()) { + cell.appendChild(document.createElement("p")); + } + }); + return tree.innerHTML; } diff --git a/apps/web/src/specs/features/tiptap-editor/empty-table-cell-paste.spec.ts b/apps/web/src/specs/features/tiptap-editor/empty-table-cell-paste.spec.ts new file mode 100644 index 0000000000..19a310b350 --- /dev/null +++ b/apps/web/src/specs/features/tiptap-editor/empty-table-cell-paste.spec.ts @@ -0,0 +1,83 @@ +import { vi } from "vitest"; + +vi.mock("@/features/tiptap-editor/extensions", () => ({ + HIVE_POST_PURE_REGEX: /$a^/, + LOOM_REGEX: /$a^/, + TAG_MENTION_PURE_REGEX: /$a^/, + USER_MENTION_PURE_REGEX: /$a^/, + YOUTUBE_REGEX: /$a^/ +})); + +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import Table from "@tiptap/extension-table"; +import TableCell from "@tiptap/extension-table-cell"; +import TableHeader from "@tiptap/extension-table-header"; +import TableRow from "@tiptap/extension-table-row"; +import { simpleMarkdownToHTML } from "@ecency/render-helper"; + +import { parseAllExtensionsToDoc } from "@/features/tiptap-editor/functions/parse-all-extensions-to-doc"; + +const TABLE_EXTENSIONS = [StarterKit, Table, TableRow, TableCell, TableHeader]; + +/** Pastes markdown exactly as the clipboard text strategy does. */ +function pasteMarkdown(markdown: string): string { + const editor = new Editor({ extensions: TABLE_EXTENSIONS, content: "

" }); + try { + editor.chain().insertContent(parseAllExtensionsToDoc(simpleMarkdownToHTML(markdown))).run(); + return editor.getHTML(); + } finally { + editor.destroy(); + } +} + +const rowCount = (html: string) => (html.match(/ { + // Regression: a blank cell renders as , which the ProseMirror + // tableCell schema rejects with "Invalid content for node tableCell: <>". + // insertContent throws, so nothing is inserted and the table never appears. + // One blank cell anywhere was enough to lose the whole table. + it("does not throw on a trailing blank cell", () => { + expect(() => pasteMarkdown("| A | B |\n| --- | --- |\n| 1 | |")).not.toThrow(); + }); + + it("keeps every row when a cell is blank", () => { + expect(rowCount(pasteMarkdown("| A | B |\n| --- | --- |\n| 1 | |"))).toBe(2); + }); + + it.each([ + ["trailing blank", "| A | B |\n| --- | --- |\n| 1 | |"], + ["leading blank", "| A | B |\n| --- | --- |\n| | 2 |"], + ["middle blank", "| A | B | C |\n| --- | --- | --- |\n| 1 | | 3 |"], + ["whole row blank", "| A | B |\n| --- | --- |\n| | |"], + ["blank header cell", "| A | |\n| --- | --- |\n| 1 | 2 |"], + ["several blank rows", "| A | B |\n| --- | --- |\n| 1 | |\n| | 4 |\n| | |"] + ])("survives a %s", (_label, markdown) => { + const html = pasteMarkdown(markdown); + expect(html).toContain(" { + const html = pasteMarkdown("| Date | Memo |\n| --- | --- |\n| 2019-02-11 | |\n| 2023-01-06 | closed off |"); + + expect(html).toContain("2019-02-11"); + expect(html).toContain("2023-01-06"); + expect(html).toContain("closed off"); + }); + + it("still pastes a table with no blank cells at all", () => { + const html = pasteMarkdown("| A | B |\n| --- | --- |\n| 1 | 2 |"); + + expect(rowCount(html)).toBe(2); + expect(html).toContain("1"); + expect(html).toContain("2"); + }); + + it("fills a blank cell with an empty paragraph rather than dropping it", () => { + const doc = parseAllExtensionsToDoc("
1
"); + + expect(doc).toContain("

"); + }); +}); From 73525ff8d1f5898b4f575d05a925a6a0de0e56c7 Mon Sep 17 00:00:00 2001 From: feruzm Date: Thu, 13 Aug 2026 20:27:09 +0000 Subject: [PATCH 2/2] fix(editor): replace blank cell content instead of appending to it Review follow-up. The blank-cell guard also matches cells holding only whitespace or  , which the browser already renders as one paragraph. Appending left that invisible text in place alongside the new empty paragraph, so such a cell rendered as two paragraphs and roughly doubled in height. Only a genuinely empty cell was correct. Measured before this commit: a cell containing just   produced  

and two paragraphs in the editor, against one on develop. Same for a spaces-only cell. Since the guard has already established the content is invisible, clear it and insert a single empty paragraph. All three blank shapes now normalise to

and render as one paragraph. Adds coverage for  , plain spaces, a tab and mixed invisible content, an editor-level assertion that such a cell renders as exactly one paragraph, and a case confirming cells with real content are untouched. Also annotates the spec helper and the it.each parameters per review. --- .../functions/parse-all-extensions-to-doc.ts | 5 ++ .../empty-table-cell-paste.spec.ts | 47 ++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts b/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts index d161a47f9d..7e370c3cfc 100644 --- a/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts +++ b/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts @@ -180,8 +180,13 @@ export function parseAllExtensionsToDoc(value?: string) { // insert with "Invalid content for node tableCell: <>". Because insertContent // throws, NOTHING is pasted: the table does not appear at all, rather than // appearing with a gap. One blank cell anywhere is enough to lose the table. + // Replace rather than append: the guard also matches cells holding only + // whitespace or  , which the browser already renders as one paragraph. + // Appending to those would leave the invisible text AND an empty paragraph + // behind, doubling the cell's height for no visible reason. (Array.from(tree.querySelectorAll("td, th")) as HTMLElement[]).forEach((cell) => { if (!cell.firstElementChild && !cell.textContent?.trim()) { + cell.textContent = ""; cell.appendChild(document.createElement("p")); } }); diff --git a/apps/web/src/specs/features/tiptap-editor/empty-table-cell-paste.spec.ts b/apps/web/src/specs/features/tiptap-editor/empty-table-cell-paste.spec.ts index 19a310b350..865c2ce676 100644 --- a/apps/web/src/specs/features/tiptap-editor/empty-table-cell-paste.spec.ts +++ b/apps/web/src/specs/features/tiptap-editor/empty-table-cell-paste.spec.ts @@ -31,7 +31,18 @@ function pasteMarkdown(markdown: string): string { } } -const rowCount = (html: string) => (html.match(/

" }); + try { + editor.chain().insertContent(parseAllExtensionsToDoc(html)).run(); + return editor.getHTML(); + } finally { + editor.destroy(); + } +} + +const rowCount = (html: string): number => (html.match(/ { // Regression: a blank cell renders as , which the ProseMirror @@ -53,7 +64,7 @@ describe("pasting a markdown table with blank cells", () => { ["whole row blank", "| A | B |\n| --- | --- |\n| | |"], ["blank header cell", "| A | |\n| --- | --- |\n| 1 | 2 |"], ["several blank rows", "| A | B |\n| --- | --- |\n| 1 | |\n| | 4 |\n| | |"] - ])("survives a %s", (_label, markdown) => { + ])("survives a %s", (_label: string, markdown: string) => { const html = pasteMarkdown(markdown); expect(html).toContain(" { expect(doc).toContain("

"); }); + + // Regression: the blank guard also matches cells holding only whitespace or + //  , which the browser already renders as one paragraph. Appending to + // those left the invisible text plus an empty paragraph, doubling the cell + // height. They must be replaced, not appended to. + it.each([ + ["a non-breaking space", " "], + ["plain spaces", " "], + ["a tab", "\t"], + ["mixed invisible content", "   "] + ])("normalises a cell holding only %s to a single empty paragraph", (_label: string, filler: string) => { + const doc = parseAllExtensionsToDoc( + `
1${filler}
` + ); + + expect(doc).toContain("

"); + expect(doc).not.toContain(" 

"); + }); + + it("renders a visually blank cell as exactly one paragraph in the editor", () => { + const html = pasteHtml("
1 
"); + const lastCell = html.match(/]*>(?:(?!<\/td>).)*<\/td>\s*<\/tr>/s)?.[0] ?? ""; + + expect((lastCell.match(/

/g) || []).length).toBe(1); + }); + + it("leaves a cell with real content alone", () => { + const doc = parseAllExtensionsToDoc("
1kept
"); + + expect(doc).toContain("kept"); + expect(doc).not.toContain("

"); + }); });