Skip to content

Commit b4af8bb

Browse files
y-temp4claude
andcommitted
fix(table): prevent crash when pressing Enter in a table cell (#2792)
The table Enter handler added in #2685 only handled the case where a cell exists below the cursor. In several edge cases it returned `false` and fell through to the default `splitBlock` handler, which resolves the nearest block container of a cell to the outer `blockContainer` wrapping the whole table and then calls `tr.split(pos, 2, ...)` deep inside the cell. Splitting a tableCell this way is invalid and throws `TransformError: Cannot join tableCell onto blockContainer`, crashing the editor. The crashing edge cases were: - cursor in a cell on the last row (no cell below); - a non-empty text selection inside a last-row cell; - a multi-cell `CellSelection` (the head resolves to a `tableRow`, bypassing the previous `tableParagraph` guard entirely). Use `isInTable` to detect being inside a table (which also covers multi-cell selections), move to the cell below when there is one, and always consume the Enter key while inside a table so it never falls through to `splitBlock`. On the last row Enter is now a no-op. Adds regression tests covering all the crashing cases and confirming the existing "move to cell below" behavior is preserved. Closes #2792 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d609476 commit b4af8bb

2 files changed

Lines changed: 147 additions & 14 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { TextSelection } from "prosemirror-state";
2+
import { CellSelection } from "prosemirror-tables";
3+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
4+
5+
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
6+
import type { PartialBlock } from "../defaultBlocks.js";
7+
8+
/**
9+
* @vitest-environment jsdom
10+
*/
11+
12+
/**
13+
* Simulate a keyboard shortcut by invoking the view's handleKeyDown prop,
14+
* which is how ProseMirror routes keymap-based handlers like Enter.
15+
*/
16+
function pressEnter(editor: BlockNoteEditor) {
17+
const view = editor.prosemirrorView;
18+
const event = new KeyboardEvent("keydown", { key: "Enter" });
19+
view.someProp("handleKeyDown", (f) => f(view, event));
20+
}
21+
22+
const testDocument: PartialBlock[] = [
23+
{
24+
id: "table-0",
25+
type: "table",
26+
content: {
27+
type: "tableContent",
28+
rows: [
29+
{ cells: ["Cell 1", "Cell 2", "Cell 3"] },
30+
{ cells: ["Cell 4", "Cell 5", "Cell 6"] },
31+
{ cells: ["Cell 7", "Cell 8", "Cell 9"] },
32+
],
33+
},
34+
},
35+
];
36+
37+
describe("Table Enter keyboard shortcut", () => {
38+
let editor: BlockNoteEditor;
39+
const div = document.createElement("div");
40+
41+
beforeAll(() => {
42+
editor = BlockNoteEditor.create();
43+
editor.mount(div);
44+
});
45+
46+
afterAll(() => {
47+
editor._tiptapEditor.destroy();
48+
editor = undefined as any;
49+
});
50+
51+
beforeEach(() => {
52+
editor.replaceBlocks(editor.document, testDocument);
53+
});
54+
55+
/**
56+
* Returns the document position just inside the cell containing `cellText`.
57+
*/
58+
function posInCell(cellText: string): number {
59+
const view = editor.prosemirrorView;
60+
let pos = -1;
61+
view.state.doc.descendants((node, nodePos) => {
62+
if (pos === -1 && node.isText && node.text === cellText) {
63+
pos = nodePos;
64+
}
65+
return true;
66+
});
67+
if (pos === -1) {
68+
throw new Error(`Cell with text "${cellText}" not found`);
69+
}
70+
return pos;
71+
}
72+
73+
function setCursorInCell(cellText: string, offset = 1) {
74+
const pos = posInCell(cellText);
75+
editor.transact((tr) =>
76+
tr.setSelection(TextSelection.create(tr.doc, pos + offset)),
77+
);
78+
}
79+
80+
it("moves the selection to the cell below", () => {
81+
setCursorInCell("Cell 5");
82+
83+
pressEnter(editor);
84+
85+
const parentText =
86+
editor.prosemirrorView.state.selection.$head.parent.textContent;
87+
expect(parentText).toBe("Cell 8");
88+
});
89+
90+
it("does not crash and is a no-op on the last row", () => {
91+
setCursorInCell("Cell 8");
92+
93+
const before = editor.document;
94+
expect(() => pressEnter(editor)).not.toThrow();
95+
// The table structure must be left intact (Enter is a no-op here).
96+
expect(editor.document).toStrictEqual(before);
97+
});
98+
99+
it("does not crash with a (non-empty) text selection in the last row", () => {
100+
const start = posInCell("Cell 8");
101+
editor.transact((tr) =>
102+
tr.setSelection(TextSelection.create(tr.doc, start, start + 4)),
103+
);
104+
105+
const before = editor.document;
106+
expect(() => pressEnter(editor)).not.toThrow();
107+
expect(editor.document).toStrictEqual(before);
108+
});
109+
110+
it("does not crash with a multi-cell selection", () => {
111+
const view = editor.prosemirrorView;
112+
const cellPositions: number[] = [];
113+
view.state.doc.descendants((node, pos) => {
114+
if (node.type.name === "tableCell" || node.type.name === "tableHeader") {
115+
cellPositions.push(pos);
116+
}
117+
return true;
118+
});
119+
120+
editor.transact((tr) =>
121+
tr.setSelection(
122+
CellSelection.create(
123+
tr.doc,
124+
cellPositions[0],
125+
cellPositions[1],
126+
) as any,
127+
),
128+
);
129+
130+
const before = editor.document;
131+
expect(() => pressEnter(editor)).not.toThrow();
132+
expect(editor.document).toStrictEqual(before);
133+
});
134+
});

packages/core/src/blocks/Table/TableExtension.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,26 +35,19 @@ export const TableExtension = Extension.create({
3535
return {
3636
// Moves the selection to the cell below.
3737
Enter: () => {
38-
if (
39-
this.editor.state.selection.$head.parent.type.name !==
40-
"tableParagraph"
41-
) {
38+
// We use `isInTable` rather than checking whether the cursor's parent
39+
// is a `tableParagraph`, since for cell selections that span multiple
40+
// cells the selection head resolves to a `tableRow` instead.
41+
if (!isInTable(this.editor.state)) {
4242
return false;
4343
}
4444

4545
return this.editor.commands.command(({ state, dispatch }) => {
46-
if (!isInTable(state)) {
47-
return false;
48-
}
49-
5046
const $cell = selectionCell(state);
51-
const $nextCell = nextCell($cell, "vert", 1);
52-
53-
if (!$nextCell) {
54-
return false;
55-
}
47+
const $nextCell = $cell ? nextCell($cell, "vert", 1) : null;
5648

57-
if (dispatch) {
49+
// Moves the selection to the cell below, if there is one.
50+
if ($nextCell && dispatch) {
5851
dispatch(
5952
state.tr
6053
.setSelection(
@@ -64,6 +57,12 @@ export const TableExtension = Extension.create({
6457
);
6558
}
6659

60+
// Always consume the Enter key while inside a table, even when there
61+
// is no cell below (e.g. the last row) or the selection spans
62+
// multiple cells. Otherwise it falls through to the default
63+
// `splitBlock` handler, which tries to split the table cell and
64+
// throws `Cannot join tableCell onto blockContainer`, crashing the
65+
// editor. On the last row, Enter simply becomes a no-op.
6766
return true;
6867
});
6968
},

0 commit comments

Comments
 (0)