From 5cefc4a6552c879909f5098b4f1d4af097386bf5 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 14 Aug 2026 15:05:10 +0200 Subject: [PATCH 1/2] fix(core): only attach table handles to actual table blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TableHandlesView.mouseMoveHandler` decided a block was a table from the DOM alone, then checked only that the schema contained a table block — never that the hovered block was one. Any custom block rendering a real `` therefore ended up on the table path, and reading its content threw on every mouse move. Resolve the hovered block first, then bail out unless it really is a table block. A block ID that can't be resolved in this editor's document (a nested editor inside a custom block) now hides the handles instead of throwing `Block with ID not found`. Fixes #2964 --- .../TableHandles/TableHandles.browser.test.ts | 283 ++++++++++++++++++ .../extensions/TableHandles/TableHandles.ts | 74 ++--- packages/core/vite.config.ts | 5 +- 3 files changed, 324 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts diff --git a/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts b/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts new file mode 100644 index 0000000000..afe43eb331 --- /dev/null +++ b/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts @@ -0,0 +1,283 @@ +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { createBlockSpec } from "../../schema/index.js"; +import { TableHandlesExtension } from "./TableHandles.js"; + +// Unit tests for the mouse handling in `TableHandlesView`, which decides +// whether the row/column handles should be attached to the cell under the +// cursor. It reads the DOM (`getBoundingClientRect`, node views, hit testing), +// so it runs in the browser suite rather than as a node unit test. + +// A custom block which renders a real `
` of its own, without having any +// table content in the document. Table cells like these are indistinguishable +// from a table block's cells when only looking at the DOM. +const fakeTableBlock = createBlockSpec( + { + type: "fakeTable", + propSchema: {}, + content: "none", + }, + { + render: () => { + const dom = document.createElement("div"); + dom.className = "fake-table"; + dom.innerHTML = + "
Not a real cell
"; + + return { dom }; + }, + }, +); + +// A custom block which embeds a second editor, itself containing a table. The +// nested editor's cells live inside the outer editor's DOM, so they reach the +// outer editor's mouse handlers, but its block IDs are unknown to the outer +// document. +const nestedEditorBlock = createBlockSpec( + { + type: "nestedEditor", + propSchema: {}, + content: "none", + }, + { + render: () => { + const dom = document.createElement("div"); + dom.className = "nested-editor"; + dom.contentEditable = "false"; + + const nestedEditor = BlockNoteEditor.create({ + initialContent: [ + { + id: "nested-table", + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["Nested cell"] }], + }, + }, + ], + }); + nestedEditor.mount(dom, { portalTarget: document.body }); + + return { dom, destroy: () => nestedEditor.unmount() }; + }, + }, +); + +const schema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + fakeTable: fakeTableBlock(), + nestedEditor: nestedEditorBlock(), + }, +}); + +describe("TableHandlesView mouse handling", () => { + let editor: BlockNoteEditor; + let mountPoint: HTMLElement; + + beforeEach(() => { + mountPoint = document.createElement("div"); + document.body.appendChild(mountPoint); + + editor = BlockNoteEditor.create({ + schema, + initialContent: [ + { + id: "table-0", + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["Cell 1", "Cell 2", "Cell 3"] }, + { cells: ["Cell 4", "Cell 5", "Cell 6"] }, + ], + }, + }, + { id: "paragraph-0", type: "paragraph", content: "Paragraph" }, + { id: "fake-table-0", type: "fakeTable" }, + { id: "nested-editor-0", type: "nestedEditor" }, + ], + }) as BlockNoteEditor; + editor.mount(mountPoint); + }); + + afterEach(() => { + editor.unmount(); + editor._tiptapEditor.destroy(); + mountPoint.remove(); + }); + + function tableHandlesState() { + return editor.getExtension(TableHandlesExtension)!.store.state; + } + + function queryElement(selector: string) { + const element = mountPoint.querySelector(selector); + if (!element) { + throw new Error(`No element matching "${selector}"`); + } + return element; + } + + // The cell in the (only) real table block, at the given row and column. + function tableCell(rowIndex: number, colIndex: number) { + const cell = queryElement( + `[data-id="table-0"] tbody tr:nth-child(${rowIndex + 1})`, + ).children[colIndex]; + if (!(cell instanceof HTMLElement)) { + throw new Error(`No cell at row ${rowIndex}, column ${colIndex}`); + } + return cell; + } + + function dispatchMouseEvent( + type: "mousemove" | "mousedown", + el: HTMLElement, + ) { + const rect = el.getBoundingClientRect(); + el.dispatchEvent( + new MouseEvent(type, { + bubbles: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + }), + ); + } + + // Moves the mouse over `el` and asserts the handler survived it. An event + // listener that throws doesn't propagate the error to `dispatchEvent`'s + // caller - the browser reports it as an `error` event on `window` instead - + // so the error has to be picked up from there to be visible to the test. + function moveMouseOver(el: HTMLElement) { + const errors: unknown[] = []; + const collectError = (event: ErrorEvent) => { + errors.push(event.error ?? event.message); + }; + + window.addEventListener("error", collectError); + try { + dispatchMouseEvent("mousemove", el); + } finally { + window.removeEventListener("error", collectError); + } + + expect(errors).toEqual([]); + } + + it("attaches the handles to the hovered cell", () => { + moveMouseOver(tableCell(1, 1)); + + expect(tableHandlesState()).toMatchObject({ + show: true, + rowIndex: 1, + colIndex: 1, + block: { id: "table-0" }, + }); + }); + + it("only offers the add/remove buttons on the last row and column", () => { + moveMouseOver(tableCell(0, 0)); + + expect(tableHandlesState()).toMatchObject({ + showAddOrRemoveRowsButton: false, + showAddOrRemoveColumnsButton: false, + }); + + moveMouseOver(tableCell(1, 2)); + + expect(tableHandlesState()).toMatchObject({ + showAddOrRemoveRowsButton: true, + showAddOrRemoveColumnsButton: true, + }); + }); + + it("hides the handles when the mouse leaves the table", () => { + moveMouseOver(tableCell(0, 0)); + expect(tableHandlesState()?.show).toBe(true); + + moveMouseOver(queryElement('[data-id="paragraph-0"]')); + + expect(tableHandlesState()?.show).toBe(false); + }); + + it("hides the handles while text is being selected with the mouse", () => { + moveMouseOver(tableCell(0, 0)); + expect(tableHandlesState()?.show).toBe(true); + + dispatchMouseEvent("mousedown", tableCell(0, 0)); + moveMouseOver(tableCell(0, 1)); + + expect(tableHandlesState()?.show).toBe(false); + }); + + it("keeps the handles on the same cell while frozen", () => { + moveMouseOver(tableCell(0, 0)); + editor.getExtension(TableHandlesExtension)!.freezeHandles(); + + moveMouseOver(tableCell(1, 2)); + + expect(tableHandlesState()).toMatchObject({ + show: true, + rowIndex: 0, + colIndex: 0, + }); + + editor.getExtension(TableHandlesExtension)!.unfreezeHandles(); + moveMouseOver(tableCell(1, 2)); + + expect(tableHandlesState()).toMatchObject({ + show: true, + rowIndex: 1, + colIndex: 2, + }); + }); + + it("does not show the handles when the editor is not editable", () => { + editor.isEditable = false; + + moveMouseOver(tableCell(0, 0)); + + expect(tableHandlesState()?.show).toBeFalsy(); + }); + + // Regression test for https://github.com/TypeCellOS/BlockNote/issues/2964. + // The hovered block used to be treated as a table as soon as the schema had + // a table block in it, so a custom block rendering a `` ended up on + // the table path and crashed on its (absent) table content. + it("ignores cells of a custom block that renders its own table", () => { + moveMouseOver(queryElement(".fake-table td")); + + expect(tableHandlesState()?.show).toBeFalsy(); + }); + + it("hides the handles when moving from a table onto a custom block's table", () => { + moveMouseOver(tableCell(0, 0)); + expect(tableHandlesState()?.show).toBe(true); + + moveMouseOver(queryElement(".fake-table td")); + + expect(tableHandlesState()?.show).toBe(false); + }); + + // The other half of #2964: the hovered cell belongs to a nested editor, so + // its block ID cannot be resolved in the outer editor's document. That used + // to throw `Block with ID not found` on every mouse move. + it("ignores table cells belonging to a nested editor", () => { + moveMouseOver(queryElement(".nested-editor td")); + + expect(tableHandlesState()?.show).toBeFalsy(); + }); + + it("hides the handles when moving from a table onto a nested editor's table", () => { + moveMouseOver(tableCell(0, 0)); + expect(tableHandlesState()?.show).toBe(true); + + moveMouseOver(queryElement(".nested-editor td")); + + expect(tableHandlesState()?.show).toBe(false); + }); +}); diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index 25d09380f1..ed44239660 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -27,7 +27,7 @@ import { import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../api/nodeUtil.js"; import { - editorHasBlockWithType, + blockHasType, isTableCellNode, isTableCellSelection, } from "../../blocks/defaultBlockTypeGuards.js"; @@ -188,6 +188,17 @@ export class TableHandlesView implements PluginView { this.mouseState = "down"; }; + // Hides the handles if they're currently shown. Used whenever the mouse moves + // somewhere that shouldn't have table handles attached to it. + hideHandles = () => { + if (this.state?.show) { + this.state.show = false; + this.state.showAddOrRemoveRowsButton = false; + this.state.showAddOrRemoveColumnsButton = false; + this.emitUpdate(); + } + }; + mouseUpHandler = (event: MouseEvent) => { this.mouseState = "up"; this.mouseMoveHandler(event); @@ -219,22 +230,12 @@ export class TableHandlesView implements PluginView { // hide draghandles when selecting text as they could be in the way of the user this.mouseState = "selecting"; - if (this.state?.show) { - this.state.show = false; - this.state.showAddOrRemoveRowsButton = false; - this.state.showAddOrRemoveColumnsButton = false; - this.emitUpdate(); - } + this.hideHandles(); return; } if (!target || !this.editor.isEditable) { - if (this.state?.show) { - this.state.show = false; - this.state.showAddOrRemoveRowsButton = false; - this.state.showAddOrRemoveColumnsButton = false; - this.emitUpdate(); - } + this.hideHandles(); return; } @@ -248,38 +249,40 @@ export class TableHandlesView implements PluginView { if (!blockEl) { return; } - this.tableElement = blockEl.node; - - let tableBlock: - | BlockFromConfigNoChildren - | undefined; const { pmNodeInfo, doc } = this.editor.transact((tr) => ({ pmNodeInfo: getNodeById(blockEl.id, tr.doc), doc: tr.doc, })); + + // The hovered cell may belong to a document other than this editor's, as a + // custom block can embed a nested editor which itself contains a table. The + // nested editor's DOM is inside this view's DOM, so its cells still reach + // this handler, but its block IDs are unknown here. if (!pmNodeInfo) { - throw new Error(`Block with ID ${blockEl.id} not found`); + this.hideHandles(); + return; } - const block = nodeToBlock( - pmNodeInfo.node, - doc, - ) as unknown as BlockFromConfigNoChildren< + const block = nodeToBlock(pmNodeInfo.node, doc); + + // `domCellAround` matches any `TD`/`TH` in the DOM, so the hovered block may + // just as well be a custom block that renders a table of its own. Checking + // the block itself (rather than only whether the schema has a table block) + // keeps the handles off blocks that have no table content to work with. + if (!blockHasType(block, this.editor, "table")) { + this.hideHandles(); + return; + } + + const tableBlock = block as unknown as BlockFromConfigNoChildren< DefaultBlockSchema["table"], any, any >; - if (editorHasBlockWithType(this.editor, "table")) { - this.tablePos = pmNodeInfo.posBeforeNode + 1; - tableBlock = block; - } - - if (!tableBlock) { - return; - } - + this.tablePos = pmNodeInfo.posBeforeNode + 1; + this.tableElement = blockEl.node; this.tableId = blockEl.id; const widgetContainer = target.domNode .closest(".tableWrapper") @@ -916,11 +919,8 @@ export const TableHandlesExtension = createExtension(({ editor }) => { * interfering with open submenus. */ hideHandlesIfNotFrozen() { - if (!view!.menuFrozen && view!.state?.show) { - view!.state.show = false; - view!.state.showAddOrRemoveRowsButton = false; - view!.state.showAddOrRemoveColumnsButton = false; - view!.emitUpdate(); + if (!view!.menuFrozen) { + view!.hideHandles(); } }, diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index eca8efa6a5..2763b9723c 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -1,6 +1,6 @@ import * as path from "path"; import { webpackStats } from "rollup-plugin-webpack-stats"; -import { defineConfig } from "vite-plus"; +import { configDefaults, defineConfig } from "vite-plus"; import pkg from "./package.json"; // import eslintPlugin from "vite-plugin-eslint"; @@ -21,6 +21,9 @@ export default defineConfig({ test: { environment: "jsdom", setupFiles: ["./vitestSetup.ts"], + // `.browser.test` files need a real browser; the tests package's browser + // suite runs them. + exclude: [...configDefaults.exclude, "**/*.browser.test.*"], }, plugins: [webpackStats()], build: { From 62c1d6e99e08c36b8dfbd874363f6c5cbcb13faf Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 14 Aug 2026 15:38:55 +0200 Subject: [PATCH 2/2] fix(core): resolve the table's position from the current document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tablePos` was captured when the handles attached to a cell and never updated, so any change before the table left it stale — a collaborator inserting a block above it, or an extension editing while a handle menu sits open. The drop-cursor decorations and `setCellSelection` then resolved into the wrong node, throwing `RangeError: Not a table node`. Resolve the position from `tableId` against the document each consumer is working with. The decorations callback can't read a cached position either way, as ProseMirror computes decorations for a transaction before it calls the plugin view's `update`. --- .../TableHandles/TableHandles.browser.test.ts | 22 +++++++++ .../extensions/TableHandles/TableHandles.ts | 49 +++++++++++++++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts b/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts index afe43eb331..30c7549284 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts @@ -280,4 +280,26 @@ describe("TableHandlesView mouse handling", () => { expect(tableHandlesState()?.show).toBe(false); }); + + // The table's position is captured when the handles attach to a cell, but + // the table shifts whenever content before it changes - a collaborator or an + // extension editing while a handle menu sits open, say. Acting on the handles + // afterwards used to resolve the stale position, landing in the wrong node. + it("acts on the hovered table after content is inserted before it", () => { + moveMouseOver(tableCell(0, 0)); + + editor.insertBlocks( + [{ type: "paragraph", content: "Inserted" }], + "table-0", + "before", + ); + + editor + .getExtension(TableHandlesExtension)! + .addRowOrColumn(0, { orientation: "row", side: "below" }); + + expect( + mountPoint.querySelectorAll('[data-id="table-0"] tbody tr'), + ).toHaveLength(3); + }); }); diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index ed44239660..bb396fbdd7 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -1,3 +1,4 @@ +import type { Node } from "prosemirror-model"; import { EditorState, Plugin, PluginKey, PluginView } from "prosemirror-state"; import { CellSelection, @@ -530,6 +531,22 @@ export class TableHandlesView implements PluginView { return true; }; + + // Resolves the position just inside the hovered table, within `doc`. The + // position is looked up by ID on each use rather than read from a cached one, + // as anything changing before the table (a collaborator inserting a block + // above it, say) shifts it: a stale position resolves into the wrong node, or + // past the end of the document entirely. + getTablePos(doc: Node): number | undefined { + if (this.tableId === undefined) { + return undefined; + } + + const pmNodeInfo = getNodeById(this.tableId, doc); + + return pmNodeInfo && pmNodeInfo.posBeforeNode + 1; + } + // Updates drag handles when the table is modified or removed. update() { if (!this.state || !this.state.show) { @@ -547,6 +564,7 @@ export class TableHandlesView implements PluginView { ) { this.state = undefined; this.tableId = undefined; + this.tablePos = undefined; this.tableElement = undefined; this.emitUpdate(); @@ -554,6 +572,9 @@ export class TableHandlesView implements PluginView { } this.state.block = refreshedBlock as typeof this.state.block; + // The table may have shifted in this update, so re-resolve its position. + this.tablePos = this.getTablePos(this.pmView.state.doc); + const { height: rowCount, width: colCount } = getDimensionsOfTable( this.state.block, ); @@ -650,12 +671,21 @@ export const TableHandlesExtension = createExtension(({ editor }) => { if ( view === undefined || view.state === undefined || - view.state.draggingState === undefined || - view.tablePos === undefined + view.state.draggingState === undefined ) { return; } + // Resolved against the state being rendered rather than read from + // `view.tablePos`, as ProseMirror computes decorations for a + // transaction before it calls the plugin view's `update` - so the + // cached position is still the pre-transaction one here. + const tablePos = view.getTablePos(state.doc); + + if (tablePos === undefined) { + return; + } + const newIndex = view.state.draggingState.draggedCellOrientation === "row" ? view.state.rowIndex @@ -686,7 +716,7 @@ export const TableHandlesExtension = createExtension(({ editor }) => { } // Gets the table to show the drop cursor in. - const tableResolvedPos = state.doc.resolve(view.tablePos + 1); + const tableResolvedPos = state.doc.resolve(tablePos + 1); if (view.state.draggingState.draggedCellOrientation === "row") { const cellsInRow = getCellsAtRowHandle( @@ -954,7 +984,18 @@ export const TableHandlesExtension = createExtension(({ editor }) => { throw new Error("Table handles view not initialized"); } - const tableResolvedPos = state.doc.resolve(view.tablePos! + 1); + // Resolved against `state`, which may be several transactions ahead of + // the mouse move that attached the handles (a menu can stay open across + // edits from elsewhere, e.g. a collaborator). + const tablePos = view.getTablePos(state.doc); + + if (tablePos === undefined) { + throw new Error( + "Attempted to select table cells, but the hovered table is no longer in the document.", + ); + } + + const tableResolvedPos = state.doc.resolve(tablePos + 1); const startRowResolvedPos = state.doc.resolve( tableResolvedPos.posAtIndex(relativeStartCell.row) + 1, );