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("");
+
+ 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(
+ ``
+ );
+
+ expect(doc).toContain(" | ");
+ expect(doc).not.toContain(" ");
+ });
+
+ it("renders a visually blank cell as exactly one paragraph in the editor", () => {
+ const html = pasteHtml("
");
+ 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(" ");
+
+ expect(doc).toContain("kept");
+ expect(doc).not.toContain(" | | ");
+ });
+});