Files
tolaria/src/components/SingleEditorView.test.tsx

1189 lines
38 KiB
TypeScript
Raw Normal View History

import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
2026-04-21 16:54:52 +02:00
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'
2026-04-21 16:54:52 +02:00
const state = vi.hoisted(() => ({
capturedLinkToolbarProps: null as null | Record<string, unknown>,
2026-04-21 16:54:52 +02:00
capturedToolbarProps: null as null | Record<string, unknown>,
capturedSuggestionProps: {} as Record<string, Record<string, unknown>>,
capturedImageDropArgs: null as null | Record<string, unknown>,
capturedBlockNoteOnChange: null as null | (() => void),
capturedMantineGetStyleNonce: null as null | (() => string),
blockNoteViewError: null as Error | null,
2026-04-21 16:54:52 +02:00
hoverGuardMock: vi.fn(),
imageDropState: { isDragOver: false },
linkActivationMock: vi.fn(),
personMentionCandidates: [] as Record<string, unknown>[],
2026-04-21 16:54:52 +02:00
wikilinkEntriesRef: { current: [] as VaultEntry[] },
wikilinkCandidates: [] as Record<string, unknown>[],
2026-04-21 16:54:52 +02:00
}))
vi.mock('@blocknote/react', () => ({
ComponentsContext: {
Provider: ({ children }: { children?: ReactNode }) => <>{children}</>,
},
BlockNoteViewRaw: (props: {
children?: ReactNode
editable?: boolean
className?: string
formattingToolbar?: boolean
linkToolbar?: boolean
2026-04-21 16:54:52 +02:00
slashMenu?: boolean
sideMenu?: boolean
onChange?: () => void
2026-04-24 22:26:07 +02:00
theme?: string
2026-04-21 16:54:52 +02:00
}) => {
if (state.blockNoteViewError) {
const error = state.blockNoteViewError
throw error
}
2026-04-21 16:54:52 +02:00
const {
children,
editable,
className,
formattingToolbar,
linkToolbar,
2026-04-21 16:54:52 +02:00
slashMenu,
sideMenu,
...restProps
} = props
state.capturedBlockNoteOnChange = props.onChange ?? null
2026-04-21 16:54:52 +02:00
void formattingToolbar
void slashMenu
void sideMenu
return (
<div
data-testid="blocknote-view"
data-editable={editable !== false ? 'true' : 'false'}
data-link-toolbar={linkToolbar !== false ? 'true' : 'false'}
2026-04-21 16:54:52 +02:00
className={className}
{...restProps}
>
{children}
</div>
)
},
LinkToolbarController: (props: Record<string, unknown>) => {
state.capturedLinkToolbarProps = props
return <div data-testid="link-toolbar-controller" />
},
LinkToolbar: ({ children }: { children?: ReactNode }) => (
<div className="bn-link-toolbar">{children}</div>
),
EditLinkButton: () => <button type="button">Edit Link</button>,
DeleteLinkButton: () => <button type="button">Remove Link</button>,
2026-04-21 16:54:52 +02:00
SideMenuController: () => <div data-testid="side-menu-controller" />,
SuggestionMenuController: (props: Record<string, unknown>) => {
state.capturedSuggestionProps[String(props.triggerCharacter)] = props
return <div data-testid={`suggestion-${String(props.triggerCharacter)}`} />
},
useComponentsContext: () => ({
LinkToolbar: {
Button: ({
children,
icon,
label,
onClick,
}: {
children?: ReactNode
icon?: ReactNode
label?: string
onClick?: () => void
}) => (
<button onClick={onClick} type="button">
{icon}
{label}
{children}
</button>
),
},
}),
2026-04-21 16:54:52 +02:00
useCreateBlockNote: vi.fn(),
useDictionary: () => ({
link_toolbar: {
open: { tooltip: 'Open in a new tab' },
},
}),
2026-04-21 16:54:52 +02:00
}))
vi.mock('@blocknote/mantine', () => ({
components: {},
}))
vi.mock('@mantine/core', async () => {
const React = await vi.importActual<typeof import('react')>('react')
return {
MantineContext: React.createContext(null),
MantineProvider: ({
children,
getStyleNonce,
}: {
children?: ReactNode
getStyleNonce?: () => string
}) => {
state.capturedMantineGetStyleNonce = getStyleNonce ?? null
return <>{children}</>
},
2026-04-21 16:54:52 +02:00
}
})
vi.mock('../hooks/useTheme', () => ({
useEditorTheme: () => ({ cssVars: { '--editor-accent': '#abc' } }),
}))
vi.mock('../hooks/useImageDrop', () => ({
useImageDrop: (args: Record<string, unknown>) => {
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),
}))
2026-04-21 16:54:52 +02:00
vi.mock('../utils/typeColors', () => ({
buildTypeEntryMap: () => ({}),
}))
vi.mock('../utils/wikilinkSuggestions', () => ({
MIN_QUERY_LENGTH: 2,
deduplicateByPath: <T,>(items: T[]) => items,
preFilterWikilinks: () => state.wikilinkCandidates,
2026-04-21 16:54:52 +02:00
}))
vi.mock('../utils/personMentionSuggestions', () => ({
PERSON_MENTION_MIN_QUERY: 1,
filterPersonMentions: () => state.personMentionCandidates,
2026-04-21 16:54:52 +02:00
}))
vi.mock('../utils/suggestionEnrichment', () => ({
attachClickHandlers: <T,>(items: T[]) => items,
enrichSuggestionItems: <T,>(items: T[]) => items,
2026-05-11 16:37:06 +02:00
hasMultipleSuggestionWorkspaces: () => false,
2026-04-21 16:54:52 +02:00
}))
vi.mock('./WikilinkSuggestionMenu', () => ({
WikilinkSuggestionMenu: () => <div data-testid="wikilink-suggestion-menu" />,
}))
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: () => <div data-testid="tolaria-formatting-toolbar" />,
TolariaFormattingToolbarController: (props: Record<string, unknown>) => {
state.capturedToolbarProps = props
return <div data-testid="tolaria-formatting-toolbar-controller" />
},
}))
vi.mock('./tolariaBlockNoteSideMenu', () => ({
TolariaSideMenu: () => <div data-testid="tolaria-side-menu" />,
}))
vi.mock('./useEditorLinkActivation', () => ({
useEditorLinkActivation: (containerRef: unknown, onNavigateWikilink: unknown, vaultPath: unknown) => (
state.linkActivationMock(containerRef, onNavigateWikilink, vaultPath)
2026-04-21 16:54:52 +02:00
),
}))
import { openExternalUrl, openLocalFile } from '../utils/url'
2026-04-21 16:54:52 +02:00
import { SingleEditorView } from './SingleEditorView'
const mockOpenExternalUrl = vi.mocked(openExternalUrl)
const mockOpenLocalFile = vi.mocked(openLocalFile)
2026-04-21 16:54:52 +02:00
function makeEntry(overrides: Partial<VaultEntry> = {}): 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,
}))
2026-04-21 16:54:52 +02:00
return {
document: [
{ id: 'heading-block', type: 'heading', content: [], children: [] },
cursorBlock,
],
domElement: undefined as HTMLElement | undefined,
2026-04-21 16:54:52 +02:00
tryParseMarkdownToBlocks: vi.fn(async () => [
{ type: 'table', content: { type: 'tableContent' } },
]),
blocksToHTMLLossy: vi.fn(() => '<table>seeded</table>'),
_tiptapEditor: {
commands: {
setContent: vi.fn(),
setTextSelection: vi.fn(),
},
state: { doc: { content: { size: 100 } } },
view: {
dom: tiptapDom,
posAtCoords: vi.fn(() => ({ pos: 1 })),
},
},
2026-04-21 16:54:52 +02:00
focus: vi.fn(),
getBlock: vi.fn(() => null),
2026-04-21 16:54:52 +02:00
getTextCursorPosition: vi.fn(() => ({ block: cursorBlock })),
insertBlocks: vi.fn(),
insertInlineContent: vi.fn(),
replaceBlocks: vi.fn(() => {
state.blockNoteViewError = null
}),
2026-04-21 16:54:52 +02:00
setTextCursorPosition: vi.fn(),
}
}
function renderEditorHarness(editor = createEditor(), options: { vaultPath?: string } = {}) {
2026-04-27 18:31:51 +02:00
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
vaultPath={options.vaultPath}
2026-04-27 18:31:51 +02:00
/>,
{ wrapper: TooltipProvider },
2026-04-27 18:31:51 +02:00
)
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
return { container: container!, editor }
}
function renderEditorHarnessInScrollArea(editor = createEditor()) {
render(
<div className="editor-scroll-area" data-testid="editor-scroll-area">
<div className="editor-content-wrapper">
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>
</div>
</div>,
{ 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 }
}
2026-04-27 18:31:51 +02:00
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 }
}
2026-04-27 18:31:51 +02:00
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
}
2026-05-03 18:15:36 +02:00
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
}
2026-05-03 18:15:36 +02:00
function clipboardDataFor(formats: Record<string, string>) {
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(
<SingleEditorView
editor={createEditor() as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
vaultPath={options.vaultPath}
/>,
)
const LinkToolbarComponent = state.capturedLinkToolbarProps?.linkToolbar as React.ComponentType<LinkToolbarHarnessProps>
render(
<LinkToolbarComponent
url={options.url}
text={options.text ?? 'Example'}
range={{ from: 1, to: 8 }}
/>,
)
}
2026-04-21 16:54:52 +02:00
describe('SingleEditorView', () => {
beforeEach(() => {
vi.clearAllMocks()
state.capturedLinkToolbarProps = null
2026-04-21 16:54:52 +02:00
state.capturedToolbarProps = null
state.capturedSuggestionProps = {}
state.capturedImageDropArgs = null
state.capturedBlockNoteOnChange = null
state.capturedMantineGetStyleNonce = null
state.blockNoteViewError = null
2026-04-21 16:54:52 +02:00
state.imageDropState.isDragOver = false
state.personMentionCandidates = []
2026-04-21 16:54:52 +02:00
state.wikilinkEntriesRef.current = []
state.wikilinkCandidates = []
mockOpenExternalUrl.mockClear()
mockOpenLocalFile.mockClear()
2026-04-24 22:26:07 +02:00
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
2026-04-21 16:54:52 +02:00
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(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
{ 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()
}
})
2026-04-21 16:54:52 +02:00
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(
<SingleEditorView
editor={editor as never}
entries={entries}
onNavigateWikilink={vi.fn()}
/>,
)
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('<table>seeded</table>')
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(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
vaultPath="/vault"
/>,
)
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(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
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),
}),
}),
}))
2026-04-21 16:54:52 +02:00
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()
2026-04-21 16:54:52 +02:00
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(
<SingleEditorView
editor={createEditor() as never}
entries={[reloadedEntry]}
onNavigateWikilink={vi.fn()}
/>,
)
}).not.toThrow()
})
it('ignores stale suggestion item clicks after the editor DOM disconnects', () => {
const editor = createEditor()
editor.domElement = document.createElement('div')
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
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()
})
2026-05-11 16:37:06 +02:00
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(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
;(state.capturedSuggestionProps['[['].onItemClick as (item: { onItemClick: () => void }) => void)({
onItemClick: itemClick,
})
expect(itemClick).toHaveBeenCalledOnce()
} finally {
editor.domElement.remove()
}
})
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(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const getItems = state.capturedSuggestionProps['[['].getItems as (
query: string
) => Promise<Array<{ onItemClick: () => void }>>
const items = await getItems('al')
expect(items).toHaveLength(1)
expect(() => items[0].onItemClick()).not.toThrow()
expect(staleItemClick).not.toHaveBeenCalled()
})
2026-04-24 22:26:07 +02:00
it('passes the active document theme to BlockNote', () => {
document.documentElement.setAttribute('data-theme', 'dark')
document.documentElement.classList.add('dark')
render(
<SingleEditorView
editor={createEditor() as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
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(
<SingleEditorView
editor={createEditor() as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
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(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
onChange={onChange}
/>,
)
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)
})
2026-04-24 20:13:34 +02:00
it('copies selected fenced code text without markdown escape backslashes', async () => {
2026-04-27 18:31:51 +02:00
const json = '{\n "id": "Demo"\n}'
const { container } = renderEditorHarness()
const { codeBlock, code } = createCodeBlockFixture(json)
await act(async () => {
container.appendChild(codeBlock)
await Promise.resolve()
})
2026-04-27 18:31:51 +02:00
selectNodeContents(code)
const clipboardData = { setData: vi.fn() }
fireEvent.copy(code, { clipboardData })
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', json)
})
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 () => {
2026-04-27 18:31:51 +02:00
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()
})
2026-04-27 18:31:51 +02:00
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', '<strong>Bold copy</strong>')
2026-04-27 18:31:51 +02:00
})
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,
})
})
2026-05-03 18:15:36 +02:00
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': '<h1>Pasted <em>Title</em></h1><table><tr><td>Cell</td></tr></table>',
'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()
})
2026-04-24 20:13:34 +02:00
it('routes clicks on the empty title wrapper back into the H1 block', async () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
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', () => {
2026-04-27 18:31:51 +02:00
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', () => {
2026-04-27 18:31:51 +02:00
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(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
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()
})
2026-04-21 16:54:52 +02:00
})