From 064a9fce88729bf7c0336117ede7381d23ece5c9 Mon Sep 17 00:00:00 2001 From: yousefed Date: Sun, 11 Oct 2026 08:23:31 +0200 Subject: [PATCH 1/7] fix(core): resolve the side menu block from the pointer target The side menu now uses the element under the pointer (the event target) to decide which block it shows for. It no longer computes coordinates at the right edge of the editor and hit-tests them. - Which block: the block of the target, then the deepest block at the pointer's height. Only in the editor's padding does the side menu probe once, next to the pointer. - Moving towards the menu: the menu keeps its block while the pointer is inside the area between its exit point and the menu. It follows the pointer after a rest of 350 ms, or when the pointer turns away. This replaces the 50 px offset for columns. - A document change re-resolves the block under the pointer, also when the pointer is on the menu. - The menu hides when the pointer leaves the window, and stays while its block is dragged. - New editor option `sideMenu: { hoverMargin, dropMargin }`. By default the menu shows only over the editor; drops are accepted up to 250 px outside. - `referencePos` is the box of the hovered block. - The extension uses the `createExtension` and store pattern. The drop handling moved unchanged to `blockDropHandling.ts`. `SideMenuView` and `sideMenuPluginKey` are removed. --- .../docs/react/components/side-menu.mdx | 23 + .../exporters/html/externalHTMLExporter.ts | 2 +- .../exporters/markdown/markdownExporter.ts | 2 +- packages/core/src/editor/BlockNoteEditor.ts | 8 + .../core/src/extensions/SideMenu/SideMenu.ts | 1120 ++++++----------- .../extensions/SideMenu/blockDropHandling.ts | 340 +++++ .../SideMenu/blockGeometry.browser.test.ts | 169 +++ .../extensions/SideMenu/blockGeometry.test.ts | 46 + .../src/extensions/SideMenu/blockGeometry.ts | 56 + .../extensions/SideMenu/sideMenuHover.test.ts | 36 + .../src/extensions/SideMenu/sideMenuHover.ts | 284 +++++ packages/core/src/extensions/blockDOM.ts | 51 + .../SideMenu/SideMenuController.tsx | 13 +- .../sidemenu/sideMenuHover.test.tsx | 926 ++++++++++++++ tests/src/end-to-end/tables/tables.test.tsx | 4 +- 15 files changed, 2306 insertions(+), 774 deletions(-) create mode 100644 packages/core/src/extensions/SideMenu/blockDropHandling.ts create mode 100644 packages/core/src/extensions/SideMenu/blockGeometry.browser.test.ts create mode 100644 packages/core/src/extensions/SideMenu/blockGeometry.test.ts create mode 100644 packages/core/src/extensions/SideMenu/blockGeometry.ts create mode 100644 packages/core/src/extensions/SideMenu/sideMenuHover.test.ts create mode 100644 packages/core/src/extensions/SideMenu/sideMenuHover.ts create mode 100644 packages/core/src/extensions/blockDOM.ts create mode 100644 tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx diff --git a/docs/content/docs/react/components/side-menu.mdx b/docs/content/docs/react/components/side-menu.mdx index cec3bfedc5..959d0168b6 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`. + +To show the menu wherever a block can be dropped, set both to the same value. 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..41426c5e52 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,8 +10,14 @@ 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, @@ -32,802 +28,392 @@ export type SideMenuState< 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; +const INTENT_TIMEOUT = 350; - 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"); - } - - 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); + // 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 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; - } - - 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) { + 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; + 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(); + 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, + { + isWithinEditorUI: editor.isWithinEditor, + hoverMargin, + }, ); - - if (distance < minDistance) { - minDistance = distance; - closestEditor = editor; + if (resolution.kind === "editorUI") { + 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; 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) }; + return; + } } - return; + 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. The + * pointer may rest on the menu (e.g. after clicking one of its buttons), so + * this looks past the menu at what is underneath: after a document change, + * that can be another block, e.g. the one below a removed block, which moved + * up into its place. */ - blockDragEnd() { - unsetDragImage(editor.prosemirrorView.root); - if (view) { - view.isDragOrigin = false; + function refresh() { + if (!pointer) { + return; } + const target = + editor.prosemirrorView.root + .elementsFromPoint(pointer.x, pointer.y) + .find((element) => !menuElement?.contains(element)) ?? null; + update(pointer, 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/blockDropHandling.ts b/packages/core/src/extensions/SideMenu/blockDropHandling.ts new file mode 100644 index 0000000000..3873596276 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/blockDropHandling.ts @@ -0,0 +1,340 @@ +import { DOMParser, Slice } from "@tiptap/pm/model"; +import { TextSelection } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; + +/** 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 closest editor visually to the given coordinates. + */ +function findClosestEditorElement( + view: EditorView, + coords: { clientX: number; clientY: number }, +) { + const editors = Array.from(view.root.querySelectorAll(".bn-editor")); + + 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 unchanged from the + * former `SideMenuView` class into listeners bound to the extension's mount + * signal. `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; + + // Tells us which editor instance is the closest to the drag event (whether or not it is actually reasonably close) + const closestEditor = findClosestEditorElement(view, event); + + // We arbitrarily decide how far is "too far" from the closest editor to be considered a drop point + if (!closestEditor || closestEditor.distance > dropMargin) { + 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; + } + + const element = document.createElement("div"); + element.innerHTML = html; + + 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..2c2e549ae5 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/blockGeometry.browser.test.ts @@ -0,0 +1,169 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + getNestedBlockAtCursor, + getDirectChildBlocks, +} 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("getDirectChildBlocks", () => { + it("returns direct child blocks, skipping nested grandchildren", () => { + const { columnList, columnA, columnB } = buildColumnList(); + + // The blocks inside each column must not come back as the list's own + // children. The `closest` check stops the walk one level down. + expect(getDirectChildBlocks(columnList)).toEqual([columnA, columnB]); + }); + + it("sees through blockOuter wrappers to the blockContainer child", () => { + const { columnA, childA } = buildColumnList(); + + // The column's own direct child is the wrapped blockContainer, not the + // blockOuter chrome (which isn't a block in the selector's sense). + expect(getDirectChildBlocks(columnA)).toEqual([childA.blockContainer]); + }); +}); + +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..dc4ae46c40 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/blockGeometry.ts @@ -0,0 +1,56 @@ +import { BLOCK_SELECTOR } from "../blockDOM.js"; + +export function getDirectChildBlocks(container: Element): Element[] { + const children: Element[] = []; + for (const child of container.querySelectorAll(BLOCK_SELECTOR)) { + if (child.parentElement?.closest(BLOCK_SELECTOR) === container) { + children.push(child); + } + } + return children; +} + +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; +} + +// Descend through child blocks, including regular blocks with padded frames. +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/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..f8e05a287c --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuHover.ts @@ -0,0 +1,284 @@ +import { EditorView } from "@tiptap/pm/view"; + +import { getBlockFromElement } from "../blockDOM.js"; +import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; +import { getNestedBlockAtCursor } from "./blockGeometry.js"; + +export type Point = { x: number; y: number }; + +export type Rect = { left: number; top: number; right: number; bottom: number }; + +/** A block's root element and identity, as `getBlockFromElement` resolves it. */ +type DOMBlock = NonNullable>; + +export type HoveredBlock = Pick; + +/** + * 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)) { + if (!view.dom.contains(element)) { + // UI painted over the blocks, e.g. a toolbar: look below it. + continue; + } + return getBlockFromElement(element, view); + } + return undefined; +} + +/** + * 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. + */ +function getBlockAtHeight( + view: EditorView, + block: DOMBlock, + pointer: Point, +): DOMBlock | undefined { + return getBlockFromElement(getNestedBlockAtCursor(block.node, pointer), view); +} + +/** + * 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, +): DOMBlock | 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 ? getBlockAtHeight(view, 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" }; + +export type HoverOptions = { + /** 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; +}; + +/** + * 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, + { isWithinEditorUI, hoverMargin }: HoverOptions, +): 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: DOMBlock | undefined): HoverResolution { + const resolved = block + ? getDraggableBlockFromElement(block.node, 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 = getBlockFromElement(target, view); + return draggable( + block + ? getBlockAtHeight(view, 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). */ +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. + */ +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/blockDOM.ts b/packages/core/src/extensions/blockDOM.ts new file mode 100644 index 0000000000..b96ef83e42 --- /dev/null +++ b/packages/core/src/extensions/blockDOM.ts @@ -0,0 +1,51 @@ +import type { EditorView } from "prosemirror-view"; + +// 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"])'; + +/** Resolves block identity independently of whether it gets a drag handle. */ +export function getBlockFromElement( + element: Element, + view: Pick, +): { node: HTMLElement; id: string; type: string } | undefined { + if (!view.dom.contains(element)) { + return undefined; + } + const node = element.closest(BLOCK_SELECTOR); + if (!node || node === view.dom || !view.dom.contains(node)) { + return undefined; + } + + // Embedded editors own their blocks, even though their DOM is inside ours. + const owner = element.closest(".bn-editor"); + if (owner && owner !== view.dom) { + return undefined; + } + + const id = node.getAttribute("data-id"); + const nodeType = node.getAttribute("data-node-type"); + // The regular block's own content precedes its nested blockGroup, even + // when a renderFrame adds chrome around it. + const type = + nodeType === "blockContainer" + ? node + .querySelector("[data-content-type]") + ?.getAttribute("data-content-type") + : nodeType; + // A matched block root must expose its own identity. Validate this at the + // DOM boundary so callers never need casts or non-null assertions. + const HTMLElementClass = node.ownerDocument.defaultView?.HTMLElement; + if ( + !HTMLElementClass || + !(node instanceof HTMLElementClass) || + !id || + !type + ) { + throw new Error( + "Block root is missing its HTML element, ID, or block type.", + ); + } + return { node, id, type }; +} 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/sideMenuHover.test.tsx b/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx new file mode 100644 index 0000000000..d153e1a79b --- /dev/null +++ b/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx @@ -0,0 +1,926 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + type PartialBlock, +} 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 { 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. The `known issue` tests describe the wanted +// behavior and are marked `test.fails` until a fix lands; a fix flips them to +// `test`. The other tests guard behavior that works today. + +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 }]); + 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 (the diagonal case below still fails). + 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"); + }); +}); + +describe("side menu hover: known issues", () => { + // #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 and hides the menu. + 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"); + }); + + // BLO-963: no menu when the editor's right side is outside the viewport. + // Note: in a 3000px wide editor the blocks are centered, so block "a" starts + // beyond the 1280px viewport and the pointer never gets there: the browser + // dispatches no events for it. The test below reproduces BLO-963 with the + // block's left part on screen. + // Fails by design: the last pointer event lands ~110px left of the + // off-screen block, outside the narrow gutter band. + test.fails("shows the menu when the editor's right side is outside the viewport", async () => { + await render( + , + ); + await waitForSelector(".bn-editor"); + + const rect = blockRect("a"); + await walk([{ x: rect.x + 60, y: rect.y + rect.height / 2 }]); + expect(menuBlock()).toBe("a"); + }); + + // 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); + }); + // #2526: in columns inside a toggle, the add button of a left-column block + // disappears when the pointer moves 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: 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`. + await walk([{ x: rect.x + 6, y: -20 }], 4); + 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 (it then broke the next toggle test). +// 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"); + const chevron = getRect(await waitForSelector(".bn-toggle-button")); + await mouseSequence([ + { + type: "click", + x: chevron.x + chevron.width / 2, + y: chevron.y + chevron.height / 2, + }, + ]); + await sleep(100); + + await hoverBlock("drag"); + expect(menuBlock()).toBe("drag"); + const handle = getRect(await waitForSelector(DRAG_HANDLE_SELECTOR)); + const target = getRect( + await waitForSelector(".bn-toggle-add-block-button"), + ); + await walk([ + { x: handle.x + handle.width / 2, y: handle.y + handle.height / 2 }, + ]); + await mouseSequence([{ type: "down" }]); + await sleep(100); + await mouseSequence([ + { + type: "move", + x: target.x + target.width / 2, + y: target.y + target.height / 2, + steps: 5, + }, + ]); + await sleep(100); + await mouseSequence([{ type: "up" }]); + await sleep(100); + + expect( + editors.main.getBlock("t")!.children.map((child) => child.id), + ).toEqual(["drag"]); + expect(document.querySelector(".bn-drag-preview")).toBeNull(); + }); +}); + +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"); + }); +}); 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 = ( From 6081901aa406eb2c125c48090b008a15334fd2cf Mon Sep 17 00:00:00 2001 From: yousefed Date: Sun, 11 Oct 2026 08:25:19 +0200 Subject: [PATCH 2/7] test(core): drag test without the toggle drop, which main does not have --- .../sidemenu/sideMenuHover.test.tsx | 37 ++++++------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx b/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx index d153e1a79b..4c1d43903a 100644 --- a/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx +++ b/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx @@ -615,56 +615,41 @@ describe("side menu hover: engaging a block", () => { // 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 (it then broke the next toggle test). +// 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"); - const chevron = getRect(await waitForSelector(".bn-toggle-button")); - await mouseSequence([ - { - type: "click", - x: chevron.x + chevron.width / 2, - y: chevron.y + chevron.height / 2, - }, - ]); - await sleep(100); await hoverBlock("drag"); expect(menuBlock()).toBe("drag"); const handle = getRect(await waitForSelector(DRAG_HANDLE_SELECTOR)); - const target = getRect( - await waitForSelector(".bn-toggle-add-block-button"), - ); + 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 + target.width / 2, - y: target.y + target.height / 2, - steps: 5, - }, + { 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.getBlock("t")!.children.map((child) => child.id), - ).toEqual(["drag"]); + expect(editors.main.document.map((block) => block.id)).toEqual([ + "drag", + "a", + "b", + ]); expect(document.querySelector(".bn-drag-preview")).toBeNull(); }); }); From 7093c51afaff6e8d37a570e395f397a2471daaa7 Mon Sep 17 00:00:00 2001 From: yousefed Date: Sun, 11 Oct 2026 08:33:13 +0200 Subject: [PATCH 3/7] fix(core): drop a dragged block into the editor on top where editors overlap The drop handling selected the editor closest to a drag event. Where two editors overlap, both are at distance 0, and the editor that comes first in the document won, usually the one at the bottom. The side menu of the top editor now shows there, so a block dragged in the top editor was dropped into the bottom editor. If the target of the drag event is in an editor, that editor is now the only candidate. The distance only decides when the pointer is outside every editor. --- .../extensions/SideMenu/blockDropHandling.ts | 19 ++- .../sidemenu/overlappingEditors.test.tsx | 121 ++++++++++++++++++ 2 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 tests/src/end-to-end/sidemenu/overlappingEditors.test.tsx diff --git a/packages/core/src/extensions/SideMenu/blockDropHandling.ts b/packages/core/src/extensions/SideMenu/blockDropHandling.ts index 3873596276..67e5723e64 100644 --- a/packages/core/src/extensions/SideMenu/blockDropHandling.ts +++ b/packages/core/src/extensions/SideMenu/blockDropHandling.ts @@ -10,13 +10,18 @@ function isSynthetic(event: Event): boolean { } /** - * Finds the closest editor visually to the given coordinates. + * 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, it is the editor closest to the event. */ -function findClosestEditorElement( - view: EditorView, - coords: { clientX: number; clientY: number }, -) { - const editors = Array.from(view.root.querySelectorAll(".bn-editor")); +function findDropEditor(view: EditorView, event: DragEvent) { + const target = + event.target instanceof Element ? event.target.closest(".bn-editor") : null; + const editors = target + ? [target] + : Array.from(view.root.querySelectorAll(".bn-editor")); + const coords = event; if (editors.length === 0) { return null; @@ -135,7 +140,7 @@ export function registerBlockDropHandlers( const isOrigin = textContentIsBeingDragged || sideMenuIsBeingDragged; // Tells us which editor instance is the closest to the drag event (whether or not it is actually reasonably close) - const closestEditor = findClosestEditorElement(view, event); + const closestEditor = findDropEditor(view, event); // We arbitrarily decide how far is "too far" from the closest editor to be considered a drop point if (!closestEditor || closestEditor.distance > dropMargin) { 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..30df9014fb --- /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 }]); + 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", + ]); + }); + }); +} From 0fefeee91085863c6d56108a7af9c23b78f82ccd Mon Sep 17 00:00:00 2001 From: yousefed Date: Sun, 11 Oct 2026 08:43:53 +0200 Subject: [PATCH 4/7] fix(core): parse the drag data of a block in an inert document The drop handling parsed the `blocknote/html` drag data with `innerHTML` on an element of the page. An element of the page loads resources and runs event handlers in the HTML (e.g. `onerror` on an image), also when it is not in the document. A document from `DOMParser.parseFromString` is inert. The code is the same as on `main`; it moved in the previous commit, which is why code scanning reports it now. --- .../core/src/extensions/SideMenu/blockDropHandling.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/src/extensions/SideMenu/blockDropHandling.ts b/packages/core/src/extensions/SideMenu/blockDropHandling.ts index 67e5723e64..0a76f03e72 100644 --- a/packages/core/src/extensions/SideMenu/blockDropHandling.ts +++ b/packages/core/src/extensions/SideMenu/blockDropHandling.ts @@ -194,8 +194,13 @@ export function registerBlockDropHandlers( return; } - const element = document.createElement("div"); - element.innerHTML = html; + // 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, { From 0121307fc322775d2a0f87ff2beab1ed98e4fc54 Mon Sep 17 00:00:00 2001 From: yousefed Date: Sun, 11 Oct 2026 09:35:36 +0200 Subject: [PATCH 5/7] fix(core): side menu regressions found in review - After a document change while the pointer is on the menu (e.g. a click on "+"), the menu resolves its block just right of the menu, on the line of its block. Before, it resolved at the pointer, which for a block in a column is over the column to its left, so the menu moved to that column. - After a document change, the menu looks past all UI of the editor, not only the registered menu element. A custom menu that does not call `setMenuElement` now also moves to the block that takes the place of a removed block. - A block that embeds another editor gets its side menu from the gutter of the outer editor again. The probe skips the blocks of the nested editor, and the child blocks of a block do not include them. - `getBlockFromElement` returns `undefined` for a block root without an ID, instead of an exception, and does not read the block type, which the side menu does not use on main. - Tests: regression tests for the first and third point, `await cleanup()`, and the window-leave test now moves in one step, so it tests `mouseout`. --- .../core/src/extensions/SideMenu/SideMenu.ts | 35 ++++-- .../extensions/SideMenu/blockDropHandling.ts | 6 +- .../src/extensions/SideMenu/blockGeometry.ts | 11 +- .../src/extensions/SideMenu/sideMenuHover.ts | 11 +- packages/core/src/extensions/blockDOM.ts | 27 +---- .../sidemenu/overlappingEditors.test.tsx | 2 +- .../sidemenu/sideMenuHover.test.tsx | 104 +++++++++++++++++- 7 files changed, 151 insertions(+), 45 deletions(-) diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index 41426c5e52..1fdcb2b143 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -220,21 +220,38 @@ export const SideMenuExtension = createExtension( } /** - * Re-resolves the block for the last pointer position, without intent. The - * pointer may rest on the menu (e.g. after clicking one of its buttons), so - * this looks past the menu at what is underneath: after a document change, - * that can be another block, e.g. the one below a removed block, which moved - * up into its place. + * 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. */ 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 = - editor.prosemirrorView.root - .elementsFromPoint(pointer.x, pointer.y) - .find((element) => !menuElement?.contains(element)) ?? null; - update(pointer, target, false); + view.root + .elementsFromPoint(point.x, point.y) + .find( + (element) => + view.dom.contains(element) || !editor.isWithinEditor(element), + ) ?? null; + update(point, target, false); } return { diff --git a/packages/core/src/extensions/SideMenu/blockDropHandling.ts b/packages/core/src/extensions/SideMenu/blockDropHandling.ts index 0a76f03e72..0e2657466c 100644 --- a/packages/core/src/extensions/SideMenu/blockDropHandling.ts +++ b/packages/core/src/extensions/SideMenu/blockDropHandling.ts @@ -61,9 +61,11 @@ function findDropEditor(view: EditorView, event: DragEvent) { } /** - * Drag & drop between blocks and between editors. Moved unchanged from the + * Drag & drop between blocks and between editors. Moved from the * former `SideMenuView` class into listeners bound to the extension's mount - * signal. `isDragOrigin` reports whether a side menu drag started in this + * 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. */ diff --git a/packages/core/src/extensions/SideMenu/blockGeometry.ts b/packages/core/src/extensions/SideMenu/blockGeometry.ts index dc4ae46c40..67e158c9e8 100644 --- a/packages/core/src/extensions/SideMenu/blockGeometry.ts +++ b/packages/core/src/extensions/SideMenu/blockGeometry.ts @@ -1,9 +1,18 @@ import { BLOCK_SELECTOR } from "../blockDOM.js"; +/** + * 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) { + if ( + child.parentElement?.closest(BLOCK_SELECTOR) === container && + child.closest(".bn-editor") === editor + ) { children.push(child); } } diff --git a/packages/core/src/extensions/SideMenu/sideMenuHover.ts b/packages/core/src/extensions/SideMenu/sideMenuHover.ts index f8e05a287c..4ff729bd95 100644 --- a/packages/core/src/extensions/SideMenu/sideMenuHover.ts +++ b/packages/core/src/extensions/SideMenu/sideMenuHover.ts @@ -26,11 +26,14 @@ function clamp(value: number, min: number, max: number) { /** 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)) { - if (!view.dom.contains(element)) { - // UI painted over the blocks, e.g. a toolbar: look below it. - continue; + // 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 = view.dom.contains(element) + ? getBlockFromElement(element, view) + : undefined; + if (block) { + return block; } - return getBlockFromElement(element, view); } return undefined; } diff --git a/packages/core/src/extensions/blockDOM.ts b/packages/core/src/extensions/blockDOM.ts index b96ef83e42..0376102cda 100644 --- a/packages/core/src/extensions/blockDOM.ts +++ b/packages/core/src/extensions/blockDOM.ts @@ -9,7 +9,7 @@ export const BLOCK_SELECTOR = export function getBlockFromElement( element: Element, view: Pick, -): { node: HTMLElement; id: string; type: string } | undefined { +): { node: HTMLElement; id: string } | undefined { if (!view.dom.contains(element)) { return undefined; } @@ -25,27 +25,10 @@ export function getBlockFromElement( } const id = node.getAttribute("data-id"); - const nodeType = node.getAttribute("data-node-type"); - // The regular block's own content precedes its nested blockGroup, even - // when a renderFrame adds chrome around it. - const type = - nodeType === "blockContainer" - ? node - .querySelector("[data-content-type]") - ?.getAttribute("data-content-type") - : nodeType; - // A matched block root must expose its own identity. Validate this at the - // DOM boundary so callers never need casts or non-null assertions. const HTMLElementClass = node.ownerDocument.defaultView?.HTMLElement; - if ( - !HTMLElementClass || - !(node instanceof HTMLElementClass) || - !id || - !type - ) { - throw new Error( - "Block root is missing its HTML element, ID, or block type.", - ); + if (!id || !HTMLElementClass || !(node instanceof HTMLElementClass)) { + // E.g. a custom block from a hand-written node that renders no ID. + return undefined; } - return { node, id, type }; + return { node, id }; } diff --git a/tests/src/end-to-end/sidemenu/overlappingEditors.test.tsx b/tests/src/end-to-end/sidemenu/overlappingEditors.test.tsx index 30df9014fb..51baa997af 100644 --- a/tests/src/end-to-end/sidemenu/overlappingEditors.test.tsx +++ b/tests/src/end-to-end/sidemenu/overlappingEditors.test.tsx @@ -54,7 +54,7 @@ function Overlapping(props: { bottomEditable: boolean }) { afterEach(async () => { await mouseSequence([{ type: "move", x: 1, y: 1 }]); - cleanup(); + await cleanup(); }); function menuBlock(name: string): string | undefined { diff --git a/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx b/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx index 4c1d43903a..842bc4f28f 100644 --- a/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx +++ b/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx @@ -2,6 +2,7 @@ import { BlockNoteEditor, BlockNoteSchema, type PartialBlock, + defaultBlockSpecs, } from "@blocknote/core"; import { SideMenuExtension, @@ -10,7 +11,7 @@ import { import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; -import { useCreateBlockNote } from "@blocknote/react"; +import { createReactBlockSpec, useCreateBlockNote } from "@blocknote/react"; import { multiColumnDropCursor, withMultiColumn, @@ -29,9 +30,8 @@ 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. The `known issue` tests describe the wanted -// behavior and are marked `test.fails` until a fix lands; a fix flips them to -// `test`. The other tests guard behavior that works today. +// whether it shows at all. A test that no implementation can pass is marked +// `test.fails`, with the reason. The other tests guard behavior that works today. const schema = withMultiColumn(BlockNoteSchema.create()); type Editor = BlockNoteEditor< @@ -65,7 +65,7 @@ function Editor(props: { afterEach(async () => { // Moves the pointer away so the next test starts without a menu. await mouseSequence([{ type: "move", x: 1, y: 1 }]); - cleanup(); + await cleanup(); for (const name of Object.keys(editors)) { delete editors[name]; } @@ -479,7 +479,9 @@ describe("side menu hover: pointer input", () => { expect(menuBlock()).toBe("a"); // Above the test frame: the frame's document gets a `mouseout` without // a `relatedTarget`. - await walk([{ x: rect.x + 6, y: -20 }], 4); + // 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(); }); @@ -909,3 +911,93 @@ describe("side menu hover: elements over the editor", () => { 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"); + } + }); +}); From 4f45a564cad549e3df7d1f61e504c53ea64544ad Mon Sep 17 00:00:00 2001 From: yousefed Date: Sun, 11 Oct 2026 14:41:42 +0200 Subject: [PATCH 6/7] Measure dropMargin from the editor element, regroup hover tests dropMargin now counts from the editor element's box, like hoverMargin; the distance to the blocks still picks the drop editor and where the drop lands. Drop the test.fails case (unreachable by construction) and move the tests that pass on main into "works today". --- .../docs/react/components/side-menu.mdx | 2 +- .../extensions/SideMenu/blockDropHandling.ts | 17 +- .../src/extensions/SideMenu/sideMenuHover.ts | 2 +- .../sidemenu/sideMenuHover.test.tsx | 170 ++++++++++-------- 4 files changed, 112 insertions(+), 79 deletions(-) diff --git a/docs/content/docs/react/components/side-menu.mdx b/docs/content/docs/react/components/side-menu.mdx index 959d0168b6..f4db4c09b6 100644 --- a/docs/content/docs/react/components/side-menu.mdx +++ b/docs/content/docs/react/components/side-menu.mdx @@ -72,4 +72,4 @@ const editor = useCreateBlockNote({ `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`. -To show the menu wherever a block can be dropped, set both to the same value. +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/extensions/SideMenu/blockDropHandling.ts b/packages/core/src/extensions/SideMenu/blockDropHandling.ts index 0e2657466c..495a0196b4 100644 --- a/packages/core/src/extensions/SideMenu/blockDropHandling.ts +++ b/packages/core/src/extensions/SideMenu/blockDropHandling.ts @@ -2,6 +2,8 @@ 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 }; @@ -14,6 +16,11 @@ function isSynthetic(event: Event): boolean { * 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, it is the editor closest to the event. + * + * Distances count to the blocks, not to the editor element (as `dropMargin` + * does): 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) { const target = @@ -144,8 +151,14 @@ export function registerBlockDropHandlers( // Tells us which editor instance is the closest to the drag event (whether or not it is actually reasonably close) const closestEditor = findDropEditor(view, event); - // We arbitrarily decide how far is "too far" from the closest editor to be considered a drop point - if (!closestEditor || closestEditor.distance > dropMargin) { + // `dropMargin` counts from the editor element's box, like `hoverMargin`. + if ( + !closestEditor || + distanceToRect( + { x: event.clientX, y: event.clientY }, + closestEditor.element.getBoundingClientRect(), + ) > dropMargin + ) { return undefined; } diff --git a/packages/core/src/extensions/SideMenu/sideMenuHover.ts b/packages/core/src/extensions/SideMenu/sideMenuHover.ts index 4ff729bd95..b69b2aad6e 100644 --- a/packages/core/src/extensions/SideMenu/sideMenuHover.ts +++ b/packages/core/src/extensions/SideMenu/sideMenuHover.ts @@ -214,7 +214,7 @@ function distanceToSegment(point: Point, a: Point, b: Point): number { } /** The distance from the point to the rect (0 when it is inside). */ -function distanceToRect(point: Point, rect: Rect): number { +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), diff --git a/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx b/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx index 842bc4f28f..593f534818 100644 --- a/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx +++ b/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx @@ -30,8 +30,8 @@ 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. A test that no implementation can pass is marked -// `test.fails`, with the reason. The other tests guard behavior that works today. +// 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< @@ -210,9 +210,10 @@ describe("side menu hover: works today", () => { 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 (the diagonal case below still fails). + // no longer reproduces. test("keeps column three's block while walking left onto its add button", async () => { await render(); await waitForSelector(".bn-editor"); @@ -229,12 +230,10 @@ describe("side menu hover: works today", () => { ]); expect(menuBlock()).toBe("c3-list"); }); -}); -describe("side menu hover: known issues", () => { // #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 and hides the menu. + // 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"); @@ -249,28 +248,65 @@ describe("side menu hover: known issues", () => { expect(menuBlock()).toBe("c2"); }); - // BLO-963: no menu when the editor's right side is outside the viewport. - // Note: in a 3000px wide editor the blocks are centered, so block "a" starts - // beyond the 1280px viewport and the pointer never gets there: the browser - // dispatches no events for it. The test below reproduces BLO-963 with the - // block's left part on screen. - // Fails by design: the last pointer event lands ~110px left of the - // off-screen block, outside the narrow gutter band. - test.fails("shows the menu when the editor's right side is outside the viewport", async () => { + // #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 rect = blockRect("a"); - await walk([{ x: rect.x + 60, y: rect.y + rect.height / 2 }]); - expect(menuBlock()).toBe("a"); + 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 () => { @@ -364,62 +400,6 @@ describe("side menu hover: known issues", () => { const add = document.querySelector(DRAG_HANDLE_ADD_SELECTOR); expect(add?.checkVisibility({ visibilityProperty: true })).toBe(true); }); - // #2526: in columns inside a toggle, the add button of a left-column block - // disappears when the pointer moves 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: heading for the menu", () => { @@ -654,6 +634,46 @@ describe.skipIf(browserName !== "chromium")("side menu hover: dragging", () => { ]); 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", () => { From ceea356322321d40ebcbd892f98802ae13f30039 Mon Sep 17 00:00:00 2001 From: yousefed Date: Sun, 11 Oct 2026 15:33:15 +0200 Subject: [PATCH 7/7] Address review: resolve blocks with less DOM work, pick drop editors within dropMargin - blockDOM.ts moves into SideMenu/ and holds the DOM-structure helpers; getBlockFromElement becomes getBlockElement, and the second lookup after the descent is gone. Tests for it are co-located. - findDropEditor only considers editors within dropMargin, then picks the one with the nearest blocks. - resolveHoveredBlock takes isWithinEditorUI and hoverMargin as parameters. - Comments: safePolygon note, SideMenuState.block, update() branches, FIXME on resolving blocks through ProseMirror. - Remove the unused SideMenuState duplicate in dragging.ts. --- .../core/src/extensions/SideMenu/SideMenu.ts | 21 +++- .../SideMenu/blockDOM.browser.test.ts | 102 ++++++++++++++++++ .../core/src/extensions/SideMenu/blockDOM.ts | 50 +++++++++ .../extensions/SideMenu/blockDropHandling.ts | 38 +++---- .../SideMenu/blockGeometry.browser.test.ts | 23 +--- .../src/extensions/SideMenu/blockGeometry.ts | 30 ++---- .../core/src/extensions/SideMenu/dragging.ts | 10 -- .../src/extensions/SideMenu/sideMenuHover.ts | 58 ++++------ packages/core/src/extensions/blockDOM.ts | 34 ------ .../getDraggableBlockFromElement.ts | 2 + 10 files changed, 220 insertions(+), 148 deletions(-) create mode 100644 packages/core/src/extensions/SideMenu/blockDOM.browser.test.ts create mode 100644 packages/core/src/extensions/SideMenu/blockDOM.ts delete mode 100644 packages/core/src/extensions/blockDOM.ts diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index 1fdcb2b143..ec7904dc35 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -24,7 +24,12 @@ export type SideMenuState< 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; }; @@ -150,6 +155,7 @@ export const SideMenuExtension = createExtension( // 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)) { @@ -159,12 +165,14 @@ export const SideMenuExtension = createExtension( // anywhere but onto its block follows the pointer at once. clearIntent(); lastOnActive = point; + // On the menu: keep the menu as it is. return; } if (!editor.isEditable) { clearIntent(); hideMenu(); + // Read-only editor: hide the menu. return; } @@ -172,12 +180,11 @@ export const SideMenuExtension = createExtension( editor.prosemirrorView, target, point, - { - isWithinEditorUI: editor.isWithinEditor, - hoverMargin, - }, + editor.isWithinEditor, + hoverMargin, ); if (resolution.kind === "editorUI") { + // On other UI of the editor (e.g. a toolbar): keep the menu as it is. return; } @@ -186,6 +193,7 @@ export const SideMenuExtension = createExtension( if (resolution.kind === "block" && resolution.block.id === active) { clearIntent(); lastOnActive = point; + // On the block of the menu: keep the menu as it is. return; } @@ -195,10 +203,13 @@ export const SideMenuExtension = createExtension( // 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; } } + // 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; 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
+
+
+
+
+
B
+
+
+
+
`; + +// A block that embeds another editor, with a block of its own. +const nestedEditor = ` +
+
+
+
+
+
inner
+
+
+
+
+
`; + +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 index 495a0196b4..46c38002f1 100644 --- a/packages/core/src/extensions/SideMenu/blockDropHandling.ts +++ b/packages/core/src/extensions/SideMenu/blockDropHandling.ts @@ -15,19 +15,29 @@ function isSynthetic(event: Event): boolean { * 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, it is the editor closest to the event. + * on top. Otherwise, the candidates are the editors within `dropMargin` of the + * event, and it is the one closest to the event. * - * Distances count to the blocks, not to the editor element (as `dropMargin` - * does): 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. + * `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) { +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")); + : Array.from(view.root.querySelectorAll(".bn-editor")).filter( + (editor) => + distanceToRect(point, editor.getBoundingClientRect()) <= dropMargin, + ); const coords = event; if (editors.length === 0) { @@ -148,17 +158,9 @@ export function registerBlockDropHandlers( // Tells us that the current editor instance has a drag ongoing (either text or side menu) const isOrigin = textContentIsBeingDragged || sideMenuIsBeingDragged; - // Tells us which editor instance is the closest to the drag event (whether or not it is actually reasonably close) - const closestEditor = findDropEditor(view, event); - - // `dropMargin` counts from the editor element's box, like `hoverMargin`. - if ( - !closestEditor || - distanceToRect( - { x: event.clientX, y: event.clientY }, - closestEditor.element.getBoundingClientRect(), - ) > dropMargin - ) { + // The editor the drag event is about, if any is within `dropMargin` + const closestEditor = findDropEditor(view, event, dropMargin); + if (!closestEditor) { return undefined; } diff --git a/packages/core/src/extensions/SideMenu/blockGeometry.browser.test.ts b/packages/core/src/extensions/SideMenu/blockGeometry.browser.test.ts index 2c2e549ae5..590a115ec1 100644 --- a/packages/core/src/extensions/SideMenu/blockGeometry.browser.test.ts +++ b/packages/core/src/extensions/SideMenu/blockGeometry.browser.test.ts @@ -1,9 +1,6 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; -import { - getNestedBlockAtCursor, - getDirectChildBlocks, -} from "./blockGeometry.js"; +import { getNestedBlockAtCursor } from "./blockGeometry.js"; // Exercise container hit testing with real layout, including nested columns. @@ -79,24 +76,6 @@ function buildVerticalContainer() { return { callout, first, second }; } -describe("getDirectChildBlocks", () => { - it("returns direct child blocks, skipping nested grandchildren", () => { - const { columnList, columnA, columnB } = buildColumnList(); - - // The blocks inside each column must not come back as the list's own - // children. The `closest` check stops the walk one level down. - expect(getDirectChildBlocks(columnList)).toEqual([columnA, columnB]); - }); - - it("sees through blockOuter wrappers to the blockContainer child", () => { - const { columnA, childA } = buildColumnList(); - - // The column's own direct child is the wrapped blockContainer, not the - // blockOuter chrome (which isn't a block in the selector's sense). - expect(getDirectChildBlocks(columnA)).toEqual([childA.blockContainer]); - }); -}); - describe("getNestedBlockAtCursor", () => { it("keeps a regular block as the probe target", () => { const { childA } = buildColumnList(); diff --git a/packages/core/src/extensions/SideMenu/blockGeometry.ts b/packages/core/src/extensions/SideMenu/blockGeometry.ts index 67e158c9e8..3891af4812 100644 --- a/packages/core/src/extensions/SideMenu/blockGeometry.ts +++ b/packages/core/src/extensions/SideMenu/blockGeometry.ts @@ -1,23 +1,4 @@ -import { BLOCK_SELECTOR } from "../blockDOM.js"; - -/** - * 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; -} +import { BLOCK_SELECTOR, getDirectChildBlocks } from "./blockDOM.js"; export type BlockRect = { top: number; @@ -45,7 +26,14 @@ export function rectIndexAtCursor( return verticalMatch; } -// Descend through child blocks, including regular blocks with padded frames. +/** + * 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 }, 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.ts b/packages/core/src/extensions/SideMenu/sideMenuHover.ts index b69b2aad6e..6ee14a1710 100644 --- a/packages/core/src/extensions/SideMenu/sideMenuHover.ts +++ b/packages/core/src/extensions/SideMenu/sideMenuHover.ts @@ -1,17 +1,14 @@ import { EditorView } from "@tiptap/pm/view"; -import { getBlockFromElement } from "../blockDOM.js"; 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 }; -/** A block's root element and identity, as `getBlockFromElement` resolves it. */ -type DOMBlock = NonNullable>; - -export type HoveredBlock = Pick; +export type HoveredBlock = { node: HTMLElement; id: string }; /** * How far inside the edge of the blocks the probe point goes. A probe exactly @@ -28,9 +25,7 @@ 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 = view.dom.contains(element) - ? getBlockFromElement(element, view) - : undefined; + const block = getBlockElement(element, view); if (block) { return block; } @@ -38,22 +33,6 @@ function getBlockAtPoint(view: EditorView, point: Point) { return undefined; } -/** - * 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. - */ -function getBlockAtHeight( - view: EditorView, - block: DOMBlock, - pointer: Point, -): DOMBlock | undefined { - return getBlockFromElement(getNestedBlockAtCursor(block.node, pointer), view); -} - /** * 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 @@ -70,7 +49,7 @@ function getBlockAtHeight( function probeBlockAtHeight( view: EditorView, pointer: Point, -): DOMBlock | undefined { +): Element | undefined { const blocks = view.dom.firstElementChild?.getBoundingClientRect(); if (!blocks || pointer.y < blocks.top || pointer.y > blocks.bottom) { return undefined; @@ -80,7 +59,7 @@ function probeBlockAtHeight( x: clamp(pointer.x, blocks.left + inset, blocks.right - inset), y: pointer.y, }); - return reference ? getBlockAtHeight(view, reference, pointer) : undefined; + return reference ? getNestedBlockAtCursor(reference, pointer) : undefined; } export type HoverResolution = @@ -91,13 +70,6 @@ export type HoverResolution = /** The pointer is not over this editor's blocks. */ | { kind: "none" }; -export type HoverOptions = { - /** 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; -}; - /** * Which of this editor's blocks the pointer is about, from the topmost element * under the pointer (the event target). @@ -106,13 +78,16 @@ export function resolveHoveredBlock( view: EditorView, target: Element | null, pointer: Point, - { isWithinEditorUI, hoverMargin }: HoverOptions, + /** 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: DOMBlock | undefined): HoverResolution { + function draggable(block: Element | undefined): HoverResolution { const resolved = block - ? getDraggableBlockFromElement(block.node, view) + ? getDraggableBlockFromElement(block, view) : undefined; return resolved ? { kind: "block", block: resolved } : { kind: "none" }; } @@ -125,10 +100,10 @@ export function resolveHoveredBlock( // 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 = getBlockFromElement(target, view); + const block = getBlockElement(target, view); return draggable( block - ? getBlockAtHeight(view, block, pointer) + ? getNestedBlockAtCursor(block, pointer) : probeBlockAtHeight(view, pointer), ); } @@ -259,6 +234,13 @@ function convexHull(points: Point[]): Point[] { * 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, diff --git a/packages/core/src/extensions/blockDOM.ts b/packages/core/src/extensions/blockDOM.ts deleted file mode 100644 index 0376102cda..0000000000 --- a/packages/core/src/extensions/blockDOM.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { EditorView } from "prosemirror-view"; - -// 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"])'; - -/** Resolves block identity independently of whether it gets a drag handle. */ -export function getBlockFromElement( - element: Element, - view: Pick, -): { node: HTMLElement; id: string } | undefined { - if (!view.dom.contains(element)) { - return undefined; - } - const node = element.closest(BLOCK_SELECTOR); - if (!node || node === view.dom || !view.dom.contains(node)) { - return undefined; - } - - // Embedded editors own their blocks, even though their DOM is inside ours. - const owner = element.closest(".bn-editor"); - if (owner && owner !== view.dom) { - return undefined; - } - - const id = node.getAttribute("data-id"); - const HTMLElementClass = node.ownerDocument.defaultView?.HTMLElement; - if (!id || !HTMLElementClass || !(node instanceof HTMLElementClass)) { - // E.g. a custom block from a hand-written node that renders no ID. - return undefined; - } - return { node, id }; -} 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,