Skip to content

Commit 5cefc4a

Browse files
committed
fix(core): only attach table handles to actual table blocks
`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 `<table>` 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 <id> not found`. Fixes #2964
1 parent ea5d803 commit 5cefc4a

3 files changed

Lines changed: 324 additions & 38 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
2+
3+
import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js";
4+
import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js";
5+
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
6+
import { createBlockSpec } from "../../schema/index.js";
7+
import { TableHandlesExtension } from "./TableHandles.js";
8+
9+
// Unit tests for the mouse handling in `TableHandlesView`, which decides
10+
// whether the row/column handles should be attached to the cell under the
11+
// cursor. It reads the DOM (`getBoundingClientRect`, node views, hit testing),
12+
// so it runs in the browser suite rather than as a node unit test.
13+
14+
// A custom block which renders a real `<table>` of its own, without having any
15+
// table content in the document. Table cells like these are indistinguishable
16+
// from a table block's cells when only looking at the DOM.
17+
const fakeTableBlock = createBlockSpec(
18+
{
19+
type: "fakeTable",
20+
propSchema: {},
21+
content: "none",
22+
},
23+
{
24+
render: () => {
25+
const dom = document.createElement("div");
26+
dom.className = "fake-table";
27+
dom.innerHTML =
28+
"<table><tbody><tr><td>Not a real cell</td></tr></tbody></table>";
29+
30+
return { dom };
31+
},
32+
},
33+
);
34+
35+
// A custom block which embeds a second editor, itself containing a table. The
36+
// nested editor's cells live inside the outer editor's DOM, so they reach the
37+
// outer editor's mouse handlers, but its block IDs are unknown to the outer
38+
// document.
39+
const nestedEditorBlock = createBlockSpec(
40+
{
41+
type: "nestedEditor",
42+
propSchema: {},
43+
content: "none",
44+
},
45+
{
46+
render: () => {
47+
const dom = document.createElement("div");
48+
dom.className = "nested-editor";
49+
dom.contentEditable = "false";
50+
51+
const nestedEditor = BlockNoteEditor.create({
52+
initialContent: [
53+
{
54+
id: "nested-table",
55+
type: "table",
56+
content: {
57+
type: "tableContent",
58+
rows: [{ cells: ["Nested cell"] }],
59+
},
60+
},
61+
],
62+
});
63+
nestedEditor.mount(dom, { portalTarget: document.body });
64+
65+
return { dom, destroy: () => nestedEditor.unmount() };
66+
},
67+
},
68+
);
69+
70+
const schema = BlockNoteSchema.create({
71+
blockSpecs: {
72+
...defaultBlockSpecs,
73+
fakeTable: fakeTableBlock(),
74+
nestedEditor: nestedEditorBlock(),
75+
},
76+
});
77+
78+
describe("TableHandlesView mouse handling", () => {
79+
let editor: BlockNoteEditor<any, any, any>;
80+
let mountPoint: HTMLElement;
81+
82+
beforeEach(() => {
83+
mountPoint = document.createElement("div");
84+
document.body.appendChild(mountPoint);
85+
86+
editor = BlockNoteEditor.create({
87+
schema,
88+
initialContent: [
89+
{
90+
id: "table-0",
91+
type: "table",
92+
content: {
93+
type: "tableContent",
94+
rows: [
95+
{ cells: ["Cell 1", "Cell 2", "Cell 3"] },
96+
{ cells: ["Cell 4", "Cell 5", "Cell 6"] },
97+
],
98+
},
99+
},
100+
{ id: "paragraph-0", type: "paragraph", content: "Paragraph" },
101+
{ id: "fake-table-0", type: "fakeTable" },
102+
{ id: "nested-editor-0", type: "nestedEditor" },
103+
],
104+
}) as BlockNoteEditor<any, any, any>;
105+
editor.mount(mountPoint);
106+
});
107+
108+
afterEach(() => {
109+
editor.unmount();
110+
editor._tiptapEditor.destroy();
111+
mountPoint.remove();
112+
});
113+
114+
function tableHandlesState() {
115+
return editor.getExtension(TableHandlesExtension)!.store.state;
116+
}
117+
118+
function queryElement(selector: string) {
119+
const element = mountPoint.querySelector<HTMLElement>(selector);
120+
if (!element) {
121+
throw new Error(`No element matching "${selector}"`);
122+
}
123+
return element;
124+
}
125+
126+
// The cell in the (only) real table block, at the given row and column.
127+
function tableCell(rowIndex: number, colIndex: number) {
128+
const cell = queryElement(
129+
`[data-id="table-0"] tbody tr:nth-child(${rowIndex + 1})`,
130+
).children[colIndex];
131+
if (!(cell instanceof HTMLElement)) {
132+
throw new Error(`No cell at row ${rowIndex}, column ${colIndex}`);
133+
}
134+
return cell;
135+
}
136+
137+
function dispatchMouseEvent(
138+
type: "mousemove" | "mousedown",
139+
el: HTMLElement,
140+
) {
141+
const rect = el.getBoundingClientRect();
142+
el.dispatchEvent(
143+
new MouseEvent(type, {
144+
bubbles: true,
145+
clientX: rect.left + rect.width / 2,
146+
clientY: rect.top + rect.height / 2,
147+
}),
148+
);
149+
}
150+
151+
// Moves the mouse over `el` and asserts the handler survived it. An event
152+
// listener that throws doesn't propagate the error to `dispatchEvent`'s
153+
// caller - the browser reports it as an `error` event on `window` instead -
154+
// so the error has to be picked up from there to be visible to the test.
155+
function moveMouseOver(el: HTMLElement) {
156+
const errors: unknown[] = [];
157+
const collectError = (event: ErrorEvent) => {
158+
errors.push(event.error ?? event.message);
159+
};
160+
161+
window.addEventListener("error", collectError);
162+
try {
163+
dispatchMouseEvent("mousemove", el);
164+
} finally {
165+
window.removeEventListener("error", collectError);
166+
}
167+
168+
expect(errors).toEqual([]);
169+
}
170+
171+
it("attaches the handles to the hovered cell", () => {
172+
moveMouseOver(tableCell(1, 1));
173+
174+
expect(tableHandlesState()).toMatchObject({
175+
show: true,
176+
rowIndex: 1,
177+
colIndex: 1,
178+
block: { id: "table-0" },
179+
});
180+
});
181+
182+
it("only offers the add/remove buttons on the last row and column", () => {
183+
moveMouseOver(tableCell(0, 0));
184+
185+
expect(tableHandlesState()).toMatchObject({
186+
showAddOrRemoveRowsButton: false,
187+
showAddOrRemoveColumnsButton: false,
188+
});
189+
190+
moveMouseOver(tableCell(1, 2));
191+
192+
expect(tableHandlesState()).toMatchObject({
193+
showAddOrRemoveRowsButton: true,
194+
showAddOrRemoveColumnsButton: true,
195+
});
196+
});
197+
198+
it("hides the handles when the mouse leaves the table", () => {
199+
moveMouseOver(tableCell(0, 0));
200+
expect(tableHandlesState()?.show).toBe(true);
201+
202+
moveMouseOver(queryElement('[data-id="paragraph-0"]'));
203+
204+
expect(tableHandlesState()?.show).toBe(false);
205+
});
206+
207+
it("hides the handles while text is being selected with the mouse", () => {
208+
moveMouseOver(tableCell(0, 0));
209+
expect(tableHandlesState()?.show).toBe(true);
210+
211+
dispatchMouseEvent("mousedown", tableCell(0, 0));
212+
moveMouseOver(tableCell(0, 1));
213+
214+
expect(tableHandlesState()?.show).toBe(false);
215+
});
216+
217+
it("keeps the handles on the same cell while frozen", () => {
218+
moveMouseOver(tableCell(0, 0));
219+
editor.getExtension(TableHandlesExtension)!.freezeHandles();
220+
221+
moveMouseOver(tableCell(1, 2));
222+
223+
expect(tableHandlesState()).toMatchObject({
224+
show: true,
225+
rowIndex: 0,
226+
colIndex: 0,
227+
});
228+
229+
editor.getExtension(TableHandlesExtension)!.unfreezeHandles();
230+
moveMouseOver(tableCell(1, 2));
231+
232+
expect(tableHandlesState()).toMatchObject({
233+
show: true,
234+
rowIndex: 1,
235+
colIndex: 2,
236+
});
237+
});
238+
239+
it("does not show the handles when the editor is not editable", () => {
240+
editor.isEditable = false;
241+
242+
moveMouseOver(tableCell(0, 0));
243+
244+
expect(tableHandlesState()?.show).toBeFalsy();
245+
});
246+
247+
// Regression test for https://github.com/TypeCellOS/BlockNote/issues/2964.
248+
// The hovered block used to be treated as a table as soon as the schema had
249+
// a table block in it, so a custom block rendering a `<table>` ended up on
250+
// the table path and crashed on its (absent) table content.
251+
it("ignores cells of a custom block that renders its own table", () => {
252+
moveMouseOver(queryElement(".fake-table td"));
253+
254+
expect(tableHandlesState()?.show).toBeFalsy();
255+
});
256+
257+
it("hides the handles when moving from a table onto a custom block's table", () => {
258+
moveMouseOver(tableCell(0, 0));
259+
expect(tableHandlesState()?.show).toBe(true);
260+
261+
moveMouseOver(queryElement(".fake-table td"));
262+
263+
expect(tableHandlesState()?.show).toBe(false);
264+
});
265+
266+
// The other half of #2964: the hovered cell belongs to a nested editor, so
267+
// its block ID cannot be resolved in the outer editor's document. That used
268+
// to throw `Block with ID <id> not found` on every mouse move.
269+
it("ignores table cells belonging to a nested editor", () => {
270+
moveMouseOver(queryElement(".nested-editor td"));
271+
272+
expect(tableHandlesState()?.show).toBeFalsy();
273+
});
274+
275+
it("hides the handles when moving from a table onto a nested editor's table", () => {
276+
moveMouseOver(tableCell(0, 0));
277+
expect(tableHandlesState()?.show).toBe(true);
278+
279+
moveMouseOver(queryElement(".nested-editor td"));
280+
281+
expect(tableHandlesState()?.show).toBe(false);
282+
});
283+
});

0 commit comments

Comments
 (0)