From cc2675d69b5215a032046725ceb4a062cfdeb86a Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 11 Feb 2026 20:25:43 +0100 Subject: [PATCH 1/2] fix: handle pasting into table cells better, by collapsing their content to inline #2410 --- packages/core/src/editor/transformPasted.ts | 64 ++++++++++ .../pasteHTMLWithParagraphsInTableCell.json | 114 ++++++++++++++++++ .../pasteHTMLWithParagraphsInTableCell.json | 59 +++++++++ .../plain/pasteMultilineTextInTableCell.json | 114 ++++++++++++++++++ .../clipboard/paste/pasteTestInstances.ts | 50 ++++++++ 5 files changed, 401 insertions(+) create mode 100644 tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithParagraphsInTableCell.json create mode 100644 tests/src/unit/core/clipboard/paste/__snapshots__/text/plain/pasteHTMLWithParagraphsInTableCell.json create mode 100644 tests/src/unit/core/clipboard/paste/__snapshots__/text/plain/pasteMultilineTextInTableCell.json diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts index 42e45fd122..fa1ab7251a 100644 --- a/packages/core/src/editor/transformPasted.ts +++ b/packages/core/src/editor/transformPasted.ts @@ -2,6 +2,51 @@ import { Fragment, Schema, Slice } from "@tiptap/pm/model"; import { EditorView } from "@tiptap/pm/view"; import { getBlockInfoFromSelection } from "../api/getBlockInfoFromPos.js"; +import { findParentNodeClosestToPos } from "@tiptap/core"; + +/** + * Checks if the current selection is inside a table cell. + * Returns the depth of the tableCell/tableHeader node if found, -1 otherwise. + */ +function isInTableCell(view: EditorView): boolean { + return ( + findParentNodeClosestToPos(view.state.selection.$from, (n) => { + return n.type.name === "tableCell" || n.type.name === "tableHeader"; + }) !== undefined + ); +} + +/** + * Converts block content to inline content with hard breaks. + * This is used when pasting into table cells which can only contain inline content. + */ +function convertBlocksToInlineContent( + fragment: Fragment, + schema: Schema, +): Fragment { + const hardBreak = schema.nodes.hardBreak; + let flattenedFragment = Fragment.empty; + + fragment.forEach((node) => { + if (node.isInline) { + // This is a paragraph or similar - extract its inline content + flattenedFragment = flattenedFragment.append( + convertBlocksToInlineContent(node.content, schema), + ); + // Add hard break after each block (except we'll remove the last one) + flattenedFragment = flattenedFragment.addToEnd(hardBreak.create()); + } else if (node.isText) { + flattenedFragment = flattenedFragment.addToEnd(node); + } + }); + + // Remove the last hard break if present + if (flattenedFragment.lastChild?.type?.name === "hardBreak") { + flattenedFragment.cut(0, flattenedFragment.childCount - 1); + } + + return flattenedFragment; +} // helper function to remove a child from a fragment function removeChild(node: Fragment, n: number) { @@ -65,6 +110,25 @@ export function transformPasted(slice: Slice, view: EditorView) { let f = Fragment.from(slice.content); f = wrapTableRows(f, view.state.schema); + if (isInTableCell(view)) { + // If the pasted content has block-level elements, convert to inline content + let hasBlockContent = false; + f.descendants((node) => { + if (node.isInline && node.childCount > 0) { + // This is a paragraph with content + hasBlockContent = true; + } + }); + + if (hasBlockContent && f.childCount > 1) { + return new Slice( + convertBlocksToInlineContent(f, view.state.schema), + 0, + 0, + ); + } + } + if (!shouldApplyFix(f, view)) { // Don't apply the fix. return new Slice(f, slice.openStart, slice.openEnd); diff --git a/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithParagraphsInTableCell.json b/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithParagraphsInTableCell.json new file mode 100644 index 0000000000..60375f6ace --- /dev/null +++ b/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithParagraphsInTableCell.json @@ -0,0 +1,114 @@ +[ + { + "children": [], + "content": { + "columnWidths": [ + undefined, + ], + "headerCols": undefined, + "headerRows": undefined, + "rows": [ + { + "cells": [ + { + "content": [ + { + "styles": {}, + "text": "Cell 1Paragraph 1", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "colspan": 1, + "rowspan": 1, + "textAlignment": "left", + "textColor": "default", + }, + "type": "tableCell", + }, + ], + }, + ], + "type": "tableContent", + }, + "id": "1", + "props": { + "textColor": "default", + }, + "type": "table", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 2", + "type": "text", + }, + ], + "id": "2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 3", + "type": "text", + }, + ], + "id": "3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": { + "columnWidths": [ + undefined, + ], + "headerCols": undefined, + "headerRows": undefined, + "rows": [ + { + "cells": [ + { + "content": [ + { + "styles": {}, + "text": "Cell 2", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "colspan": 1, + "rowspan": 1, + "textAlignment": "left", + "textColor": "default", + }, + "type": "tableCell", + }, + ], + }, + ], + "type": "tableContent", + }, + "id": "4", + "props": { + "textColor": "default", + }, + "type": "table", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/clipboard/paste/__snapshots__/text/plain/pasteHTMLWithParagraphsInTableCell.json b/tests/src/unit/core/clipboard/paste/__snapshots__/text/plain/pasteHTMLWithParagraphsInTableCell.json new file mode 100644 index 0000000000..47d6a80276 --- /dev/null +++ b/tests/src/unit/core/clipboard/paste/__snapshots__/text/plain/pasteHTMLWithParagraphsInTableCell.json @@ -0,0 +1,59 @@ +[ + { + "children": [], + "content": { + "columnWidths": [ + undefined, + undefined, + ], + "headerCols": undefined, + "headerRows": undefined, + "rows": [ + { + "cells": [ + { + "content": [ + { + "styles": {}, + "text": "Cell 1

Paragraph 1

Paragraph 2

Paragraph 3

", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "colspan": 1, + "rowspan": 1, + "textAlignment": "left", + "textColor": "default", + }, + "type": "tableCell", + }, + { + "content": [ + { + "styles": {}, + "text": "Cell 2", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "colspan": 1, + "rowspan": 1, + "textAlignment": "left", + "textColor": "default", + }, + "type": "tableCell", + }, + ], + }, + ], + "type": "tableContent", + }, + "id": "1", + "props": { + "textColor": "default", + }, + "type": "table", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/clipboard/paste/__snapshots__/text/plain/pasteMultilineTextInTableCell.json b/tests/src/unit/core/clipboard/paste/__snapshots__/text/plain/pasteMultilineTextInTableCell.json new file mode 100644 index 0000000000..43210208e8 --- /dev/null +++ b/tests/src/unit/core/clipboard/paste/__snapshots__/text/plain/pasteMultilineTextInTableCell.json @@ -0,0 +1,114 @@ +[ + { + "children": [], + "content": { + "columnWidths": [ + undefined, + ], + "headerCols": undefined, + "headerRows": undefined, + "rows": [ + { + "cells": [ + { + "content": [ + { + "styles": {}, + "text": "Cell 1Line 1", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "colspan": 1, + "rowspan": 1, + "textAlignment": "left", + "textColor": "default", + }, + "type": "tableCell", + }, + ], + }, + ], + "type": "tableContent", + }, + "id": "1", + "props": { + "textColor": "default", + }, + "type": "table", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Line 2", + "type": "text", + }, + ], + "id": "2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Line 3", + "type": "text", + }, + ], + "id": "3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": { + "columnWidths": [ + undefined, + ], + "headerCols": undefined, + "headerRows": undefined, + "rows": [ + { + "cells": [ + { + "content": [ + { + "styles": {}, + "text": "Cell 2", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "colspan": 1, + "rowspan": 1, + "textAlignment": "left", + "textColor": "default", + }, + "type": "tableCell", + }, + ], + }, + ], + "type": "tableContent", + }, + "id": "4", + "props": { + "textColor": "default", + }, + "type": "table", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts b/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts index 404cd8cb83..70682f6443 100644 --- a/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts +++ b/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts @@ -137,6 +137,56 @@ export const pasteTestInstancesHTML: TestInstance< }, executeTest: testPasteHTML, }, + { + testCase: { + name: "pasteMultilineTextInTableCell", + content: `Line 1\nLine 2\nLine 3`, + document: [ + { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [["Cell 1"], ["Cell 2"]], + }, + ], + }, + }, + ], + getPasteSelection: (doc) => { + const startPos = getPosOfTextNode(doc, "Cell 1", true); + + return TextSelection.create(doc, startPos); + }, + }, + executeTest: testPasteMarkdown, + }, + { + testCase: { + name: "pasteHTMLWithParagraphsInTableCell", + content: `

Paragraph 1

Paragraph 2

Paragraph 3

`, + document: [ + { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [["Cell 1"], ["Cell 2"]], + }, + ], + }, + }, + ], + getPasteSelection: (doc) => { + const startPos = getPosOfTextNode(doc, "Cell 1", true); + + return TextSelection.create(doc, startPos); + }, + }, + executeTest: testPasteHTML, + }, ]; export const pasteTestInstancesMarkdown: TestInstance< From b172c84c09ad73f085534442b4e5924bc09cb07a Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 20 Feb 2026 16:04:11 +0100 Subject: [PATCH 2/2] fix: flatten everything that is not table content --- docs/package.json | 2 +- packages/core/src/editor/transformPasted.ts | 43 +++++++------ ...HTMLWithMultipleCheckboxesInTableCell.json | 61 ++++++++++++++++++ .../pasteHTMLWithParagraphsInTableCell.json | 63 ++----------------- .../plain/pasteMultilineTextInTableCell.json | 63 ++----------------- .../clipboard/paste/pasteTestInstances.ts | 27 ++++++++ 6 files changed, 123 insertions(+), 136 deletions(-) create mode 100644 tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithMultipleCheckboxesInTableCell.json diff --git a/docs/package.json b/docs/package.json index 3332900345..9286b69aeb 100644 --- a/docs/package.json +++ b/docs/package.json @@ -131,4 +131,4 @@ "tw-animate-css": "^1.4.0", "typescript": "^5.9.3" } -} +} \ No newline at end of file diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts index fa1ab7251a..2985ad33dc 100644 --- a/packages/core/src/editor/transformPasted.ts +++ b/packages/core/src/editor/transformPasted.ts @@ -25,27 +25,30 @@ function convertBlocksToInlineContent( schema: Schema, ): Fragment { const hardBreak = schema.nodes.hardBreak; - let flattenedFragment = Fragment.empty; + let result = Fragment.empty; fragment.forEach((node) => { - if (node.isInline) { - // This is a paragraph or similar - extract its inline content - flattenedFragment = flattenedFragment.append( + if (node.isTextblock && node.childCount > 0) { + // Extract inline content from paragraphs, headings, etc. + result = result.append(node.content); + result = result.addToEnd(hardBreak.create()); + } else if (node.isText) { + result = result.addToEnd(node); + } else if (node.isBlock && node.childCount > 0) { + // Recurse into block containers, blockGroups, etc. + result = result.append( convertBlocksToInlineContent(node.content, schema), ); - // Add hard break after each block (except we'll remove the last one) - flattenedFragment = flattenedFragment.addToEnd(hardBreak.create()); - } else if (node.isText) { - flattenedFragment = flattenedFragment.addToEnd(node); + result = result.addToEnd(hardBreak.create()); } }); - // Remove the last hard break if present - if (flattenedFragment.lastChild?.type?.name === "hardBreak") { - flattenedFragment.cut(0, flattenedFragment.childCount - 1); + // Remove trailing hard break + if (result.lastChild?.type === hardBreak) { + result = result.cut(0, result.size - 1); } - return flattenedFragment; + return result; } // helper function to remove a child from a fragment @@ -111,16 +114,18 @@ export function transformPasted(slice: Slice, view: EditorView) { f = wrapTableRows(f, view.state.schema); if (isInTableCell(view)) { - // If the pasted content has block-level elements, convert to inline content - let hasBlockContent = false; + let hasTableContent = false; f.descendants((node) => { - if (node.isInline && node.childCount > 0) { - // This is a paragraph with content - hasBlockContent = true; + if (node.type.isInGroup("tableContent")) { + hasTableContent = true; } }); - - if (hasBlockContent && f.childCount > 1) { + if ( + !hasTableContent && + // is the content valid for a table paragraph? + !view.state.schema.nodes.tableParagraph.validContent(f) + ) { + // if not, convert the content to inline content return new Slice( convertBlocksToInlineContent(f, view.state.schema), 0, diff --git a/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithMultipleCheckboxesInTableCell.json b/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithMultipleCheckboxesInTableCell.json new file mode 100644 index 0000000000..adfce7adf4 --- /dev/null +++ b/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithMultipleCheckboxesInTableCell.json @@ -0,0 +1,61 @@ +[ + { + "children": [], + "content": { + "columnWidths": [ + undefined, + undefined, + ], + "headerCols": undefined, + "headerRows": undefined, + "rows": [ + { + "cells": [ + { + "content": [ + { + "styles": {}, + "text": "Cell 1ABC + Unit tests covering the new feature have been added. + All existing tests pass.", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "colspan": 1, + "rowspan": 1, + "textAlignment": "left", + "textColor": "default", + }, + "type": "tableCell", + }, + { + "content": [ + { + "styles": {}, + "text": "Cell 2", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "colspan": 1, + "rowspan": 1, + "textAlignment": "left", + "textColor": "default", + }, + "type": "tableCell", + }, + ], + }, + ], + "type": "tableContent", + }, + "id": "1", + "props": { + "textColor": "default", + }, + "type": "table", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithParagraphsInTableCell.json b/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithParagraphsInTableCell.json index 60375f6ace..5ead9fbbfb 100644 --- a/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithParagraphsInTableCell.json +++ b/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithParagraphsInTableCell.json @@ -4,6 +4,7 @@ "content": { "columnWidths": [ undefined, + undefined, ], "headerCols": undefined, "headerRows": undefined, @@ -14,7 +15,9 @@ "content": [ { "styles": {}, - "text": "Cell 1Paragraph 1", + "text": "Cell 1Paragraph 1 +Paragraph 2 +Paragraph 3", "type": "text", }, ], @@ -27,62 +30,6 @@ }, "type": "tableCell", }, - ], - }, - ], - "type": "tableContent", - }, - "id": "1", - "props": { - "textColor": "default", - }, - "type": "table", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Paragraph 2", - "type": "text", - }, - ], - "id": "2", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Paragraph 3", - "type": "text", - }, - ], - "id": "3", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": { - "columnWidths": [ - undefined, - ], - "headerCols": undefined, - "headerRows": undefined, - "rows": [ - { - "cells": [ { "content": [ { @@ -105,7 +52,7 @@ ], "type": "tableContent", }, - "id": "4", + "id": "1", "props": { "textColor": "default", }, diff --git a/tests/src/unit/core/clipboard/paste/__snapshots__/text/plain/pasteMultilineTextInTableCell.json b/tests/src/unit/core/clipboard/paste/__snapshots__/text/plain/pasteMultilineTextInTableCell.json index 43210208e8..9bd2f935b2 100644 --- a/tests/src/unit/core/clipboard/paste/__snapshots__/text/plain/pasteMultilineTextInTableCell.json +++ b/tests/src/unit/core/clipboard/paste/__snapshots__/text/plain/pasteMultilineTextInTableCell.json @@ -4,6 +4,7 @@ "content": { "columnWidths": [ undefined, + undefined, ], "headerCols": undefined, "headerRows": undefined, @@ -14,7 +15,9 @@ "content": [ { "styles": {}, - "text": "Cell 1Line 1", + "text": "Cell 1Line 1 +Line 2 +Line 3", "type": "text", }, ], @@ -27,62 +30,6 @@ }, "type": "tableCell", }, - ], - }, - ], - "type": "tableContent", - }, - "id": "1", - "props": { - "textColor": "default", - }, - "type": "table", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Line 2", - "type": "text", - }, - ], - "id": "2", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Line 3", - "type": "text", - }, - ], - "id": "3", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": { - "columnWidths": [ - undefined, - ], - "headerCols": undefined, - "headerRows": undefined, - "rows": [ - { - "cells": [ { "content": [ { @@ -105,7 +52,7 @@ ], "type": "tableContent", }, - "id": "4", + "id": "1", "props": { "textColor": "default", }, diff --git a/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts b/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts index 70682f6443..cf9e0d33dd 100644 --- a/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts +++ b/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts @@ -187,6 +187,33 @@ export const pasteTestInstancesHTML: TestInstance< }, executeTest: testPasteHTML, }, + { + testCase: { + name: "pasteHTMLWithMultipleCheckboxesInTableCell", + content: `
  • ABC
  • +
  • Unit tests covering the new feature have been added.
  • +
  • All existing tests pass.
  • `, + document: [ + { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [["Cell 1"], ["Cell 2"]], + }, + ], + }, + }, + ], + getPasteSelection: (doc) => { + const startPos = getPosOfTextNode(doc, "Cell 1", true); + + return TextSelection.create(doc, startPos); + }, + }, + executeTest: testPasteHTML, + }, ]; export const pasteTestInstancesMarkdown: TestInstance<