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