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..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 @@ -174,5 +174,22 @@ 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. + // 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")); + } + }); + 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..865c2ce676 --- /dev/null +++ b/apps/web/src/specs/features/tiptap-editor/empty-table-cell-paste.spec.ts @@ -0,0 +1,126 @@ +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(); + } +} + +/** Pastes raw HTML through the same parse step, for cases markdown cannot express. */ +function pasteHtml(html: string): string { + const editor = new Editor({ extensions: TABLE_EXTENSIONS, content: "

" }); + 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 + // 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: string, markdown: string) => { + 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("

"); + }); + + // 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("

"); + }); +});