import type { BlockNoteEditor, BlockSchema, InlineContentSchema, StyleSchema, } from '@blocknote/core' import { useEffect, useRef, type RefObject } from 'react' type RectLike = Pick const HOVER_BRIDGE_PADDING_X = 8 const HOVER_BRIDGE_PADDING_Y = 8 const FORMATTING_TOOLBAR_FILE_BLOCK_TYPES = new Set([ 'audio', 'file', 'image', 'video', ]) function isVisibleRect(rect: RectLike) { return rect.right > rect.left && rect.bottom > rect.top } function getSelectedFileBlockBridgeElement( container: HTMLElement, blockId: string, ) { const selectedBlock = container.querySelector( `.bn-block[data-id="${blockId}"]`, ) if (!selectedBlock) return null return ( selectedBlock.querySelector( '[data-file-block] .bn-visual-media-wrapper', ) ?? selectedBlock.querySelector( '[data-file-block] .bn-file-name-with-icon', ) ?? selectedBlock.querySelector('[data-file-block] .bn-add-file-button') ?? selectedBlock.querySelector('[data-file-block]') ) } export function isWithinFormattingToolbarHoverBridge( point: { x: number; y: number }, fileBlockRect: RectLike, toolbarRect: RectLike, ) { if (!isVisibleRect(fileBlockRect) || !isVisibleRect(toolbarRect)) { return false } const left = Math.min(fileBlockRect.left, toolbarRect.left) - HOVER_BRIDGE_PADDING_X const right = Math.max(fileBlockRect.right, toolbarRect.right) + HOVER_BRIDGE_PADDING_X const top = Math.min(fileBlockRect.top, toolbarRect.top) - HOVER_BRIDGE_PADDING_Y const bottom = Math.max(fileBlockRect.bottom, toolbarRect.bottom) + HOVER_BRIDGE_PADDING_Y return ( point.x >= left && point.x <= right && point.y >= top && point.y <= bottom ) } export function shouldSuppressFormattingToolbarHoverUpdate({ eventTarget, point, container, doc, selectedFileBlockId, }: { eventTarget: EventTarget | null point: { x: number; y: number } container: HTMLElement | null doc: Document selectedFileBlockId: string | null }) { if (!container || !selectedFileBlockId) return false if ( eventTarget instanceof Element && eventTarget.closest('.bn-formatting-toolbar') ) { return true } const selectedFileBlock = getSelectedFileBlockBridgeElement( container, selectedFileBlockId, ) const toolbar = doc.querySelector('.bn-formatting-toolbar') if (!selectedFileBlock || !toolbar) return false return isWithinFormattingToolbarHoverBridge( point, selectedFileBlock.getBoundingClientRect(), toolbar.getBoundingClientRect(), ) } function getActiveFormattingToolbarFileBlockId( editor: BlockNoteEditor, ) { let selectedBlock: ReturnType['block'] | null = null try { selectedBlock = editor.getSelection()?.blocks[0] ?? null } catch { selectedBlock = null } if (!selectedBlock) { try { selectedBlock = editor.getTextCursorPosition().block } catch { selectedBlock = null } } if (!selectedBlock) return null return FORMATTING_TOOLBAR_FILE_BLOCK_TYPES.has(selectedBlock.type) ? selectedBlock.id : null } function restoreFormattingToolbarFileBlockSelection( editor: BlockNoteEditor, selectedFileBlockIdRef: RefObject, ) { const selectedFileBlockId = selectedFileBlockIdRef.current if (!selectedFileBlockId) return if (getActiveFormattingToolbarFileBlockId(editor) === selectedFileBlockId) return try { editor.setTextCursorPosition(selectedFileBlockId) } catch { // The image block may have been deleted or replaced while the toolbar stayed open. } } function useLastSelectedFormattingToolbarFileBlockId( selectedFileBlockId: string | null, isOpen: boolean, ) { const lastSelectedFileBlockIdRef = useRef(selectedFileBlockId) useEffect(() => { if (selectedFileBlockId) { lastSelectedFileBlockIdRef.current = selectedFileBlockId return } if (!isOpen) { lastSelectedFileBlockIdRef.current = null } }, [isOpen, selectedFileBlockId]) return lastSelectedFileBlockIdRef } function getFormattingToolbarHoverGuardEnvironment(container: HTMLElement | null) { const doc = container?.ownerDocument const view = doc?.defaultView if (!container || !doc || !view) return null return { container, doc, view } } function createFormattingToolbarHoverGuardHandler({ editor, container, doc, selectedFileBlockIdRef, }: { editor: BlockNoteEditor container: HTMLElement doc: Document selectedFileBlockIdRef: RefObject }) { return (event: MouseEvent) => { if ( !shouldSuppressFormattingToolbarHoverUpdate({ eventTarget: event.target, point: { x: event.clientX, y: event.clientY }, container, doc, selectedFileBlockId: selectedFileBlockIdRef.current, }) ) { return } restoreFormattingToolbarFileBlockSelection(editor, selectedFileBlockIdRef) event.stopPropagation() } } export function useBlockNoteFormattingToolbarHoverGuard({ editor, container, selectedFileBlockId, isOpen, }: { editor: BlockNoteEditor container: HTMLElement | null selectedFileBlockId: string | null isOpen: boolean }) { const lastSelectedFileBlockIdRef = useLastSelectedFormattingToolbarFileBlockId( selectedFileBlockId, isOpen, ) useEffect(() => { if (!isOpen) return const environment = getFormattingToolbarHoverGuardEnvironment(container) if (!environment) return const handleMouseMove = createFormattingToolbarHoverGuardHandler({ editor, container: environment.container, doc: environment.doc, selectedFileBlockIdRef: lastSelectedFileBlockIdRef, }) environment.view.addEventListener('mousemove', handleMouseMove, true) return () => { environment.view.removeEventListener('mousemove', handleMouseMove, true) } }, [container, editor, isOpen, lastSelectedFileBlockIdRef]) }