import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ReactNode } from 'react' import type { VaultEntry } from '../types' import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce' import { insertPlainTextFromClipboardText } from '../utils/plainTextPaste' import { TooltipProvider } from './ui/tooltip' const state = vi.hoisted(() => ({ capturedLinkToolbarProps: null as null | Record, capturedToolbarProps: null as null | Record, capturedSuggestionProps: {} as Record>, capturedImageDropArgs: null as null | Record, capturedBlockNoteOnChange: null as null | (() => void), capturedMantineGetStyleNonce: null as null | (() => string), blockNoteViewError: null as Error | null, hoverGuardMock: vi.fn(), imageDropState: { isDragOver: false }, linkActivationMock: vi.fn(), personMentionCandidates: [] as Record[], wikilinkEntriesRef: { current: [] as VaultEntry[] }, wikilinkCandidates: [] as Record[], })) vi.mock('@blocknote/react', () => ({ ComponentsContext: { Provider: ({ children }: { children?: ReactNode }) => <>{children}, }, BlockNoteViewRaw: (props: { children?: ReactNode editable?: boolean className?: string emojiPicker?: boolean formattingToolbar?: boolean linkToolbar?: boolean slashMenu?: boolean sideMenu?: boolean onChange?: () => void theme?: string }) => { if (state.blockNoteViewError) { const error = state.blockNoteViewError throw error } const { children, editable, className, emojiPicker, formattingToolbar, linkToolbar, slashMenu, sideMenu, ...restProps } = props state.capturedBlockNoteOnChange = props.onChange ?? null void emojiPicker void formattingToolbar void slashMenu void sideMenu return (
{children}
) }, LinkToolbarController: (props: Record) => { state.capturedLinkToolbarProps = props return
}, LinkToolbar: ({ children }: { children?: ReactNode }) => (
{children}
), EditLinkButton: () => , DeleteLinkButton: () => , SideMenuController: () =>
, SuggestionMenuController: (props: Record) => { state.capturedSuggestionProps[String(props.triggerCharacter)] = props return
}, GridSuggestionMenuController: (props: Record) => { state.capturedSuggestionProps[String(props.triggerCharacter)] = props return
}, useComponentsContext: () => ({ LinkToolbar: { Button: ({ children, icon, label, onClick, }: { children?: ReactNode icon?: ReactNode label?: string onClick?: () => void }) => ( ), }, }), useCreateBlockNote: vi.fn(), useDictionary: () => ({ link_toolbar: { open: { tooltip: 'Open in a new tab' }, }, }), })) vi.mock('@blocknote/mantine', () => ({ components: {}, })) vi.mock('@mantine/core', async () => { const React = await vi.importActual('react') return { MantineContext: React.createContext(null), MantineProvider: ({ children, getStyleNonce, }: { children?: ReactNode getStyleNonce?: () => string }) => { state.capturedMantineGetStyleNonce = getStyleNonce ?? null return <>{children} }, } }) vi.mock('../hooks/useTheme', () => ({ useEditorTheme: () => ({ cssVars: { '--editor-accent': '#abc' } }), })) vi.mock('../hooks/useImageDrop', () => ({ useImageDrop: (args: Record) => { state.capturedImageDropArgs = args return state.imageDropState }, })) vi.mock('../utils/url', () => ({ normalizeExternalUrl: vi.fn((url: string) => ( url.startsWith('http://') || url.startsWith('https://') ? url : null )), openExternalUrl: vi.fn().mockResolvedValue(undefined), openLocalFile: vi.fn().mockResolvedValue(undefined), })) vi.mock('../utils/typeColors', () => ({ buildTypeEntryMap: () => ({}), })) vi.mock('../utils/wikilinkSuggestions', () => ({ MIN_QUERY_LENGTH: 2, deduplicateByPath: (items: T[]) => items, preFilterWikilinks: () => state.wikilinkCandidates, })) vi.mock('../utils/personMentionSuggestions', () => ({ PERSON_MENTION_MIN_QUERY: 1, filterPersonMentions: () => state.personMentionCandidates, })) vi.mock('../utils/suggestionEnrichment', () => ({ attachClickHandlers: (items: T[]) => items, enrichSuggestionItems: (items: T[]) => items, hasMultipleSuggestionWorkspaces: () => false, })) vi.mock('./WikilinkSuggestionMenu', () => ({ WikilinkSuggestionMenu: () =>
, })) vi.mock('./editorSchema', () => ({ _wikilinkEntriesRef: state.wikilinkEntriesRef, })) vi.mock('./blockNoteSideMenuHoverGuard', () => ({ useBlockNoteSideMenuHoverGuard: (containerRef: unknown) => state.hoverGuardMock(containerRef), })) vi.mock('./tolariaEditorFormattingConfig', () => ({ getTolariaSlashMenuItems: vi.fn(async () => []), })) vi.mock('./tolariaEditorFormatting', () => ({ TolariaFormattingToolbar: () =>
, TolariaFormattingToolbarController: (props: Record) => { state.capturedToolbarProps = props return
}, })) vi.mock('./tolariaBlockNoteSideMenu', () => ({ TolariaSideMenu: () =>
, })) vi.mock('./useEditorLinkActivation', () => ({ useEditorLinkActivation: (containerRef: unknown, onNavigateWikilink: unknown, vaultPath: unknown) => ( state.linkActivationMock(containerRef, onNavigateWikilink, vaultPath) ), })) import { openExternalUrl, openLocalFile } from '../utils/url' import { SingleEditorView } from './SingleEditorView' const mockOpenExternalUrl = vi.mocked(openExternalUrl) const mockOpenLocalFile = vi.mocked(openLocalFile) function makeEntry(overrides: Partial = {}): VaultEntry { return { path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: 'Active', archived: false, modifiedAt: 1, createdAt: 1, fileSize: 10, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null, view: null, visible: null, organized: false, favorite: false, favoriteIndex: null, listPropertiesDisplay: [], outgoingLinks: [], properties: {}, hasH1: true, fileKind: 'markdown', ...overrides, } } function createEditor() { const cursorBlock = { id: 'cursor-block', type: 'paragraph', content: [], children: [] } const tiptapDom = document.createElement('div') tiptapDom.getBoundingClientRect = vi.fn(() => ({ bottom: 420, height: 360, left: 120, right: 720, toJSON: () => ({}), top: 60, width: 600, x: 120, y: 60, })) return { document: [ { id: 'heading-block', type: 'heading', content: [], children: [] }, cursorBlock, ], domElement: undefined as HTMLElement | undefined, tryParseMarkdownToBlocks: vi.fn(async () => [ { type: 'table', content: { type: 'tableContent' } }, ]), blocksToHTMLLossy: vi.fn(() => 'seeded
'), _tiptapEditor: { commands: { setContent: vi.fn(), setTextSelection: vi.fn(), }, state: { doc: { content: { size: 100 } } }, view: { dom: tiptapDom, posAtCoords: vi.fn(() => ({ pos: 1 })), }, }, focus: vi.fn(), getBlock: vi.fn(() => null), getTextCursorPosition: vi.fn(() => ({ block: cursorBlock })), insertBlocks: vi.fn(), insertInlineContent: vi.fn(), replaceBlocks: vi.fn(() => { state.blockNoteViewError = null }), setTextCursorPosition: vi.fn(), } } function renderEditorHarness(editor = createEditor(), options: { vaultPath?: string } = {}) { render( , { wrapper: TooltipProvider }, ) const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container') expect(container).toBeTruthy() return { container: container!, editor } } function renderEditorHarnessInScrollArea(editor = createEditor()) { render(
, { wrapper: TooltipProvider }, ) const scrollArea = screen.getByTestId('editor-scroll-area') const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container') expect(container).toBeTruthy() return { container: container!, editor, scrollArea } } function createCodeBlockFixture(text: string) { const codeBlock = document.createElement('div') codeBlock.setAttribute('data-content-type', 'codeBlock') const pre = document.createElement('pre') const code = document.createElement('code') code.textContent = text pre.appendChild(code) codeBlock.appendChild(pre) return { codeBlock, code } } function createParagraphFixture(text: string) { const paragraph = document.createElement('p') const textNode = document.createTextNode(text) paragraph.appendChild(textNode) return { paragraph, textNode } } function selectNodeContents(node: Node) { const range = document.createRange() range.selectNodeContents(node) const selection = window.getSelection() selection?.removeAllRanges() selection?.addRange(range) } function appendToolbarButton(container: Element, className: string, text: string) { const toolbar = document.createElement('div') toolbar.className = className const button = document.createElement('button') button.type = 'button' button.textContent = text toolbar.appendChild(button) container.appendChild(toolbar) return button } function createTitleHeadingFixture(container: Element) { const titleHeading = document.createElement('div') titleHeading.setAttribute('data-content-type', 'heading') titleHeading.setAttribute('data-level', '1') const inlineHeading = document.createElement('div') inlineHeading.className = 'bn-inline-content' titleHeading.appendChild(inlineHeading) container.appendChild(titleHeading) return inlineHeading } function createListItemFixture(container: Element, contentType: 'bulletListItem' | 'checkListItem', text = '') { const listItem = document.createElement('div') listItem.setAttribute('data-content-type', contentType) const inlineContent = document.createElement('div') inlineContent.className = 'bn-inline-content' inlineContent.textContent = text listItem.appendChild(inlineContent) container.appendChild(listItem) return inlineContent } function clipboardDataFor(formats: Record) { return { getData: vi.fn((format: string) => formats[format] ?? ''), } } type LinkToolbarHarnessProps = { url: string text: string range: { from: number; to: number } setToolbarOpen?: (open: boolean) => void setToolbarPositionFrozen?: (open: boolean) => void } function renderLinkToolbarOpenButton(options: { url: string text?: string vaultPath?: string }) { render( , ) const LinkToolbarComponent = state.capturedLinkToolbarProps?.linkToolbar as React.ComponentType render( , ) } describe('SingleEditorView', () => { beforeEach(() => { vi.clearAllMocks() state.capturedLinkToolbarProps = null state.capturedToolbarProps = null state.capturedSuggestionProps = {} state.capturedImageDropArgs = null state.capturedBlockNoteOnChange = null state.capturedMantineGetStyleNonce = null state.blockNoteViewError = null state.imageDropState.isDragOver = false state.personMentionCandidates = [] state.wikilinkEntriesRef.current = [] state.wikilinkCandidates = [] mockOpenExternalUrl.mockClear() mockOpenLocalFile.mockClear() document.documentElement.removeAttribute('data-theme') document.documentElement.classList.remove('dark') delete window.__laputaTest }) it('repairs the live editor document before remounting after a stale missing-id block error', async () => { state.blockNoteViewError = new Error("Block doesn't have id") const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) const editor = createEditor() editor.document = [ { type: 'paragraph', content: [{ type: 'text', text: 'Recovered body', styles: {} }], children: [ { type: 'paragraph', content: [{ type: 'text', text: 'Recovered child', styles: {} }], children: [], }, ], }, ] try { render( , { wrapper: TooltipProvider, onRecoverableError: () => {} }, ) await waitFor(() => { expect(screen.getByTestId('blocknote-view')).toBeInTheDocument() }) expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true') expect(editor.replaceBlocks).toHaveBeenCalledTimes(1) expect(editor.replaceBlocks.mock.calls[0][1]).toEqual([ expect.objectContaining({ id: expect.any(String), children: [ expect.objectContaining({ id: expect.any(String), }), ], }), ]) } finally { consoleError.mockRestore() } }) it('registers the seeded BlockNote test bridge, applies column widths, and cleans it up on unmount', async () => { const editor = createEditor() const entries = [makeEntry()] const { unmount } = render( , ) expect(state.wikilinkEntriesRef.current).toEqual(entries) expect(typeof window.__laputaTest?.seedBlockNoteTable).toBe('function') await act(async () => { await window.__laputaTest?.seedBlockNoteTable?.([120, null, 80]) }) expect(editor.blocksToHTMLLossy).toHaveBeenCalledWith([ expect.objectContaining({ type: 'table', content: expect.objectContaining({ type: 'tableContent', columnWidths: [120, null, 80], }), }), expect.objectContaining({ type: 'paragraph' }), ]) expect(editor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('seeded
') expect(editor.focus).toHaveBeenCalled() unmount() expect(window.__laputaTest?.seedBlockNoteTable).toBeUndefined() }) it('shows the drag overlay and inserts dropped images after the active cursor block', () => { state.imageDropState.isDragOver = true const editor = createEditor() render( , ) expect(screen.getByText('Drop image here')).toBeInTheDocument() act(() => { (state.capturedImageDropArgs?.onImageUrl as (url: string) => void)('https://example.com/image.png') }) expect(editor.insertBlocks).toHaveBeenCalledWith( [{ type: 'image', props: { url: 'https://example.com/image.png' } }], expect.objectContaining({ id: 'cursor-block' }), 'after', ) }) it('wires the toolbar mouse guard and suggestion item click handlers', () => { const editor = createEditor() render( , ) expect(state.hoverGuardMock).toHaveBeenCalledOnce() expect(state.linkActivationMock).toHaveBeenCalledOnce() expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-link-toolbar', 'false') expect(state.capturedLinkToolbarProps).toEqual(expect.objectContaining({ linkToolbar: expect.any(Function), floatingUIOptions: expect.objectContaining({ elementProps: expect.objectContaining({ onMouseDownCapture: expect.any(Function), }), }), })) const onMouseDownCapture = ( (state.capturedToolbarProps?.floatingUIOptions as { elementProps: { onMouseDownCapture: (event: { target: HTMLElement; preventDefault: () => void }) => void } }) ).elementProps.onMouseDownCapture const menuTrigger = document.createElement('button') menuTrigger.setAttribute('aria-haspopup', 'menu') const menuPreventDefault = vi.fn() onMouseDownCapture({ target: menuTrigger, preventDefault: menuPreventDefault }) expect(menuPreventDefault).not.toHaveBeenCalled() const normalTarget = document.createElement('div') const normalPreventDefault = vi.fn() onMouseDownCapture({ target: normalTarget, preventDefault: normalPreventDefault }) expect(normalPreventDefault).toHaveBeenCalledOnce() const linkToolbarMouseDownCapture = ( (state.capturedLinkToolbarProps?.floatingUIOptions as { elementProps: { onMouseDownCapture: (event: { target: HTMLElement; preventDefault: () => void }) => void } }) ).elementProps.onMouseDownCapture const linkInput = document.createElement('input') const linkInputPreventDefault = vi.fn() linkToolbarMouseDownCapture({ target: linkInput, preventDefault: linkInputPreventDefault }) expect(linkInputPreventDefault).not.toHaveBeenCalled() const linkActionTarget = document.createElement('button') const linkActionPreventDefault = vi.fn() linkToolbarMouseDownCapture({ target: linkActionTarget, preventDefault: linkActionPreventDefault }) expect(linkActionPreventDefault).toHaveBeenCalledOnce() const onWikiItemClick = vi.fn() const onMentionItemClick = vi.fn() ;(state.capturedSuggestionProps['[['].onItemClick as (item: { onItemClick: () => void }) => void)({ onItemClick: onWikiItemClick }) ;(state.capturedSuggestionProps['@'].onItemClick as (item: { onItemClick: () => void }) => void)({ onItemClick: onMentionItemClick }) expect(onWikiItemClick).toHaveBeenCalledOnce() expect(onMentionItemClick).toHaveBeenCalledOnce() }) it('renders when a reload returns an entry with missing suggestion metadata', () => { const reloadedEntry = { ...makeEntry({ path: '/vault/project/reloaded.md', title: 'Reloaded' }), filename: undefined, aliases: undefined, isA: undefined, } as unknown as VaultEntry expect(() => { render( , ) }).not.toThrow() }) it('ignores stale suggestion item clicks after the editor DOM disconnects', () => { const editor = createEditor() editor.domElement = document.createElement('div') render( , ) const staleItemClick = vi.fn(() => { throw new TypeError('Cannot read properties of undefined (reading isConnected)') }) expect(() => { ;(state.capturedSuggestionProps['[['].onItemClick as (item: { onItemClick: () => void }) => void)({ onItemClick: staleItemClick, }) }).not.toThrow() expect(staleItemClick).not.toHaveBeenCalled() }) it('runs suggestion item clicks when BlockNote keeps the editor DOM outside the React container', () => { const editor = createEditor() editor.domElement = document.createElement('div') document.body.appendChild(editor.domElement) const itemClick = vi.fn() try { render( , ) ;(state.capturedSuggestionProps['[['].onItemClick as (item: { onItemClick: () => void }) => void)({ onItemClick: itemClick, }) expect(itemClick).toHaveBeenCalledOnce() } finally { editor.domElement.remove() } }) it('inserts the selected emoji from shortcode suggestions', async () => { const editor = createEditor() render( , ) const getEmojiItems = state.capturedSuggestionProps[':'].getItems as ( query: string ) => Promise void }>> const italyItems = await getEmojiItems(':it') expect(italyItems[0]).toMatchObject({ id: '🇮🇹' }) expect(italyItems[0].name).toMatch(/italy/i) const items = await getEmojiItems(':rocket') const rocketItem = items.find(item => item.id === '🚀') expect(rocketItem).toMatchObject({ name: 'rocket' }) rocketItem!.onItemClick() expect(editor.insertInlineContent).toHaveBeenCalledWith('🚀', { updateSelection: true }) }) it('guards stale click handlers stored on wikilink suggestion items', async () => { const editor = createEditor() editor.domElement = document.createElement('div') const staleItemClick = vi.fn(() => { throw new TypeError('Cannot read properties of undefined (reading isConnected)') }) state.wikilinkCandidates = [{ title: 'Alpha', path: '/vault/project/alpha.md', onItemClick: staleItemClick, }] render( , ) const getItems = state.capturedSuggestionProps['[['].getItems as ( query: string ) => Promise void }>> const items = await getItems('al') expect(items).toHaveLength(1) expect(() => items[0].onItemClick()).not.toThrow() expect(staleItemClick).not.toHaveBeenCalled() }) it('passes the active document theme to BlockNote', () => { document.documentElement.setAttribute('data-theme', 'dark') document.documentElement.classList.add('dark') render( , ) expect(screen.getByTestId('blocknote-view')).toHaveAttribute('theme', 'dark') expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-mantine-color-scheme', 'dark') }) it('passes the runtime CSP style nonce to Mantine fallback style tags', () => { render( , ) expect(state.capturedMantineGetStyleNonce?.()).toBe(RUNTIME_STYLE_NONCE) }) it('defers rich-editor change propagation until IME composition ends', async () => { const editor = createEditor() const onChange = vi.fn() render( , ) const blockNoteView = screen.getByTestId('blocknote-view') fireEvent.compositionStart(blockNoteView) act(() => { state.capturedBlockNoteOnChange?.() }) expect(onChange).not.toHaveBeenCalled() fireEvent.compositionEnd(blockNoteView) await act(async () => { await Promise.resolve() }) expect(onChange).toHaveBeenCalledTimes(1) }) it('copies selected fenced code text without markdown escape backslashes', async () => { const json = '{\n "id": "Demo"\n}' const { container } = renderEditorHarness() const { codeBlock, code } = createCodeBlockFixture(json) await act(async () => { container.appendChild(codeBlock) await Promise.resolve() }) selectNodeContents(code) const clipboardData = { setData: vi.fn() } fireEvent.copy(code, { clipboardData }) expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', json) }) it('copies CJK fenced code text from the selected DOM fragment when selection text is mojibake', async () => { const text = 'const label = "中文測試"' const { container } = renderEditorHarness() const { codeBlock, code } = createCodeBlockFixture(text) await act(async () => { container.appendChild(codeBlock) await Promise.resolve() }) const range = document.createRange() range.selectNodeContents(code) const getSelection = vi.spyOn(window, 'getSelection').mockReturnValue({ rangeCount: 1, isCollapsed: false, getRangeAt: () => range, toString: () => 'const label = "中文測試"', } as Selection) const clipboardData = { setData: vi.fn() } fireEvent.copy(code, { clipboardData }) getSelection.mockRestore() expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', text) }) it('copies fenced code from the code-block action button', async () => { const json = '{\n "id": "Demo"\n}' const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(window.navigator, 'clipboard', { configurable: true, value: { writeText }, }) const { container, editor } = renderEditorHarness() const { codeBlock } = createCodeBlockFixture(json) act(() => { container.appendChild(codeBlock) }) fireEvent.mouseMove(codeBlock) const copyButton = await screen.findByRole('button', { name: 'Copy code to clipboard' }) fireEvent.click(copyButton) await waitFor(() => expect(writeText).toHaveBeenCalledWith(json)) expect(editor.focus).not.toHaveBeenCalled() }) it('keeps full-note copy selections from collapsing to code-block text only', async () => { const { container } = renderEditorHarness() const paragraph = document.createElement('p') paragraph.textContent = 'Before' const { codeBlock, code } = createCodeBlockFixture('const value = 1') await act(async () => { container.append(paragraph, codeBlock) await Promise.resolve() }) const range = document.createRange() range.setStartBefore(paragraph) range.setEndAfter(codeBlock) const selection = window.getSelection() selection?.removeAllRanges() selection?.addRange(range) const clipboardData = { setData: vi.fn() } fireEvent.copy(code, { clipboardData }) expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'Beforeconst value = 1') expect(clipboardData.setData).not.toHaveBeenCalledWith('text/plain', 'const value = 1') }) it('copies ordinary selected editor text without appending a newline', async () => { const { container } = renderEditorHarness() const { paragraph, textNode } = createParagraphFixture('Only copied words') await act(async () => { container.appendChild(paragraph) await Promise.resolve() }) selectNodeContents(textNode) const clipboardData = { setData: vi.fn() } fireEvent.copy(paragraph, { clipboardData }) expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'Only copied words') }) it('removes one synthetic terminal newline while preserving internal newlines', async () => { const { container } = renderEditorHarness() const { paragraph, textNode } = createParagraphFixture('First line\nSecond line\n') await act(async () => { container.appendChild(paragraph) await Promise.resolve() }) selectNodeContents(textNode) const clipboardData = { setData: vi.fn() } fireEvent.copy(paragraph, { clipboardData }) expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'First line\nSecond line') }) it('keeps selected rich text available as HTML when normalizing plain copy text', async () => { const { container } = renderEditorHarness() const paragraph = document.createElement('p') const strong = document.createElement('strong') strong.textContent = 'Bold copy' paragraph.appendChild(strong) await act(async () => { container.appendChild(paragraph) await Promise.resolve() }) selectNodeContents(strong) const clipboardData = { setData: vi.fn() } fireEvent.copy(strong, { clipboardData }) expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'Bold copy') expect(clipboardData.setData).toHaveBeenCalledWith('text/html', 'Bold copy') }) it('handles registered plain-text paste requests through BlockNote insertion', () => { const { container, editor } = renderEditorHarness() fireEvent.focus(container) expect(insertPlainTextFromClipboardText('Plain\nText')).toBe(true) expect(editor.focus).toHaveBeenCalled() expect(editor.insertInlineContent).toHaveBeenCalledWith('Plain\nText', { updateSelection: true, }) }) it('routes rich title-heading paste through safe plain-text inline insertion', () => { const { container, editor } = renderEditorHarness() const inlineHeading = createTitleHeadingFixture(container) const clipboardData = clipboardDataFor({ 'text/html': '

Pasted Title

Cell
', 'text/plain': 'Pasted Title\nCell', }) const didBubble = fireEvent.paste(inlineHeading, { clipboardData }) expect(didBubble).toBe(false) expect(editor.focus).toHaveBeenCalled() expect(editor.insertInlineContent).toHaveBeenCalledWith('Pasted Title\nCell', { updateSelection: true, }) }) it('leaves plain title-heading paste on BlockNote native handling', () => { const { container, editor } = renderEditorHarness() const inlineHeading = createTitleHeadingFixture(container) const clipboardData = clipboardDataFor({ 'text/plain': 'Plain Title' }) const didBubble = fireEvent.paste(inlineHeading, { clipboardData }) expect(didBubble).toBe(true) expect(editor.insertInlineContent).not.toHaveBeenCalled() }) it.each([ ['bulletListItem', 'Pasted bullet text'], ['checkListItem', 'Pasted checklist text'], ] as const)('routes plain paste in an empty %s through BlockNote insertion', (contentType, text) => { const { container, editor } = renderEditorHarness() const listItem = createListItemFixture(container, contentType) const clipboardData = clipboardDataFor({ 'text/plain': text }) const didBubble = fireEvent.paste(listItem, { clipboardData }) expect(didBubble).toBe(false) expect(editor.focus).toHaveBeenCalled() expect(editor.insertInlineContent).toHaveBeenCalledWith(text, { updateSelection: true, }) }) it('leaves non-empty list item paste on BlockNote native handling', () => { const { container, editor } = renderEditorHarness() const listItem = createListItemFixture(container, 'bulletListItem', 'Existing text') const clipboardData = clipboardDataFor({ 'text/plain': 'Plain Title' }) const didBubble = fireEvent.paste(listItem, { clipboardData }) expect(didBubble).toBe(true) expect(editor.insertInlineContent).not.toHaveBeenCalled() }) it('routes clicks on the empty title wrapper back into the H1 block', async () => { const editor = createEditor() render( , ) const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container') expect(container).toBeTruthy() const titleBlockOuter = document.createElement('div') titleBlockOuter.className = 'bn-block-outer' const titleBlock = document.createElement('div') titleBlock.className = 'bn-block' const titleHeading = document.createElement('div') titleHeading.setAttribute('data-content-type', 'heading') titleHeading.setAttribute('data-level', '1') const inlineHeading = document.createElement('div') inlineHeading.className = 'bn-inline-content' titleHeading.appendChild(inlineHeading) titleBlock.appendChild(titleHeading) titleBlockOuter.appendChild(titleBlock) container?.appendChild(titleBlockOuter) fireEvent.click(titleBlockOuter) await act(async () => { await Promise.resolve() }) expect(editor.setTextCursorPosition).toHaveBeenCalledWith('heading-block', 'end') expect(editor.focus).toHaveBeenCalled() }) it('ignores editor-container click handling for link toolbar interactions', () => { const { container, editor } = renderEditorHarness() const linkAction = appendToolbarButton(container, 'bn-link-toolbar', 'Open in a new tab') fireEvent.click(linkAction) expect(editor.setTextCursorPosition).not.toHaveBeenCalled() expect(editor.focus).not.toHaveBeenCalled() }) it('ignores editor-container click handling for BlockNote side-menu actions', () => { const { container, editor } = renderEditorHarness() const action = appendToolbarButton(container, 'bn-side-menu', 'Add block') fireEvent.click(action) expect(editor.setTextCursorPosition).not.toHaveBeenCalled() expect(editor.focus).not.toHaveBeenCalled() }) it('falls back to the nearest editable block when the trailing block has no inline content', () => { const editor = createEditor() editor.document = [ { id: 'paragraph-block', type: 'paragraph', content: [], children: [] }, { id: 'image-block', type: 'image', children: [] }, ] editor.setTextCursorPosition = vi.fn((blockId: string) => { if (blockId === 'image-block') { throw new Error('Attempting to set selection anchor in block without content (id image-block)') } }) render( , ) const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container') expect(container).toBeTruthy() expect(() => fireEvent.click(container!)).not.toThrow() expect(editor.setTextCursorPosition).toHaveBeenCalledWith('paragraph-block', 'end') expect(editor.focus).toHaveBeenCalled() }) it('extends mouse selections from editor whitespace using clamped BlockNote coordinates', () => { const { container, editor } = renderEditorHarness() editor._tiptapEditor.view.posAtCoords .mockReturnValueOnce({ pos: 4 }) .mockReturnValueOnce({ pos: 18 }) .mockReturnValueOnce({ pos: 18 }) fireEvent.mouseDown(container, { button: 0, clientX: 12, clientY: 72 }) fireEvent.mouseMove(window, { buttons: 1, clientX: 680, clientY: 180 }) fireEvent.mouseUp(window, { clientX: 680, clientY: 180 }) expect(editor.focus).toHaveBeenCalled() expect(editor._tiptapEditor.view.posAtCoords).toHaveBeenNthCalledWith(1, { left: 121, top: 72, }) expect(editor._tiptapEditor.commands.setTextSelection).toHaveBeenNthCalledWith(1, { from: 4, to: 4, }) expect(editor._tiptapEditor.commands.setTextSelection).toHaveBeenLastCalledWith({ from: 4, to: 18, }) fireEvent.click(container) expect(editor.setTextCursorPosition).not.toHaveBeenCalled() }) it('extends mouse selections from the surrounding editor scroll whitespace', () => { const { editor, scrollArea } = renderEditorHarnessInScrollArea() editor._tiptapEditor.view.posAtCoords .mockReturnValueOnce({ pos: 5 }) .mockReturnValueOnce({ pos: 22 }) .mockReturnValueOnce({ pos: 22 }) fireEvent.mouseDown(scrollArea, { button: 0, clientX: 24, clientY: 96 }) fireEvent.mouseMove(window, { buttons: 1, clientX: 920, clientY: 190 }) fireEvent.mouseUp(window, { clientX: 920, clientY: 190 }) expect(editor.focus).toHaveBeenCalled() expect(editor._tiptapEditor.view.posAtCoords).toHaveBeenNthCalledWith(1, { left: 121, top: 96, }) expect(editor._tiptapEditor.view.posAtCoords).toHaveBeenNthCalledWith(2, { left: 719, top: 190, }) expect(editor._tiptapEditor.commands.setTextSelection).toHaveBeenLastCalledWith({ from: 5, to: 22, }) }) it('extends mouse selections to the document end when dragging below the editor content', () => { const { container, editor } = renderEditorHarness() editor._tiptapEditor.state.doc.content.size = 42 editor._tiptapEditor.view.posAtCoords .mockReturnValueOnce({ pos: 7 }) .mockReturnValue(null) fireEvent.mouseDown(container, { button: 0, clientX: 250, clientY: 80 }) fireEvent.mouseMove(window, { buttons: 1, clientX: 260, clientY: 900 }) fireEvent.mouseUp(window, { clientX: 260, clientY: 900 }) expect(editor._tiptapEditor.view.posAtCoords).toHaveBeenNthCalledWith(2, { left: 260, top: 419, }) expect(editor._tiptapEditor.commands.setTextSelection).toHaveBeenLastCalledWith({ from: 7, to: 41, }) }) it('leaves native BlockNote and non-primary mouse selections alone', () => { const { container, editor } = renderEditorHarness() const editable = document.createElement('div') editable.setAttribute('contenteditable', 'true') container.appendChild(editable) fireEvent.mouseDown(editable, { button: 0, clientX: 200, clientY: 80 }) fireEvent.mouseMove(window, { buttons: 1, clientX: 260, clientY: 120 }) fireEvent.mouseDown(container, { button: 2, clientX: 200, clientY: 80 }) expect(editor._tiptapEditor.view.posAtCoords).not.toHaveBeenCalled() expect(editor._tiptapEditor.commands.setTextSelection).not.toHaveBeenCalled() }) it('routes the custom link-toolbar open action through openExternalUrl', () => { renderLinkToolbarOpenButton({ url: 'https://example.com/docs' }) fireEvent.click(screen.getByRole('button', { name: 'Open in a new tab' })) expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://example.com/docs') }) it('routes link-toolbar attachment actions through the active vault path', () => { renderLinkToolbarOpenButton({ url: 'attachments/report.pdf', text: 'report.pdf', vaultPath: '/vault', }) fireEvent.click(screen.getByRole('button', { name: 'Open in a new tab' })) expect(mockOpenLocalFile).toHaveBeenCalledWith('/vault/attachments/report.pdf', '/vault') expect(mockOpenExternalUrl).not.toHaveBeenCalled() }) it('opens BlockNote file block names through the active vault path', () => { const editor = createEditor() editor.getBlock.mockReturnValue({ type: 'file', props: { url: 'asset://localhost/%2Fvault%2Fattachments%2Freport.pdf' }, }) const { container } = renderEditorHarness(editor, { vaultPath: '/vault' }) const blockContainer = document.createElement('div') blockContainer.setAttribute('data-node-type', 'blockContainer') blockContainer.dataset.id = 'pdf-block' const fileBlock = document.createElement('div') fileBlock.setAttribute('data-file-block', '') const fileName = document.createElement('span') fileName.className = 'bn-file-name-with-icon' fileName.textContent = 'report.pdf' fileBlock.appendChild(fileName) blockContainer.appendChild(fileBlock) container.appendChild(blockContainer) fireEvent.click(fileName) expect(editor.getBlock).toHaveBeenCalledWith('pdf-block') expect(mockOpenLocalFile).toHaveBeenCalledWith('/vault/attachments/report.pdf', '/vault') expect(editor.setTextCursorPosition).not.toHaveBeenCalled() expect(editor.focus).not.toHaveBeenCalled() }) })