Files
tolaria/src/components/tolariaEditorFormatting.behavior.test.tsx
2026-05-14 12:14:16 +02:00

555 lines
18 KiB
TypeScript

import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
const {
blockHasTypeMock,
editorHasBlockWithTypeMock,
formattingToolbarStore,
hoverGuardMock,
positionPopoverState,
showState,
useBlockNoteEditorMock,
} = vi.hoisted(() => ({
blockHasTypeMock: vi.fn(() => true),
editorHasBlockWithTypeMock: vi.fn(() => true),
formattingToolbarStore: { setState: vi.fn() },
hoverGuardMock: vi.fn(),
positionPopoverState: { lastProps: null as null | Record<string, unknown> },
showState: { value: true },
useBlockNoteEditorMock: vi.fn(),
}))
function MockIcon() {
return <svg data-testid="mock-icon" />
}
vi.mock('@blocknote/react', () => ({
FormattingToolbar: ({ children }: { children?: ReactNode }) => (
<div data-testid="mock-formatting-toolbar">{children}</div>
),
getFormattingToolbarItems: () => [
<div key="blockTypeSelect" />,
<div key="boldStyleButton" />,
<div key="italicStyleButton" />,
<div key="strikeStyleButton" />,
<div key="fileDownloadButton" />,
<div key="createLinkButton" />,
],
PositionPopover: (props: Record<string, unknown> & { children?: ReactNode }) => {
positionPopoverState.lastProps = props
return <div data-testid="mock-position-popover">{props.children}</div>
},
useBlockNoteEditor: useBlockNoteEditorMock,
useComponentsContext: () => ({
FormattingToolbar: {
Button: ({
children,
icon,
label,
onClick,
}: {
children?: ReactNode
icon?: ReactNode
label: string
onClick: () => void
}) => (
<button onClick={onClick} type="button">
{icon}
{label}
{children}
</button>
),
},
}),
useDictionary: () => ({
formatting_toolbar: {
file_download: {
tooltip: {
file: 'Download file',
image: 'Download image',
},
},
},
}),
useEditorState: ({ editor, selector }: { editor: unknown; selector: (context: { editor: unknown }) => unknown }) => selector({ editor }),
useExtension: () => ({ store: formattingToolbarStore }),
useExtensionState: () => showState.value,
}))
vi.mock('@blocknote/core', () => ({
blockHasType: blockHasTypeMock,
defaultProps: { textAlignment: 'left' },
editorHasBlockWithType: editorHasBlockWithTypeMock,
}))
vi.mock('@blocknote/core/extensions', () => ({
FormattingToolbarExtension: Symbol('FormattingToolbarExtension'),
}))
vi.mock('@mantine/core', () => ({
Button: ({ children, ...props }: { children?: ReactNode }) => <button type="button" {...props}>{children}</button>,
CheckIcon: () => <span data-testid="mantine-check">check</span>,
Menu: Object.assign(
({ children }: { children?: ReactNode }) => <div data-testid="mantine-menu">{children}</div>,
{
Target: ({ children }: { children?: ReactNode }) => <>{children}</>,
Dropdown: ({ children, ...props }: { children?: ReactNode }) => <div {...props}>{children}</div>,
Item: ({ children, ...props }: { children?: ReactNode }) => <button type="button" {...props}>{children}</button>,
},
),
}))
vi.mock('@phosphor-icons/react', () => ({
ArrowSquareOut: MockIcon,
CaretDown: MockIcon,
Code: MockIcon,
TextB: MockIcon,
TextItalic: MockIcon,
TextStrikethrough: MockIcon,
}))
vi.mock('./tolariaEditorFormattingConfig', () => ({
filterTolariaFormattingToolbarItems: (items: ReactNode[]) => items,
getTolariaBlockTypeSelectItems: () => [
{ name: 'Paragraph', type: 'paragraph', props: {}, icon: MockIcon },
{ name: 'Heading 1', type: 'heading', props: { level: 1 }, icon: MockIcon },
],
}))
vi.mock('./blockNoteFormattingToolbarHoverGuard', () => ({
useBlockNoteFormattingToolbarHoverGuard: hoverGuardMock,
}))
vi.mock('../utils/url', () => ({
normalizeExternalUrl: vi.fn((url: string) => url),
openExternalUrl: vi.fn().mockResolvedValue(undefined),
openLocalFile: vi.fn().mockResolvedValue(undefined),
}))
import { openLocalFile } from '../utils/url'
import {
TolariaFormattingToolbar,
TolariaFormattingToolbarController,
} from './tolariaEditorFormatting'
const mockOpenLocalFile = vi.mocked(openLocalFile)
function createMockEditor(blockType = 'image', props: Record<string, unknown> = {}) {
const selectedBlock = {
id: 'file-block',
type: blockType,
props: { textAlignment: 'center', level: 1, ...props },
content: [{ type: 'text', text: 'Selected block' }],
}
const domElement = document.createElement('div')
domElement.appendChild(document.createElement('div'))
document.body.appendChild(domElement)
return {
isEditable: true,
schema: {
styleSchema: {
bold: { type: 'bold', propSchema: 'boolean' },
italic: { type: 'italic', propSchema: 'boolean' },
strike: { type: 'strike', propSchema: 'boolean' },
code: { type: 'code', propSchema: 'boolean' },
},
},
prosemirrorState: { selection: { from: 1, to: 5 } },
domElement,
focus: vi.fn(),
getActiveStyles: () => ({ bold: true }),
getSelection: () => ({ blocks: [selectedBlock] }),
getTextCursorPosition: () => ({ block: selectedBlock }),
toggleStyles: vi.fn(),
transact: vi.fn((callback: () => void) => callback()),
updateBlock: vi.fn(),
}
}
describe('tolariaEditorFormatting behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
positionPopoverState.lastProps = null
showState.value = true
useBlockNoteEditorMock.mockReturnValue(createMockEditor())
})
it('renders toolbar controls, inserts the inline code button, and updates block types', () => {
const editor = createMockEditor('paragraph')
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbar />)
fireEvent.click(screen.getByRole('button', { name: /bold/i }))
fireEvent.click(screen.getByRole('button', { name: /inline code/i }))
fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }))
expect(editor.focus).toHaveBeenCalled()
expect(editor.toggleStyles).toHaveBeenCalledWith({ bold: true })
expect(editor.toggleStyles).toHaveBeenCalledWith({ code: true })
expect(editor.transact).toHaveBeenCalledTimes(1)
expect(editor.updateBlock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'file-block' }),
{ type: 'heading', props: { level: 1 } },
)
})
it('opens selected file blocks through the active vault path', () => {
const editor = createMockEditor('file', {
url: 'asset://localhost/%2Fvault%2Fattachments%2Freport.pdf',
})
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbar vaultPath="/vault" />)
fireEvent.click(screen.getByRole('button', { name: 'Download file' }))
expect(editor.focus).toHaveBeenCalled()
expect(mockOpenLocalFile).toHaveBeenCalledWith('/vault/attachments/report.pdf', '/vault')
})
it('controls the floating toolbar placement, hover guard, and escape-key close behavior', () => {
const editor = createMockEditor()
const toolbarComponent = () => <div data-testid="custom-toolbar">Toolbar</div>
useBlockNoteEditorMock.mockReturnValue(editor)
render(
<TolariaFormattingToolbarController
formattingToolbar={toolbarComponent}
floatingUIOptions={{ useFloatingOptions: { placement: 'top-start' } }}
/>,
)
expect(screen.getByTestId('custom-toolbar')).toBeInTheDocument()
expect(hoverGuardMock).toHaveBeenCalledWith({
editor,
container: editor.domElement,
selectedFileBlockId: 'file-block',
isOpen: true,
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({
open: true,
placement: 'top-start',
}),
}))
const onOpenChange = positionPopoverState.lastProps?.useFloatingOptions as {
onOpenChange: (open: boolean, event: unknown, reason?: string) => void
}
onOpenChange.onOpenChange(false, undefined, 'escape-key')
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
expect(editor.focus).toHaveBeenCalledTimes(1)
})
it('uses block alignment when deciding the floating placement', () => {
const editor = createMockEditor()
editor.getTextCursorPosition = () => ({
block: {
id: 'paragraph-block',
type: 'paragraph',
props: { textAlignment: 'right' },
content: [{ type: 'text', text: 'Paragraph' }],
},
})
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbarController />)
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
useFloatingOptions: expect.objectContaining({
placement: 'top-end',
}),
}))
})
it('falls back to top-start and focuses the block type trigger on mouse down', () => {
const editor = createMockEditor('paragraph')
const focusSpy = vi.spyOn(HTMLButtonElement.prototype, 'focus').mockImplementation(() => {})
blockHasTypeMock.mockReturnValue(false)
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbarController />)
fireEvent.mouseDown(screen.getAllByRole('button', { name: 'Paragraph' })[0] as HTMLButtonElement)
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
useFloatingOptions: expect.objectContaining({
placement: 'top-start',
}),
}))
expect(focusSpy).toHaveBeenCalled()
focusSpy.mockRestore()
})
it('keeps the toolbar open during close grace and clears the timeout on unmount', () => {
vi.useFakeTimers()
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
const editor = createMockEditor('paragraph')
useBlockNoteEditorMock.mockReturnValue(editor)
const { rerender, unmount } = render(<TolariaFormattingToolbarController />)
showState.value = false
rerender(<TolariaFormattingToolbarController />)
expect(screen.getByTestId('mock-position-popover')).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(50)
})
unmount()
expect(clearTimeoutSpy).toHaveBeenCalled()
clearTimeoutSpy.mockRestore()
vi.useRealTimers()
})
it('ignores internal pointer and focus transitions before closing on external blur', () => {
const editor = createMockEditor('paragraph')
useBlockNoteEditorMock.mockReturnValue(editor)
render(
<TolariaFormattingToolbarController
formattingToolbar={() => <button data-testid="toolbar-action" type="button">Toolbar</button>}
/>,
)
const toolbarWrapper = screen.getByTestId('toolbar-action').parentElement as HTMLElement
fireEvent.pointerEnter(toolbarWrapper)
fireEvent.pointerLeave(toolbarWrapper, { relatedTarget: screen.getByTestId('toolbar-action') })
fireEvent.focus(toolbarWrapper)
fireEvent.blur(toolbarWrapper, { relatedTarget: screen.getByTestId('toolbar-action') })
expect(screen.getByTestId('toolbar-action')).toBeInTheDocument()
fireEvent.pointerLeave(toolbarWrapper, { relatedTarget: document.body })
fireEvent.blur(toolbarWrapper, { relatedTarget: document.body })
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
})
it('deduplicates floating toolbar store writes during close races', () => {
const editor = createMockEditor('paragraph')
useBlockNoteEditorMock.mockReturnValue(editor)
render(
<TolariaFormattingToolbarController
formattingToolbar={() => <button data-testid="toolbar-action" type="button">Toolbar</button>}
/>,
)
const toolbarWrapper = screen.getByTestId('toolbar-action').parentElement as HTMLElement
const floatingOptions = positionPopoverState.lastProps?.useFloatingOptions as {
onOpenChange: (open: boolean, event: unknown, reason?: string) => void
}
floatingOptions.onOpenChange(true, undefined)
floatingOptions.onOpenChange(false, undefined)
floatingOptions.onOpenChange(false, undefined)
fireEvent.blur(toolbarWrapper, { relatedTarget: document.body })
expect(formattingToolbarStore.setState).toHaveBeenCalledTimes(1)
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
})
it('hides the floating toolbar while the editor is composing IME text', () => {
vi.useFakeTimers()
try {
const editor = createMockEditor('paragraph')
const editorInput = editor.domElement.firstElementChild as HTMLElement
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbarController />)
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
act(() => {
fireEvent.compositionStart(editorInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
fireEvent.compositionEnd(editorInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
vi.advanceTimersByTime(250)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
} finally {
vi.useRealTimers()
}
})
it('keeps the floating toolbar hidden through rapid Zhuyin composition settle cycles', () => {
vi.useFakeTimers()
try {
const editor = createMockEditor('paragraph')
const editorInput = editor.domElement.firstElementChild as HTMLElement
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbarController />)
act(() => {
fireEvent.compositionStart(editorInput)
fireEvent.compositionEnd(editorInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
vi.advanceTimersByTime(120)
fireEvent.compositionStart(editorInput)
fireEvent.compositionEnd(editorInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
vi.advanceTimersByTime(249)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
vi.advanceTimersByTime(1)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
} finally {
vi.useRealTimers()
}
})
it('ignores composition events that start outside the editor', () => {
const editor = createMockEditor('paragraph')
const outsideInput = document.createElement('input')
document.body.appendChild(outsideInput)
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbarController />)
act(() => {
fireEvent.compositionStart(outsideInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
})
it('binds composition listeners after BlockNote provides its editor element', () => {
const editor = createMockEditor('paragraph')
const lateEditorElement = editor.domElement
const editorInput = lateEditorElement.firstElementChild as HTMLElement
editor.domElement = undefined as unknown as HTMLElement
useBlockNoteEditorMock.mockReturnValue(editor)
const { rerender } = render(<TolariaFormattingToolbarController />)
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
editor.domElement = lateEditorElement
rerender(<TolariaFormattingToolbarController />)
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
act(() => {
fireEvent.compositionStart(editorInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
})
it('does not open the floating toolbar when the editor anchor element is unavailable', () => {
const editor = createMockEditor()
editor.domElement = document.createElement('div')
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbarController />)
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({
open: false,
}),
}))
})
it('stays stable when BlockNote selection reads throw during inline action churn', () => {
const editor = createMockEditor('paragraph')
const selectionError = new RangeError('Index 0 out of range for <>')
editor.getSelection = vi.fn(() => {
throw selectionError
})
editor.getTextCursorPosition = vi.fn(() => {
throw selectionError
})
useBlockNoteEditorMock.mockReturnValue(editor)
expect(() => {
render(
<>
<TolariaFormattingToolbar />
<TolariaFormattingToolbarController />
</>,
)
}).not.toThrow()
})
})