Skip to content

Commit f150e1e

Browse files
authored
fix: handle pasting into table cells better, by collapsing their content to inline #2410 (#2449)
1 parent aa159a6 commit f150e1e

7 files changed

Lines changed: 389 additions & 1 deletion

File tree

docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,4 @@
131131
"tw-animate-css": "^1.4.0",
132132
"typescript": "^5.9.3"
133133
}
134-
}
134+
}

packages/core/src/editor/transformPasted.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,54 @@ import { Fragment, Schema, Slice } from "@tiptap/pm/model";
22
import { EditorView } from "@tiptap/pm/view";
33

44
import { getBlockInfoFromSelection } from "../api/getBlockInfoFromPos.js";
5+
import { findParentNodeClosestToPos } from "@tiptap/core";
6+
7+
/**
8+
* Checks if the current selection is inside a table cell.
9+
* Returns the depth of the tableCell/tableHeader node if found, -1 otherwise.
10+
*/
11+
function isInTableCell(view: EditorView): boolean {
12+
return (
13+
findParentNodeClosestToPos(view.state.selection.$from, (n) => {
14+
return n.type.name === "tableCell" || n.type.name === "tableHeader";
15+
}) !== undefined
16+
);
17+
}
18+
19+
/**
20+
* Converts block content to inline content with hard breaks.
21+
* This is used when pasting into table cells which can only contain inline content.
22+
*/
23+
function convertBlocksToInlineContent(
24+
fragment: Fragment,
25+
schema: Schema,
26+
): Fragment {
27+
const hardBreak = schema.nodes.hardBreak;
28+
let result = Fragment.empty;
29+
30+
fragment.forEach((node) => {
31+
if (node.isTextblock && node.childCount > 0) {
32+
// Extract inline content from paragraphs, headings, etc.
33+
result = result.append(node.content);
34+
result = result.addToEnd(hardBreak.create());
35+
} else if (node.isText) {
36+
result = result.addToEnd(node);
37+
} else if (node.isBlock && node.childCount > 0) {
38+
// Recurse into block containers, blockGroups, etc.
39+
result = result.append(
40+
convertBlocksToInlineContent(node.content, schema),
41+
);
42+
result = result.addToEnd(hardBreak.create());
43+
}
44+
});
45+
46+
// Remove trailing hard break
47+
if (result.lastChild?.type === hardBreak) {
48+
result = result.cut(0, result.size - 1);
49+
}
50+
51+
return result;
52+
}
553

654
// helper function to remove a child from a fragment
755
function removeChild(node: Fragment, n: number) {
@@ -65,6 +113,27 @@ export function transformPasted(slice: Slice, view: EditorView) {
65113
let f = Fragment.from(slice.content);
66114
f = wrapTableRows(f, view.state.schema);
67115

116+
if (isInTableCell(view)) {
117+
let hasTableContent = false;
118+
f.descendants((node) => {
119+
if (node.type.isInGroup("tableContent")) {
120+
hasTableContent = true;
121+
}
122+
});
123+
if (
124+
!hasTableContent &&
125+
// is the content valid for a table paragraph?
126+
!view.state.schema.nodes.tableParagraph.validContent(f)
127+
) {
128+
// if not, convert the content to inline content
129+
return new Slice(
130+
convertBlocksToInlineContent(f, view.state.schema),
131+
0,
132+
0,
133+
);
134+
}
135+
}
136+
68137
if (!shouldApplyFix(f, view)) {
69138
// Don't apply the fix.
70139
return new Slice(f, slice.openStart, slice.openEnd);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
[
2+
{
3+
"children": [],
4+
"content": {
5+
"columnWidths": [
6+
undefined,
7+
undefined,
8+
],
9+
"headerCols": undefined,
10+
"headerRows": undefined,
11+
"rows": [
12+
{
13+
"cells": [
14+
{
15+
"content": [
16+
{
17+
"styles": {},
18+
"text": "Cell 1ABC
19+
Unit tests covering the new feature have been added.
20+
All existing tests pass.",
21+
"type": "text",
22+
},
23+
],
24+
"props": {
25+
"backgroundColor": "default",
26+
"colspan": 1,
27+
"rowspan": 1,
28+
"textAlignment": "left",
29+
"textColor": "default",
30+
},
31+
"type": "tableCell",
32+
},
33+
{
34+
"content": [
35+
{
36+
"styles": {},
37+
"text": "Cell 2",
38+
"type": "text",
39+
},
40+
],
41+
"props": {
42+
"backgroundColor": "default",
43+
"colspan": 1,
44+
"rowspan": 1,
45+
"textAlignment": "left",
46+
"textColor": "default",
47+
},
48+
"type": "tableCell",
49+
},
50+
],
51+
},
52+
],
53+
"type": "tableContent",
54+
},
55+
"id": "1",
56+
"props": {
57+
"textColor": "default",
58+
},
59+
"type": "table",
60+
},
61+
]
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
[
2+
{
3+
"children": [],
4+
"content": {
5+
"columnWidths": [
6+
undefined,
7+
undefined,
8+
],
9+
"headerCols": undefined,
10+
"headerRows": undefined,
11+
"rows": [
12+
{
13+
"cells": [
14+
{
15+
"content": [
16+
{
17+
"styles": {},
18+
"text": "Cell 1Paragraph 1
19+
Paragraph 2
20+
Paragraph 3",
21+
"type": "text",
22+
},
23+
],
24+
"props": {
25+
"backgroundColor": "default",
26+
"colspan": 1,
27+
"rowspan": 1,
28+
"textAlignment": "left",
29+
"textColor": "default",
30+
},
31+
"type": "tableCell",
32+
},
33+
{
34+
"content": [
35+
{
36+
"styles": {},
37+
"text": "Cell 2",
38+
"type": "text",
39+
},
40+
],
41+
"props": {
42+
"backgroundColor": "default",
43+
"colspan": 1,
44+
"rowspan": 1,
45+
"textAlignment": "left",
46+
"textColor": "default",
47+
},
48+
"type": "tableCell",
49+
},
50+
],
51+
},
52+
],
53+
"type": "tableContent",
54+
},
55+
"id": "1",
56+
"props": {
57+
"textColor": "default",
58+
},
59+
"type": "table",
60+
},
61+
]
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
[
2+
{
3+
"children": [],
4+
"content": {
5+
"columnWidths": [
6+
undefined,
7+
undefined,
8+
],
9+
"headerCols": undefined,
10+
"headerRows": undefined,
11+
"rows": [
12+
{
13+
"cells": [
14+
{
15+
"content": [
16+
{
17+
"styles": {},
18+
"text": "Cell 1<p>Paragraph 1</p><p>Paragraph 2</p><p>Paragraph 3</p>",
19+
"type": "text",
20+
},
21+
],
22+
"props": {
23+
"backgroundColor": "default",
24+
"colspan": 1,
25+
"rowspan": 1,
26+
"textAlignment": "left",
27+
"textColor": "default",
28+
},
29+
"type": "tableCell",
30+
},
31+
{
32+
"content": [
33+
{
34+
"styles": {},
35+
"text": "Cell 2",
36+
"type": "text",
37+
},
38+
],
39+
"props": {
40+
"backgroundColor": "default",
41+
"colspan": 1,
42+
"rowspan": 1,
43+
"textAlignment": "left",
44+
"textColor": "default",
45+
},
46+
"type": "tableCell",
47+
},
48+
],
49+
},
50+
],
51+
"type": "tableContent",
52+
},
53+
"id": "1",
54+
"props": {
55+
"textColor": "default",
56+
},
57+
"type": "table",
58+
},
59+
]
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
[
2+
{
3+
"children": [],
4+
"content": {
5+
"columnWidths": [
6+
undefined,
7+
undefined,
8+
],
9+
"headerCols": undefined,
10+
"headerRows": undefined,
11+
"rows": [
12+
{
13+
"cells": [
14+
{
15+
"content": [
16+
{
17+
"styles": {},
18+
"text": "Cell 1Line 1
19+
Line 2
20+
Line 3",
21+
"type": "text",
22+
},
23+
],
24+
"props": {
25+
"backgroundColor": "default",
26+
"colspan": 1,
27+
"rowspan": 1,
28+
"textAlignment": "left",
29+
"textColor": "default",
30+
},
31+
"type": "tableCell",
32+
},
33+
{
34+
"content": [
35+
{
36+
"styles": {},
37+
"text": "Cell 2",
38+
"type": "text",
39+
},
40+
],
41+
"props": {
42+
"backgroundColor": "default",
43+
"colspan": 1,
44+
"rowspan": 1,
45+
"textAlignment": "left",
46+
"textColor": "default",
47+
},
48+
"type": "tableCell",
49+
},
50+
],
51+
},
52+
],
53+
"type": "tableContent",
54+
},
55+
"id": "1",
56+
"props": {
57+
"textColor": "default",
58+
},
59+
"type": "table",
60+
},
61+
]

0 commit comments

Comments
 (0)