224 lines
6.9 KiB
TypeScript
224 lines
6.9 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
import {
|
|
buildCodeMirrorRestoreState,
|
|
captureRawEditorPositionSnapshot,
|
|
captureRichEditorPositionSnapshot,
|
|
restoreBlockNoteView,
|
|
restoreCodeMirrorView,
|
|
type BlockNotePositionEditor,
|
|
type CodeMirrorViewLike,
|
|
} from './editorModePosition'
|
|
|
|
interface MockBlock {
|
|
id: string
|
|
markdown: string
|
|
content?: unknown
|
|
}
|
|
|
|
const content = '---\ntitle: Demo\n---\n# Title\n\nParagraph one\n\n## Tail'
|
|
const blocks: MockBlock[] = [
|
|
{ id: 'title', markdown: '# Title', content: [] },
|
|
{ id: 'details', markdown: 'Paragraph one', content: [] },
|
|
{ id: 'tail', markdown: '## Tail', content: [] },
|
|
]
|
|
|
|
function makeEditor(blocks: MockBlock[]): BlockNotePositionEditor {
|
|
let selectedBlocks: MockBlock[] | undefined
|
|
const cursorBlock = blocks[0]
|
|
|
|
return {
|
|
document: blocks,
|
|
getSelection: () => selectedBlocks ? { blocks: selectedBlocks } : undefined,
|
|
getTextCursorPosition: () => ({ block: cursorBlock }),
|
|
blocksToMarkdownLossy: (items: unknown[]) => (items as MockBlock[]).map(item => item.markdown).join('\n\n'),
|
|
setSelection: vi.fn(),
|
|
setTextCursorPosition: vi.fn(),
|
|
focus: vi.fn(),
|
|
}
|
|
}
|
|
|
|
function installRawView(view: CodeMirrorViewLike) {
|
|
const host = document.createElement('div')
|
|
host.setAttribute('data-testid', 'raw-editor-codemirror')
|
|
Object.assign(host, { __cmView: view })
|
|
document.body.appendChild(host)
|
|
return host
|
|
}
|
|
|
|
function captureAndRestoreRawSelection(
|
|
selection: { anchor: number; head: number },
|
|
) {
|
|
const editor = makeEditor(blocks)
|
|
const view: CodeMirrorViewLike = {
|
|
state: {
|
|
doc: { toString: () => content },
|
|
selection: { main: selection },
|
|
},
|
|
scrollDOM: { scrollTop: 48 },
|
|
dispatch: vi.fn(),
|
|
focus: vi.fn(),
|
|
}
|
|
|
|
installRawView(view)
|
|
const snapshot = captureRawEditorPositionSnapshot(document)
|
|
|
|
return {
|
|
editor,
|
|
restored: restoreBlockNoteView(editor, snapshot!, document),
|
|
}
|
|
}
|
|
|
|
describe('editorModePosition', () => {
|
|
beforeEach(() => {
|
|
const scrollHost = document.createElement('div')
|
|
scrollHost.className = 'editor-scroll-area'
|
|
scrollHost.scrollTop = 128
|
|
document.body.appendChild(scrollHost)
|
|
const detailBlock = document.createElement('div')
|
|
detailBlock.setAttribute('data-id', 'details')
|
|
detailBlock.scrollIntoView = vi.fn()
|
|
document.body.appendChild(detailBlock)
|
|
const tailBlock = document.createElement('div')
|
|
tailBlock.setAttribute('data-id', 'tail')
|
|
tailBlock.scrollIntoView = vi.fn()
|
|
document.body.appendChild(tailBlock)
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
it('maps the current BlockNote block to a raw-editor restore selection', () => {
|
|
const editor = makeEditor(blocks)
|
|
editor.getTextCursorPosition = () => ({ block: blocks[1] })
|
|
|
|
const snapshot = captureRichEditorPositionSnapshot(editor, document)
|
|
expect(snapshot).toEqual({
|
|
anchorBlockIndex: 1,
|
|
headBlockIndex: 1,
|
|
scrollTop: 128,
|
|
})
|
|
|
|
const restoreState = buildCodeMirrorRestoreState(editor, content, snapshot!)
|
|
expect(restoreState?.scrollTop).toBe(128)
|
|
expect(content.slice(restoreState!.anchor, restoreState!.head).trim()).toBe('Paragraph one')
|
|
})
|
|
|
|
it('restores a raw-editor selection and scroll position through the DOM bridge', () => {
|
|
const dispatch = vi.fn()
|
|
const focus = vi.fn()
|
|
const view: CodeMirrorViewLike = {
|
|
state: {
|
|
doc: { toString: () => content },
|
|
selection: { main: { anchor: 0, head: 0 } },
|
|
},
|
|
scrollDOM: { scrollTop: 0 },
|
|
dispatch,
|
|
focus,
|
|
}
|
|
installRawView(view)
|
|
|
|
const restored = restoreCodeMirrorView(document, {
|
|
anchor: 10,
|
|
head: 21,
|
|
scrollTop: 96,
|
|
})
|
|
|
|
expect(restored).toBe(true)
|
|
expect(dispatch).toHaveBeenCalledWith({ selection: { anchor: 10, head: 21 } })
|
|
expect(view.scrollDOM.scrollTop).toBe(96)
|
|
expect(focus).toHaveBeenCalled()
|
|
})
|
|
|
|
it('clamps stale raw-editor restore selections to the current document', () => {
|
|
const shortContent = '# Short'
|
|
const dispatch = vi.fn((spec: { selection: { anchor: number; head: number } }) => {
|
|
if (spec.selection.anchor > shortContent.length || spec.selection.head > shortContent.length) {
|
|
throw new RangeError('Selection points outside of document')
|
|
}
|
|
})
|
|
const view: CodeMirrorViewLike = {
|
|
state: {
|
|
doc: { toString: () => shortContent },
|
|
selection: { main: { anchor: 0, head: 0 } },
|
|
},
|
|
scrollDOM: { scrollTop: 0 },
|
|
dispatch,
|
|
focus: vi.fn(),
|
|
}
|
|
installRawView(view)
|
|
|
|
const restored = restoreCodeMirrorView(document, {
|
|
anchor: 500,
|
|
head: 800,
|
|
scrollTop: 12,
|
|
})
|
|
|
|
expect(restored).toBe(true)
|
|
expect(dispatch).toHaveBeenCalledWith({
|
|
selection: {
|
|
anchor: shortContent.length,
|
|
head: shortContent.length,
|
|
},
|
|
})
|
|
expect(view.scrollDOM.scrollTop).toBe(12)
|
|
})
|
|
|
|
it('maps a raw-editor cursor back to the nearest BlockNote block', () => {
|
|
const paragraphOffset = content.indexOf('Paragraph one') + 5
|
|
const { editor, restored } = captureAndRestoreRawSelection({
|
|
anchor: paragraphOffset,
|
|
head: paragraphOffset,
|
|
})
|
|
|
|
expect(restored).toBe(true)
|
|
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('details', 'end')
|
|
expect(editor.focus).toHaveBeenCalled()
|
|
})
|
|
|
|
it('restores a multi-block raw selection back into a BlockNote block range', () => {
|
|
const startOffset = content.indexOf('# Title')
|
|
const endOffset = content.indexOf('## Tail') + '## Tail'.length
|
|
const { editor, restored } = captureAndRestoreRawSelection({
|
|
anchor: startOffset,
|
|
head: endOffset,
|
|
})
|
|
|
|
expect(restored).toBe(true)
|
|
expect(editor.setSelection).toHaveBeenCalledWith('title', 'tail')
|
|
expect(editor.focus).toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back to the nearest content block when raw restore lands on media', () => {
|
|
const contentWithMedia = '---\ntitle: Demo\n---\n# Title\n\n\n\nParagraph tail'
|
|
const mediaBlocks: MockBlock[] = [
|
|
{ id: 'title', markdown: '# Title', content: [] },
|
|
{ id: 'image', markdown: '' },
|
|
{ id: 'tail', markdown: 'Paragraph tail', content: [] },
|
|
]
|
|
const editor = makeEditor(mediaBlocks)
|
|
const view: CodeMirrorViewLike = {
|
|
state: {
|
|
doc: { toString: () => contentWithMedia },
|
|
selection: {
|
|
main: {
|
|
anchor: contentWithMedia.indexOf('![media]') + 3,
|
|
head: contentWithMedia.indexOf('![media]') + 3,
|
|
},
|
|
},
|
|
},
|
|
scrollDOM: { scrollTop: 48 },
|
|
dispatch: vi.fn(),
|
|
focus: vi.fn(),
|
|
}
|
|
|
|
installRawView(view)
|
|
const snapshot = captureRawEditorPositionSnapshot(document)
|
|
const restored = restoreBlockNoteView(editor, snapshot!, document)
|
|
|
|
expect(restored).toBe(true)
|
|
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('tail', 'end')
|
|
expect(editor.focus).toHaveBeenCalled()
|
|
})
|
|
})
|