import { fireEvent, render, screen } from '@testing-library/react' import type { DragEventHandler, PropsWithChildren, ReactNode } from 'react' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { TolariaSideMenu } from './tolariaBlockNoteSideMenu' type MockBlock = { children?: MockBlock[] id: string type: string content?: unknown } type SideMenuButtonProps = { draggable?: boolean icon?: ReactNode label: string onClick?: () => void onDragEnd?: DragEventHandler onDragStart?: DragEventHandler } type MenuItemProps = PropsWithChildren<{ checked?: boolean className?: string onClick?: () => void }> type MockEditor = { domElement: HTMLElement focus: ReturnType getBlock: ReturnType insertBlocks: ReturnType removeBlocks: ReturnType setTextCursorPosition: ReturnType settings: { tables: { headers: boolean } } transact: ReturnType updateBlock: ReturnType } let mockEditor: MockEditor let mockSideMenu: { blockDragEnd: ReturnType blockDragStart: ReturnType freezeMenu: ReturnType unfreezeMenu: ReturnType } let mockSuggestionMenu: { openSuggestionMenu: ReturnType } let sideMenuBlock: MockBlock | undefined const originalElementsFromPoint = document.elementsFromPoint beforeAll(() => { if (typeof globalThis.PointerEvent !== 'undefined') return class TestPointerEvent extends MouseEvent { readonly isPrimary: boolean readonly pointerId: number constructor(type: string, init: PointerEventInit = {}) { super(type, init) this.isPrimary = init.isPrimary ?? true this.pointerId = init.pointerId ?? 1 } } Object.defineProperty(globalThis, 'PointerEvent', { configurable: true, value: TestPointerEvent, }) }) function targetBlockId(block: MockBlock | string) { return typeof block === 'string' ? block : block.id } function staleBlockError(block: MockBlock | string) { return new Error(`Block with ID ${targetBlockId(block)} not found`) } function requireLiveBlock(block: MockBlock | string) { const liveBlock = mockEditor.getBlock(targetBlockId(block)) if (!liveBlock) throw staleBlockError(block) return liveBlock } vi.mock('@blocknote/core/extensions', () => ({ SideMenuExtension: { key: 'side-menu' }, SuggestionMenu: { key: 'suggestion-menu' }, })) vi.mock('@blocknote/react', () => ({ AddBlockButton: () => ( ), DragHandleMenu: ({ children }: PropsWithChildren) => (
{children}
), DragHandleButton: () => { return ( ) }, RemoveBlockItem: ({ children }: PropsWithChildren) => (
{ if (sideMenuBlock) mockEditor.removeBlocks([sideMenuBlock]) }} > {children}
), SideMenu: ({ children }: PropsWithChildren) =>
{children}
, useBlockNoteEditor: () => mockEditor, useComponentsContext: () => ({ Generic: { Menu: { Item: ({ children, onClick }: MenuItemProps) => (
{children}
), Root: ({ children, onOpenChange }: PropsWithChildren<{ onOpenChange?: (open: boolean) => void }>) => (
onOpenChange?.(true)} > {children}
), Trigger: ({ children }: PropsWithChildren) =>
{children}
, }, }, SideMenu: { Button: ({ draggable, label, onClick, onDragEnd, onDragStart }: SideMenuButtonProps) => ( ), }, }), useDictionary: () => ({ drag_handle: { delete_menuitem: 'Delete', header_row_menuitem: 'Header row', header_column_menuitem: 'Header column', colors_menuitem: 'Colors', }, side_menu: { add_block_label: 'Add block', drag_handle_label: 'Drag block', }, }), useExtension: (extension: { key: string }) => ( extension.key === 'suggestion-menu' ? mockSuggestionMenu : mockSideMenu ), useExtensionState: (_extension: unknown, options?: { selector?: (state: { block?: MockBlock }) => unknown }) => ( options?.selector ? options.selector({ block: sideMenuBlock }) : { block: sideMenuBlock } ), })) function renderSideMenuWithBlock(block: MockBlock | undefined) { sideMenuBlock = block render() } function rect(left: number, top: number, width: number, height: number) { return DOMRect.fromRect({ x: left, y: top, width, height }) } function blockElement(id: string, bounds: DOMRect) { const element = document.createElement('div') element.dataset.id = id element.dataset.nodeType = 'blockContainer' element.getBoundingClientRect = vi.fn(() => bounds) return element } function dispatchPointerEvent( target: EventTarget, type: 'pointerdown' | 'pointermove' | 'pointerup', init: PointerEventInit, ) { target.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, isPrimary: true, pointerId: 1, ...init, })) } function testBlock(id: string, type: string, content: unknown): MockBlock { return { id, type, content, children: [] } } function dispatchHandlePointerReorder(dragHandle: HTMLElement) { dispatchPointerEvent(dragHandle.parentElement!, 'pointerdown', { button: 0, clientX: 80, clientY: 90 }) dispatchPointerEvent(document, 'pointermove', { clientX: 130, clientY: 122 }) dispatchPointerEvent(document, 'pointerup', { clientX: 130, clientY: 122 }) } function renderPointerReorderFixture() { const draggedBlock = testBlock('dragged-block', 'heading', ['Notes']) const targetBlock = testBlock('target-block', 'paragraph', ['Paragraph']) const draggedElement = blockElement(draggedBlock.id, rect(120, 80, 420, 40)) const targetElement = blockElement(targetBlock.id, rect(120, 120, 420, 40)) mockEditor.domElement.append(draggedElement, targetElement) mockEditor.getBlock.mockImplementation((id: string) => ( id === draggedBlock.id ? draggedBlock : id === targetBlock.id ? targetBlock : undefined )) document.elementsFromPoint = vi.fn(() => [targetElement, mockEditor.domElement]) renderSideMenuWithBlock(draggedBlock) return { draggedBlock, draggedElement, dragHandle: screen.getByRole('button', { name: 'Drag block' }), targetBlock, } } describe('TolariaSideMenu', () => { beforeEach(() => { const editorElement = document.createElement('div') editorElement.className = 'bn-editor' editorElement.getBoundingClientRect = vi.fn(() => rect(100, 50, 500, 400)) document.body.appendChild(editorElement) sideMenuBlock = { id: 'stale-block', type: 'paragraph', content: ['old text'], children: [], } mockEditor = { domElement: editorElement, focus: vi.fn(), getBlock: vi.fn(() => undefined), insertBlocks: vi.fn((_blocks, block: MockBlock | string) => { requireLiveBlock(block) return [{ id: 'inserted-block', type: 'paragraph', content: [] }] }), removeBlocks: vi.fn((blocks: Array) => { blocks.forEach(requireLiveBlock) return blocks }), setTextCursorPosition: vi.fn((block: MockBlock | string) => { requireLiveBlock(block) }), settings: { tables: { headers: true } }, transact: vi.fn((callback: () => void) => callback()), updateBlock: vi.fn((block: MockBlock | string) => { requireLiveBlock(block) return block }), } mockSideMenu = { blockDragEnd: vi.fn(), blockDragStart: vi.fn((_event, block: MockBlock) => { requireLiveBlock(block) }), freezeMenu: vi.fn(), unfreezeMenu: vi.fn(), } mockSuggestionMenu = { openSuggestionMenu: vi.fn() } }) afterEach(() => { document.elementsFromPoint = originalElementsFromPoint document.body.innerHTML = '' }) it('replaces BlockNote block colors with markdown-safe drag-handle items', () => { mockEditor.getBlock.mockReturnValue(sideMenuBlock) renderSideMenuWithBlock(sideMenuBlock) expect(screen.getByTestId('side-menu')).toBeInTheDocument() expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([ 'Add block', 'Drag block', ]) expect(screen.getByText('Delete')).toBeInTheDocument() expect(screen.queryByText('Colors')).not.toBeInTheDocument() }) it('ignores add-block clicks when reload churn leaves the side menu with a stale block', () => { renderSideMenuWithBlock(sideMenuBlock) expect(() => fireEvent.click(screen.getByRole('button', { name: 'Add block' }))).not.toThrow() expect(mockEditor.insertBlocks).not.toHaveBeenCalled() expect(mockEditor.setTextCursorPosition).not.toHaveBeenCalled() expect(mockSuggestionMenu.openSuggestionMenu).not.toHaveBeenCalled() }) it('resolves the live block before adding a block after reload churn', () => { const staleBlock = { id: 'same-id', type: 'paragraph', content: [] } const liveBlock = { id: 'same-id', type: 'paragraph', content: ['fresh text'] } mockEditor.getBlock.mockReturnValue(liveBlock) renderSideMenuWithBlock(staleBlock) fireEvent.click(screen.getByRole('button', { name: 'Add block' })) expect(mockEditor.insertBlocks).toHaveBeenCalledWith([{ type: 'paragraph' }], liveBlock.id, 'after') expect(mockEditor.setTextCursorPosition).toHaveBeenCalledWith('inserted-block') expect(mockSuggestionMenu.openSuggestionMenu).toHaveBeenCalledWith('/') }) it('ignores delete clicks when the side-menu block disappeared during a reload', () => { renderSideMenuWithBlock(sideMenuBlock) expect(() => fireEvent.click(screen.getByText('Delete'))).not.toThrow() expect(mockEditor.removeBlocks).not.toHaveBeenCalled() }) it('resolves the live table block before toggling table headers', () => { const staleTable = { id: 'table-block', type: 'table', content: { type: 'tableContent', rows: [], headerRows: undefined }, } const liveTable = { id: 'table-block', type: 'table', content: { type: 'tableContent', rows: [], headerRows: undefined }, } mockEditor.getBlock.mockReturnValue(liveTable) renderSideMenuWithBlock(staleTable) fireEvent.click(screen.getByText('Header row')) expect(mockEditor.updateBlock).toHaveBeenCalledWith(liveTable.id, { content: { ...liveTable.content, headerRows: 1 }, }) }) it('ignores stale drag starts after reload churn', () => { renderSideMenuWithBlock(sideMenuBlock) expect(() => fireEvent.dragStart(screen.getByRole('button', { name: 'Drag block' }))).not.toThrow() expect(mockSideMenu.blockDragStart).not.toHaveBeenCalled() }) it('reorders blocks with pointer movement instead of BlockNote HTML drag data', () => { const { draggedBlock, dragHandle, targetBlock } = renderPointerReorderFixture() dispatchHandlePointerReorder(dragHandle) expect(mockSideMenu.blockDragStart).not.toHaveBeenCalled() expect(mockEditor.focus).toHaveBeenCalled() expect(mockEditor.transact).toHaveBeenCalled() expect(mockEditor.removeBlocks).toHaveBeenCalledWith([draggedBlock.id]) expect(mockEditor.insertBlocks).toHaveBeenCalledWith([draggedBlock], targetBlock.id, 'before') }) it('shows and clears pointer reorder affordances while dragging', () => { const { draggedElement, dragHandle } = renderPointerReorderFixture() dispatchPointerEvent(dragHandle.parentElement!, 'pointerdown', { button: 0, clientX: 140, clientY: 90 }) dispatchPointerEvent(document, 'pointermove', { clientX: 180, clientY: 122 }) const preview = screen.getByTestId('editor-block-drag-preview') const indicator = screen.getByTestId('editor-block-drop-indicator') expect(preview).toHaveStyle({ left: '160px', opacity: '0.72', top: '112px', }) expect(indicator).toHaveStyle({ display: 'block', left: '120px', top: '119px', width: '420px', }) expect(draggedElement).toHaveStyle({ opacity: '0.35' }) dispatchPointerEvent(document, 'pointerup', { clientX: 180, clientY: 122 }) expect(screen.queryByTestId('editor-block-drag-preview')).not.toBeInTheDocument() expect(screen.queryByTestId('editor-block-drop-indicator')).not.toBeInTheDocument() expect(draggedElement.style.opacity).toBe('') }) it('keeps click-to-open menu behavior when the handle does not move', () => { mockEditor.getBlock.mockReturnValue(sideMenuBlock) renderSideMenuWithBlock(sideMenuBlock) const dragHandle = screen.getByRole('button', { name: 'Drag block' }) dispatchPointerEvent(dragHandle.parentElement!, 'pointerdown', { button: 0, clientX: 80, clientY: 90 }) dispatchPointerEvent(document, 'pointerup', { clientX: 80, clientY: 90 }) fireEvent.click(dragHandle) expect(mockSideMenu.freezeMenu).toHaveBeenCalled() }) it('suppresses the follow-up menu click after a pointer reorder', () => { const { dragHandle } = renderPointerReorderFixture() dispatchHandlePointerReorder(dragHandle) fireEvent.click(dragHandle) expect(mockSideMenu.freezeMenu).not.toHaveBeenCalled() }) })