From db9f29c16b32bb85ae5934f7b068b0852009a951 Mon Sep 17 00:00:00 2001 From: claude_code Date: Thu, 2 Jul 2026 18:56:57 +0300 Subject: [PATCH] fix(editor): copy tables to clipboard as Markdown, not newline-joined cells clipboardTextSerializer only produced Markdown for lists, so copying a table and pasting into a plain-text/Markdown target emitted one cell value per line (ProseMirror's default text serializer). Route tables through htmlToMarkdown (turndown + GFM) as well. - Extract the decision into a pure, exported classifyClipboardSelection() helper; the existing list rule (2+ items) is preserved exactly. - Handle whole-table selections (top-level `table` node) and partial cell selections (bare `tableRow` nodes), wrapping bare rows in so the GFM turndown rule detects them. - Add unit tests for classifyClipboardSelection (6 cases). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extensions/markdown-clipboard.test.ts | 49 ++++++++++- .../editor/extensions/markdown-clipboard.ts | 83 ++++++++++++++++--- 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.test.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.test.ts index 8c17a4f1..f2b332b6 100644 --- a/apps/client/src/features/editor/extensions/markdown-clipboard.test.ts +++ b/apps/client/src/features/editor/extensions/markdown-clipboard.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; -import { normalizeTableColumnWidths } from "./markdown-clipboard"; +import { + normalizeTableColumnWidths, + classifyClipboardSelection, +} from "./markdown-clipboard"; // normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document). function root(html: string): HTMLElement { @@ -124,3 +127,47 @@ describe("normalizeTableColumnWidths", () => { ).toEqual([null, null]); }); }); + +describe("classifyClipboardSelection", () => { + it("serializes a list of 2+ items as markdown", () => { + expect( + classifyClipboardSelection([{ name: "bulletList", childCount: 2 }]), + ).toEqual({ asMarkdown: true, wrapBareRows: false }); + }); + + it("leaves a single-item list as plain text", () => { + expect( + classifyClipboardSelection([{ name: "bulletList", childCount: 1 }]), + ).toEqual({ asMarkdown: false, wrapBareRows: false }); + }); + + it("serializes a whole table without wrapping bare rows", () => { + expect( + classifyClipboardSelection([{ name: "table", childCount: 3 }]), + ).toEqual({ asMarkdown: true, wrapBareRows: false }); + }); + + it("serializes a partial cell selection (bare rows) and flags wrapping", () => { + expect( + classifyClipboardSelection([ + { name: "tableRow", childCount: 2 }, + { name: "tableRow", childCount: 2 }, + ]), + ).toEqual({ asMarkdown: true, wrapBareRows: true }); + }); + + it("leaves plain paragraphs as plain text", () => { + expect( + classifyClipboardSelection([{ name: "paragraph", childCount: 1 }]), + ).toEqual({ asMarkdown: false, wrapBareRows: false }); + }); + + it("does not wrap when rows are mixed with other block types", () => { + expect( + classifyClipboardSelection([ + { name: "tableRow", childCount: 2 }, + { name: "paragraph", childCount: 1 }, + ]), + ).toEqual({ asMarkdown: false, wrapBareRows: false }); + }); +}); diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.ts index c8e36a1b..fa387569 100644 --- a/apps/client/src/features/editor/extensions/markdown-clipboard.ts +++ b/apps/client/src/features/editor/extensions/markdown-clipboard.ts @@ -27,24 +27,36 @@ export const MarkdownClipboard = Extension.create({ key: new PluginKey("markdownClipboard"), props: { clipboardTextSerializer: (slice) => { - const listTypes = ["bulletList", "orderedList", "taskList"]; - let topLevelCount = 0; - let hasList = false; + const topLevelNodes: { name: string; childCount: number }[] = []; slice.content.forEach((node) => { - if (listTypes.includes(node.type.name)) { - hasList = true; - topLevelCount += node.childCount; - } else { - topLevelCount++; - } + topLevelNodes.push({ + name: node.type.name, + childCount: node.childCount, + }); }); - if (!hasList || topLevelCount < 2) return null; + const { asMarkdown, wrapBareRows } = + classifyClipboardSelection(topLevelNodes); + if (!asMarkdown) return null; const div = document.createElement("div"); const serializer = DOMSerializer.fromSchema(this.editor.schema); const fragment = serializer.serializeFragment(slice.content); - div.appendChild(fragment); + + if (wrapBareRows) { + // A partial table cell-selection serializes to bare nodes + // (prosemirror-tables returns the whole `table` node only when the + // entire table is selected). Bare would be foster-parented + // away by the HTML parser inside htmlToMarkdown, so wrap them in + //
first for the GFM turndown rule to detect them. + const table = document.createElement("table"); + const tbody = document.createElement("tbody"); + tbody.appendChild(fragment); + table.appendChild(tbody); + div.appendChild(table); + } else { + div.appendChild(fragment); + } return htmlToMarkdown(div.innerHTML); }, handlePaste: (view, event, slice) => { @@ -153,6 +165,55 @@ export const MarkdownClipboard = Extension.create({ }, }); +/** + * Decide whether a copied slice's plain-text clipboard payload should be + * serialized as Markdown (instead of ProseMirror's default text serializer, + * which joins block leaves with newlines — the "one value per line" bug for + * tables). + * + * Serialize as Markdown for structured content: + * - lists with 2+ total items (a single copied bullet stays literal text); + * - a whole table (top-level `table` node); + * - a partial table cell-selection, which prosemirror-tables copies as bare + * `tableRow` nodes (only a full-table selection yields a `table` node). + * + * `wrapBareRows` flags the bare-rows case so the caller wraps the serialized + * nodes in
before the HTML->Markdown step. Plain paragraphs + * return asMarkdown=false so a simple text copy stays literal, and internal + * copy/paste keeps using the richer text/html clipboard payload. + */ +export function classifyClipboardSelection( + nodes: { name: string; childCount: number }[], +): { asMarkdown: boolean; wrapBareRows: boolean } { + const listTypes = ["bulletList", "orderedList", "taskList"]; + let topLevelCount = 0; + let hasList = false; + let hasTable = false; + let tableRowCount = 0; + let nonRowCount = 0; + + for (const node of nodes) { + if (listTypes.includes(node.name)) { + hasList = true; + topLevelCount += node.childCount; + nonRowCount++; + } else { + if (node.name === "table") hasTable = true; + if (node.name === "tableRow") tableRowCount++; + else nonRowCount++; + topLevelCount++; + } + } + + // Bare tableRow nodes at the top level only occur for a partial cell + // selection; a slice never mixes bare rows with other block types, so + // "every top-level node is a row" is a safe signal to wrap-and-serialize. + const wrapBareRows = tableRowCount > 0 && nonRowCount === 0; + const asMarkdown = + (hasList && topLevelCount >= 2) || hasTable || wrapBareRows; + return { asMarkdown, wrapBareRows }; +} + /** * Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the * canonical invariant (the live footnoteSyncPlugin never reorders an existing