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 42e45fd122..2985ad33dc 100644 --- a/packages/core/src/editor/transformPasted.ts +++ b/packages/core/src/editor/transformPasted.ts @@ -2,6 +2,54 @@ 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 result = Fragment.empty; + + fragment.forEach((node) => { + 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), + ); + result = result.addToEnd(hardBreak.create()); + } + }); + + // Remove trailing hard break + if (result.lastChild?.type === hardBreak) { + result = result.cut(0, result.size - 1); + } + + return result; +} // helper function to remove a child from a fragment function removeChild(node: Fragment, n: number) { @@ -65,6 +113,27 @@ export function transformPasted(slice: Slice, view: EditorView) { let f = Fragment.from(slice.content); f = wrapTableRows(f, view.state.schema); + if (isInTableCell(view)) { + let hasTableContent = false; + f.descendants((node) => { + if (node.type.isInGroup("tableContent")) { + hasTableContent = true; + } + }); + 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, + 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/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 new file mode 100644 index 0000000000..5ead9fbbfb --- /dev/null +++ b/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithParagraphsInTableCell.json @@ -0,0 +1,61 @@ +[ + { + "children": [], + "content": { + "columnWidths": [ + undefined, + undefined, + ], + "headerCols": undefined, + "headerRows": undefined, + "rows": [ + { + "cells": [ + { + "content": [ + { + "styles": {}, + "text": "Cell 1Paragraph 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/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..9bd2f935b2 --- /dev/null +++ b/tests/src/unit/core/clipboard/paste/__snapshots__/text/plain/pasteMultilineTextInTableCell.json @@ -0,0 +1,61 @@ +[ + { + "children": [], + "content": { + "columnWidths": [ + undefined, + undefined, + ], + "headerCols": undefined, + "headerRows": undefined, + "rows": [ + { + "cells": [ + { + "content": [ + { + "styles": {}, + "text": "Cell 1Line 1 +Line 2 +Line 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/pasteTestInstances.ts b/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts index 404cd8cb83..cf9e0d33dd 100644 --- a/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts +++ b/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts @@ -137,6 +137,83 @@ 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, + }, + { + testCase: { + name: "pasteHTMLWithMultipleCheckboxesInTableCell", + content: `