import { BlockNoteEditor, type PartialBlock } from '@blocknote/core' import type { Node as ProsemirrorNode } from '@tiptap/pm/model' import { afterEach, describe, expect, it } from 'vitest' import { schema } from './editorSchema' import { blockSelectionAfterArrow, createRichEditorBlockSelectionExtension, richEditorBlockSelectionPluginKey, } from './richEditorBlockSelectionExtension' import { toggleCollapsedHeading } from './tolariaCollapsedSections' type MountedEditor = { cleanup: () => void editor: ReturnType mount: HTMLElement } type FixtureBlock = PartialBlock class TestClipboardData { private readonly data = new Map() clearData() { this.data.clear() } getData(type: string) { return this.data.get(type) ?? '' } setData(type: string, value: string) { this.data.set(type, value) } } function defaultBlocks(): FixtureBlock[] { return [ { id: 'one', type: 'paragraph', content: 'One' }, { id: 'two', type: 'paragraph', content: 'Two' }, { id: 'three', type: 'paragraph', content: 'Three' }, ] } function headingSectionBlocks(): FixtureBlock[] { return [ { id: 'heading', type: 'heading', content: 'Heading', props: { level: 2 } }, { id: 'hidden', type: 'paragraph', content: 'Hidden paragraph' }, { id: 'next', type: 'heading', content: 'Next heading', props: { level: 2 } }, ] } function collapsedListBlocks(): FixtureBlock[] { return [ { id: 'parent', type: 'bulletListItem', content: 'Parent', children: [{ id: 'child', type: 'bulletListItem', content: 'Child' }], }, { id: 'next', type: 'paragraph', content: 'Next paragraph' }, ] } function createMountedEditor(initialContent: FixtureBlock[] = defaultBlocks()): MountedEditor { const mount = document.createElement('div') document.body.appendChild(mount) const editor = BlockNoteEditor.create({ extensions: [createRichEditorBlockSelectionExtension()], initialContent, schema, }) editor.mount(mount) return { editor, mount, cleanup: () => { editor.unmount() mount.remove() }, } } function dispatchEditorKey(editor: MountedEditor['editor'], key: string, options: KeyboardEventInit = {}) { const view = editor._tiptapEditor.view const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key, ...options, }) const handled = view.someProp('handleKeyDown', (handler) => handler(view, event)) return { event, handled: handled === true } } function dispatchEditorClipboardEvent( editor: MountedEditor['editor'], type: 'copy' | 'cut' | 'paste', clipboardData = new TestClipboardData(), ) { const view = editor._tiptapEditor.view const event = new Event(type, { bubbles: true, cancelable: true, }) Object.defineProperty(event, 'clipboardData', { value: clipboardData }) const handled = view.someProp('handleDOMEvents', (handlers) => { const handler = handlers[type] return handler?.(view, event) === true ? true : undefined }) return { clipboardData, event, handled: handled === true } } function selectedBlockIds(editor: MountedEditor['editor']): string[] { return richEditorBlockSelectionPluginKey.getState(editor._tiptapEditor.state)?.blockIds ?? [] } function fixtureBlockIds(editor: MountedEditor['editor']): string[] { return editor.document .map((block) => block.id) .filter((id) => id === 'one' || id === 'two' || id === 'three') } function documentSnapshot(editor: MountedEditor['editor']): string { return JSON.stringify(editor.document) } function textFromContent(content: unknown): string { if (typeof content === 'string') return content if (!Array.isArray(content)) return '' return content.map((part) => ( typeof part === 'object' && part !== null && 'text' in part && typeof part.text === 'string' ? part.text : '' )).join('') } function blockTextEntries(blocks: readonly unknown[] | undefined): string[] { if (!blocks) return [] return blocks.flatMap((block) => { if (typeof block !== 'object' || block === null) return [] const entry = block as { children?: unknown[]; content?: unknown } return [ textFromContent(entry.content), ...blockTextEntries(entry.children), ].filter((text) => text.length > 0) }) } function nonEmptyDocumentText(editor: MountedEditor['editor']): string[] { return blockTextEntries(editor.document) } function blockIdFromNode(node: ProsemirrorNode): string | null { const attrs = node.attrs as Record return typeof attrs.id === 'string' ? attrs.id : null } function textBeforeCursorInBlock(editor: MountedEditor['editor'], blockId: string): string { const { doc, selection } = editor._tiptapEditor.state let blockStart: number | null = null doc.descendants((node, pos) => { if (blockStart !== null) return false if (node.type.isInGroup('bnBlock') && blockIdFromNode(node) === blockId) { blockStart = pos return false } return true }) if (blockStart === null) throw new Error(`Unable to find block ${blockId}`) return doc.textBetween(blockStart, selection.from, '\n', '\n') } describe('rich editor block selection extension', () => { const mountedEditors: MountedEditor[] = [] afterEach(() => { while (mountedEditors.length > 0) { mountedEditors.pop()?.cleanup() } }) function mountEditor() { const mounted = createMountedEditor() mountedEditors.push(mounted) return mounted.editor } function mountEditorWithContent(initialContent: FixtureBlock[]) { const mounted = createMountedEditor(initialContent) mountedEditors.push(mounted) return mounted.editor } function selectBlock(editor: MountedEditor['editor'], blockId: string) { editor.setTextCursorPosition(blockId, 'end') dispatchEditorKey(editor, 'Escape') } function collapseAndSelectBlock(editor: MountedEditor['editor'], blockId: string) { toggleCollapsedHeading(editor, blockId) selectBlock(editor, blockId) } it('promotes the current cursor block to editor-owned block selection on Escape', () => { const editor = mountEditor() editor.setTextCursorPosition('two', 'end') const result = dispatchEditorKey(editor, 'Escape') expect(result.handled).toBe(true) expect(result.event.defaultPrevented).toBe(true) expect(selectedBlockIds(editor)).toEqual(['two']) expect(editor._tiptapEditor.state.selection.empty).toBe(true) }) it('promotes a native multi-block text selection to a block selection range', () => { const editor = mountEditor() editor.setSelection('one', 'three') const result = dispatchEditorKey(editor, 'Escape') expect(result.handled).toBe(true) expect(selectedBlockIds(editor)).toEqual(['one', 'two', 'three']) expect(editor._tiptapEditor.state.selection.empty).toBe(true) }) it('keeps arrows inside the editor while block selection is active', () => { const editor = mountEditor() editor.setTextCursorPosition('two', 'end') dispatchEditorKey(editor, 'Escape') const result = dispatchEditorKey(editor, 'ArrowDown') expect(result.handled).toBe(true) expect(result.event.defaultPrevented).toBe(true) expect(selectedBlockIds(editor)).toEqual(['three']) }) it('renders a decoration class for selected blocks', () => { const mounted = createMountedEditor() mountedEditors.push(mounted) mounted.editor.setTextCursorPosition('two', 'end') dispatchEditorKey(mounted.editor, 'Escape') const selectedBlocks = mounted.mount.querySelectorAll('.tolaria-rich-editor-block-selected') expect(selectedBlocks).toHaveLength(1) expect(selectedBlocks[0].getAttribute('data-tolaria-block-selection')).toBe('single') }) it('lets a second Escape fall through to app-level note-list navigation', () => { const editor = mountEditor() editor.setTextCursorPosition('two', 'end') dispatchEditorKey(editor, 'Escape') const result = dispatchEditorKey(editor, 'Escape') expect(result.event.defaultPrevented).toBe(false) expect(selectedBlockIds(editor)).toEqual([]) }) it('deletes the selected block and keeps the next block selected', () => { const editor = mountEditor() editor.setTextCursorPosition('two', 'end') dispatchEditorKey(editor, 'Escape') const result = dispatchEditorKey(editor, 'Delete') expect(result.handled).toBe(true) expect(result.event.defaultPrevented).toBe(true) expect(editor.document.map((block) => block.id)).not.toContain('two') expect(editor.document.map((block) => block.id).slice(0, 2)).toEqual(['one', 'three']) expect(selectedBlockIds(editor)).toEqual(['three']) }) it('ignores printable text while block selection is active', () => { const editor = mountEditor() editor.setTextCursorPosition('two', 'end') dispatchEditorKey(editor, 'Escape') const before = documentSnapshot(editor) const result = dispatchEditorKey(editor, 'x') expect(result.handled).toBe(true) expect(result.event.defaultPrevented).toBe(true) expect(documentSnapshot(editor)).toBe(before) expect(selectedBlockIds(editor)).toEqual(['two']) }) it('uses Enter to edit the selected block at the end', () => { const editor = mountEditor() editor.setTextCursorPosition('two', 'start') dispatchEditorKey(editor, 'Escape') const result = dispatchEditorKey(editor, 'Enter') expect(result.handled).toBe(true) expect(result.event.defaultPrevented).toBe(true) expect(selectedBlockIds(editor)).toEqual([]) expect(editor.getTextCursorPosition().block.id).toBe('two') expect(textBeforeCursorInBlock(editor, 'two')).toContain('Two') }) it('keeps block selection active after moving the selected block with Mod+Shift+Arrow', () => { const editor = mountEditor() editor.setTextCursorPosition('two', 'end') dispatchEditorKey(editor, 'Escape') const down = dispatchEditorKey(editor, 'ArrowDown', { metaKey: true, shiftKey: true }) expect(down.handled).toBe(true) expect(down.event.defaultPrevented).toBe(true) expect(fixtureBlockIds(editor)).toEqual(['one', 'three', 'two']) expect(selectedBlockIds(editor)).toEqual(['two']) const up = dispatchEditorKey(editor, 'ArrowUp', { metaKey: true, shiftKey: true }) expect(up.handled).toBe(true) expect(up.event.defaultPrevented).toBe(true) expect(fixtureBlockIds(editor)).toEqual(['one', 'two', 'three']) expect(selectedBlockIds(editor)).toEqual(['two']) }) it('copies and pastes selected blocks after the selected block', () => { const editor = mountEditor() editor.setTextCursorPosition('two', 'end') dispatchEditorKey(editor, 'Escape') const copy = dispatchEditorClipboardEvent(editor, 'copy') expect(copy.handled).toBe(true) expect(copy.event.defaultPrevented).toBe(true) expect(copy.clipboardData.getData('text/plain')).toContain('Two') const paste = dispatchEditorClipboardEvent(editor, 'paste', copy.clipboardData) expect(paste.handled).toBe(true) expect(paste.event.defaultPrevented).toBe(true) expect(nonEmptyDocumentText(editor)).toEqual(['One', 'Two', 'Two', 'Three']) }) it('skips hidden heading section blocks when navigating block selection', () => { const editor = mountEditorWithContent(headingSectionBlocks()) collapseAndSelectBlock(editor, 'heading') dispatchEditorKey(editor, 'ArrowDown') expect(selectedBlockIds(editor)).toEqual(['next']) }) it('skips hidden list item children when navigating block selection', () => { const editor = mountEditorWithContent(collapsedListBlocks()) collapseAndSelectBlock(editor, 'parent') dispatchEditorKey(editor, 'ArrowDown') expect(selectedBlockIds(editor)).toEqual(['next']) }) it('toggles collapsible selected blocks with Mod+Enter', () => { const editor = mountEditorWithContent(headingSectionBlocks()) selectBlock(editor, 'heading') const collapse = dispatchEditorKey(editor, 'Enter', { metaKey: true }) expect(collapse.handled).toBe(true) expect(collapse.event.defaultPrevented).toBe(true) dispatchEditorKey(editor, 'ArrowDown') expect(selectedBlockIds(editor)).toEqual(['next']) }) it('copies hidden section blocks with a selected collapsed heading', () => { const editor = mountEditorWithContent(headingSectionBlocks()) collapseAndSelectBlock(editor, 'heading') const copy = dispatchEditorClipboardEvent(editor, 'copy') expect(copy.handled).toBe(true) expect(copy.clipboardData.getData('text/plain')).toContain('Heading') expect(copy.clipboardData.getData('text/plain')).toContain('Hidden paragraph') expect(copy.clipboardData.getData('text/plain')).not.toContain('Next heading') }) it('cuts hidden section blocks with a selected collapsed heading', () => { const editor = mountEditorWithContent(headingSectionBlocks()) collapseAndSelectBlock(editor, 'heading') const cut = dispatchEditorClipboardEvent(editor, 'cut') expect(cut.handled).toBe(true) expect(cut.clipboardData.getData('text/plain')).toContain('Hidden paragraph') expect(nonEmptyDocumentText(editor)).toEqual(['Next heading']) expect(selectedBlockIds(editor)).toEqual(['next']) }) it('moves hidden section blocks with a selected collapsed heading', () => { const mounted = createMountedEditor([ { id: 'first', type: 'heading', content: 'First', props: { level: 2 } }, { id: 'first-body', type: 'paragraph', content: 'First body' }, { id: 'second', type: 'heading', content: 'Second', props: { level: 2 } }, { id: 'second-body', type: 'paragraph', content: 'Second body' }, { id: 'third', type: 'heading', content: 'Third', props: { level: 2 } }, ]) mountedEditors.push(mounted) const { editor } = mounted toggleCollapsedHeading(editor, 'first') toggleCollapsedHeading(editor, 'second') editor.setTextCursorPosition('first', 'end') dispatchEditorKey(editor, 'Escape') const down = dispatchEditorKey(editor, 'ArrowDown', { metaKey: true, shiftKey: true }) expect(down.handled).toBe(true) expect(nonEmptyDocumentText(editor)).toEqual(['Second', 'Second body', 'First', 'First body', 'Third']) expect(selectedBlockIds(editor)).toEqual(['first']) }) it('pastes after a selected collapsed heading section', () => { const editor = mountEditorWithContent([ { id: 'source', type: 'paragraph', content: 'Source paragraph' }, ...headingSectionBlocks(), ]) selectBlock(editor, 'source') const copy = dispatchEditorClipboardEvent(editor, 'copy') collapseAndSelectBlock(editor, 'heading') const paste = dispatchEditorClipboardEvent(editor, 'paste', copy.clipboardData) expect(paste.handled).toBe(true) expect(nonEmptyDocumentText(editor)).toEqual([ 'Source paragraph', 'Heading', 'Hidden paragraph', 'Source paragraph', 'Next heading', ]) }) it('cuts hidden list item children with a selected collapsed parent', () => { const editor = mountEditorWithContent(collapsedListBlocks()) collapseAndSelectBlock(editor, 'parent') const cut = dispatchEditorClipboardEvent(editor, 'cut') expect(cut.handled).toBe(true) expect(cut.clipboardData.getData('text/plain')).toContain('Child') expect(nonEmptyDocumentText(editor)).toEqual(['Next paragraph']) expect(selectedBlockIds(editor)).toEqual(['next']) }) it('moves hidden list item children with a selected collapsed parent', () => { const editor = mountEditorWithContent(collapsedListBlocks()) collapseAndSelectBlock(editor, 'parent') const down = dispatchEditorKey(editor, 'ArrowDown', { metaKey: true, shiftKey: true }) expect(down.handled).toBe(true) expect(nonEmptyDocumentText(editor)).toEqual(['Next paragraph', 'Parent', 'Child']) expect(selectedBlockIds(editor)).toEqual(['parent']) }) }) describe('blockSelectionAfterArrow', () => { it('moves a single selected block up and down by document order', () => { const blockIds = ['one', 'two', 'three'] expect(blockSelectionAfterArrow(['two'], blockIds, 'up', false)).toEqual(['one']) expect(blockSelectionAfterArrow(['two'], blockIds, 'down', false)).toEqual(['three']) }) it('extends a selected block range when Shift is held', () => { const blockIds = ['one', 'two', 'three'] expect(blockSelectionAfterArrow(['two'], blockIds, 'up', true)).toEqual(['one', 'two']) expect(blockSelectionAfterArrow(['one', 'two'], blockIds, 'down', true)).toEqual(['one', 'two', 'three']) }) })