From dbb7ad2c1fa952b6aec9e05be8eb4fc6f685530f Mon Sep 17 00:00:00 2001 From: feruzm Date: Thu, 13 Aug 2026 19:23:44 +0000 Subject: [PATCH 1/2] fix(editor): keep markdown tables intact through the publish editor The publish editor destroyed every table. On each update use-publish-editor serializes the document back to markdown with markdownToHtml, and that Turndown instance only registered the strikethrough GFM plugin. Turndown has no built-in table rule, so was flattened into loose cell text, both for a pasted table and for one built with the toolbar's insert-table button. Registering the gfm tables plugin alone was not enough: TipTap renders tables as
..., keeping the header cells as , so the colgroup made it miss the header and prepend an empty row. Stripping colgroup first restores the check; it only carries editor column widths, which markdown cannot express. Separately, wide tables were unreachable once rendered. A display:table box is not a scroll container, so the existing overflow-x:auto never applied, and the overflow-hidden utility clipped the overflow instead. Measured on the post column, an 18-column table needed 1732px inside 700px with no way to scroll to the cut-off columns. display:block makes the table its own scroll container while rows still lay out as a table, and width:100% keeps tables that already fit looking exactly as before. --- .../functions/markdown-to-html.ts | 16 ++- .../tiptap-editor/table-roundtrip.spec.ts | 105 ++++++++++++++++++ apps/web/src/styles/_markdown.scss | 9 +- 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/specs/features/tiptap-editor/table-roundtrip.spec.ts diff --git a/apps/web/src/features/tiptap-editor/functions/markdown-to-html.ts b/apps/web/src/features/tiptap-editor/functions/markdown-to-html.ts index 1c8a816392..a5ca93b303 100644 --- a/apps/web/src/features/tiptap-editor/functions/markdown-to-html.ts +++ b/apps/web/src/features/tiptap-editor/functions/markdown-to-html.ts @@ -1,5 +1,5 @@ // @ts-ignore -import { strikethrough } from "@joplin/turndown-plugin-gfm"; +import { strikethrough, tables } from "@joplin/turndown-plugin-gfm"; import Turndown from "turndown"; import { TEXT_COLOR_CLASS_PREFIX } from "@/app/publish/_constants/text-colors"; @@ -46,6 +46,15 @@ export function markdownToHtml(html: string | undefined) { html = html.replace(/]*data-type="mention"[^>]*>([^<]*)<\/span>/gi, "$1"); html = html.replace(/]*data-type="tag"[^>]*>([^<]*)<\/span>/gi, "$1"); + // TipTap renders tables as
in the first tbody row. The plugin's heading-row check only accepts a tbody whose previous sibling is absent or an empty
……, with the + // header cells as row rather than in a . + // The GFM table rule only accepts a whose previous sibling is absent + // or an empty , so the makes it miss the heading row and + // emit an empty one above the real headers. Dropping (it carries + // only editor column widths, which markdown cannot express anyway) restores + // the check without touching the plugin. + html = html.replace(//gi, ""); + return new Turndown({ codeBlockStyle: "fenced" }) @@ -170,5 +179,10 @@ export function markdownToHtml(html: string | undefined) { } }) .use(strikethrough) + // Turndown has no built-in table rule. Without this the editor's + // HTML -> markdown pass drops every
in the first
and leaves the cell text + // stacked as loose paragraphs, so a pasted or inserted table is + // destroyed on the next serialization. + .use(tables) .turndown(html); } diff --git a/apps/web/src/specs/features/tiptap-editor/table-roundtrip.spec.ts b/apps/web/src/specs/features/tiptap-editor/table-roundtrip.spec.ts new file mode 100644 index 0000000000..aff581e819 --- /dev/null +++ b/apps/web/src/specs/features/tiptap-editor/table-roundtrip.spec.ts @@ -0,0 +1,105 @@ +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 { markdownToHtml } from "@/features/tiptap-editor/functions/markdown-to-html"; +import { parseAllExtensionsToDoc } from "@/features/tiptap-editor/functions/parse-all-extensions-to-doc"; + +const TABLE_MARKDOWN = [ + "| Date | Account | Amount |", + "| --- | --- | --- |", + "| 2023-01-06 | valueplan | 52,239.55 |", + "| 2020-04-03 | ecency | 941.94 |" +].join("\n"); + +/** + * Mirrors what the publish editor actually does: paste plain text (converted by + * the clipboard strategy), then serialize the document back to markdown the way + * `use-publish-editor` does on every update. + */ +function pasteThenSerialize(markdown: string) { + const editor = new Editor({ + extensions: [StarterKit, Table, TableRow, TableCell, TableHeader], + content: "

" + }); + try { + editor.chain().insertContent(parseAllExtensionsToDoc(simpleMarkdownToHTML(markdown))).run(); + return markdownToHtml(editor.getHTML()); + } finally { + editor.destroy(); + } +} + +describe("markdown table round-trip through the publish editor", () => { + // Regression: Turndown was built with only the `strikethrough` GFM plugin, so + // it had no rule for
and flattened every pasted table into loose text + // on the first serialization pass. + it("keeps a pasted table a table", () => { + const result = pasteThenSerialize(TABLE_MARKDOWN); + + expect(result).toContain("| Date | Account | Amount |"); + expect(result).toContain("| 2023-01-06 | valueplan | 52,239.55 |"); + expect(result).toContain("| 2020-04-03 | ecency | 941.94 |"); + }); + + it("keeps every row on its own line with a delimiter row", () => { + const lines = pasteThenSerialize(TABLE_MARKDOWN) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("|")); + + // header + delimiter + 2 body rows + expect(lines).toHaveLength(4); + expect(lines[1]).toMatch(/^\|[\s-|:]+\|$/); + lines.forEach((line) => expect(line.split("|")).toHaveLength(5)); + }); + + // Regression: TipTap emits before and keeps the header + // cells as , which made the GFM rule miss the heading + // row and prepend an empty one ("| | | |") above the real headers. + it("uses the real header row rather than prepending an empty one", () => { + const lines = pasteThenSerialize(TABLE_MARKDOWN) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("|")); + + expect(lines[0]).toContain("Date"); + expect(lines[0]).toContain("Account"); + expect(lines[0]).not.toMatch(/^\|(\s*\|)+$/); + }); + + it("does not flatten cells into loose paragraphs", () => { + const result = pasteThenSerialize(TABLE_MARKDOWN); + + // the pre-fix output was "Date\n\nAccount\n\nAmount\n\n2023-01-06\n\n..." + expect(result).not.toMatch(/^Date\s*$/m); + expect(result).not.toMatch(/^valueplan\s*$/m); + }); + + it("serializes a table built with the editor's own insertTable command", () => { + const editor = new Editor({ + extensions: [StarterKit, Table, TableRow, TableCell, TableHeader], + content: "

" + }); + try { + editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run(); + expect(markdownToHtml(editor.getHTML())).toMatch(/\|.*\|/); + } finally { + editor.destroy(); + } + }); +}); diff --git a/apps/web/src/styles/_markdown.scss b/apps/web/src/styles/_markdown.scss index a625a4b170..f1b65933c1 100644 --- a/apps/web/src/styles/_markdown.scss +++ b/apps/web/src/styles/_markdown.scss @@ -240,12 +240,19 @@ } table { + // `overflow-x` is inert on a `display: table` box, so a table wider than the + // post column was never scrollable, and the `overflow-hidden` utility below + // then clipped it, putting the right-hand columns permanently out of reach. + // `display: block` turns the table itself into the scroll container while its + // rows still lay out as a table internally; `width: 100%` keeps tables that + // already fit rendering full-width exactly as before. + display: block; word-break: normal !important; overflow-x: auto; width: 100%; max-width: 100%; - @apply border dark:border-gray-700 overflow-hidden table-auto border-collapse text-xs sm:text-sm md:text-base; + @apply border dark:border-gray-700 table-auto border-collapse text-xs sm:text-sm md:text-base; tr { @apply [&:last-child>td]:border-b-0 [&:nth-child(even)]:bg-light-200 dark:[&:nth-child(even)]:bg-dark-300; From 5c73b5eb8b97999ae19cc5e893fddf20b8c4cdda Mon Sep 17 00:00:00 2001 From: feruzm Date: Thu, 13 Aug 2026 19:50:45 +0000 Subject: [PATCH 2/2] fix(editor): keep single-cell tables, drop the table CSS change Review follow-up on two counts. The GFM table rule deliberately skips single-cell tables, and the toolbar can produce one: insert a table, then deleteColumn and deleteRow. Such a table serialized to bare cell text, or to nothing at all when the cell was empty, so it disappeared on the next draft load or publish. GFM has no syntax for a headerless single-cell table, so a rule added after the plugin keeps it as HTML instead, which the renderer accepts and the sanitizer allows. Covered by three specs driving deleteColumn/deleteRow. The _markdown.scss change is reverted because the bug it claimed to fix does not exist. `.markdown-view` already sets `overflow-x: auto`, so a table wider than the post column has always scrolled inside the post body, with no page-level sideways scroll. The earlier measurement left that container rule out, which is the whole mechanism. Worse, the change regressed narrow tables: `display: block` keeps the table element at 100% while its rows fall back to an anonymous shrink-to-fit table box, so a 3-column table's visible grid collapsed from 702px to 78px. Measured in a headless browser both ways. Also addresses review nits on the spec: an explicit return type on the helper and an unambiguous delimiter character class. --- .../functions/markdown-to-html.ts | 18 +++++ .../tiptap-editor/table-roundtrip.spec.ts | 72 +++++++++++++++---- apps/web/src/styles/_markdown.scss | 9 +-- 3 files changed, 77 insertions(+), 22 deletions(-) diff --git a/apps/web/src/features/tiptap-editor/functions/markdown-to-html.ts b/apps/web/src/features/tiptap-editor/functions/markdown-to-html.ts index a5ca93b303..705b03b8c4 100644 --- a/apps/web/src/features/tiptap-editor/functions/markdown-to-html.ts +++ b/apps/web/src/features/tiptap-editor/functions/markdown-to-html.ts @@ -4,6 +4,12 @@ import Turndown from "turndown"; import { TEXT_COLOR_CLASS_PREFIX } from "@/app/publish/_constants/text-colors"; +/** Total
inside that
/ in a table, used to spot the single-cell case GFM cannot express. */ +function countTableCells(node: Node): number { + const el = node as HTMLElement; + return typeof el.querySelectorAll === "function" ? el.querySelectorAll("th, td").length : 0; +} + const CENTERED_TEXT_RULE_NODES = ["P", "H1", "H2", "H3", "H4", "H5", "H6"]; const CENTERED_TEXT_ALIGNMENTS = new Set(["center", "right", "left", "justify"]); @@ -184,5 +190,17 @@ export function markdownToHtml(html: string | undefined) { // stacked as loose paragraphs, so a pasted or inserted table is // destroyed on the next serialization. .use(tables) + // Added AFTER the plugin so it takes precedence (Turndown checks the most + // recently added rule first). The GFM rule deliberately skips single-cell + // tables, treating them as layout markup, but the editor can produce one + // from the toolbar: insert a table, then deleteColumn and deleteRow. Such a + // table serialized to bare cell text, or to nothing at all when the cell was + // empty, so it disappeared on the next draft load or publish. GFM cannot + // express a headerless single-cell table, so keep it as HTML, which the + // renderer accepts and the sanitizer allows. + .addRule("singleCellTable", { + filter: (node) => node.nodeName === "TABLE" && countTableCells(node) <= 1, + replacement: (_content, node) => `\n\n${(node as HTMLElement).outerHTML}\n\n` + }) .turndown(html); } diff --git a/apps/web/src/specs/features/tiptap-editor/table-roundtrip.spec.ts b/apps/web/src/specs/features/tiptap-editor/table-roundtrip.spec.ts index aff581e819..a77988f0f4 100644 --- a/apps/web/src/specs/features/tiptap-editor/table-roundtrip.spec.ts +++ b/apps/web/src/specs/features/tiptap-editor/table-roundtrip.spec.ts @@ -26,16 +26,15 @@ const TABLE_MARKDOWN = [ "| 2020-04-03 | ecency | 941.94 |" ].join("\n"); +const TABLE_EXTENSIONS = [StarterKit, Table, TableRow, TableCell, TableHeader]; + /** * Mirrors what the publish editor actually does: paste plain text (converted by * the clipboard strategy), then serialize the document back to markdown the way * `use-publish-editor` does on every update. */ -function pasteThenSerialize(markdown: string) { - const editor = new Editor({ - extensions: [StarterKit, Table, TableRow, TableCell, TableHeader], - content: "

" - }); +function pasteThenSerialize(markdown: string): string { + const editor = new Editor({ extensions: TABLE_EXTENSIONS, content: "

" }); try { editor.chain().insertContent(parseAllExtensionsToDoc(simpleMarkdownToHTML(markdown))).run(); return markdownToHtml(editor.getHTML()); @@ -44,6 +43,17 @@ function pasteThenSerialize(markdown: string) { } } +/** Runs `build` against a live editor, then serializes exactly as publish does. */ +function buildThenSerialize(build: (editor: Editor) => void): string { + const editor = new Editor({ extensions: TABLE_EXTENSIONS, content: "

" }); + try { + build(editor); + return markdownToHtml(editor.getHTML()); + } finally { + editor.destroy(); + } +} + describe("markdown table round-trip through the publish editor", () => { // Regression: Turndown was built with only the `strikethrough` GFM plugin, so // it had no rule for and flattened every pasted table into loose text @@ -64,7 +74,7 @@ describe("markdown table round-trip through the publish editor", () => { // header + delimiter + 2 body rows expect(lines).toHaveLength(4); - expect(lines[1]).toMatch(/^\|[\s-|:]+\|$/); + expect(lines[1]).toMatch(/^\|[-\s|:]+\|$/); lines.forEach((line) => expect(line.split("|")).toHaveLength(5)); }); @@ -91,15 +101,49 @@ describe("markdown table round-trip through the publish editor", () => { }); it("serializes a table built with the editor's own insertTable command", () => { - const editor = new Editor({ - extensions: [StarterKit, Table, TableRow, TableCell, TableHeader], - content: "

" + const result = buildThenSerialize((editor) => { + editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run(); + }); + + expect(result).toMatch(/\|.*\|/); + }); +}); + +describe("single-cell tables the GFM rule cannot express", () => { + // Regression: the GFM table rule deliberately skips single-cell tables, so a + // 1x1 built from the toolbar serialized to bare text, or to nothing at all + // when empty, and vanished on the next draft load or publish. + const shrinkToSingleCell = (editor: Editor) => { + editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run(); + editor.chain().focus().deleteColumn().run(); + editor.chain().focus().deleteRow().run(); + }; + + it("keeps an empty 1x1 table instead of serializing it away", () => { + const result = buildThenSerialize(shrinkToSingleCell); + + expect(result.trim()).not.toBe(""); + expect(result).toContain(" { + const result = buildThenSerialize((editor) => { + shrinkToSingleCell(editor); + editor.commands.insertContent("solo"); }); - try { + + expect(result).toContain(" { + const result = buildThenSerialize((editor) => { editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run(); - expect(markdownToHtml(editor.getHTML())).toMatch(/\|.*\|/); - } finally { - editor.destroy(); - } + }); + + expect(result).not.toContain("td]:border-b-0 [&:nth-child(even)]:bg-light-200 dark:[&:nth-child(even)]:bg-dark-300;