import { afterEach, describe, expect, it, vi } from 'vitest' import { renderHook } from '@testing-library/react' import { isWithinFormattingToolbarHoverBridge, shouldSuppressFormattingToolbarHoverUpdate, useBlockNoteFormattingToolbarHoverGuard, } from './blockNoteFormattingToolbarHoverGuard' function rect(left: number, top: number, width: number, height: number) { return DOMRect.fromRect({ x: left, y: top, width, height }) } function setRect(element: HTMLElement, nextRect: DOMRect) { element.getBoundingClientRect = () => nextRect } function appendFileBlock({ container, blockId, innerClass, }: { container: HTMLElement blockId: string innerClass: string }) { const block = document.createElement('div') block.className = 'bn-block' block.dataset.id = blockId const fileBlock = document.createElement('div') fileBlock.dataset.fileBlock = 'true' const inner = document.createElement('div') inner.className = innerClass fileBlock.appendChild(inner) block.appendChild(fileBlock) container.appendChild(block) return inner } function createToolbarHoverScenario(withToolbarButton = false) { const container = document.createElement('div') const block = document.createElement('div') block.className = 'bn-block' block.dataset.id = 'image-block' const fileBlock = document.createElement('div') fileBlock.dataset.fileBlock = 'true' block.appendChild(fileBlock) container.appendChild(block) document.body.appendChild(container) const toolbar = document.createElement('div') toolbar.className = 'bn-formatting-toolbar' const toolbarButton = withToolbarButton ? document.createElement('button') : null if (toolbarButton) toolbar.appendChild(toolbarButton) document.body.appendChild(toolbar) setRect(fileBlock, rect(300, 130, 140, 90)) setRect(toolbar, rect(322, 78, 96, 24)) return { container, toolbarButton } } function makeEditor(overrides: Record = {}) { return { getSelection: vi.fn(() => null), getTextCursorPosition: vi.fn(() => ({ block: { id: 'paragraph-block', type: 'paragraph' } })), setTextCursorPosition: vi.fn(), ...overrides, } } describe('blockNoteFormattingToolbarHoverGuard', () => { afterEach(() => { document.body.innerHTML = '' }) it('treats the gap between the selected image block and toolbar as part of the hover bridge', () => { expect( isWithinFormattingToolbarHoverBridge( { x: 368, y: 104 }, rect(300, 130, 140, 90), rect(322, 78, 96, 24), ), ).toBe(true) }) it('ignores invisible rectangles and missing hover bridge context', () => { expect( isWithinFormattingToolbarHoverBridge( { x: 10, y: 10 }, rect(300, 130, 0, 90), rect(322, 78, 96, 24), ), ).toBe(false) expect( shouldSuppressFormattingToolbarHoverUpdate({ eventTarget: document.body, point: { x: 350, y: 90 }, container: null, doc: document, selectedFileBlockId: 'image-block', }), ).toBe(false) expect( shouldSuppressFormattingToolbarHoverUpdate({ eventTarget: document.body, point: { x: 350, y: 90 }, container: document.createElement('div'), doc: document, selectedFileBlockId: null, }), ).toBe(false) }) it.each([ { name: 'suppresses hover updates when the pointer is already over the toolbar', point: { x: 350, y: 90 }, eventTarget: 'toolbarButton', expected: true, withToolbarButton: true, }, { name: 'suppresses hover updates while the pointer crosses the image-toolbar bridge', point: { x: 368, y: 104 }, eventTarget: 'body', expected: true, withToolbarButton: false, }, { name: 'leaves unrelated pointer movement alone', point: { x: 520, y: 220 }, eventTarget: 'body', expected: false, withToolbarButton: false, }, ])('$name', ({ point, eventTarget, expected, withToolbarButton }) => { const { container, toolbarButton } = createToolbarHoverScenario(withToolbarButton) const target = eventTarget === 'toolbarButton' ? toolbarButton : document.body expect( shouldSuppressFormattingToolbarHoverUpdate({ eventTarget: target as EventTarget, point, container, doc: document, selectedFileBlockId: 'image-block', }), ).toBe(expected) }) it('uses the filename bridge fallback and restores the last selected file block while the toolbar stays open', () => { const container = document.createElement('div') document.body.appendChild(container) const bridge = appendFileBlock({ container, blockId: 'image-block', innerClass: 'bn-file-name-with-icon', }) const toolbar = document.createElement('div') toolbar.className = 'bn-formatting-toolbar' document.body.appendChild(toolbar) setRect(bridge, rect(300, 130, 140, 90)) setRect(toolbar, rect(322, 78, 96, 24)) const editor = makeEditor() const { rerender } = renderHook( ({ selectedFileBlockId, isOpen }) => useBlockNoteFormattingToolbarHoverGuard({ editor: editor as never, container, selectedFileBlockId, isOpen, }), { initialProps: { selectedFileBlockId: 'image-block', isOpen: true }, }, ) rerender({ selectedFileBlockId: null, isOpen: true }) window.dispatchEvent(new MouseEvent('mousemove', { clientX: 368, clientY: 104, bubbles: true, })) expect(editor.setTextCursorPosition).toHaveBeenCalledWith('image-block') vi.mocked(editor.setTextCursorPosition).mockClear() rerender({ selectedFileBlockId: null, isOpen: false }) rerender({ selectedFileBlockId: null, isOpen: true }) window.dispatchEvent(new MouseEvent('mousemove', { clientX: 368, clientY: 104, bubbles: true, })) expect(editor.setTextCursorPosition).not.toHaveBeenCalled() }) it('uses the add-file-button fallback and swallows restore errors when the block disappears', () => { const container = document.createElement('div') document.body.appendChild(container) const bridge = appendFileBlock({ container, blockId: 'image-block', innerClass: 'bn-add-file-button', }) const toolbar = document.createElement('div') toolbar.className = 'bn-formatting-toolbar' document.body.appendChild(toolbar) setRect(bridge, rect(300, 130, 140, 90)) setRect(toolbar, rect(322, 78, 96, 24)) const editor = makeEditor({ setTextCursorPosition: vi.fn(() => { throw new Error('gone') }), }) const { rerender } = renderHook( ({ selectedFileBlockId, isOpen }) => useBlockNoteFormattingToolbarHoverGuard({ editor: editor as never, container, selectedFileBlockId, isOpen, }), { initialProps: { selectedFileBlockId: 'image-block', isOpen: true }, }, ) rerender({ selectedFileBlockId: null, isOpen: true }) expect(() => { window.dispatchEvent(new MouseEvent('mousemove', { clientX: 368, clientY: 104, bubbles: true, })) }).not.toThrow() }) it('leaves the cursor alone when the selected file block is already active', () => { const container = document.createElement('div') document.body.appendChild(container) const bridge = appendFileBlock({ container, blockId: 'image-block', innerClass: 'bn-file-name-with-icon', }) const toolbar = document.createElement('div') toolbar.className = 'bn-formatting-toolbar' document.body.appendChild(toolbar) setRect(bridge, rect(300, 130, 140, 90)) setRect(toolbar, rect(322, 78, 96, 24)) const editor = makeEditor({ getSelection: vi.fn(() => ({ blocks: [{ id: 'image-block', type: 'image' }], })), }) renderHook(() => useBlockNoteFormattingToolbarHoverGuard({ editor: editor as never, container, selectedFileBlockId: 'image-block', isOpen: true, }), ) window.dispatchEvent(new MouseEvent('mousemove', { clientX: 368, clientY: 104, bubbles: true, })) expect(editor.setTextCursorPosition).not.toHaveBeenCalled() }) })