491 lines
16 KiB
TypeScript
491 lines
16 KiB
TypeScript
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<typeof BlockNoteEditor.create>
|
|
mount: HTMLElement
|
|
}
|
|
|
|
type FixtureBlock = PartialBlock<typeof schema.blockSchema, typeof schema.inlineContentSchema, typeof schema.styleSchema>
|
|
|
|
class TestClipboardData {
|
|
private readonly data = new Map<string, string>()
|
|
|
|
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<string, unknown>
|
|
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'])
|
|
})
|
|
})
|