From dba1c65d4d6cd05ac77cd921c5a12140418ea355 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 22 Jul 2026 17:53:52 +0200 Subject: [PATCH] Fixed error when `getCellSelection` called while cursor is in table block but outside cell --- .../src/blocks/Table/TableExtension.test.ts | 102 ++++++++++++++++++ .../core/src/blocks/defaultBlockTypeGuards.ts | 6 ++ .../extensions/TableHandles/TableHandles.ts | 30 ++++-- 3 files changed, 129 insertions(+), 9 deletions(-) diff --git a/packages/core/src/blocks/Table/TableExtension.test.ts b/packages/core/src/blocks/Table/TableExtension.test.ts index 3d942f7808..83abd32c8e 100644 --- a/packages/core/src/blocks/Table/TableExtension.test.ts +++ b/packages/core/src/blocks/Table/TableExtension.test.ts @@ -1,3 +1,4 @@ +import { GapCursor } from "@tiptap/pm/gapcursor"; import { TextSelection } from "prosemirror-state"; import { CellSelection } from "prosemirror-tables"; import { @@ -10,6 +11,7 @@ import { } from "vite-plus/test"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { TableHandlesExtension } from "../../extensions/TableHandles/TableHandles.js"; import type { PartialBlock } from "../defaultBlocks.js"; /** @@ -127,3 +129,103 @@ describe("Table Enter keyboard shortcut", () => { expect(editor.document).toStrictEqual(before); }); }); + +describe("TableHandlesExtension.getCellSelection", () => { + let editor: BlockNoteEditor; + const div = document.createElement("div"); + + beforeAll(() => { + editor = BlockNoteEditor.create(); + editor.mount(div); + }); + + afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + }); + + function getCellSelection() { + return editor.getExtension(TableHandlesExtension)!.getCellSelection(); + } + + it("returns undefined for a gap cursor left of a leading table without crashing", () => { + // A table as the first block in the document, matching the crash repro + // where clicking in the left margin places a gap cursor at doc start. + editor.replaceBlocks(editor.document, [ + { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["Cell 1", "Cell 2"] }], + }, + }, + ]); + + editor.transact((tr) => tr.setSelection(new GapCursor(tr.doc.resolve(0)))); + + expect(() => getCellSelection()).not.toThrow(); + expect(getCellSelection()).toBeUndefined(); + }); + + it("returns undefined for a gap cursor next to a nested block without crashing", () => { + // A table as the first block, followed by a nested block. Clicking the + // nested block's left margin places a gap cursor that is not inside a cell. + editor.replaceBlocks(editor.document, [ + { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["Cell 1", "Cell 2"] }], + }, + }, + { + type: "paragraph", + content: "Parent", + children: [{ type: "paragraph", content: "Nested" }], + }, + ]); + + let nestedPos = -1; + editor.prosemirrorView.state.doc.descendants((node, pos) => { + if (nestedPos === -1 && node.isText && node.text === "Nested") { + nestedPos = pos; + } + return true; + }); + editor.transact((tr) => + tr.setSelection(new GapCursor(tr.doc.resolve(nestedPos - 1))), + ); + + expect(() => getCellSelection()).not.toThrow(); + expect(getCellSelection()).toBeUndefined(); + }); + + it("returns the cell indices for a text selection inside a cell", () => { + editor.replaceBlocks(editor.document, [ + { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["Cell 1", "Cell 2"] }, + { cells: ["Cell 3", "Cell 4"] }, + ], + }, + }, + ]); + + let cellPos = -1; + editor.prosemirrorView.state.doc.descendants((node, pos) => { + if (cellPos === -1 && node.isText && node.text === "Cell 4") { + cellPos = pos; + } + return true; + }); + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)), + ); + + expect(getCellSelection()?.from).toEqual({ row: 1, col: 1 }); + expect(getCellSelection()?.to).toEqual({ row: 1, col: 1 }); + }); +}); diff --git a/packages/core/src/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts index e9a4f8e9b5..cff35254b3 100644 --- a/packages/core/src/blocks/defaultBlockTypeGuards.ts +++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts @@ -1,3 +1,4 @@ +import { Node } from "prosemirror-model"; import { CellSelection } from "prosemirror-tables"; import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import { BlockConfig, PropSchema, PropSpec } from "../schema/index.js"; @@ -159,3 +160,8 @@ export function isTableCellSelection( ): selection is CellSelection { return selection instanceof CellSelection; } + +export function isTableCellNode(node: Node): boolean { + const tableRole = node.type.spec.tableRole; + return tableRole === "cell" || tableRole === "header_cell"; +} diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index 4616d76b70..446c21248b 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -28,6 +28,7 @@ import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../api/nodeUtil.js"; import { editorHasBlockWithType, + isTableCellNode, isTableCellSelection, } from "../../blocks/defaultBlockTypeGuards.js"; import { DefaultBlockSchema } from "../../blocks/defaultBlocks.js"; @@ -1106,15 +1107,26 @@ export const TableHandlesExtension = createExtension(({ editor }) => { // When the selection is a normal text selection // Assumes we are within a tableParagraph // And find the from and to cells by resolving the positions - $fromCell = tr.doc.resolve( - selection.$from.pos - selection.$from.parentOffset - 1, - ); - $toCell = tr.doc.resolve( - selection.$to.pos - selection.$to.parentOffset - 1, - ); - - // Opt-out when the selection is not pointing into cells - if ($fromCell.pos === 0 || $toCell.pos === 0) { + const fromCellPos = + selection.$from.pos - selection.$from.parentOffset - 1; + const toCellPos = selection.$to.pos - selection.$to.parentOffset - 1; + + // Opt-out when the selection is not pointing into cells. This happens when the selection + // is at the start of the table's `blockContainer` node and therefore just before the + // actual `table` node. + if (fromCellPos < 0 || toCellPos < 0) { + return undefined; + } + + $fromCell = tr.doc.resolve(fromCellPos); + $toCell = tr.doc.resolve(toCellPos); + + // Opt-out when the selection is not actually pointing into table + // cells (e.g. a gap cursor next to a nested block). + if ( + !isTableCellNode($fromCell.parent) || + !isTableCellNode($toCell.parent) + ) { return undefined; } }