diff --git a/docs/content/docs/react/components/side-menu.mdx b/docs/content/docs/react/components/side-menu.mdx
index cec3bfedc5..f4db4c09b6 100644
--- a/docs/content/docs/react/components/side-menu.mdx
+++ b/docs/content/docs/react/components/side-menu.mdx
@@ -50,3 +50,26 @@ You can also change the items in the Drag Handle Menu. The demo below adds an it
Here, we use the `SideMenu` component but keep the default buttons (we don't pass any children). Instead, we pass our customized Drag Handle Menu using the `dragHandleMenu` prop.
+
+## Hover and Drop Areas
+
+By default, the Block Side Menu shows only while the mouse is over the editor, including its padding, where the menu sits. A dragged block can still be dropped when it is up to 250px outside the editor. You can change both distances with the `sideMenu` editor option:
+
+```tsx
+import { useCreateBlockNote } from "@blocknote/react";
+
+const editor = useCreateBlockNote({
+ sideMenu: {
+ // Also show the menu up to 100px left or right of the editor.
+ hoverMargin: 100,
+ // Accept drops up to 400px outside the editor.
+ dropMargin: 400,
+ },
+});
+```
+
+`hoverMargin:` How far left or right of the editor (in px) the mouse still shows the menu, for the block at the height of the mouse. Defaults to `0`.
+
+`dropMargin:` How far outside the editor (in px), on any side, a dragged block can still be dropped. The block goes to the nearest position in the editor. Defaults to `250`.
+
+Both distances count from the editor's edge, so with the same value, the menu shows left and right of the editor as far out as a block can be dropped.
diff --git a/packages/core/src/api/exporters/html/externalHTMLExporter.ts b/packages/core/src/api/exporters/html/externalHTMLExporter.ts
index 2149c884e7..2f6334941f 100644
--- a/packages/core/src/api/exporters/html/externalHTMLExporter.ts
+++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts
@@ -27,7 +27,7 @@ import {
// inside a list are un-nested and a new list is created after them.
// 4. The HTML is wrapped in a single `div` element.
-// Needs to be sync because it's used in drag handler event (SideMenuPlugin)
+// Needs to be sync because it's used in drag handler event (SideMenuExtension)
export const createExternalHTMLExporter = <
BSchema extends BlockSchema,
I extends InlineContentSchema,
diff --git a/packages/core/src/api/exporters/markdown/markdownExporter.ts b/packages/core/src/api/exporters/markdown/markdownExporter.ts
index 98be8b38c6..3f93d1d03d 100644
--- a/packages/core/src/api/exporters/markdown/markdownExporter.ts
+++ b/packages/core/src/api/exporters/markdown/markdownExporter.ts
@@ -11,7 +11,7 @@ import { createExternalHTMLExporter } from "../html/externalHTMLExporter.js";
import { EMPTY_BLOCK_PLACEHOLDER } from "../html/util/serializeBlocksExternalHTML.js";
import { htmlToMarkdown } from "./htmlToMarkdown.js";
-// Needs to be sync because it's used in drag handler event (SideMenuPlugin)
+// Needs to be sync because it's used in drag handler event (SideMenuExtension)
export function cleanHTMLToMarkdown(cleanHTMLString: string) {
// The external HTML exporter fills empty inline-content blocks with a
// placeholder character so they survive an HTML round trip (see
diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts
index 3390325563..4ea58a4792 100644
--- a/packages/core/src/editor/BlockNoteEditor.ts
+++ b/packages/core/src/editor/BlockNoteEditor.ts
@@ -21,6 +21,7 @@ import {
import {
BlockChangeExtension,
DropCursorOptions,
+ SideMenuOptions,
} from "../extensions/index.js";
import { UniqueID } from "../extensions/tiptap-extensions/UniqueID/UniqueID.js";
import type { Dictionary } from "../i18n/dictionary.js";
@@ -126,6 +127,13 @@ export interface BlockNoteEditorOptions<
*/
dropCursor?: DropCursorOptions;
+ /**
+ * Options for the side menu, the menu with the drag handle and "+" button
+ * next to the block under the mouse.
+ * @remarks `SideMenuOptions`
+ */
+ sideMenu?: SideMenuOptions;
+
/**
* The content that should be in the editor when it's created, represented as an array of {@link PartialBlock} objects.
*
diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts
index 112803b95d..ec7904dc35 100644
--- a/packages/core/src/extensions/SideMenu/SideMenu.ts
+++ b/packages/core/src/extensions/SideMenu/SideMenu.ts
@@ -1,18 +1,8 @@
-import { DOMParser, Slice } from "@tiptap/pm/model";
-import {
- EditorState,
- Plugin,
- PluginKey,
- PluginView,
- TextSelection,
-} from "@tiptap/pm/state";
-import { EditorView } from "@tiptap/pm/view";
-
import { Block } from "../../blocks/defaultBlocks.js";
-import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import {
createExtension,
createStore,
+ ExtensionOptions,
} from "../../editor/BlockNoteExtension.js";
import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js";
import {
@@ -20,814 +10,438 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../schema/index.js";
-import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js";
+import { registerBlockDropHandlers } from "./blockDropHandling.js";
import { dragStart, unsetDragImage } from "./dragging.js";
+import {
+ HoveredBlock,
+ isHeadingToMenu,
+ Point,
+ resolveHoveredBlock,
+} from "./sideMenuHover.js";
export type SideMenuState<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
> = UiElementPosition & {
- // The block that the side menu is attached to.
+ /**
+ * The block that the side menu is attached to. While the menu is hidden
+ * (`show` is `false`), the block it was last attached to, so it can animate
+ * out next to it. The store's state is `undefined` until the menu first
+ * shows.
+ */
block: Block;
};
-const DISTANCE_TO_CONSIDER_EDITOR_BOUNDS = 250;
-
-function getBlockFromCoords(
- view: EditorView,
- coords: { left: number; top: number },
- adjustForColumns = true,
-) {
- const elements = view.root.elementsFromPoint(coords.left, coords.top);
-
- for (const element of elements) {
- if (!view.dom.contains(element)) {
- // probably a ui overlay like formatting toolbar etc
- continue;
- }
- if (adjustForColumns) {
- const column = element.closest("[data-node-type=columnList]");
- if (column) {
- return getBlockFromCoords(
- view,
- {
- // TODO can we do better than this?
- left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself
- top: coords.top,
- },
- false,
- );
- }
- }
- return getDraggableBlockFromElement(element, view);
- }
- return undefined;
-}
-
-function getBlockFromMousePos(
- mousePos: {
- x: number;
- y: number;
- },
- view: EditorView,
-): { node: HTMLElement; id: string } | undefined {
- // Editor itself may have padding or other styling which affects
- // size/position, so we get the boundingRect of the first child (i.e. the
- // blockGroup that wraps all blocks in the editor) for more accurate side
- // menu placement.
- if (!view.dom.firstChild) {
- return;
- }
-
- const editorBoundingBox = (
- view.dom.firstChild as HTMLElement
- ).getBoundingClientRect();
-
- // Gets block at mouse cursor's position.
- const coords = {
- // Clamps the x position to the editor's bounding box.
- left: Math.min(
- Math.max(editorBoundingBox.left + 10, mousePos.x),
- editorBoundingBox.right - 10,
- ),
- top: mousePos.y,
- };
-
- const referenceBlock = getBlockFromCoords(view, coords);
-
- if (!referenceBlock) {
- // could not find the reference block
- return undefined;
- }
-
+/**
+ * How far the areas around the editor where the side menu responds reach, in
+ * px from the editor element's box. The editor element itself (including its
+ * padding, where the menu sits) always counts.
+ */
+export type SideMenuOptions = {
/**
- * Because blocks may be nested, we need to check the right edge of the parent block:
- * ```
- * | BlockA |
- * x | BlockB y|
- * ```
- * Hovering at position x (left edge of BlockB) would return BlockA.
- * Instead, we check at position y (right edge of BlockA) to correctly identify BlockB.
+ * How far left or right of the editor element the pointer still shows the
+ * side menu, for the block at the pointer's height. Above or below the
+ * editor, no block is at that height.
+ * @default 0
*/
- const referenceBlocksBoundingBox =
- referenceBlock.node.getBoundingClientRect();
- return getBlockFromCoords(
- view,
- {
- left: referenceBlocksBoundingBox.right - 10,
- top: mousePos.y,
- },
- false,
- );
-}
+ hoverMargin?: number;
+ /**
+ * How far outside the editor element, on any side, a dragged block can still
+ * be dropped into it (the drop goes to the nearest position in the editor).
+ * @default 250
+ */
+ dropMargin?: number;
+};
/**
- * With the sidemenu plugin we can position a menu next to a hovered block.
+ * How long the pointer may rest on its way to the menu (inside the triangle
+ * from where it left the block to the menu) before the menu follows it to the
+ * block it is over.
*/
-export class SideMenuView<
- BSchema extends BlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
-> implements PluginView {
- public state?: SideMenuState;
- public readonly emitUpdate: (state: SideMenuState) => void;
-
- private mousePos: { x: number; y: number } | undefined;
-
- private hoveredBlock: HTMLElement | undefined;
-
- public menuFrozen = false;
-
- public isDragOrigin = false;
-
- constructor(
- private readonly editor: BlockNoteEditor,
- private readonly pmView: EditorView,
- emitUpdate: (state: SideMenuState) => void,
- ) {
- this.emitUpdate = () => {
- if (!this.state) {
- throw new Error("Attempting to update uninitialized side menu");
- }
+const INTENT_TIMEOUT = 350;
- emitUpdate(this.state);
- };
-
- this.pmView.root.addEventListener(
- "dragstart",
- this.onDragStart as EventListener,
- );
- this.pmView.root.addEventListener(
- "dragover",
- this.onDragOver as EventListener,
- );
- this.pmView.root.addEventListener(
- "drop",
- this.onDrop as EventListener,
- true,
- );
- this.pmView.root.addEventListener(
- "dragend",
- this.onDragEnd as EventListener,
- true,
- );
-
- // Shows or updates menu position whenever the cursor moves, if the menu isn't frozen.
- this.pmView.root.addEventListener(
- "mousemove",
- this.onMouseMove as EventListener,
- true,
- );
-
- // Hides and unfreezes the menu whenever the user presses a key.
- this.pmView.root.addEventListener(
- "keydown",
- this.onKeyDown as EventListener,
- true,
- );
+/** The event's target as an element (text nodes resolve to their parent). */
+function targetElement(target: EventTarget | null): Element | null {
+ if (!target || !("nodeType" in target)) {
+ return null;
}
+ const node = target as Node;
+ return node.nodeType === Node.ELEMENT_NODE
+ ? (node as Element)
+ : node.parentElement;
+}
- updateState = (state: SideMenuState) => {
- this.state = state;
- this.emitUpdate(this.state);
- };
-
- updateStateFromMousePos = () => {
- if (this.menuFrozen || !this.mousePos) {
- return;
- }
-
- const closestEditor = this.findClosestEditorElement({
- clientX: this.mousePos.x,
- clientY: this.mousePos.y,
- });
+/**
+ * Shows a menu next to the block the pointer is about. This
+ * extension owns that decision: it listens to the pointer in `mount` and
+ * writes the store; the UI only reads the store, positions the menu, and
+ * registers the menu's element with `setMenuElement`.
+ *
+ * The block comes from the topmost element under the pointer (so an editor
+ * covered by another element or editor gets no menu), falling back to probing
+ * at the pointer's height when that element is padding or the gutter beside
+ * the blocks. When the pointer leaves the block towards the menu, the menu
+ * stays with the block while the pointer is inside the triangle from its exit
+ * point to the menu, until it rests there for {@link INTENT_TIMEOUT}. Once on
+ * the menu, leaving it anywhere but onto its block follows the pointer at
+ * once. The menu hides when the pointer leaves the window.
+ */
+export const SideMenuExtension = createExtension(
+ ({
+ editor,
+ options,
+ }: ExtensionOptions<{ sideMenu?: SideMenuOptions } | undefined>) => {
+ const store = createStore | undefined>(
+ undefined,
+ );
- if (
- closestEditor?.element !== this.pmView.dom ||
- closestEditor.distance > DISTANCE_TO_CONSIDER_EDITOR_BOUNDS
- ) {
- if (this.state?.show) {
- this.state.show = false;
- this.updateState(this.state);
+ const hoverMargin = options?.sideMenu?.hoverMargin ?? 0;
+
+ let menuFrozen = false;
+ let isDragOrigin = false;
+ // The side menu's DOM element, registered by the UI that renders it.
+ let menuElement: HTMLElement | undefined;
+ // The last pointer position over this document.
+ let pointer: Point | undefined;
+ // The last pointer position over the active block (or the menu).
+ let lastOnActive: Point | undefined;
+ // Set while the pointer is heading from the active block to the menu.
+ let intent:
+ | { exit: Point; timer: ReturnType }
+ | undefined;
+
+ function clearIntent() {
+ if (intent) {
+ clearTimeout(intent.timer);
+ intent = undefined;
}
- return;
}
- const block = getBlockFromMousePos(this.mousePos, this.pmView);
-
- // Closes the menu if the mouse cursor is beyond the editor vertically.
- if (!block || !this.editor.isEditable) {
- if (this.state?.show) {
- this.state.show = false;
- this.updateState(this.state);
+ function hideMenu() {
+ const state = store.state;
+ if (state?.show) {
+ store.setState({ ...state, show: false });
}
-
- return;
- }
-
- // Doesn't update if the menu is already open and the mouse cursor is still hovering the same block.
- if (
- this.state?.show &&
- this.hoveredBlock?.hasAttribute("data-id") &&
- this.hoveredBlock?.getAttribute("data-id") === block.id
- ) {
- return;
}
- this.hoveredBlock = block.node;
-
- // Shows or updates elements.
- if (this.editor.isEditable) {
- const blockContentBoundingBox = block.node.getBoundingClientRect();
- const column = block.node.closest("[data-node-type=column]");
- const sideMenuBlock = this.editor.getBlock(
- this.hoveredBlock!.getAttribute("data-id")!,
- );
- if (!sideMenuBlock) {
- if (this.state?.show) {
- this.state.show = false;
- this.hoveredBlock = undefined;
- this.emitUpdate(this.state);
- }
+ function showMenu(hovered: HoveredBlock) {
+ const block = editor.getBlock(hovered.id);
+ if (!block) {
+ hideMenu();
return;
}
- this.state = {
+ store.setState({
show: true,
- referencePos: new DOMRect(
- column
- ? // We take the first child as column elements have some default
- // padding. This is a little weird since this child element will
- // be the first block, but since it's always non-nested and we
- // only take the x coordinate, it's ok.
- column.firstElementChild!.getBoundingClientRect().x
- : (
- this.pmView.dom.firstChild as HTMLElement
- ).getBoundingClientRect().x,
- blockContentBoundingBox.y,
- blockContentBoundingBox.width,
- blockContentBoundingBox.height,
- ),
- block: sideMenuBlock,
- };
- this.updateState(this.state);
- }
- };
-
- /**
- * If a block is being dragged, ProseMirror usually gets the context of what's
- * being dragged from `view.dragging`, which is automatically set when a
- * `dragstart` event fires in the editor. However, if the user tries to drag
- * and drop blocks between multiple editors, only the one in which the drag
- * began has that context, so we need to set it on the others manually. This
- * ensures that PM always drops the blocks in between other blocks, and not
- * inside them.
- *
- * After the `dragstart` event fires on the drag handle, it sets
- * `blocknote/html` data on the clipboard. This handler fires right after,
- * parsing the `blocknote/html` data into nodes and setting them on
- * `view.dragging`.
- *
- * Note: Setting `view.dragging` on `dragover` would be better as the user
- * could then drag between editors in different windows, but you can only
- * access `dataTransfer` contents on `dragstart` and `drop` events.
- */
- onDragStart = (event: DragEvent) => {
- const html = event.dataTransfer?.getData("blocknote/html");
- if (!html) {
- return;
+ // The hovered block's box: the menu goes next to the block itself, as
+ // the React UI anchors it to the block's element.
+ referencePos: hovered.node.getBoundingClientRect(),
+ block,
+ });
}
- if (this.pmView.dragging) {
- // already dragging, so no-op
- return;
- }
-
- const element = document.createElement("div");
- element.innerHTML = html;
-
- const parser = DOMParser.fromSchema(this.pmView.state.schema);
- const node = parser.parse(element, {
- topNode: this.pmView.state.schema.nodes["blockGroup"].create(),
- });
-
- this.pmView.dragging = {
- slice: new Slice(node.content, 0, 0),
- move: true,
- };
- };
+ /**
+ * Updates the menu for the pointer at `point` over `target`. With
+ * `allowIntent`, a pointer heading to the menu keeps the active block.
+ */
+ function update(
+ point: Point,
+ target: Element | null,
+ allowIntent: boolean,
+ ) {
+ // While a block is dragged by its handle, the menu must stay as it is:
+ // the handle is the drag source, and if the menu hid or moved, React would
+ // remove it from the DOM, so its `dragend` (and `blockDragEnd`, which
+ // removes the drag image) would never fire. The drop's document change
+ // otherwise re-resolves the block at the pointer's pre-drag position.
+ if (menuFrozen || isDragOrigin) {
+ // Frozen or dragging: keep the menu as it is.
+ return;
+ }
+ if (target && menuElement?.contains(target)) {
+ // On the menu: the pointer got where it was heading. Recording the
+ // point here makes it the exit point if the pointer leaves the menu,
+ // and as nothing is closer to the menu than the menu, leaving it
+ // anywhere but onto its block follows the pointer at once.
+ clearIntent();
+ lastOnActive = point;
+ // On the menu: keep the menu as it is.
+ return;
+ }
- /**
- * Finds the closest editor visually to the given coordinates
- */
- private findClosestEditorElement = (coords: {
- clientX: number;
- clientY: number;
- }) => {
- // Get all editor elements in the document
- const editors = Array.from(this.pmView.root.querySelectorAll(".bn-editor"));
-
- if (editors.length === 0) {
- return null;
- }
+ if (!editor.isEditable) {
+ clearIntent();
+ hideMenu();
+ // Read-only editor: hide the menu.
+ return;
+ }
- // Find the editor with the smallest distance to the coordinates
- let closestEditor = editors[0];
- let minDistance = Number.MAX_VALUE;
-
- editors.forEach((editor) => {
- const rect = editor
- .querySelector(".bn-block-group")!
- .getBoundingClientRect();
-
- const distanceX =
- coords.clientX < rect.left
- ? rect.left - coords.clientX
- : coords.clientX > rect.right
- ? coords.clientX - rect.right
- : 0;
-
- const distanceY =
- coords.clientY < rect.top
- ? rect.top - coords.clientY
- : coords.clientY > rect.bottom
- ? coords.clientY - rect.bottom
- : 0;
-
- const distance = Math.sqrt(
- Math.pow(distanceX, 2) + Math.pow(distanceY, 2),
+ const resolution = resolveHoveredBlock(
+ editor.prosemirrorView,
+ target,
+ point,
+ editor.isWithinEditor,
+ hoverMargin,
);
-
- if (distance < minDistance) {
- minDistance = distance;
- closestEditor = editor;
+ if (resolution.kind === "editorUI") {
+ // On other UI of the editor (e.g. a toolbar): keep the menu as it is.
+ return;
}
- });
-
- return {
- element: closestEditor,
- distance: minDistance,
- };
- };
-
- /**
- * This dragover event handler listens at the document level,
- * and is trying to handle dragover events for all editors.
- *
- * It specifically is trying to handle the following cases:
- * - If the dragover event is within the bounds of any editor, then it does nothing
- * - If the dragover event is outside the bounds of any editor, but close enough (within DISTANCE_TO_CONSIDER_EDITOR_BOUNDS) to the closest editor,
- * then it dispatches a synthetic dragover event to the closest editor (which will trigger the drop-cursor to be shown on that editor)
- * - If the dragover event is outside the bounds of the current editor, then it will dispatch a synthetic dragleave event to the current editor
- * (which will trigger the drop-cursor to be removed from the current editor)
- *
- * The synthetic event is a necessary evil because we do not control prosemirror-dropcursor to be able to show the drop-cursor within the range we want
- */
- onDragOver = (event: DragEvent) => {
- if ((event as any).synthetic) {
- return;
- }
-
- // Relevance gate: Only handle drags that belong to BlockNote
- // This prevents interference with external drag-and-drop libraries
- // by avoiding calls to closeDropCursor() for non-BlockNote drags
- const isBlockNoteDrag =
- this.pmView.dragging !== null ||
- this.isDragOrigin ||
- event.dataTransfer?.types.includes("blocknote/html") ||
- (event.target instanceof Node && this.pmView.dom.contains(event.target));
-
- if (!isBlockNoteDrag) {
- // Not a BlockNote-related drag, return early without any processing
- return;
- }
-
- const dragEventContext = this.getDragEventContext(event);
-
- if (!dragEventContext || !dragEventContext.isDropPoint) {
- // This is not a drag event that we are interested in
- // so, we close the drop-cursor
- this.closeDropCursor();
- return;
- }
-
- if (
- dragEventContext.isDropPoint &&
- !dragEventContext.isDropWithinEditorBounds
- ) {
- // we are the drop point, but the drag over event is not within the bounds of this editor instance
- // so, we need to dispatch an event that is in the bounds of this editor instance
- this.dispatchSyntheticEvent(event);
- }
- };
-
- /**
- * Closes the drop-cursor for the current editor
- */
- private closeDropCursor = () => {
- const evt = new Event("dragleave", { bubbles: false });
- // It needs to be synthetic, so we don't accidentally think it is a real dragend event
- (evt as any).synthetic = true;
- // We dispatch the event to the current editor, so that the drop-cursor is removed for it
- this.pmView.dom.dispatchEvent(evt);
- };
-
- /**
- * It is surprisingly difficult to determine the information we need to know about a drag event
- *
- * This function is trying to determine the following:
- * - Whether the current editor instance is the drop point
- * - Whether the current editor instance is the drag origin
- * - Whether the drop event is within the bounds of the current editor instance
- */
- getDragEventContext = (event: DragEvent) => {
- // Relevance gate: Only handle drags that belong to BlockNote
- // Check if at least one of the following is true:
- // 1. ProseMirror drag started in an editor
- // 2. Side menu drag
- // 3. BlockNote-specific data type in the drag
- // 4. (optional stricter mode) Event target is inside this editor
- const isBlockNoteDrag =
- this.pmView.dragging !== null ||
- this.isDragOrigin ||
- event.dataTransfer?.types.includes("blocknote/html") ||
- (event.target instanceof Node && this.pmView.dom.contains(event.target));
-
- if (!isBlockNoteDrag) {
- // Not a BlockNote-related drag, return early
- return undefined;
- }
-
- // We need to check if there is text content that is being dragged (select some text & just drag it)
- const textContentIsBeingDragged =
- !event.dataTransfer?.types.includes("blocknote/html") &&
- !!this.pmView.dragging;
- // This is the side menu drag from this plugin
- const sideMenuIsBeingDragged = !!this.isDragOrigin;
- // Tells us that the current editor instance has a drag ongoing (either text or side menu)
- const isDragOrigin = textContentIsBeingDragged || sideMenuIsBeingDragged;
-
- // Tells us which editor instance is the closest to the drag event (whether or not it is actually reasonably close)
- const closestEditor = this.findClosestEditorElement(event);
-
- // We arbitrarily decide how far is "too far" from the closest editor to be considered a drop point
- if (
- !closestEditor ||
- closestEditor.distance > DISTANCE_TO_CONSIDER_EDITOR_BOUNDS
- ) {
- // we are too far from the closest editor, or no editor was found
- return undefined;
- }
-
- // We check if the closest editor is the same as the current editor instance (which is the drop point)
- const isDropPoint = closestEditor.element === this.pmView.dom;
- // We check if the current editor instance is the same as the editor instance that the drag event is happening within
- const isDropWithinEditorBounds =
- isDropPoint && closestEditor.distance === 0;
-
- // We never want to handle drop events that are not related to us
- if (!isDropPoint && !isDragOrigin) {
- // we are not the drop point or drag origin, so not relevant to us
- return undefined;
- }
-
- return {
- isDropPoint,
- isDropWithinEditorBounds,
- isDragOrigin,
- };
- };
-
- /**
- * The drop event handler listens at the document level,
- * and handles drop events for all editors.
- *
- * It specifically handles the following cases:
- * - If we are both the drag origin and drop point:
- * - Let normal drop handling take over
- * - If we are the drop point but not the drag origin:
- * - Collapse selection to prevent PM from deleting unrelated content
- * - If drop event is outside our editor bounds, dispatch synthetic drop event to our editor
- * - If we are the drag origin but not the drop point:
- * - Delete the dragged content from our editor after a delay
- */
- onDrop = (event: DragEvent) => {
- if ((event as any).synthetic) {
- return;
- }
- // Relevance gate: Only handle drags that belong to BlockNote
- // This prevents interference with external drag-and-drop libraries
- const isBlockNoteDrag =
- this.pmView.dragging !== null ||
- this.isDragOrigin ||
- event.dataTransfer?.types.includes("blocknote/html") ||
- (event.target instanceof Node && this.pmView.dom.contains(event.target));
-
- if (!isBlockNoteDrag) {
- // Not a BlockNote-related drag, return early without any processing
- return;
- }
-
- const context = this.getDragEventContext(event);
- if (!context) {
- this.closeDropCursor();
- // This is not a drag event that we are interested in
- return;
- }
- const { isDropPoint, isDropWithinEditorBounds, isDragOrigin } = context;
-
- if (!isDropWithinEditorBounds && isDropPoint) {
- // Any time that the drop event is outside of the editor bounds (but still close to an editor instance)
- // We dispatch a synthetic event that is in the bounds of the editor instance, to have the correct drop point
- this.dispatchSyntheticEvent(event);
- }
-
- if (isDropPoint) {
- // The current instance is the drop point
-
- if (this.pmView.dragging) {
- // Do not collapse selection when text content is being dragged
+ const state = store.state;
+ const active = state?.show ? state.block.id : undefined;
+ if (resolution.kind === "block" && resolution.block.id === active) {
+ clearIntent();
+ lastOnActive = point;
+ // On the block of the menu: keep the menu as it is.
return;
}
- // Because the editor selection is unrelated to the dragged content, we
- // don't want PM to delete its content. Therefore, we collapse the
- // selection.
- this.pmView.dispatch(
- this.pmView.state.tr.setSelection(
- TextSelection.create(
- this.pmView.state.tr.doc,
- this.pmView.state.tr.selection.anchor,
- ),
- ),
- );
- return;
- } else if (isDragOrigin) {
- // The current instance is the drag origin, but not the drop point
- // our content got dropped somewhere else
-
- // Because the editor from which the block originates doesn't get a drop
- // event on it, PM doesn't delete its selected content. Therefore, we
- // need to do so manually.
- //
- // Note: Deleting the selected content from the editor from which the
- // block originates, may change its height. This can cause the position of
- // the editor in which the block is being dropping to shift, before it
- // can handle the drop event. That in turn can cause the drop to happen
- // somewhere other than the user intended. To get around this, we delay
- // deleting the selected content until all editors have had the chance to
- // handle the event.
- setTimeout(
- () => this.pmView.dispatch(this.pmView.state.tr.deleteSelection()),
- 0,
- );
- return;
- }
- };
- onDragEnd = (event: DragEvent) => {
- if ((event as any).synthetic) {
- return;
- }
- // When the user starts dragging a block, `view.dragging` is set on all
- // BlockNote editors. However, when the drag ends, only the editor that the
- // drag originated in automatically clears `view.dragging`. Therefore, we
- // have to manually clear it on all editors.
- this.pmView.dragging = null;
- };
-
- onKeyDown = (_event: KeyboardEvent) => {
- if (this.state?.show && this.editor.isFocused()) {
- // Typing in editor should hide side menu
- this.state.show = false;
- this.emitUpdate(this.state);
- }
- };
-
- onMouseMove = (event: MouseEvent) => {
- if (this.menuFrozen) {
- return;
- }
-
- // Synthetic mousemove events created via `new Event("mousemove")` (e.g.
- // dispatched by browser extensions) have no `clientX`/`clientY`, which
- // would make `elementsFromPoint` throw on the resulting non-finite
- // coordinates.
- if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) {
- return;
- }
-
- // Leaves the menu as it is while the pointer is over this editor's own UI
- // (a toolbar, a menu, the side menu itself) rather than its content: the
- // block under that UI is not what the pointer is about. It also keeps taps
- // on the UI working on iOS Safari, which delivers a tap as a hover first
- // and drops the click when that hover changes the page (WebKit's
- // ContentChangeObserver); with the mobile toolbar far below the blocks,
- // the hover would hide a shown side menu and every button needed two taps.
- if (
- event.target instanceof Node &&
- !this.pmView.dom.contains(event.target) &&
- this.editor.isWithinEditor(event.target as Element)
- ) {
- return;
- }
-
- this.mousePos = { x: event.clientX, y: event.clientY };
-
- // We want the full area of the editor to check if the cursor is hovering
- // above it though.
- const editorOuterBoundingBox = this.pmView.dom.getBoundingClientRect();
- const cursorWithinEditor =
- this.mousePos.x > editorOuterBoundingBox.left &&
- this.mousePos.x < editorOuterBoundingBox.right &&
- this.mousePos.y > editorOuterBoundingBox.top &&
- this.mousePos.y < editorOuterBoundingBox.bottom;
-
- // Doesn't update if the mouse hovers an element that's over the editor but
- // isn't a part of it or the side menu.
- if (
- // Cursor is within the editor area
- cursorWithinEditor &&
- // An element is hovered
- event &&
- event.target &&
- // Element is outside this editor and its portaled UI
- !this.editor.isWithinEditor(event.target as HTMLElement)
- ) {
- if (this.state?.show) {
- this.state.show = false;
- this.emitUpdate(this.state);
+ if (allowIntent && active !== undefined && menuElement?.isConnected) {
+ const exit = intent?.exit ?? lastOnActive ?? point;
+ if (isHeadingToMenu(point, exit, menuElement.getBoundingClientRect())) {
+ // Restarts the timeout, so it measures how long the pointer rests.
+ clearIntent();
+ intent = { exit, timer: setTimeout(releaseIntent, INTENT_TIMEOUT) };
+ // On the way to the menu: keep the menu on its block for now.
+ return;
+ }
}
- return;
+ // Not on the way to the menu: show the menu for the block under the
+ // pointer, or hide it if there is no block.
+ clearIntent();
+ if (resolution.kind === "block") {
+ lastOnActive = point;
+ showMenu(resolution.block);
+ } else {
+ hideMenu();
+ }
}
- this.updateStateFromMousePos();
- };
-
- private dispatchSyntheticEvent(event: DragEvent) {
- const evt = new Event(event.type as "dragover", event) as any;
- const dropPointBoundingBox = (
- this.pmView.dom.firstChild as HTMLElement
- ).getBoundingClientRect();
- evt.clientX = event.clientX;
- evt.clientY = event.clientY;
-
- evt.clientX = Math.min(
- Math.max(event.clientX, dropPointBoundingBox.left),
- dropPointBoundingBox.left + dropPointBoundingBox.width,
- );
- evt.clientY = Math.min(
- Math.max(event.clientY, dropPointBoundingBox.top),
- dropPointBoundingBox.top + dropPointBoundingBox.height,
- );
-
- evt.dataTransfer = event.dataTransfer;
- evt.preventDefault = () => event.preventDefault();
- evt.synthetic = true; // prevent recursion
- this.pmView.dom.dispatchEvent(evt);
- }
-
- // Needed in cases where the editor state updates without the mouse cursor
- // moving, as some state updates can require a side menu update. For example,
- // adding a button to the side menu which removes the block can cause the
- // block below to jump up into the place of the removed block when clicked,
- // allowing the user to click the button again without moving the cursor. This
- // would otherwise not update the side menu, and so clicking the button again
- // would attempt to remove the same block again, causing an error.
- update(_view: EditorView, prevState: EditorState) {
- const docChanged = !prevState.doc.eq(this.pmView.state.doc);
- if (docChanged && this.state?.show) {
- this.updateStateFromMousePos();
+ function endBlockDrag() {
+ unsetDragImage(editor.prosemirrorView.root);
+ isDragOrigin = false;
}
- }
- destroy() {
- if (this.state?.show) {
- this.state.show = false;
- this.emitUpdate(this.state);
+ /** The pointer rested on its way to the menu: follow it after all. */
+ function releaseIntent() {
+ intent = undefined;
+ refresh();
}
- this.pmView.root.removeEventListener(
- "mousemove",
- this.onMouseMove as EventListener,
- true,
- );
- this.pmView.root.removeEventListener(
- "dragstart",
- this.onDragStart as EventListener,
- );
- this.pmView.root.removeEventListener(
- "dragover",
- this.onDragOver as EventListener,
- );
- this.pmView.root.removeEventListener(
- "drop",
- this.onDrop as EventListener,
- true,
- );
- this.pmView.root.removeEventListener(
- "dragend",
- this.onDragEnd as EventListener,
- true,
- );
- this.pmView.root.removeEventListener(
- "keydown",
- this.onKeyDown as EventListener,
- true,
- );
- }
-}
-
-export const sideMenuPluginKey = new PluginKey("SideMenuPlugin");
-
-export const SideMenuExtension = createExtension(({ editor }) => {
- let view: SideMenuView | undefined;
- const store = createStore | undefined>(
- undefined,
- );
-
- return {
- key: "sideMenu",
- store,
- prosemirrorPlugins: [
- new Plugin({
- key: sideMenuPluginKey,
- view: (editorView) => {
- view = new SideMenuView(editor, editorView, (state) => {
- // TODO: Without spreading the state, in some cases like toggling
- // `show`, this doesn't trigger an update.
- store.setState({ ...state });
- });
- return view;
- },
- }),
- ],
-
- /**
- * Handles drag & drop events for blocks.
- */
- blockDragStart(
- event: { dataTransfer: DataTransfer | null; clientY: number },
- block: Block,
- ) {
- if (view) {
- view.isDragOrigin = true;
- }
- dragStart(event, block, editor);
- },
/**
- * Handles drag & drop events for blocks.
+ * Re-resolves the block for the last pointer position, without intent,
+ * e.g. after a document change. If the pointer is on the menu (e.g. it
+ * clicked a menu button), this resolves just right of the menu instead:
+ * the menu is next to its block, so that point is on the line of the block,
+ * in its column. The pointer itself can be over another block, e.g. the
+ * "+" of a block in a column is over the column to its left. If the block
+ * was removed, the block that moved up into its place takes over.
*/
- blockDragEnd() {
- unsetDragImage(editor.prosemirrorView.root);
- if (view) {
- view.isDragOrigin = false;
+ function refresh() {
+ if (!pointer) {
+ return;
}
+ const view = editor.prosemirrorView;
+ const menu = menuElement?.isConnected
+ ? menuElement.getBoundingClientRect()
+ : undefined;
+ const onMenu =
+ menu !== undefined &&
+ pointer.x >= menu.left &&
+ pointer.x <= menu.right &&
+ pointer.y >= menu.top &&
+ pointer.y <= menu.bottom;
+ const point = onMenu ? { x: menu.right + 1, y: pointer.y } : pointer;
+ // Looks past other UI of the editor at what is underneath.
+ const target =
+ view.root
+ .elementsFromPoint(point.x, point.y)
+ .find(
+ (element) =>
+ view.dom.contains(element) || !editor.isWithinEditor(element),
+ ) ?? null;
+ update(point, target, false);
+ }
- editor.blur();
- },
+ return {
+ key: "sideMenu",
+ store,
+
+ mount({
+ root,
+ signal,
+ }: {
+ root: Document | ShadowRoot;
+ signal: AbortSignal;
+ }) {
+ registerBlockDropHandlers(
+ editor.prosemirrorView,
+ () => isDragOrigin,
+ options?.sideMenu?.dropMargin ?? 250,
+ signal,
+ );
- /**
- * Whether the side menu is currently frozen (e.g. because the drag handle
- * menu is open).
- */
- get menuFrozen() {
- return view!.menuFrozen;
- },
+ // Shows or updates the menu whenever the pointer moves, unless frozen.
+ root.addEventListener(
+ // `mousemove`, not `pointermove`: iOS Safari delivers a tap as a hover
+ // (one compatibility `mousemove` to the tap point, then the click),
+ // which is how the side menu shows on phones.
+ "mousemove",
+ (event) => {
+ // Synthetic events created via `new Event("mousemove")` (e.g.
+ // dispatched by browser extensions) have no `clientX`/`clientY`,
+ // which would make `elementsFromPoint` throw on the resulting
+ // non-finite coordinates.
+ const { clientX, clientY, buttons } = event as Partial;
+ if (
+ menuFrozen ||
+ typeof clientX !== "number" ||
+ typeof clientY !== "number" ||
+ !Number.isFinite(clientX) ||
+ !Number.isFinite(clientY)
+ ) {
+ return;
+ }
+ if (isDragOrigin && buttons === 0) {
+ // The drag ended without `dragend` reaching the handle (e.g. the
+ // menu was unmounted by its controller): clean up now.
+ endBlockDrag();
+ }
+ const point = { x: clientX, y: clientY };
+ pointer = point;
+ update(point, targetElement(event.target), true);
+ },
+ { signal, capture: true },
+ );
- /**
- * Freezes the side menu. When frozen, the side menu will stay
- * attached to the same block regardless of which block is hovered by the
- * mouse cursor.
- */
- freezeMenu() {
- view!.menuFrozen = true;
- view!.state!.show = true;
- view!.emitUpdate(view!.state!);
- },
+ // The pointer left the window: no block is hovered any more. Only
+ // `mouseout` reports this (with no `relatedTarget`), as `mouseleave`
+ // doesn't reach a document or shadow root.
+ root.addEventListener(
+ "mouseout",
+ (event) => {
+ if (
+ (event as MouseEvent).relatedTarget === null &&
+ !menuFrozen &&
+ !isDragOrigin
+ ) {
+ pointer = undefined;
+ clearIntent();
+ hideMenu();
+ }
+ },
+ { signal, capture: true },
+ );
- /**
- * Unfreezes the side menu. When frozen, the side menu will stay
- * attached to the same block regardless of which block is hovered by the
- * mouse cursor.
- */
- unfreezeMenu() {
- view!.menuFrozen = false;
- view!.state!.show = false;
- view!.emitUpdate(view!.state!);
- },
+ // Hides the menu whenever the user types.
+ root.addEventListener(
+ "keydown",
+ () => {
+ if (store.state?.show && editor.isFocused()) {
+ clearIntent();
+ hideMenu();
+ }
+ },
+ { signal, capture: true },
+ );
- /**
- * Hides the side menu unless it is currently frozen (e.g. the drag
- * handle menu is open). Used to dismiss the menu on scroll without
- * interfering with open submenus.
- */
- hideMenuIfNotFrozen() {
- if (!view!.menuFrozen && view!.state?.show) {
- view!.state.show = false;
- view!.emitUpdate(view!.state!);
- }
- },
- } as const;
-});
+ // Needed in cases where the document changes without the pointer
+ // moving: blocks move under it when blocks above it change. For
+ // example, a side menu button which removes the block can cause the
+ // block below to jump up into the place of the removed block when
+ // clicked, allowing the user to click the button again without moving
+ // the pointer. This would otherwise not update the side menu, and so
+ // clicking the button again would attempt to remove the same block
+ // again, causing an error.
+ const unsubscribeOnChange = editor.onChange(() => {
+ if (store.state?.show) {
+ clearIntent();
+ refresh();
+ }
+ });
+
+ signal.addEventListener("abort", () => {
+ unsubscribeOnChange();
+ clearIntent();
+ hideMenu();
+ });
+ },
+
+ /**
+ * Registers the element that renders the side menu. Moving the pointer
+ * from a block towards this element keeps the menu on that block, and
+ * hovering it never changes the block.
+ */
+ setMenuElement(element: HTMLElement | undefined) {
+ menuElement = element;
+ },
+
+ /**
+ * Handles drag & drop events for blocks.
+ */
+ blockDragStart(
+ event: { dataTransfer: DataTransfer | null; clientY: number },
+ block: Block,
+ ) {
+ isDragOrigin = true;
+ dragStart(event, block, editor);
+ },
+
+ /**
+ * Handles drag & drop events for blocks.
+ */
+ blockDragEnd() {
+ endBlockDrag();
+ editor.blur();
+ },
+
+ /**
+ * Whether the side menu is currently frozen (e.g. because the drag handle
+ * menu is open).
+ */
+ get menuFrozen() {
+ return menuFrozen;
+ },
+
+ /**
+ * Freezes the side menu. When frozen, the side menu will stay
+ * attached to the same block regardless of which block is hovered by the
+ * mouse cursor.
+ */
+ freezeMenu() {
+ menuFrozen = true;
+ clearIntent();
+ const state = store.state;
+ if (state) {
+ store.setState({ ...state, show: true });
+ }
+ },
+
+ /**
+ * Unfreezes the side menu. When frozen, the side menu will stay
+ * attached to the same block regardless of which block is hovered by the
+ * mouse cursor.
+ */
+ unfreezeMenu() {
+ menuFrozen = false;
+ hideMenu();
+ },
+
+ /**
+ * Hides the side menu unless it is currently frozen (e.g. the drag
+ * handle menu is open). Used to dismiss the menu on scroll without
+ * interfering with open submenus.
+ */
+ hideMenuIfNotFrozen() {
+ // A drag can scroll the page, but hiding the menu would remove the drag
+ // source (see `update`).
+ if (!menuFrozen && !isDragOrigin) {
+ clearIntent();
+ hideMenu();
+ }
+ },
+ } as const;
+ },
+);
diff --git a/packages/core/src/extensions/SideMenu/blockDOM.browser.test.ts b/packages/core/src/extensions/SideMenu/blockDOM.browser.test.ts
new file mode 100644
index 0000000000..0af1da05bb
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/blockDOM.browser.test.ts
@@ -0,0 +1,102 @@
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import { getBlockElement, getDirectChildBlocks } from "./blockDOM.js";
+
+let root: HTMLElement | undefined;
+
+afterEach(() => {
+ root?.remove();
+ root = undefined;
+});
+
+/** Attaches the markup to the document and returns a lookup by `data-id`. */
+function mount(html: string) {
+ root = document.createElement("div");
+ root.innerHTML = html;
+ document.body.append(root);
+ return (id: string) =>
+ root!.querySelector(
+ `[data-id="${id}"]:not([data-node-type="blockOuter"])`,
+ )!;
+}
+
+// A column list with one block per column, the way BlockNote renders it: each
+// regular block is a `blockContainer` wrapped in `blockOuter` chrome.
+const columns = `
+ `;
+
+// A block that embeds another editor, with a block of its own.
+const nestedEditor = `
+ `;
+
+describe("getDirectChildBlocks", () => {
+ it("returns direct child blocks, skipping nested grandchildren", () => {
+ const byId = mount(columns);
+ // The blocks inside each column must not come back as the list's own
+ // children.
+ expect(getDirectChildBlocks(byId("list"))).toEqual([
+ byId("col-a"),
+ byId("col-b"),
+ ]);
+ });
+
+ it("sees through blockOuter wrappers to the blockContainer child", () => {
+ const byId = mount(columns);
+ expect(getDirectChildBlocks(byId("col-a"))).toEqual([byId("a")]);
+ });
+
+ it("skips the blocks of an editor nested in the block", () => {
+ const byId = mount(nestedEditor);
+ expect(getDirectChildBlocks(byId("outer"))).toEqual([]);
+ });
+});
+
+describe("getBlockElement", () => {
+ it("returns the block an element is in", () => {
+ const byId = mount(columns);
+ const editor = root!.querySelector(".bn-editor")!;
+ const text = byId("a").querySelector("span")!;
+ expect(getBlockElement(text, { dom: editor })).toBe(byId("a"));
+ expect(getBlockElement(byId("col-a"), { dom: editor })).toBe(byId("col-a"));
+ });
+
+ it("returns nothing for the editor element itself", () => {
+ mount(columns);
+ const editor = root!.querySelector(".bn-editor")!;
+ expect(getBlockElement(editor, { dom: editor })).toBeUndefined();
+ });
+
+ it("leaves the blocks of a nested editor to that editor", () => {
+ const byId = mount(nestedEditor);
+ const outer = root!.querySelector(".bn-editor")!;
+ const inner = root!.querySelector('[data-test="inner"]')!;
+ expect(getBlockElement(byId("inner"), { dom: outer })).toBeUndefined();
+ expect(getBlockElement(byId("inner"), { dom: inner })).toBe(byId("inner"));
+ // The inner editor's own padding is in the outer block, which belongs to
+ // the outer editor.
+ expect(getBlockElement(inner, { dom: inner })).toBeUndefined();
+ });
+});
diff --git a/packages/core/src/extensions/SideMenu/blockDOM.ts b/packages/core/src/extensions/SideMenu/blockDOM.ts
new file mode 100644
index 0000000000..2a9132280f
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/blockDOM.ts
@@ -0,0 +1,50 @@
+import type { EditorView } from "prosemirror-view";
+
+// FIXME: this file finds blocks through their rendered DOM (selectors,
+// `closest`). Resolving through ProseMirror instead would drop the selectors:
+// `view.posAtDOM` for the block an element is in, the document for a block's
+// children, and `view.nodeDOM` for their boxes. Check `posAtDOM` inside node
+// views and nested editors first. The same applies to
+// `getDraggableBlockFromElement`.
+
+// UniqueID and the block renderers put data-id on every block root.
+// Ordinary blocks also copy it to blockOuter, which is only rendering chrome.
+export const BLOCK_SELECTOR =
+ '[data-node-type][data-id]:not([data-node-type="blockOuter"])';
+
+/**
+ * Returns the root element of the block of this editor that the element is
+ * in, if there is one.
+ */
+export function getBlockElement(
+ element: Element,
+ view: Pick,
+): Element | undefined {
+ // Embedded editors own their blocks, even though their DOM is inside ours.
+ if (element.closest(".bn-editor") !== view.dom) {
+ return undefined;
+ }
+ const block = element.closest(BLOCK_SELECTOR);
+ return block && block !== view.dom && view.dom.contains(block)
+ ? block
+ : undefined;
+}
+
+/**
+ * Returns the child blocks of a block element: its nested blocks, or the
+ * children of a container. The blocks of an editor nested in the block are
+ * not its children: they belong to that editor.
+ */
+export function getDirectChildBlocks(container: Element): Element[] {
+ const editor = container.closest(".bn-editor");
+ const children: Element[] = [];
+ for (const child of container.querySelectorAll(BLOCK_SELECTOR)) {
+ if (
+ child.parentElement?.closest(BLOCK_SELECTOR) === container &&
+ child.closest(".bn-editor") === editor
+ ) {
+ children.push(child);
+ }
+ }
+ return children;
+}
diff --git a/packages/core/src/extensions/SideMenu/blockDropHandling.ts b/packages/core/src/extensions/SideMenu/blockDropHandling.ts
new file mode 100644
index 0000000000..46c38002f1
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/blockDropHandling.ts
@@ -0,0 +1,367 @@
+import { DOMParser, Slice } from "@tiptap/pm/model";
+import { TextSelection } from "@tiptap/pm/state";
+import { EditorView } from "@tiptap/pm/view";
+
+import { distanceToRect } from "./sideMenuHover.js";
+
+/** Marks events we dispatch ourselves, so the handlers ignore them. */
+type SyntheticEvent = Event & { synthetic?: boolean };
+
+function isSynthetic(event: Event): boolean {
+ return (event as SyntheticEvent).synthetic === true;
+}
+
+/**
+ * Finds the editor that a drag event is about, and the distance from the
+ * event to the blocks of that editor. If the event target is in an editor,
+ * that editor is the only candidate: where editors overlap, it is the editor
+ * on top. Otherwise, the candidates are the editors within `dropMargin` of the
+ * event, and it is the one closest to the event.
+ *
+ * `dropMargin` counts from the editor element, like `hoverMargin`. The
+ * distance that picks the closest editor counts to its blocks: the drop lands
+ * at the nearest block position, so with uneven editor padding, the editor
+ * whose blocks are nearest is the right pick. A drop outside the blocks, even
+ * in the editor's padding, is moved onto them.
+ */
+function findDropEditor(
+ view: EditorView,
+ event: DragEvent,
+ dropMargin: number,
+) {
+ const point = { x: event.clientX, y: event.clientY };
+ const target =
+ event.target instanceof Element ? event.target.closest(".bn-editor") : null;
+ const editors = target
+ ? [target]
+ : Array.from(view.root.querySelectorAll(".bn-editor")).filter(
+ (editor) =>
+ distanceToRect(point, editor.getBoundingClientRect()) <= dropMargin,
+ );
+ const coords = event;
+
+ if (editors.length === 0) {
+ return null;
+ }
+
+ let closestEditor = editors[0];
+ let minDistance = Number.MAX_VALUE;
+
+ for (const editor of editors) {
+ const rect = editor
+ .querySelector(".bn-block-group")!
+ .getBoundingClientRect();
+
+ const distanceX =
+ coords.clientX < rect.left
+ ? rect.left - coords.clientX
+ : coords.clientX > rect.right
+ ? coords.clientX - rect.right
+ : 0;
+
+ const distanceY =
+ coords.clientY < rect.top
+ ? rect.top - coords.clientY
+ : coords.clientY > rect.bottom
+ ? coords.clientY - rect.bottom
+ : 0;
+
+ const distance = Math.sqrt(Math.pow(distanceX, 2) + Math.pow(distanceY, 2));
+
+ if (distance < minDistance) {
+ minDistance = distance;
+ closestEditor = editor;
+ }
+ }
+
+ return { element: closestEditor, distance: minDistance };
+}
+
+/**
+ * Drag & drop between blocks and between editors. Moved from the
+ * former `SideMenuView` class into listeners bound to the extension's mount
+ * signal. It changes two things: where
+ * editors overlap, the editor under the drag event wins (see
+ * `findDropEditor`), and the drag data is parsed in an inert document. `isDragOrigin` reports whether a side menu drag started in this
+ * editor. A drop outside every editor but within `dropMargin` (px) of this
+ * one goes into this editor.
+ */
+export function registerBlockDropHandlers(
+ view: EditorView,
+ isDragOrigin: () => boolean,
+ dropMargin: number,
+ signal: AbortSignal,
+) {
+ function isBlockNoteDrag(event: DragEvent) {
+ return (
+ view.dragging !== null ||
+ isDragOrigin() ||
+ event.dataTransfer?.types.includes("blocknote/html") ||
+ (event.target instanceof Node && view.dom.contains(event.target))
+ );
+ }
+
+ /**
+ * Closes the drop-cursor for the current editor
+ */
+ function closeDropCursor() {
+ const evt: SyntheticEvent = new Event("dragleave", { bubbles: false });
+ // It needs to be synthetic, so we don't accidentally think it is a real dragend event
+ evt.synthetic = true;
+ // We dispatch the event to the current editor, so that the drop-cursor is removed for it
+ view.dom.dispatchEvent(evt);
+ }
+
+ /**
+ * Re-dispatches a drag event on this editor, with its coordinates clamped
+ * into the editor's blocks. A plain `Event` with the drag event's fields
+ * copied on, as a `DragEvent`'s coordinates can't be set.
+ */
+ function dispatchSyntheticEvent(event: DragEvent) {
+ const dropPointBoundingBox = (
+ view.dom.firstChild as HTMLElement
+ ).getBoundingClientRect();
+ const evt = Object.assign(new Event(event.type, event), {
+ clientX: Math.min(
+ Math.max(event.clientX, dropPointBoundingBox.left),
+ dropPointBoundingBox.left + dropPointBoundingBox.width,
+ ),
+ clientY: Math.min(
+ Math.max(event.clientY, dropPointBoundingBox.top),
+ dropPointBoundingBox.top + dropPointBoundingBox.height,
+ ),
+ dataTransfer: event.dataTransfer,
+ preventDefault: () => event.preventDefault(),
+ synthetic: true, // prevent recursion
+ });
+ view.dom.dispatchEvent(evt);
+ }
+
+ /**
+ * It is surprisingly difficult to determine the information we need to know about a drag event
+ *
+ * This function is trying to determine the following:
+ * - Whether the current editor instance is the drop point
+ * - Whether the current editor instance is the drag origin
+ * - Whether the drop event is within the bounds of the current editor instance
+ */
+ function getDragEventContext(event: DragEvent) {
+ if (!isBlockNoteDrag(event)) {
+ return undefined;
+ }
+
+ // We need to check if there is text content that is being dragged (select some text & just drag it)
+ const textContentIsBeingDragged =
+ !event.dataTransfer?.types.includes("blocknote/html") && !!view.dragging;
+ // This is the side menu drag from this plugin
+ const sideMenuIsBeingDragged = isDragOrigin();
+ // Tells us that the current editor instance has a drag ongoing (either text or side menu)
+ const isOrigin = textContentIsBeingDragged || sideMenuIsBeingDragged;
+
+ // The editor the drag event is about, if any is within `dropMargin`
+ const closestEditor = findDropEditor(view, event, dropMargin);
+ if (!closestEditor) {
+ return undefined;
+ }
+
+ // We check if the closest editor is the same as the current editor instance (which is the drop point)
+ const isDropPoint = closestEditor.element === view.dom;
+ // We check if the current editor instance is the same as the editor instance that the drag event is happening within
+ const isDropWithinEditorBounds =
+ isDropPoint && closestEditor.distance === 0;
+
+ // We never want to handle drop events that are not related to us
+ if (!isDropPoint && !isOrigin) {
+ return undefined;
+ }
+
+ return {
+ isDropPoint,
+ isDropWithinEditorBounds,
+ isDragOrigin: isOrigin,
+ };
+ }
+
+ /**
+ * If a block is being dragged, ProseMirror usually gets the context of what's
+ * being dragged from `view.dragging`, which is automatically set when a
+ * `dragstart` event fires in the editor. However, if the user tries to drag
+ * and drop blocks between multiple editors, only the one in which the drag
+ * began has that context, so we need to set it on the others manually. This
+ * ensures that PM always drops the blocks in between other blocks, and not
+ * inside them.
+ *
+ * After the `dragstart` event fires on the drag handle, it sets
+ * `blocknote/html` data on the clipboard. This handler fires right after,
+ * parsing the `blocknote/html` data into nodes and setting them on
+ * `view.dragging`.
+ *
+ * Note: Setting `view.dragging` on `dragover` would be better as the user
+ * could then drag between editors in different windows, but you can only
+ * access `dataTransfer` contents on `dragstart` and `drop` events.
+ */
+ function onDragStart(event: DragEvent) {
+ const html = event.dataTransfer?.getData("blocknote/html");
+ if (!html) {
+ return;
+ }
+
+ if (view.dragging) {
+ // already dragging, so no-op
+ return;
+ }
+
+ // Parsed in an inert document: unlike an element of the page, it does not
+ // load resources or run event handlers in the HTML.
+ const window = view.dom.ownerDocument.defaultView ?? globalThis.window;
+ const element = new window.DOMParser().parseFromString(
+ html,
+ "text/html",
+ ).body;
+
+ const parser = DOMParser.fromSchema(view.state.schema);
+ const node = parser.parse(element, {
+ topNode: view.state.schema.nodes["blockGroup"].create(),
+ });
+
+ view.dragging = {
+ slice: new Slice(node.content, 0, 0),
+ move: true,
+ };
+ }
+
+ /**
+ * This dragover event handler listens at the document level,
+ * and is trying to handle dragover events for all editors.
+ *
+ * It specifically is trying to handle the following cases:
+ * - If the dragover event is within the bounds of any editor, then it does nothing
+ * - If the dragover event is outside the bounds of any editor, but close enough (within `dropMargin`) to the closest editor,
+ * then it dispatches a synthetic dragover event to the closest editor (which will trigger the drop-cursor to be shown on that editor)
+ * - If the dragover event is outside the bounds of the current editor, then it will dispatch a synthetic dragleave event to the current editor
+ * (which will trigger the drop-cursor to be removed from the current editor)
+ *
+ * The synthetic event is a necessary evil because we do not control prosemirror-dropcursor to be able to show the drop-cursor within the range we want
+ */
+ function onDragOver(event: DragEvent) {
+ if (isSynthetic(event)) {
+ return;
+ }
+
+ // Relevance gate: Only handle drags that belong to BlockNote
+ // This prevents interference with external drag-and-drop libraries
+ // by avoiding calls to closeDropCursor() for non-BlockNote drags
+ if (!isBlockNoteDrag(event)) {
+ return;
+ }
+
+ const dragEventContext = getDragEventContext(event);
+
+ if (!dragEventContext || !dragEventContext.isDropPoint) {
+ // This is not a drag event that we are interested in
+ // so, we close the drop-cursor
+ closeDropCursor();
+ return;
+ }
+
+ if (!dragEventContext.isDropWithinEditorBounds) {
+ // we are the drop point, but the drag over event is not within the bounds of this editor instance
+ // so, we need to dispatch an event that is in the bounds of this editor instance
+ dispatchSyntheticEvent(event);
+ }
+ }
+
+ /**
+ * The drop event handler listens at the document level,
+ * and handles drop events for all editors.
+ *
+ * It specifically handles the following cases:
+ * - If we are both the drag origin and drop point:
+ * - Let normal drop handling take over
+ * - If we are the drop point but not the drag origin:
+ * - Collapse selection to prevent PM from deleting unrelated content
+ * - If drop event is outside our editor bounds, dispatch synthetic drop event to our editor
+ * - If we are the drag origin but not the drop point:
+ * - Delete the dragged content from our editor after a delay
+ */
+ function onDrop(event: DragEvent) {
+ if (isSynthetic(event)) {
+ return;
+ }
+
+ if (!isBlockNoteDrag(event)) {
+ return;
+ }
+
+ const context = getDragEventContext(event);
+ if (!context) {
+ closeDropCursor();
+ // This is not a drag event that we are interested in
+ return;
+ }
+ const { isDropPoint, isDropWithinEditorBounds, isDragOrigin } = context;
+
+ if (!isDropWithinEditorBounds && isDropPoint) {
+ // Any time that the drop event is outside of the editor bounds (but still close to an editor instance)
+ // We dispatch a synthetic event that is in the bounds of the editor instance, to have the correct drop point
+ dispatchSyntheticEvent(event);
+ }
+
+ if (isDropPoint) {
+ // The current instance is the drop point
+
+ if (view.dragging) {
+ // Do not collapse selection when text content is being dragged
+ return;
+ }
+ // Because the editor selection is unrelated to the dragged content, we
+ // don't want PM to delete its content. Therefore, we collapse the
+ // selection.
+ view.dispatch(
+ view.state.tr.setSelection(
+ TextSelection.create(
+ view.state.tr.doc,
+ view.state.tr.selection.anchor,
+ ),
+ ),
+ );
+ return;
+ } else if (isDragOrigin) {
+ // The current instance is the drag origin, but not the drop point
+ // our content got dropped somewhere else
+
+ // Because the editor from which the block originates doesn't get a drop
+ // event on it, PM doesn't delete its selected content. Therefore, we
+ // need to do so manually.
+ //
+ // Note: Deleting the selected content from the editor from which the
+ // block originates, may change its height. This can cause the position of
+ // the editor in which the block is being dropping to shift, before it
+ // can handle the drop event. That in turn can cause the drop to happen
+ // somewhere other than the user intended. To get around this, we delay
+ // deleting the selected content until all editors have had the chance to
+ // handle the event.
+ setTimeout(() => view.dispatch(view.state.tr.deleteSelection()), 0);
+ return;
+ }
+ }
+
+ function onDragEnd(event: DragEvent) {
+ if (isSynthetic(event)) {
+ return;
+ }
+ // When the user starts dragging a block, `view.dragging` is set on all
+ // BlockNote editors. However, when the drag ends, only the editor that the
+ // drag originated in automatically clears `view.dragging`. Therefore, we
+ // have to manually clear it on all editors.
+ view.dragging = null;
+ }
+
+ // The DOM lib types `root` as `Document | ShadowRoot`, whose listener maps
+ // don't share the drag event types, so the handlers are typed by hand.
+ const root = view.root as Document;
+ root.addEventListener("dragstart", onDragStart, { signal });
+ root.addEventListener("dragover", onDragOver, { signal });
+ root.addEventListener("drop", onDrop, { signal, capture: true });
+ root.addEventListener("dragend", onDragEnd, { signal, capture: true });
+}
diff --git a/packages/core/src/extensions/SideMenu/blockGeometry.browser.test.ts b/packages/core/src/extensions/SideMenu/blockGeometry.browser.test.ts
new file mode 100644
index 0000000000..590a115ec1
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/blockGeometry.browser.test.ts
@@ -0,0 +1,148 @@
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import { getNestedBlockAtCursor } from "./blockGeometry.js";
+
+// Exercise container hit testing with real layout, including nested columns.
+
+/** Attaches a tree to the document so the browser actually lays it out. */
+function mount(el: T): T {
+ document.body.appendChild(el);
+ mounted.push(el);
+ return el;
+}
+
+let mounted: HTMLElement[] = [];
+
+afterEach(() => {
+ mounted.forEach((el) => el.remove());
+ mounted = [];
+});
+
+function el(nodeType: string): HTMLElement {
+ const node = document.createElement("div");
+ node.setAttribute("data-node-type", nodeType);
+ node.setAttribute("data-id", crypto.randomUUID());
+ return node;
+}
+
+/** The `blockOuter > blockContainer` chrome BlockNote renders around every
+ * regular block, with real text in it so it has a real height. */
+function regularChild(text = "block"): {
+ outer: HTMLElement;
+ blockContainer: HTMLElement;
+} {
+ const outer = el("blockOuter");
+ const blockContainer = el("blockContainer");
+ blockContainer.textContent = text;
+ outer.append(blockContainer);
+ return { outer, blockContainer };
+}
+
+/**
+ * A column list laid out the way the real one is: a flex row of two columns,
+ * each holding one block. Nothing declares "horizontal". The browser puts the
+ * columns side by side and the module has to notice.
+ */
+function buildColumnList() {
+ const columnList = el("columnList");
+ columnList.style.display = "flex";
+ columnList.style.width = "400px";
+
+ const columnA = el("column");
+ const columnB = el("column");
+ for (const column of [columnA, columnB]) {
+ column.style.flex = "1";
+ }
+
+ const childA = regularChild("A");
+ const childB = regularChild("B");
+ columnA.append(childA.outer);
+ columnB.append(childB.outer);
+ columnList.append(columnA, columnB);
+ mount(columnList);
+
+ return { columnList, columnA, columnB, childA, childB };
+}
+
+/** A callout: an ordinary block-flow container, so its children stack. */
+function buildVerticalContainer() {
+ const callout = el("callout");
+ callout.style.width = "400px";
+ const first = regularChild("first");
+ const second = regularChild("second");
+ callout.append(first.outer, second.outer);
+ mount(callout);
+
+ return { callout, first, second };
+}
+
+describe("getNestedBlockAtCursor", () => {
+ it("keeps a regular block as the probe target", () => {
+ const { childA } = buildColumnList();
+ expect(
+ getNestedBlockAtCursor(childA.blockContainer, { x: 10, y: 10 }),
+ ).toBe(childA.blockContainer);
+ });
+
+ it("descends into the hovered column's block", () => {
+ const { columnList, childA, childB } = buildColumnList();
+ for (const child of [childA, childB]) {
+ const rect = child.blockContainer.getBoundingClientRect();
+ expect(
+ getNestedBlockAtCursor(columnList, {
+ x: rect.left + rect.width / 2,
+ y: rect.top + rect.height / 2,
+ }),
+ ).toBe(child.blockContainer);
+ }
+ });
+
+ it("finds a stacked child from the container gutter", () => {
+ const { callout, second } = buildVerticalContainer();
+ const rect = second.blockContainer.getBoundingClientRect();
+ expect(
+ getNestedBlockAtCursor(callout, {
+ x: rect.left - 20,
+ y: rect.top + rect.height / 2,
+ }),
+ ).toBe(second.blockContainer);
+ });
+
+ it("finds a framed block's child when moving into the side-menu gutter", () => {
+ const parent = regularChild("");
+ const frame = document.createElement("div");
+ frame.style.padding = "12px 16px";
+ const title = document.createElement("div");
+ title.textContent = "Callout title";
+ const child = regularChild("Callout body");
+ frame.append(title, child.outer);
+ parent.blockContainer.append(frame);
+ parent.outer.style.width = "400px";
+ mount(parent.outer);
+
+ const rect = child.blockContainer.getBoundingClientRect();
+ for (const x of [rect.left + 20, rect.left - 12, rect.left - 40]) {
+ expect(
+ getNestedBlockAtCursor(parent.blockContainer, {
+ x,
+ y: rect.top + rect.height / 2,
+ }),
+ ).toBe(child.blockContainer);
+ }
+ const titleRect = title.getBoundingClientRect();
+ expect(
+ getNestedBlockAtCursor(parent.blockContainer, {
+ x: titleRect.left,
+ y: titleRect.top + titleRect.height / 2,
+ }),
+ ).toBe(parent.blockContainer);
+ });
+
+ it("keeps the container when the cursor misses its children", () => {
+ const { callout } = buildVerticalContainer();
+ const rect = callout.getBoundingClientRect();
+ expect(
+ getNestedBlockAtCursor(callout, { x: rect.left, y: rect.bottom + 10 }),
+ ).toBe(callout);
+ });
+});
diff --git a/packages/core/src/extensions/SideMenu/blockGeometry.test.ts b/packages/core/src/extensions/SideMenu/blockGeometry.test.ts
new file mode 100644
index 0000000000..22d8dc32c8
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/blockGeometry.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { rectIndexAtCursor, type BlockRect } from "./blockGeometry.js";
+
+// Hit testing works for both side-by-side and stacked children.
+
+const rect = (
+ top: number,
+ bottom: number,
+ left: number,
+ right: number,
+): BlockRect => ({ top, bottom, left, right });
+
+// Two columns of a column list: same vertical band, adjacent horizontally.
+const SIDE_BY_SIDE = [rect(0, 100, 0, 100), rect(0, 100, 100, 200)];
+// Two blocks of a callout: same horizontal band, stacked vertically.
+const STACKED = [rect(0, 40, 0, 200), rect(50, 90, 0, 200)];
+
+describe("rectIndexAtCursor", () => {
+ it("returns the rect whose x range contains the cursor (side-by-side)", () => {
+ // Both rects share the y range, so only x distinguishes them. The
+ // vertical-only fallback recorded for the first must not win over an x
+ // match found later in the list; otherwise hovering the second column of
+ // a row would resolve to its neighbour.
+ expect(rectIndexAtCursor(SIDE_BY_SIDE, { x: 150, y: 50 })).toBe(1);
+ expect(rectIndexAtCursor(SIDE_BY_SIDE, { x: 10, y: 50 })).toBe(0);
+ });
+
+ it("falls back to the first vertical match when x is in the gutter", () => {
+ // The cursor's y is in the first block's band but its x is left of it (the
+ // side-menu gutter). The first vertical match wins.
+ expect(rectIndexAtCursor(STACKED, { x: -20, y: 20 })).toBe(0);
+ });
+
+ it("returns undefined when the cursor misses every rect vertically", () => {
+ expect(rectIndexAtCursor(STACKED, { x: 10, y: 999 })).toBeUndefined();
+ expect(rectIndexAtCursor(STACKED, { x: 10, y: -999 })).toBeUndefined();
+ expect(rectIndexAtCursor([], { x: 10, y: 10 })).toBeUndefined();
+ });
+
+ it("includes the rect edges", () => {
+ const single = [rect(0, 40, 0, 200)];
+ expect(rectIndexAtCursor(single, { x: 0, y: 0 })).toBe(0);
+ expect(rectIndexAtCursor(single, { x: 200, y: 40 })).toBe(0);
+ });
+});
diff --git a/packages/core/src/extensions/SideMenu/blockGeometry.ts b/packages/core/src/extensions/SideMenu/blockGeometry.ts
new file mode 100644
index 0000000000..3891af4812
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/blockGeometry.ts
@@ -0,0 +1,53 @@
+import { BLOCK_SELECTOR, getDirectChildBlocks } from "./blockDOM.js";
+
+export type BlockRect = {
+ top: number;
+ bottom: number;
+ left: number;
+ right: number;
+};
+
+// X-match wins over y-only match (disambiguates side-by-side children).
+export function rectIndexAtCursor(
+ rects: BlockRect[],
+ mousePos: { x: number; y: number },
+): number | undefined {
+ let verticalMatch: number | undefined = undefined;
+ for (let i = 0; i < rects.length; i++) {
+ const rect = rects[i];
+ if (mousePos.y < rect.top || mousePos.y > rect.bottom) {
+ continue;
+ }
+ if (mousePos.x >= rect.left && mousePos.x <= rect.right) {
+ return i;
+ }
+ verticalMatch = verticalMatch ?? i;
+ }
+ return verticalMatch;
+}
+
+/**
+ * Returns the deepest block at the height of the pointer, from a block that
+ * contains it: the block itself, or one of its descendants at that height.
+ * The children of a block are below its own content (nested blocks) or fill
+ * it (a container), so the pointer on the block's own content stays on the
+ * block. The pointer in the indentation of nested blocks, or in the padding
+ * of a container beside a child, goes to the child at that height.
+ */
+export function getNestedBlockAtCursor(
+ element: Element,
+ mousePos: { x: number; y: number },
+): Element {
+ while (element.matches(BLOCK_SELECTOR)) {
+ const children = getDirectChildBlocks(element);
+ const index = rectIndexAtCursor(
+ children.map((child) => child.getBoundingClientRect()),
+ mousePos,
+ );
+ if (index === undefined) {
+ break;
+ }
+ element = children[index];
+ }
+ return element;
+}
diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts
index f8ba326538..0848bd3f99 100644
--- a/packages/core/src/extensions/SideMenu/dragging.ts
+++ b/packages/core/src/extensions/SideMenu/dragging.ts
@@ -8,7 +8,6 @@ import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks.js"
import { getNodeById } from "../../api/nodeUtil.js";
import { Block } from "../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
-import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js";
import {
BlockSchema,
InlineContentSchema,
@@ -18,15 +17,6 @@ import { MultipleNodeSelection } from "./MultipleNodeSelection.js";
let dragImageElement: Element | undefined;
-export type SideMenuState<
- BSchema extends BlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
-> = UiElementPosition & {
- // The block that the side menu is attached to.
- block: Block;
-};
-
function blockPositionsFromSelection(selection: Selection, doc: Node) {
// Absolute positions just before the first block spanned by the selection, and just after the last block. Having the
// selection start and end just before and just after the target blocks ensures no whitespace/line breaks are left
diff --git a/packages/core/src/extensions/SideMenu/sideMenuHover.test.ts b/packages/core/src/extensions/SideMenu/sideMenuHover.test.ts
new file mode 100644
index 0000000000..855d46cd59
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/sideMenuHover.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { isHeadingToMenu } from "./sideMenuHover.js";
+
+// A menu left of and above the exit point, as for a block's side menu when
+// the pointer leaves the block at its bottom left.
+const menu = { left: 0, top: 0, right: 40, bottom: 20 };
+const exit = { x: 60, y: 60 };
+
+describe("isHeadingToMenu", () => {
+ it("accepts points on the way from the exit point to the menu", () => {
+ expect(isHeadingToMenu({ x: 50, y: 40 }, exit, menu)).toBe(true);
+ expect(isHeadingToMenu({ x: 20, y: 10 }, exit, menu)).toBe(true);
+ });
+
+ it("accepts a steep move just outside the hull's tip", () => {
+ // The hull's edge from the exit point to the menu's bottom left corner
+ // has y ≈ 54.7 at x = 52; (52, 56) is ~1.1px below it.
+ expect(isHeadingToMenu({ x: 52, y: 56 }, exit, menu)).toBe(true);
+ });
+
+ it("rejects points further than the buffer from the hull", () => {
+ expect(isHeadingToMenu({ x: 40, y: 55 }, exit, menu)).toBe(false);
+ });
+
+ it("rejects moves away from the menu, even next to the exit point", () => {
+ expect(isHeadingToMenu({ x: 60, y: 61 }, exit, menu)).toBe(false);
+ expect(isHeadingToMenu({ x: 61, y: 60 }, exit, menu)).toBe(false);
+ });
+
+ it("rejects every point once the exit point is on the menu", () => {
+ expect(isHeadingToMenu({ x: 30, y: 24 }, { x: 30, y: 19 }, menu)).toBe(
+ false,
+ );
+ });
+});
diff --git a/packages/core/src/extensions/SideMenu/sideMenuHover.ts b/packages/core/src/extensions/SideMenu/sideMenuHover.ts
new file mode 100644
index 0000000000..6ee14a1710
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/sideMenuHover.ts
@@ -0,0 +1,269 @@
+import { EditorView } from "@tiptap/pm/view";
+
+import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js";
+import { getBlockElement } from "./blockDOM.js";
+import { getNestedBlockAtCursor } from "./blockGeometry.js";
+
+export type Point = { x: number; y: number };
+
+export type Rect = { left: number; top: number; right: number; bottom: number };
+
+export type HoveredBlock = { node: HTMLElement; id: string };
+
+/**
+ * How far inside the edge of the blocks the probe point goes. A probe exactly
+ * on an edge can hit the element next to it.
+ */
+const PROBE_INSET = 10;
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(Math.max(value, min), max);
+}
+
+/** Returns the block of this editor at a visible point, if there is one. */
+function getBlockAtPoint(view: EditorView, point: Point) {
+ for (const element of view.root.elementsFromPoint(point.x, point.y)) {
+ // Skips UI painted over the blocks (e.g. a toolbar), and the blocks of an
+ // editor nested in one of ours: looks below them.
+ const block = getBlockElement(element, view);
+ if (block) {
+ return block;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Finds the block at the height of the pointer, when the pointer is not on
+ * the content of a block (it is in padding, in an indentation or in the
+ * gutter).
+ *
+ * The function probes once: it finds the element at a point on the same line
+ * as the pointer, just inside the edge of the blocks, with
+ * `elementsFromPoint`. This point is next to the pointer, so it is visible
+ * when the pointer is. The probe can find a parent instead of the block on
+ * the line (e.g. left of a nested block, the element is the parent). Thus the
+ * function then goes down into the children of the found block, by their
+ * boxes, to the deepest block at the pointer's height.
+ */
+function probeBlockAtHeight(
+ view: EditorView,
+ pointer: Point,
+): Element | undefined {
+ const blocks = view.dom.firstElementChild?.getBoundingClientRect();
+ if (!blocks || pointer.y < blocks.top || pointer.y > blocks.bottom) {
+ return undefined;
+ }
+ const inset = Math.min(PROBE_INSET, blocks.width / 2);
+ const reference = getBlockAtPoint(view, {
+ x: clamp(pointer.x, blocks.left + inset, blocks.right - inset),
+ y: pointer.y,
+ });
+ return reference ? getNestedBlockAtCursor(reference, pointer) : undefined;
+}
+
+export type HoverResolution =
+ /** The pointer is over this block (directly, or at its height). */
+ | { kind: "block"; block: HoveredBlock }
+ /** The pointer is over UI that belongs to this editor; keep the menu as is. */
+ | { kind: "editorUI" }
+ /** The pointer is not over this editor's blocks. */
+ | { kind: "none" };
+
+/**
+ * Which of this editor's blocks the pointer is about, from the topmost element
+ * under the pointer (the event target).
+ */
+export function resolveHoveredBlock(
+ view: EditorView,
+ target: Element | null,
+ pointer: Point,
+ /** Whether the element is UI that belongs to this editor (a toolbar, a menu). */
+ isWithinEditorUI: (element: Element) => boolean,
+ /** How far left or right of the editor element the pointer still counts (px). */
+ hoverMargin: number,
+): HoverResolution {
+ // Resolve layout before applying drag policy: columns have no handle, but
+ // their children do, and their gutter still needs to resolve those children.
+ function draggable(block: Element | undefined): HoverResolution {
+ const resolved = block
+ ? getDraggableBlockFromElement(block, view)
+ : undefined;
+ return resolved ? { kind: "block", block: resolved } : { kind: "none" };
+ }
+
+ if (target && view.dom.contains(target)) {
+ // Embedded editors own their blocks, even though their DOM is inside ours.
+ if (target.closest(".bn-editor") !== view.dom) {
+ return { kind: "none" };
+ }
+ // Over a block (its content, its indentation, a container's padding):
+ // the deepest block at the pointer's height. Over the editor's own
+ // padding: no block is known yet, so probe for the one on the line.
+ const block = getBlockElement(target, view);
+ return draggable(
+ block
+ ? getNestedBlockAtCursor(block, pointer)
+ : probeBlockAtHeight(view, pointer),
+ );
+ }
+
+ if (target && isWithinEditorUI(target)) {
+ // A toolbar, a menu, the side menu itself, or the editor's wrapper: the
+ // block under that UI is not what the pointer is about.
+ return { kind: "editorUI" };
+ }
+
+ const editorRect = view.dom.getBoundingClientRect();
+
+ // Something else is painted over the editor (e.g. another editor in an
+ // overlay).
+ if (
+ pointer.x >= editorRect.left &&
+ pointer.x <= editorRect.right &&
+ pointer.y >= editorRect.top &&
+ pointer.y <= editorRect.bottom
+ ) {
+ return { kind: "none" };
+ }
+
+ // Beside the editor element (left or right of it, level with its blocks):
+ // only within `hoverMargin`, and not over another editor. Above or below
+ // it, no block is at the pointer's height. The gutter where the menu sits
+ // is the editor's own padding, handled above.
+ const besideEditor =
+ pointer.y >= editorRect.top &&
+ pointer.y <= editorRect.bottom &&
+ Math.max(editorRect.left - pointer.x, pointer.x - editorRect.right) <=
+ hoverMargin;
+ if (besideEditor && !target?.closest(".bn-editor")) {
+ return draggable(probeBlockAtHeight(view, pointer));
+ }
+ return { kind: "none" };
+}
+
+/**
+ * How far (in px) outside the intent hull the pointer may stray. A steep exit
+ * from a block's corner towards its menu otherwise starts just outside the
+ * hull's thin tip.
+ */
+const INTENT_BUFFER = 3;
+
+/** The z component of (a - o) × (b - o): its sign says which side of o→a b is. */
+function cross(o: Point, a: Point, b: Point) {
+ return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
+}
+
+/** Whether a point is inside the convex polygon (vertices in either order). */
+function isInConvexPolygon(point: Point, polygon: Point[]): boolean {
+ let sign = 0;
+ for (let i = 0; i < polygon.length; i++) {
+ const side = Math.sign(
+ cross(polygon[i], polygon[(i + 1) % polygon.length], point),
+ );
+ if (side === 0) {
+ continue;
+ }
+ if (sign === 0) {
+ sign = side;
+ } else if (side !== sign) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function distanceToSegment(point: Point, a: Point, b: Point): number {
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const lengthSquared = dx * dx + dy * dy;
+ const t =
+ lengthSquared === 0
+ ? 0
+ : clamp(
+ ((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSquared,
+ 0,
+ 1,
+ );
+ return Math.hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy));
+}
+
+/** The distance from the point to the rect (0 when it is inside). */
+export function distanceToRect(point: Point, rect: Rect): number {
+ return Math.hypot(
+ point.x - clamp(point.x, rect.left, rect.right),
+ point.y - clamp(point.y, rect.top, rect.bottom),
+ );
+}
+
+/**
+ * Convex hull of a small point set, with Andrew's monotone chain algorithm
+ * (https://en.wikibooks.org/wiki/Algorithm_Implementation/Geometry/Convex_hull/Monotone_chain).
+ */
+function convexHull(points: Point[]): Point[] {
+ const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
+ const lower: Point[] = [];
+ for (const p of sorted) {
+ while (
+ lower.length >= 2 &&
+ cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0
+ ) {
+ lower.pop();
+ }
+ lower.push(p);
+ }
+ const upper: Point[] = [];
+ for (const p of [...sorted].reverse()) {
+ while (
+ upper.length >= 2 &&
+ cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0
+ ) {
+ upper.pop();
+ }
+ upper.push(p);
+ }
+ lower.pop();
+ upper.pop();
+ return lower.concat(upper);
+}
+
+/**
+ * Whether the pointer is on its way from the exit point to the menu: inside
+ * the "triangle" spanned by the exit point and the menu's rect (the convex
+ * hull of the point and the rect's corners), or within {@link INTENT_BUFFER}
+ * of it, and closer to the menu than the exit point. The latter keeps the
+ * buffer around the exit point from holding on to a block the pointer leaves
+ * in another direction, e.g. down onto the next block.
+ *
+ * Similar to Floating UI's `safePolygon`
+ * (https://floating-ui.com/docs/useHover#safepolygon), with two differences:
+ * the hull covers the way to the menu whatever side the menu is on, and the
+ * intent ends when the pointer rests (`INTENT_TIMEOUT` in `SideMenu.ts`)
+ * rather than when it slows down, as people slow down when aiming at the
+ * menu's small buttons.
+ */
+export function isHeadingToMenu(
+ pointer: Point,
+ exit: Point,
+ menu: Rect,
+): boolean {
+ if (distanceToRect(pointer, menu) >= distanceToRect(exit, menu)) {
+ return false;
+ }
+ const hull = convexHull([
+ exit,
+ { x: menu.left, y: menu.top },
+ { x: menu.right, y: menu.top },
+ { x: menu.right, y: menu.bottom },
+ { x: menu.left, y: menu.bottom },
+ ]);
+ if (hull.length >= 3 && isInConvexPolygon(pointer, hull)) {
+ return true;
+ }
+ // Also covers a degenerate hull (a zero-size menu).
+ return hull.some(
+ (vertex, i) =>
+ distanceToSegment(pointer, vertex, hull[(i + 1) % hull.length]) <=
+ INTENT_BUFFER,
+ );
+}
diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.ts b/packages/core/src/extensions/getDraggableBlockFromElement.ts
index abc6bd2906..cccbdfa0f0 100644
--- a/packages/core/src/extensions/getDraggableBlockFromElement.ts
+++ b/packages/core/src/extensions/getDraggableBlockFromElement.ts
@@ -1,5 +1,7 @@
import { EditorView } from "prosemirror-view";
+// FIXME: walks the DOM to the block; could resolve through ProseMirror instead
+// (see the note in `SideMenu/blockDOM.ts`).
export function getDraggableBlockFromElement(
element: Element,
view: EditorView,
diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx
index 3ceff00278..e18cef058f 100644
--- a/packages/react/src/components/SideMenu/SideMenuController.tsx
+++ b/packages/react/src/components/SideMenu/SideMenuController.tsx
@@ -3,9 +3,9 @@ import { SideMenuExtension } from "@blocknote/core/extensions";
import { autoUpdate, offset, ReferenceElement } from "@floating-ui/react";
import { FC, useCallback, useMemo } from "react";
+import { PortalElementOverride } from "../../editor/PortalElementOverride.js";
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
import { useExtensionState } from "../../hooks/useExtension.js";
-import { PortalElementOverride } from "../../editor/PortalElementOverride.js";
import { BlockPopover } from "../Popovers/BlockPopover.js";
import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js";
import { SideMenu } from "./SideMenu.js";
@@ -89,8 +89,11 @@ export const SideMenuController = (props: {
floating: HTMLElement,
_update: () => void,
) => {
+ const sideMenu = editor.getExtension(SideMenuExtension);
+ // Lets the extension keep the block while the pointer heads to the menu.
+ sideMenu?.setMenuElement(floating);
let initialized = false;
- return autoUpdate(
+ const cleanup = autoUpdate(
reference,
floating,
() => {
@@ -100,7 +103,7 @@ export const SideMenuController = (props: {
initialized = true;
return;
}
- editor.getExtension(SideMenuExtension)?.hideMenuIfNotFrozen();
+ sideMenu?.hideMenuIfNotFrozen();
},
{
ancestorScroll: true,
@@ -109,6 +112,10 @@ export const SideMenuController = (props: {
layoutShift: false,
},
);
+ return () => {
+ cleanup();
+ sideMenu?.setMenuElement(undefined);
+ };
},
[editor],
);
diff --git a/tests/src/end-to-end/sidemenu/overlappingEditors.test.tsx b/tests/src/end-to-end/sidemenu/overlappingEditors.test.tsx
new file mode 100644
index 0000000000..51baa997af
--- /dev/null
+++ b/tests/src/end-to-end/sidemenu/overlappingEditors.test.tsx
@@ -0,0 +1,121 @@
+import { BlockNoteEditor, type PartialBlock } from "@blocknote/core";
+import { SideMenuExtension } from "@blocknote/core/extensions";
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import { useCreateBlockNote } from "@blocknote/react";
+import { afterEach, describe, expect, test } from "vite-plus/test";
+import { cleanup, render } from "vitest-browser-react";
+
+import { DRAG_HANDLE_SELECTOR } from "../../utils/const.js";
+import { sleep, waitForSelector } from "../../utils/editor.js";
+import { getRect, mouseSequence } from "../../utils/mouse.js";
+
+// Two editors stacked on the same grid cell (#2559), e.g. an editor in a
+// dialog over another one. The top editor is editable; the bottom one is
+// read-only or editable. The side menu and drops must go to the top editor.
+
+const editors: Record> = {};
+
+function Editor(props: { name: string; ids: string[]; editable: boolean }) {
+ const editor = useCreateBlockNote({
+ initialContent: props.ids.map(
+ (id) =>
+ ({
+ id,
+ type: "paragraph",
+ content: `${props.name} ${id}`,
+ }) as PartialBlock,
+ ),
+ });
+ editors[props.name] = editor;
+ return ;
+}
+
+function Overlapping(props: { bottomEditable: boolean }) {
+ return (
+
+ );
+}
+
+afterEach(async () => {
+ await mouseSequence([{ type: "move", x: 1, y: 1 }]);
+ await cleanup();
+});
+
+function menuBlock(name: string): string | undefined {
+ const state = editors[name].getExtension(SideMenuExtension)?.store.state;
+ return state?.show ? state.block.id : undefined;
+}
+
+function blockRect(name: string, id: string) {
+ return document
+ .querySelector(
+ `[data-editor-name="${name}"] .bn-block[data-id="${id}"] .bn-block-content`,
+ )!
+ .getBoundingClientRect();
+}
+
+for (const bottomEditable of [false, true]) {
+ describe(`overlapping editors, bottom editor ${bottomEditable ? "editable" : "read-only"}`, () => {
+ test("hover: the top editor shows its menu", async () => {
+ await render();
+ await waitForSelector(".bn-editor");
+ const r = blockRect("top", "t2");
+ await mouseSequence([
+ { type: "move", x: r.x + 30, y: r.y + r.height / 2, steps: 8 },
+ ]);
+ await sleep(150);
+ expect(menuBlock("top")).toBe("t2");
+ expect(menuBlock("bottom")).toBeUndefined();
+ });
+
+ test("drag and drop: a block of the top editor moves within it", async () => {
+ await render();
+ await waitForSelector(".bn-editor");
+ const r3 = blockRect("top", "t3");
+ await mouseSequence([
+ { type: "move", x: r3.x + 30, y: r3.y + r3.height / 2, steps: 8 },
+ ]);
+ await sleep(150);
+ const handle = getRect(await waitForSelector(DRAG_HANDLE_SELECTOR));
+ const t1 = blockRect("top", "t1");
+ await mouseSequence([
+ {
+ type: "move",
+ x: handle.x + handle.width / 2,
+ y: handle.y + handle.height / 2,
+ steps: 5,
+ },
+ ]);
+ await mouseSequence([{ type: "down" }]);
+ await sleep(100);
+ await mouseSequence([
+ { type: "move", x: t1.x + 30, y: t1.y + 2, steps: 5 },
+ ]);
+ await sleep(100);
+ await mouseSequence([{ type: "up" }]);
+ await sleep(150);
+ expect(editors.top.document.map((b) => b.id)).toEqual(["t3", "t1", "t2"]);
+ expect(editors.bottom.document.map((b) => b.id)).toEqual([
+ "u1",
+ "u2",
+ "u3",
+ ]);
+ });
+ });
+}
diff --git a/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx b/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx
new file mode 100644
index 0000000000..593f534818
--- /dev/null
+++ b/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx
@@ -0,0 +1,1023 @@
+import {
+ BlockNoteEditor,
+ BlockNoteSchema,
+ type PartialBlock,
+ defaultBlockSpecs,
+} from "@blocknote/core";
+import {
+ SideMenuExtension,
+ type SideMenuOptions,
+} from "@blocknote/core/extensions";
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import { createReactBlockSpec, useCreateBlockNote } from "@blocknote/react";
+import {
+ multiColumnDropCursor,
+ withMultiColumn,
+} from "@blocknote/xl-multi-column";
+import type { CSSProperties } from "react";
+import { afterEach, describe, expect, test } from "vite-plus/test";
+import { cleanup, render } from "vitest-browser-react";
+
+import {
+ DRAG_HANDLE_ADD_SELECTOR,
+ DRAG_HANDLE_SELECTOR,
+ NEST_BLOCK_BUTTON_SELECTOR,
+} from "../../utils/const.js";
+import { browserName } from "../../utils/context.js";
+import { sleep, waitForSelector } from "../../utils/editor.js";
+import { getRect, mouseSequence } from "../../utils/mouse.js";
+
+// How the side menu follows the pointer: which block it shows for, and
+// whether it shows at all. "works today" guards behavior that works on main;
+// "fixed issues" reproduces issues that fail on main.
+
+const schema = withMultiColumn(BlockNoteSchema.create());
+type Editor = BlockNoteEditor<
+ typeof schema.blockSchema,
+ typeof schema.inlineContentSchema,
+ typeof schema.styleSchema
+>;
+
+const editors: Record = {};
+
+function Editor(props: {
+ name: string;
+ content: PartialBlock[];
+ style?: CSSProperties;
+ sideMenu?: SideMenuOptions;
+}) {
+ const editor = useCreateBlockNote({
+ schema,
+ dropCursor: multiColumnDropCursor,
+ initialContent: props.content,
+ sideMenu: props.sideMenu,
+ });
+ editors[props.name] = editor;
+ return (
+
+
+
+ );
+}
+
+afterEach(async () => {
+ // Moves the pointer away so the next test starts without a menu.
+ await mouseSequence([{ type: "move", x: 1, y: 1 }]);
+ await cleanup();
+ for (const name of Object.keys(editors)) {
+ delete editors[name];
+ }
+});
+
+/** The block the side menu shows for in an editor, or `undefined`. */
+function menuBlock(name = "main"): string | undefined {
+ const state = editors[name].getExtension(SideMenuExtension)?.store.state;
+ return state?.show ? state.block.id : undefined;
+}
+
+function blockRect(id: string, name = "main"): DOMRect {
+ const element = document.querySelector(
+ `[data-editor-name="${name}"] .bn-block[data-id="${id}"] .bn-block-content`,
+ );
+ if (!element) {
+ throw new Error(`Block "${id}" is not rendered in editor "${name}"`);
+ }
+ return element.getBoundingClientRect();
+}
+
+/** Moves the pointer through the points, one small step at a time. */
+async function walk(points: { x: number; y: number }[], steps = 8) {
+ await mouseSequence(
+ points.map(({ x, y }) => ({ type: "move", x, y, steps })),
+ );
+ // The menu updates on the next frames.
+ await sleep(100);
+}
+
+async function hoverBlock(id: string, name = "main") {
+ const rect = blockRect(id, name);
+ await walk([{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }]);
+}
+
+function paragraph(id: string, text = id): PartialBlock {
+ return { id, type: "paragraph", content: text };
+}
+
+// The multi-column example's document: its column widths put the add button
+// of column two and three blocks over the column to their left.
+const columnsDocument: PartialBlock[] = [
+ paragraph("before", "Welcome to this demo!"),
+ {
+ id: "list",
+ type: "columnList",
+ children: [
+ {
+ id: "col-1",
+ type: "column",
+ props: { width: 0.8 },
+ children: [paragraph("c1", "This paragraph is in a column!")],
+ },
+ {
+ id: "col-2",
+ type: "column",
+ props: { width: 1.4 },
+ children: [
+ { id: "c2", type: "heading", content: "So is this heading!" },
+ ],
+ },
+ {
+ id: "col-3",
+ type: "column",
+ props: { width: 0.8 },
+ children: [
+ paragraph("c3", "You can have multiple blocks in a column too"),
+ {
+ id: "c3-list",
+ type: "bulletListItem",
+ content: "Block 1",
+ },
+ ],
+ },
+ ],
+ },
+ paragraph("after", "After the columns"),
+];
+
+describe("side menu hover: works today", () => {
+ test("switches between blocks stacked vertically", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ await hoverBlock("a");
+ expect(menuBlock()).toBe("a");
+ await hoverBlock("b");
+ expect(menuBlock()).toBe("b");
+ });
+
+ test("shows a nested block's menu when hovering its left side", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ const rect = blockRect("child");
+ await walk([{ x: rect.x + 4, y: rect.y + rect.height / 2 }]);
+ expect(menuBlock()).toBe("child");
+ });
+
+ test("shows the menu when hovering the editor's gutter beside a block", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ const rect = blockRect("a");
+ await walk([{ x: rect.x - 30, y: rect.y + rect.height / 2 }]);
+ expect(menuBlock()).toBe("a");
+ });
+
+ test("switches between blocks in neighbouring columns", async () => {
+ await render();
+ await waitForSelector(".bn-editor");
+
+ await hoverBlock("c2");
+ expect(menuBlock()).toBe("c2");
+ await hoverBlock("c1");
+ expect(menuBlock()).toBe("c1");
+ await hoverBlock("c3");
+ expect(menuBlock()).toBe("c3");
+ });
+
+ test("leaves the menu on a block's handle buttons", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ await hoverBlock("a");
+ const add = getRect(await waitForSelector(DRAG_HANDLE_ADD_SELECTOR));
+ await walk([{ x: add.x + add.width / 2, y: add.y + add.height / 2 }]);
+ expect(menuBlock()).toBe("a");
+ });
+
+ // The "+" of a column-three block overlaps column two's heading. Walking
+ // straight onto it keeps the menu; the #3059 review saw it jump, but that
+ // no longer reproduces.
+ test("keeps column three's block while walking left onto its add button", async () => {
+ await render();
+ await waitForSelector(".bn-editor");
+
+ // The list item sits level with column two's heading, and the button's
+ // left edge overlaps that heading.
+ await hoverBlock("c3-list");
+ expect(menuBlock()).toBe("c3-list");
+ const add = getRect(await waitForSelector(DRAG_HANDLE_ADD_SELECTOR));
+ const y = add.y + add.height / 2;
+ await walk([
+ { x: add.x + add.width + 12, y },
+ { x: add.x + 3, y },
+ ]);
+ expect(menuBlock()).toBe("c3-list");
+ });
+
+ // #3059 review (SideMenu.ts:56): the "+" of a block in column 2 or 3 sits
+ // over the column to its left. Moving diagonally towards it crosses that
+ // column; the menu must stay on the column-two block.
+ test("keeps column two's block while moving diagonally from its bottom to its add button", async () => {
+ await render();
+ await waitForSelector(".bn-editor");
+
+ // From the heading's bottom left, so the path crosses column one before
+ // it reaches the button.
+ const block = blockRect("c2");
+ await walk([{ x: block.x + 6, y: block.bottom - 4 }]);
+ expect(menuBlock()).toBe("c2");
+ const add = getRect(await waitForSelector(DRAG_HANDLE_ADD_SELECTOR));
+ await walk([{ x: add.x + add.width / 2, y: add.y + add.height / 2 }], 20);
+ expect(menuBlock()).toBe("c2");
+ });
+
+ // #2526: in columns inside a toggle, the add button of a left-column block
+ // disappeared when the pointer moved onto it.
+ test("keeps a left-column block's menu inside a toggle, and its add button works", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+ const chevron = await waitForSelector(".bn-toggle-button");
+ const c = chevron.getBoundingClientRect();
+ await mouseSequence([
+ { type: "click", x: c.x + c.width / 2, y: c.y + c.height / 2 },
+ ]);
+ await sleep(100);
+
+ const block = blockRect("left");
+ await walk([{ x: block.x + 20, y: block.y + block.height / 2 }]);
+ expect(menuBlock()).toBe("left");
+ const add = getRect(await waitForSelector(DRAG_HANDLE_ADD_SELECTOR));
+ await walk([{ x: add.x + add.width / 2, y: add.y + add.height / 2 }]);
+ expect(menuBlock()).toBe("left");
+ // The button must also work: clicking it adds a block after "left".
+ await mouseSequence([
+ { type: "click", x: add.x + add.width / 2, y: add.y + add.height / 2 },
+ ]);
+ await sleep(100);
+ const column = editors.main.getParentBlock("left")!;
+ expect(column.children.map((child) => child.id)[0]).toBe("left");
+ expect(column.children).toHaveLength(2);
+ });
+});
+
+describe("side menu hover: fixed issues", () => {
+ // BLO-963, with the hovered block's left part inside the viewport and its
+ // right side outside it.
+ test("shows the menu when a block's right side is outside the viewport", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ const rect = blockRect("a");
+ expect(rect.left).toBeLessThan(window.innerWidth - 100);
+ expect(rect.right).toBeGreaterThan(window.innerWidth);
+ await walk([{ x: rect.x + 60, y: rect.y + rect.height / 2 }]);
+ expect(menuBlock()).toBe("a");
+ });
+
+ // BLO-1072: where an editor in an overlay covers another editor, the menu
+ // goes to the editor underneath.
+ test("shows the menu of the editor on top where two editors overlap", async () => {
+ await render(
+ <>
+
+
+ >,
+ );
+ await waitForSelector(".bn-editor");
+
+ await hoverBlock("o1", "over");
+ expect(menuBlock("over")).toBe("o1");
+ expect(menuBlock("under")).toBeUndefined();
+ });
+
+ // BLO-877: the menu shows with the pointer well to the left of the editor,
+ // as long as it's at the height of a block.
+ test("hides the menu when the pointer is far to the left of the editor", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ const rect = blockRect("a");
+ await walk([{ x: rect.x - 200, y: rect.y + rect.height / 2 }]);
+ expect(menuBlock()).toBeUndefined();
+ });
+
+ // BLO-968: the menu is missing when the editor is wider than the element
+ // that scrolls it horizontally (same root cause as BLO-963: the editor's
+ // right side is not visible).
+ test("shows a visible menu when the editor scrolls horizontally", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+ document.querySelector(".bn-container")!.style.minWidth =
+ "700px";
+
+ // On the visible part of the block: its middle is scrolled out of view.
+ const scroller = document
+ .querySelector('[data-editor-name="main"]')!
+ .getBoundingClientRect();
+ const rect = blockRect("b");
+ expect(rect.right).toBeGreaterThan(scroller.right);
+ await walk([{ x: rect.x + 20, y: rect.y + rect.height / 2 }]);
+ expect(menuBlock()).toBe("b");
+ const add = document.querySelector(DRAG_HANDLE_ADD_SELECTOR);
+ expect(add?.checkVisibility({ visibilityProperty: true })).toBe(true);
+ });
+});
+
+describe("side menu hover: heading for the menu", () => {
+ // Starts at the bottom of column two's heading, then moves towards its add
+ // button (which sits over column one), stopping just below it.
+ async function headTowardsAddButton() {
+ await render();
+ await waitForSelector(".bn-editor");
+
+ const block = blockRect("c2");
+ await walk([{ x: block.x + 6, y: block.bottom - 4 }]);
+ expect(menuBlock()).toBe("c2");
+ const add = getRect(await waitForSelector(DRAG_HANDLE_ADD_SELECTOR));
+ await walk([{ x: add.x + add.width / 2, y: add.bottom + 6 }], 10);
+ expect(menuBlock()).toBe("c2");
+ }
+
+ test("follows the pointer once it rests on its way to the menu", async () => {
+ await headTowardsAddButton();
+ await sleep(500);
+ expect(menuBlock()).not.toBe("c2");
+ });
+
+ test("follows the pointer at once when it turns away from the menu", async () => {
+ await headTowardsAddButton();
+ await hoverBlock("after");
+ expect(menuBlock()).toBe("after");
+ });
+
+ // The landing rule: once the pointer reached the menu, leaving it anywhere
+ // other than its block follows the block under the pointer at once, even
+ // where the way to the menu kept the block (see `headTowardsAddButton`).
+ test("follows the pointer at once when it leaves the menu it reached", async () => {
+ await render();
+ await waitForSelector(".bn-editor");
+
+ const block = blockRect("c2");
+ await walk([{ x: block.x + 6, y: block.bottom - 4 }]);
+ const add = getRect(await waitForSelector(DRAG_HANDLE_ADD_SELECTOR));
+ await walk([{ x: add.x + add.width / 2, y: add.y + add.height / 2 }], 20);
+ expect(menuBlock()).toBe("c2");
+
+ await walk([{ x: add.x + add.width / 2, y: add.bottom + 6 }], 4);
+ expect(menuBlock()).not.toBe("c2");
+ });
+});
+
+describe("side menu hover: pointer input", () => {
+ test("hides the menu when the pointer leaves the window", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ const rect = blockRect("a");
+ await walk([{ x: rect.x + 6, y: rect.y + rect.height / 2 }]);
+ expect(menuBlock()).toBe("a");
+ // Above the test frame: the frame's document gets a `mouseout` without
+ // a `relatedTarget`.
+ // One move: no `mousemove` above the editor, only the `mouseout` when
+ // the pointer leaves the page.
+ await walk([{ x: rect.x + 6, y: -20 }], 1);
+ expect(menuBlock()).toBeUndefined();
+ });
+
+ // Browser extensions dispatch events like these. Without coordinates, the
+ // block lookup would throw on non-finite coordinates.
+ test("ignores synthetic mouse events without coordinates", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ await hoverBlock("a");
+ expect(menuBlock()).toBe("a");
+ const target = document.querySelector('.bn-block[data-id="b"]')!;
+ for (const type of ["mousemove", "mouseout"]) {
+ target.dispatchEvent(new Event(type, { bubbles: true }));
+ }
+ expect(menuBlock()).toBe("a");
+ });
+});
+
+async function walkAndSample(points: { x: number; y: number }[], steps = 8) {
+ const samples: (string | undefined)[] = [];
+ function sample() {
+ samples.push(menuBlock());
+ }
+ // `window` listeners run after every `document` listener, so this sees the
+ // menu after the extension's own listener ran.
+ window.addEventListener("mousemove", sample);
+ try {
+ await walk(points, steps);
+ } finally {
+ window.removeEventListener("mousemove", sample);
+ }
+ return samples;
+}
+
+describe("side menu hover: engaging a block", () => {
+ // Between two columns, the pointer leaves the left block's element ~15px
+ // before it reaches the next column, while the extension still resolves the
+ // left block. Releasing the block there must neither hide the menu (the
+ // "blink" band) nor re-engage the block the pointer just left, which no
+ // `mouseleave` would release again.
+ test("walking from column one to column two never hides the menu", async () => {
+ await render();
+ await waitForSelector(".bn-editor");
+
+ const c1 = blockRect("c1");
+ const c2 = blockRect("c2");
+ const y = c1.y + 6;
+ await walk([{ x: c1.x + c1.width / 2, y }]);
+ expect(menuBlock()).toBe("c1");
+
+ const samples = await walkAndSample([{ x: c2.x + 30, y }], 40);
+ expect(samples).not.toContain(undefined);
+ expect(menuBlock()).toBe("c2");
+ // Once on column two's block, the menu doesn't go back to column one's.
+ expect(samples.slice(samples.indexOf("c2"))).not.toContain("c1");
+ });
+
+ test("a block left through its column's padding and re-entered is released again", async () => {
+ await render();
+ await waitForSelector(".bn-editor");
+
+ const c1 = blockRect("c1");
+ const y = c1.y + 6;
+ await walk([{ x: c1.x + c1.width / 2, y }]);
+ expect(menuBlock()).toBe("c1");
+ // Into the padding right of the block, where c1 still resolves, and back.
+ const samples = await walkAndSample([
+ { x: c1.right + 6, y },
+ { x: c1.right - 20, y },
+ { x: c1.right + 6, y },
+ ]);
+ expect(samples).not.toContain(undefined);
+ expect(menuBlock()).toBe("c1");
+
+ // Away from c1 and its menu, the menu follows the pointer again.
+ await hoverBlock("c3");
+ expect(menuBlock()).toBe("c3");
+ await hoverBlock("after");
+ expect(menuBlock()).toBe("after");
+ });
+
+ // The menu must follow within the same move that takes the pointer onto
+ // the next block, never hide or lag in between.
+ test("moving down onto the next block follows it within the same move", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ await hoverBlock("a");
+ expect(menuBlock()).toBe("a");
+ const b = blockRect("b");
+ // The menu's block on each move the pointer is over block "b".
+ const overB: (string | undefined)[] = [];
+ function sample(event: MouseEvent) {
+ if (
+ event.target instanceof Element &&
+ event.target.closest('.bn-block[data-id="b"]')
+ ) {
+ overB.push(menuBlock());
+ }
+ }
+ window.addEventListener("mousemove", sample);
+ try {
+ await walk([{ x: b.x + b.width / 2, y: b.y + b.height / 2 }], 20);
+ } finally {
+ window.removeEventListener("mousemove", sample);
+ }
+ expect(overB.length).toBeGreaterThan(0);
+ expect(overB).toEqual(overB.map(() => "b"));
+ });
+
+ test("moving onto a nested block shows its menu while the parent is engaged", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ await hoverBlock("parent");
+ expect(menuBlock()).toBe("parent");
+ await hoverBlock("child");
+ expect(menuBlock()).toBe("child");
+ await hoverBlock("parent");
+ expect(menuBlock()).toBe("parent");
+ });
+});
+
+// The drag handle is the drag source: if the menu hid or moved during the
+// drag, React would remove the handle, its `dragend` would never fire, and the
+// drag image would stay in the document. The drop changes the document, which
+// re-resolves the block under the pointer while the drag is still on.
+// Real mouse drags are only emulated reliably in Chromium.
+describe.skipIf(browserName !== "chromium")("side menu hover: dragging", () => {
+ test("keeps the drag handle mounted until the drag ends", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ await hoverBlock("drag");
+ expect(menuBlock()).toBe("drag");
+ const handle = getRect(await waitForSelector(DRAG_HANDLE_SELECTOR));
+ const target = blockRect("a");
+ await walk([
+ { x: handle.x + handle.width / 2, y: handle.y + handle.height / 2 },
+ ]);
+ await mouseSequence([{ type: "down" }]);
+ await sleep(100);
+ // Onto the top of "a": the block goes before it.
+ await mouseSequence([
+ { type: "move", x: target.x + 20, y: target.y + 2, steps: 5 },
+ ]);
+ await sleep(100);
+ await mouseSequence([{ type: "up" }]);
+ await sleep(100);
+
+ expect(editors.main.document.map((block) => block.id)).toEqual([
+ "drag",
+ "a",
+ "b",
+ ]);
+ expect(document.querySelector(".bn-drag-preview")).toBeNull();
+ });
+
+ // `dropMargin` counts from the editor element's box, like `hoverMargin`
+ // (not from its blocks, 54px further in).
+ test("drops a block left of the editor within `dropMargin`", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ await hoverBlock("drag");
+ const handle = getRect(await waitForSelector(DRAG_HANDLE_SELECTOR));
+ const editor = document
+ .querySelector('[data-editor-name="main"] .bn-editor')!
+ .getBoundingClientRect();
+ const target = blockRect("a");
+ await walk([
+ { x: handle.x + handle.width / 2, y: handle.y + handle.height / 2 },
+ ]);
+ await mouseSequence([{ type: "down" }]);
+ await sleep(100);
+ // 30px left of the editor element, level with the top of "a".
+ await mouseSequence([
+ { type: "move", x: editor.left - 30, y: target.y + 2, steps: 5 },
+ ]);
+ await sleep(100);
+ await mouseSequence([{ type: "up" }]);
+ await sleep(100);
+
+ // The drop goes to the nearest position, the left edge of "a", which puts
+ // the two in columns.
+ expect(editors.main.document.map((block) => block.type)).toEqual([
+ "columnList",
+ "paragraph",
+ ]);
+ });
+});
+
+describe("side menu hover: the document changes under a still pointer", () => {
+ test("follows the block that moves under the pointer when a block above is removed", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ await hoverBlock("b");
+ expect(menuBlock()).toBe("b");
+ editors.main.removeBlocks(["a"]);
+ await sleep(100);
+ // "b" moved up into "a"'s place; "c" is now under the pointer.
+ expect(menuBlock()).toBe("c");
+ });
+
+ test("follows the block that moves under the pointer when a block above grows", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ await hoverBlock("b");
+ expect(menuBlock()).toBe("b");
+ editors.main.updateBlock("a", {
+ content: "A paragraph that wraps over many lines. ".repeat(12),
+ });
+ await sleep(100);
+ // "a" grew under the pointer and pushed "b" down.
+ expect(menuBlock()).toBe("a");
+ });
+
+ // A side menu button that removes its block, clicked twice without moving
+ // the pointer: the block below moves up into its place, and the second
+ // click must act on that block, not on the removed one.
+ test("follows the block below when the pointer rests on the menu and its block is removed", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ await hoverBlock("b");
+ const add = getRect(await waitForSelector(DRAG_HANDLE_ADD_SELECTOR));
+ await walk([{ x: add.x + add.width / 2, y: add.y + add.height / 2 }]);
+ expect(menuBlock()).toBe("b");
+
+ editors.main.removeBlocks(["b"]);
+ await sleep(100);
+ expect(menuBlock()).toBe("c");
+ });
+});
+
+describe("side menu hover: right padding", () => {
+ test("shows the menu for the row when hovering the editor's right padding", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ const editor = document
+ .querySelector(".bn-editor")!
+ .getBoundingClientRect();
+ const rect = blockRect("b");
+ await walk([{ x: editor.right - 10, y: rect.y + rect.height / 2 }]);
+ expect(menuBlock()).toBe("b");
+ });
+
+ test("shows the menu for the row when hovering a middle column's right padding", async () => {
+ await render();
+ await waitForSelector(".bn-editor");
+
+ const column = document
+ .querySelector('[data-node-type="column"][data-id="col-2"]')!
+ .getBoundingClientRect();
+ const rect = blockRect("c2");
+ // Right of the heading's content, inside column two.
+ const x = Math.min(column.right - 3, rect.right + 3);
+ expect(x).toBeGreaterThan(rect.right);
+ await walk([
+ { x: rect.x + 20, y: rect.y + rect.height / 2 },
+ { x, y: rect.y + rect.height / 2 },
+ ]);
+ expect(menuBlock()).toBe("c2");
+ });
+});
+
+describe("side menu hover: disabled toolbar buttons", () => {
+ // A `mouseout` without `relatedTarget` means the pointer left the window,
+ // and hides the menu. Entering a disabled element must not count as that.
+ // The pointer jumps straight onto the button, as a tap does: a path through
+ // the space between the block and the toolbar, outside the editor, hides
+ // the menu by itself.
+ test("keeps the menu when the pointer moves onto a disabled toolbar button", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ // Select a word with the mouse: the toolbar opens, the pointer is on "a".
+ const word = getRect(
+ document.querySelector('.bn-block[data-id="a"] .bn-inline-content')!,
+ );
+ await mouseSequence([
+ {
+ type: "click",
+ x: word.x + 8,
+ y: word.y + word.height / 2,
+ clickCount: 2,
+ },
+ ]);
+ await sleep(200);
+ expect(menuBlock()).toBe("a");
+ const nest = await waitForSelector(NEST_BLOCK_BUTTON_SELECTOR);
+ // The first block can't be nested.
+ expect((nest as HTMLButtonElement).disabled).toBe(true);
+
+ const r = nest.getBoundingClientRect();
+ await walk([{ x: r.x + r.width / 2, y: r.y + r.height / 2 }], 1);
+ expect(menuBlock()).toBe("a");
+ });
+});
+
+describe("side menu hover: hover margin", () => {
+ test("shows the menu left of the editor within `hoverMargin`", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ const rect = blockRect("a");
+ // Outside the editor element (its padding is 54px), within the margin.
+ await walk([{ x: rect.x - 200, y: rect.y + rect.height / 2 }]);
+ expect(menuBlock()).toBe("a");
+ });
+
+ test("doesn't show the menu above the editor within `hoverMargin`", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ const editor = document
+ .querySelector('[data-editor-name="main"] .bn-editor')!
+ .getBoundingClientRect();
+ const rect = blockRect("a");
+ await walk([{ x: rect.x + 20, y: editor.top - 100 }]);
+ expect(menuBlock()).toBeUndefined();
+ });
+
+ test("hides the menu beyond `hoverMargin`", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(".bn-editor");
+
+ const rect = blockRect("a");
+ await walk([{ x: rect.x - 200, y: rect.y + rect.height / 2 }]);
+ expect(menuBlock()).toBeUndefined();
+ });
+});
+
+describe("side menu hover: wide container padding", () => {
+ test("shows the menu for the row in a container's 100px right padding", async () => {
+ await render();
+ await waitForSelector(".bn-editor");
+ // A stylesheet rule: ProseMirror re-renders a node whose DOM is changed
+ // directly, which would drop an inline style.
+ const style = document.createElement("style");
+ style.textContent =
+ '[data-node-type="column"][data-id="col-2"] { padding-right: 100px; }';
+ document.head.append(style);
+ try {
+ await sleep(50);
+ const column = document
+ .querySelector('[data-node-type="column"][data-id="col-2"]')!
+ .getBoundingClientRect();
+ const rect = blockRect("c2");
+ expect(column.right - rect.right).toBeGreaterThanOrEqual(100);
+ // In the middle of the padding, level with the heading.
+ await walk([
+ { x: rect.x + 20, y: rect.y + rect.height / 2 },
+ { x: column.right - 50, y: rect.y + rect.height / 2 },
+ ]);
+ expect(menuBlock()).toBe("c2");
+ } finally {
+ style.remove();
+ }
+ });
+});
+
+describe("side menu hover: elements over the editor", () => {
+ // A modal (any opaque element) over part of the editor: the editor gets no
+ // menu where it is covered, and its usual menu where it is not.
+ test("shows no menu over a modal, and the usual menu next to it", async () => {
+ await render(
+ <>
+
+
+ >,
+ );
+ await waitForSelector(".bn-editor");
+
+ const b = blockRect("b");
+ const modal = document
+ .querySelector('[data-testid="modal"]')!
+ .getBoundingClientRect();
+ expect(b.right).toBeGreaterThan(modal.left);
+
+ // Over the modal, level with "b".
+ await walk([{ x: modal.left + 50, y: b.y + b.height / 2 }]);
+ expect(menuBlock()).toBeUndefined();
+
+ // On the uncovered part of "b".
+ await walk([{ x: b.x + 20, y: b.y + b.height / 2 }]);
+ expect(menuBlock()).toBe("b");
+ });
+});
+
+describe("side menu hover: regressions against main", () => {
+ // The "+" of a block in column two is over column one. Clicking it inserts
+ // a block (a document change) while the pointer rests on it: the menu must
+ // stay on its block, so a second click adds another block to column two.
+ test("keeps the menu on a column-two block when its add button is clicked", async () => {
+ await render();
+ await waitForSelector(".bn-editor");
+
+ await hoverBlock("c2");
+ const add = getRect(await waitForSelector(DRAG_HANDLE_ADD_SELECTOR));
+ const point = { x: add.x + add.width / 2, y: add.y + add.height / 2 };
+ await walk([point]);
+ expect(menuBlock()).toBe("c2");
+
+ await mouseSequence([{ type: "click", ...point }]);
+ await sleep(150);
+ expect(menuBlock()).toBe("c2");
+ await mouseSequence([{ type: "click", ...point }]);
+ await sleep(150);
+
+ const column = editors.main.getBlock("col-2")!;
+ expect(column.children[0].id).toBe("c2");
+ expect(column.children).toHaveLength(3);
+ });
+
+ // A block that embeds another editor: the gutter of the outer editor, level
+ // with the embedded editor's blocks, shows the outer block's menu.
+ test("shows the menu of a block with a nested editor from the outer gutter", async () => {
+ const Nested = createReactBlockSpec(
+ { type: "nested" as const, propSchema: {}, content: "none" },
+ {
+ render: function NestedEditor() {
+ const inner = useCreateBlockNote({
+ initialContent: [
+ { id: "i1", type: "paragraph", content: "Inner one" },
+ { id: "i2", type: "paragraph", content: "Inner two" },
+ ],
+ });
+ return (
+
+
+
+ );
+ },
+ },
+ )();
+ const nestedSchema = BlockNoteSchema.create({
+ blockSpecs: { ...defaultBlockSpecs, nested: Nested },
+ });
+ let outer: BlockNoteEditor | undefined;
+ function Outer() {
+ const editor = useCreateBlockNote({
+ schema: nestedSchema,
+ initialContent: [
+ paragraph("a"),
+ { id: "n", type: "nested" } as PartialBlock,
+ paragraph("b"),
+ ],
+ });
+ outer = editor;
+ return (
+
+
+
+ );
+ }
+ await render();
+ await waitForSelector("[data-nested-editor] .bn-editor");
+ await sleep(100);
+
+ const outerEditor = document
+ .querySelector('[data-editor-name="outer"] .bn-editor')!
+ .getBoundingClientRect();
+ const inner = document
+ .querySelector(
+ '[data-nested-editor] .bn-block[data-id="i1"] .bn-block-content',
+ )!
+ .getBoundingClientRect();
+ const y = inner.y + inner.height / 2;
+ // The left gutter and the right padding of the outer editor, each reached
+ // in one move from outside the editor.
+ for (const x of [outerEditor.left + 20, outerEditor.right - 10]) {
+ await walk([{ x: 1, y: 1 }]);
+ await walk([{ x, y }], 1);
+ const state = outer!.getExtension(SideMenuExtension)?.store.state;
+ expect(state?.show ? state.block.id : undefined).toBe("n");
+ }
+ });
+});
diff --git a/tests/src/end-to-end/tables/tables.test.tsx b/tests/src/end-to-end/tables/tables.test.tsx
index f3b6bf7ce4..354e015c49 100644
--- a/tests/src/end-to-end/tables/tables.test.tsx
+++ b/tests/src/end-to-end/tables/tables.test.tsx
@@ -158,7 +158,7 @@ describe("Check Table interactions", () => {
});
// Regression test for https://github.com/TypeCellOS/BlockNote/issues/2691.
// Drops the dragged row to the LEFT of `.bn-block-group` (where the side
- // menu sits). SideMenuView re-dispatches drops outside `.bn-block-group`
+ // menu sits). The side menu's drop handling re-dispatches drops outside `.bn-block-group`
// (within 250px) as synthetic events; without the guard in
// TableHandles.dropHandler, the synthetic drop AND the original drop both
// run the row-move logic, dragging an adjacent row along with the target.
@@ -244,7 +244,7 @@ describe("Check Table interactions", () => {
// Drop into the side-menu area: LEFT of `.bn-block-group`, vertically
// aligned with the last row. This is outside the block-group rect but
- // well within the 250px range that triggers SideMenuView's synthetic
+ // well within the 250px drop margin that triggers the side menu's synthetic
// drop re-dispatch — the same condition that surfaces the bug for
// real users dragging onto the side gutter.
const blockGroup = (