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 ce83f5d3c3..dd406bd34f 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 @@ -50,6 +50,44 @@ const RENDERS_AS_NODE = [ 'span[data-type="tag"]' ].join(", "); +/** + * What survives when an invalid container is unwrapped: its elements, plus text + * that is actually visible. Whitespace-only text is dropped, or unwrapping a list + * that held nothing but spaces would leave them behind as a blank paragraph. + */ +function keptOnUnwrap(el: Element) { + return Array.from(el.childNodes).filter( + (node) => node.nodeType === Node.ELEMENT_NODE || node.textContent?.trim() + ); +} + +/** Elements that only mean anything inside a table. */ +const TABLE_STRUCTURE = "caption, colgroup, col, thead, tbody, tfoot, tr, td, th"; + +/** Anything that must not be tucked inside a paragraph. */ +const BLOCK_LEVEL = [LEADING_BLOCK, "p", "div"].join(", "); + +/** + * Lifts a rowless table's content out of its structural wrappers. + * + * ⛔ Do not keep those wrappers. Outside a table the HTML parser will not accept + * a or a , and the markup ends up in the document as escaped + * literal text: `

<caption>Totals</caption>

`. Only the content + * inside them is worth anything, and loose inline content becomes a paragraph on + * its own. A nested table cannot be lost here, because a table holding one with + * any row at all is left alone. + */ +function unwrapTableStructure(el: Element): Node[] { + return Array.from(el.childNodes).flatMap((node) => { + if (node.nodeType !== Node.ELEMENT_NODE) { + return node.textContent?.trim() ? [node] : []; + } + + const child = node as Element; + return child.matches(TABLE_STRUCTURE) ? unwrapTableStructure(child) : [child]; + }); +} + /** True when the element holds nothing the schema would render. */ function holdsNothingRenderable(el: Element) { return !el.textContent?.trim() && !el.querySelector(RENDERS_AS_NODE); @@ -261,6 +299,47 @@ export function parseAllExtensionsToDoc(value?: string) { el.removeAttribute("data-align"); }); + // A list with no item, and a table with no row, are invalid the same way an empty + // item is: bulletList and orderedList are "listItem+", table is "tableRow+". The + // paste throws and the user loses everything, not just the empty container. + // Both render as nothing, so unwrap the list (which keeps a nested list that was + // its only child) and drop the table. This has to run BEFORE the item repair + // below so an item left empty here still gets its paragraph. + (Array.from(tree.querySelectorAll("ul, ol")) as HTMLElement[]).forEach((list) => { + const hasItem = Array.from(list.children).some((child) => child.tagName === "LI"); + if (!hasItem) { + list.replaceWith(...keptOnUnwrap(list)); + } + }); + + // Unwrap the table too rather than dropping it: a rowless one can still hold a + // caption, and that text is the author's. The wrappers themselves are discarded, + // see unwrapTableStructure. + (Array.from(tree.querySelectorAll("table")) as HTMLElement[]).forEach((table) => { + if (table.querySelector("tr")) { + return; + } + + const kept = unwrapTableStructure(table); + const isInline = kept.every( + (node) => node.nodeType !== Node.ELEMENT_NODE || !(node as Element).matches(BLOCK_LEVEL) + ); + + // ⛔ Wrap loose inline content in a paragraph rather than leaving it bare. + // When the whole paste parses to text alone, tiptap's insertContentAt takes + // its isOnlyTextContent branch and calls tr.insertText with the RAW HTML + // STRING, so `A & B` reaches the document as the literal characters + // `A &amp; B`. A block wrapper keeps it on the normal parse path. + if (kept.length && isInline) { + const paragraph = document.createElement("p"); + paragraph.append(...kept); + table.replaceWith(paragraph); + return; + } + + table.replaceWith(...kept); + }); + // ProseMirror's listItem schema is "paragraph block*", so an item's FIRST child // has to be a paragraph. Markdown routinely produces items that break that rule, // and insertContent throws for the whole paste rather than for the one bad item, diff --git a/apps/web/src/specs/features/tiptap-editor/blockquote-paste-normalization.spec.ts b/apps/web/src/specs/features/tiptap-editor/blockquote-paste-normalization.spec.ts index f143f88a06..2c90f1db2e 100644 --- a/apps/web/src/specs/features/tiptap-editor/blockquote-paste-normalization.spec.ts +++ b/apps/web/src/specs/features/tiptap-editor/blockquote-paste-normalization.spec.ts @@ -28,12 +28,9 @@ function insertPastedMarkdown(markdown: string) { describe("blockquote paste normalization", () => { // Regression: pasting a bare quote marker produced
, // which ProseMirror rejects with "RangeError: Invalid content for node blockquote: <>" - it.each([">", "> ", "hello\n\n>", ">>"])( - "inserts markdown %j without throwing", - (markdown) => { - expect(() => insertPastedMarkdown(markdown)).not.toThrow(); - } - ); + it.each([">", "> ", "hello\n\n>", ">>"])("inserts markdown %j without throwing", (markdown) => { + expect(() => insertPastedMarkdown(markdown)).not.toThrow(); + }); it("fills empty blockquotes with a paragraph", () => { expect(parseAllExtensionsToDoc("
")).toBe( diff --git a/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts b/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts new file mode 100644 index 0000000000..22d756510c --- /dev/null +++ b/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts @@ -0,0 +1,151 @@ +import { vi } from "vitest"; + +// Only the hive-post pass needs stubbing here: it is the one that reads +// `el.innerText`, which jsdom does not implement, and it calls `.trim()` on it +// unguarded, so a hive-post-shaped href throws in a spec instead of exercising +// anything. Everything else, including the YouTube and Loom passes, runs for +// real. Same re-mock pattern CLAUDE.md documents for `@/utils`. +vi.mock("@/features/tiptap-editor/extensions", async () => ({ + ...(await vi.importActual("@/features/tiptap-editor/extensions")), + HIVE_POST_PURE_REGEX: /$a^/ +})); + +import { Editor } from "@tiptap/core"; +import { simpleMarkdownToHTML } from "@ecency/render-helper"; + +import { parseAllExtensionsToDoc } from "@/features/tiptap-editor/functions/parse-all-extensions-to-doc"; +import { PUBLISH_EDITOR_EXTENSIONS } from "./publish-editor-extensions"; + +const EXTENSIONS = PUBLISH_EDITOR_EXTENSIONS; + +function insert(html: string): string { + const editor = new Editor({ extensions: EXTENSIONS, content: "

" }); + try { + editor.chain().insertContent(parseAllExtensionsToDoc(html)).run(); + return editor.getHTML(); + } finally { + editor.destroy(); + } +} + +const pasteMarkdown = (markdown: string) => insert(simpleMarkdownToHTML(markdown)); + +// Regression: bulletList and orderedList are "listItem+" and table is "tableRow+", +// so a container holding none is schema-invalid. insertContent throws from inside +// the paste handler, so the user loses the entire clipboard rather than just the +// empty container. +describe("pasting an empty list or a rowless table", () => { + it.each([ + ["an empty bullet list", ""], + ["an empty ordered list", "
    "], + ["a list holding only whitespace", ""], + ["an empty list inside an item", ""], + ["a list whose only child is a list", ""], + ["an empty table", "
    "], + ["a table with an empty body", "
    "] + ])("survives %s", (_label: string, html: string) => { + expect(() => insert(html)).not.toThrow(); + }); + + it("survives an unfenced table snippet, which the paste guard does not divert", () => { + // The guard is /<[a-z]+>.*<\/[a-z]+>/gim with no s flag, so an open and close + // tag on separate lines travels the markdown path into this function. + expect(() => pasteMarkdown("Use the table tag:\n\n\n
    ")).not.toThrow(); + }); + + it("keeps the rest of the paste when an empty list is in it", () => { + const html = insert("

    before

    after

    "); + + expect(html).toContain("before"); + expect(html).toContain("after"); + }); + + it("keeps a nested list that was its parent's only child", () => { + expect(insert("")).toContain("x"); + }); + + it("leaves an item empty by removal with a paragraph, not a broken bullet", () => { + const html = ""; + + expect(parseAllExtensionsToDoc(html)).toContain("
  1. "); + expect(insert(html)).toBe(""); + }); + + it.each([ + ["a real list", "- one\n- two", "one"], + ["a real table", "| a |\n| --- |\n| 1 |", " { + expect(pasteMarkdown(markdown)).toContain(kept); + }); + + // Review: unwrapping used to carry whitespace-only text out of the container, + // which the editor then rendered as a blank paragraph. + it.each([ + ["spaces", "

    a

    b

    "], + ["a newline", "

    a

    b

    "] + ])( + "does not leave a blank paragraph behind for a list holding only %s", + (_l: string, html: string) => { + expect(parseAllExtensionsToDoc(html)).toBe(html.replace(/