From b4af8bbddb399ed3cb60cc0f8de6df55d054a0c5 Mon Sep 17 00:00:00 2001 From: y-temp4 <13657589+y-temp4@users.noreply.github.com> Date: Wed, 27 May 2026 17:19:18 +0900 Subject: [PATCH 1/2] 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) --- .../src/blocks/Table/TableExtension.test.ts | 134 ++++++++++++++++++ .../core/src/blocks/Table/TableExtension.ts | 27 ++-- 2 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/blocks/Table/TableExtension.test.ts diff --git a/packages/core/src/blocks/Table/TableExtension.test.ts b/packages/core/src/blocks/Table/TableExtension.test.ts new file mode 100644 index 0000000000..fe704e07ae --- /dev/null +++ b/packages/core/src/blocks/Table/TableExtension.test.ts @@ -0,0 +1,134 @@ +import { TextSelection } from "prosemirror-state"; +import { CellSelection } from "prosemirror-tables"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { PartialBlock } from "../defaultBlocks.js"; + +/** + * @vitest-environment jsdom + */ + +/** + * Simulate a keyboard shortcut by invoking the view's handleKeyDown prop, + * which is how ProseMirror routes keymap-based handlers like Enter. + */ +function pressEnter(editor: BlockNoteEditor) { + const view = editor.prosemirrorView; + const event = new KeyboardEvent("keydown", { key: "Enter" }); + view.someProp("handleKeyDown", (f) => f(view, event)); +} + +const testDocument: PartialBlock[] = [ + { + id: "table-0", + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["Cell 1", "Cell 2", "Cell 3"] }, + { cells: ["Cell 4", "Cell 5", "Cell 6"] }, + { cells: ["Cell 7", "Cell 8", "Cell 9"] }, + ], + }, + }, +]; + +describe("Table Enter keyboard shortcut", () => { + let editor: BlockNoteEditor; + const div = document.createElement("div"); + + beforeAll(() => { + editor = BlockNoteEditor.create(); + editor.mount(div); + }); + + afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + }); + + beforeEach(() => { + editor.replaceBlocks(editor.document, testDocument); + }); + + /** + * Returns the document position just inside the cell containing `cellText`. + */ + function posInCell(cellText: string): number { + const view = editor.prosemirrorView; + let pos = -1; + view.state.doc.descendants((node, nodePos) => { + if (pos === -1 && node.isText && node.text === cellText) { + pos = nodePos; + } + return true; + }); + if (pos === -1) { + throw new Error(`Cell with text "${cellText}" not found`); + } + return pos; + } + + function setCursorInCell(cellText: string, offset = 1) { + const pos = posInCell(cellText); + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, pos + offset)), + ); + } + + it("moves the selection to the cell below", () => { + setCursorInCell("Cell 5"); + + pressEnter(editor); + + const parentText = + editor.prosemirrorView.state.selection.$head.parent.textContent; + expect(parentText).toBe("Cell 8"); + }); + + it("does not crash and is a no-op on the last row", () => { + setCursorInCell("Cell 8"); + + const before = editor.document; + expect(() => pressEnter(editor)).not.toThrow(); + // The table structure must be left intact (Enter is a no-op here). + expect(editor.document).toStrictEqual(before); + }); + + it("does not crash with a (non-empty) text selection in the last row", () => { + const start = posInCell("Cell 8"); + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, start, start + 4)), + ); + + const before = editor.document; + expect(() => pressEnter(editor)).not.toThrow(); + expect(editor.document).toStrictEqual(before); + }); + + it("does not crash with a multi-cell selection", () => { + const view = editor.prosemirrorView; + const cellPositions: number[] = []; + view.state.doc.descendants((node, pos) => { + if (node.type.name === "tableCell" || node.type.name === "tableHeader") { + cellPositions.push(pos); + } + return true; + }); + + editor.transact((tr) => + tr.setSelection( + CellSelection.create( + tr.doc, + cellPositions[0], + cellPositions[1], + ) as any, + ), + ); + + const before = editor.document; + expect(() => pressEnter(editor)).not.toThrow(); + expect(editor.document).toStrictEqual(before); + }); +}); diff --git a/packages/core/src/blocks/Table/TableExtension.ts b/packages/core/src/blocks/Table/TableExtension.ts index 1d2cf9d47f..6d4064f0cc 100644 --- a/packages/core/src/blocks/Table/TableExtension.ts +++ b/packages/core/src/blocks/Table/TableExtension.ts @@ -35,26 +35,19 @@ export const TableExtension = Extension.create({ return { // Moves the selection to the cell below. Enter: () => { - if ( - this.editor.state.selection.$head.parent.type.name !== - "tableParagraph" - ) { + // We use `isInTable` rather than checking whether the cursor's parent + // is a `tableParagraph`, since for cell selections that span multiple + // cells the selection head resolves to a `tableRow` instead. + if (!isInTable(this.editor.state)) { return false; } return this.editor.commands.command(({ state, dispatch }) => { - if (!isInTable(state)) { - return false; - } - const $cell = selectionCell(state); - const $nextCell = nextCell($cell, "vert", 1); - - if (!$nextCell) { - return false; - } + const $nextCell = $cell ? nextCell($cell, "vert", 1) : null; - if (dispatch) { + // Moves the selection to the cell below, if there is one. + if ($nextCell && dispatch) { dispatch( state.tr .setSelection( @@ -64,6 +57,12 @@ export const TableExtension = Extension.create({ ); } + // Always consume the Enter key while inside a table, even when there + // is no cell below (e.g. the last row) or the selection spans + // multiple cells. Otherwise it falls through to the default + // `splitBlock` handler, which tries to split the table cell and + // throws `Cannot join tableCell onto blockContainer`, crashing the + // editor. On the last row, Enter simply becomes a no-op. return true; }); }, From c4841553aa52728281467136c2f654eab413ba6f Mon Sep 17 00:00:00 2001 From: y-temp4 <13657589+y-temp4@users.noreply.github.com> Date: Wed, 27 May 2026 23:47:08 +0900 Subject: [PATCH 2/2] fix(table): remove redundant comments --- packages/core/src/blocks/Table/TableExtension.test.ts | 8 -------- packages/core/src/blocks/Table/TableExtension.ts | 10 ---------- 2 files changed, 18 deletions(-) diff --git a/packages/core/src/blocks/Table/TableExtension.test.ts b/packages/core/src/blocks/Table/TableExtension.test.ts index fe704e07ae..72596c9795 100644 --- a/packages/core/src/blocks/Table/TableExtension.test.ts +++ b/packages/core/src/blocks/Table/TableExtension.test.ts @@ -9,10 +9,6 @@ import type { PartialBlock } from "../defaultBlocks.js"; * @vitest-environment jsdom */ -/** - * Simulate a keyboard shortcut by invoking the view's handleKeyDown prop, - * which is how ProseMirror routes keymap-based handlers like Enter. - */ function pressEnter(editor: BlockNoteEditor) { const view = editor.prosemirrorView; const event = new KeyboardEvent("keydown", { key: "Enter" }); @@ -52,9 +48,6 @@ describe("Table Enter keyboard shortcut", () => { editor.replaceBlocks(editor.document, testDocument); }); - /** - * Returns the document position just inside the cell containing `cellText`. - */ function posInCell(cellText: string): number { const view = editor.prosemirrorView; let pos = -1; @@ -92,7 +85,6 @@ describe("Table Enter keyboard shortcut", () => { const before = editor.document; expect(() => pressEnter(editor)).not.toThrow(); - // The table structure must be left intact (Enter is a no-op here). expect(editor.document).toStrictEqual(before); }); diff --git a/packages/core/src/blocks/Table/TableExtension.ts b/packages/core/src/blocks/Table/TableExtension.ts index 6d4064f0cc..bd8d8bcfab 100644 --- a/packages/core/src/blocks/Table/TableExtension.ts +++ b/packages/core/src/blocks/Table/TableExtension.ts @@ -35,9 +35,6 @@ export const TableExtension = Extension.create({ return { // Moves the selection to the cell below. Enter: () => { - // We use `isInTable` rather than checking whether the cursor's parent - // is a `tableParagraph`, since for cell selections that span multiple - // cells the selection head resolves to a `tableRow` instead. if (!isInTable(this.editor.state)) { return false; } @@ -46,7 +43,6 @@ export const TableExtension = Extension.create({ const $cell = selectionCell(state); const $nextCell = $cell ? nextCell($cell, "vert", 1) : null; - // Moves the selection to the cell below, if there is one. if ($nextCell && dispatch) { dispatch( state.tr @@ -57,12 +53,6 @@ export const TableExtension = Extension.create({ ); } - // Always consume the Enter key while inside a table, even when there - // is no cell below (e.g. the last row) or the selection spans - // multiple cells. Otherwise it falls through to the default - // `splitBlock` handler, which tries to split the table cell and - // throws `Cannot join tableCell onto blockContainer`, crashing the - // editor. On the last row, Enter simply becomes a no-op. return true; }); },