2026-04-15 23:33:30 +02:00
|
|
|
import { renderHook, act } from '@testing-library/react'
|
|
|
|
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
|
import {
|
|
|
|
|
buildCodeMirrorRestoreState,
|
|
|
|
|
captureRawEditorPositionSnapshot,
|
|
|
|
|
captureRichEditorPositionSnapshot,
|
|
|
|
|
type CodeMirrorRestoreState,
|
|
|
|
|
type BlockNotePositionEditor,
|
|
|
|
|
type CodeMirrorViewLike,
|
|
|
|
|
type RawEditorPositionSnapshot,
|
|
|
|
|
} from './editorModePosition'
|
|
|
|
|
import { useEditorModePositionSync } from './useEditorModePositionSync'
|
|
|
|
|
|
|
|
|
|
interface MockBlock {
|
|
|
|
|
id: string
|
|
|
|
|
markdown: string
|
2026-04-25 21:21:41 +02:00
|
|
|
content?: unknown
|
2026-04-15 23:33:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const content = '---\ntitle: Demo\n---\n# Title\n\nParagraph one\n\n## Tail'
|
|
|
|
|
const blocks: MockBlock[] = [
|
2026-04-25 21:21:41 +02:00
|
|
|
{ id: 'title', markdown: '# Title', content: [] },
|
|
|
|
|
{ id: 'details', markdown: 'Paragraph one', content: [] },
|
|
|
|
|
{ id: 'tail', markdown: '## Tail', content: [] },
|
2026-04-15 23:33:30 +02:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
function makeEditor(): BlockNotePositionEditor {
|
|
|
|
|
return {
|
|
|
|
|
document: blocks,
|
|
|
|
|
getSelection: () => undefined,
|
|
|
|
|
getTextCursorPosition: () => ({ block: blocks[1] }),
|
|
|
|
|
blocksToMarkdownLossy: (items: unknown[]) => (items as MockBlock[]).map(item => item.markdown).join('\n\n'),
|
|
|
|
|
setSelection: vi.fn(),
|
|
|
|
|
setTextCursorPosition: vi.fn(),
|
|
|
|
|
focus: vi.fn(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function installBlockNoteScrollHost() {
|
|
|
|
|
const host = document.createElement('div')
|
|
|
|
|
host.className = 'editor-scroll-area'
|
|
|
|
|
host.scrollTop = 72
|
|
|
|
|
document.body.appendChild(host)
|
|
|
|
|
const detailBlock = document.createElement('div')
|
|
|
|
|
detailBlock.setAttribute('data-id', 'details')
|
|
|
|
|
detailBlock.scrollIntoView = vi.fn()
|
|
|
|
|
document.body.appendChild(detailBlock)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('useEditorModePositionSync', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
installBlockNoteScrollHost()
|
|
|
|
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
|
|
|
|
|
callback(0)
|
|
|
|
|
return 1
|
|
|
|
|
})
|
|
|
|
|
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.restoreAllMocks()
|
|
|
|
|
document.body.innerHTML = ''
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('restores the raw editor view after toggling from rich mode', () => {
|
|
|
|
|
const editor = makeEditor()
|
|
|
|
|
const dispatch = vi.fn()
|
|
|
|
|
const focus = vi.fn()
|
|
|
|
|
const pendingRawRestoreRef = { current: null as CodeMirrorRestoreState | null }
|
|
|
|
|
const pendingRoundTripRawRestoreRef = { current: null as { path: string; state: CodeMirrorRestoreState } | null }
|
|
|
|
|
const pendingRichRestoreRef = { current: null as RawEditorPositionSnapshot | null }
|
|
|
|
|
installRawView({
|
|
|
|
|
state: {
|
|
|
|
|
doc: { toString: () => content },
|
|
|
|
|
selection: { main: { anchor: 0, head: 0 } },
|
|
|
|
|
},
|
|
|
|
|
scrollDOM: { scrollTop: 0 },
|
|
|
|
|
dispatch,
|
|
|
|
|
focus,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { result, rerender } = renderHook(
|
|
|
|
|
({ rawMode }) => {
|
|
|
|
|
useEditorModePositionSync({
|
|
|
|
|
activeTabPath: 'note.md',
|
|
|
|
|
editor: editor as never,
|
|
|
|
|
pendingRawRestoreRef,
|
|
|
|
|
pendingRoundTripRawRestoreRef,
|
|
|
|
|
pendingRichRestoreRef,
|
|
|
|
|
rawMode,
|
|
|
|
|
})
|
|
|
|
|
return { pendingRawRestoreRef, pendingRoundTripRawRestoreRef, pendingRichRestoreRef }
|
|
|
|
|
},
|
|
|
|
|
{ initialProps: { rawMode: false } },
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
const snapshot = captureRichEditorPositionSnapshot(editor, document)
|
|
|
|
|
result.current.pendingRawRestoreRef.current = snapshot
|
|
|
|
|
? buildCodeMirrorRestoreState(editor, content, snapshot)
|
|
|
|
|
: null
|
|
|
|
|
})
|
|
|
|
|
rerender({ rawMode: true })
|
|
|
|
|
|
|
|
|
|
const selectionCall = dispatch.mock.calls[0]?.[0]
|
|
|
|
|
expect(selectionCall?.selection?.anchor).toBe(content.indexOf('Paragraph one'))
|
|
|
|
|
expect(content.slice(selectionCall.selection.anchor, selectionCall.selection.head).trim()).toBe('Paragraph one')
|
|
|
|
|
expect(focus).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-25 21:21:41 +02:00
|
|
|
it('restores the BlockNote cursor after toggling back from raw mode', async () => {
|
2026-04-15 23:33:30 +02:00
|
|
|
const editor = makeEditor()
|
|
|
|
|
const paragraphOffset = content.indexOf('Paragraph one') + 5
|
|
|
|
|
const pendingRawRestoreRef = { current: null as CodeMirrorRestoreState | null }
|
|
|
|
|
const pendingRoundTripRawRestoreRef = { current: null as { path: string; state: CodeMirrorRestoreState } | null }
|
|
|
|
|
const pendingRichRestoreRef = { current: null as RawEditorPositionSnapshot | null }
|
|
|
|
|
installRawView({
|
|
|
|
|
state: {
|
|
|
|
|
doc: { toString: () => content },
|
|
|
|
|
selection: { main: { anchor: paragraphOffset, head: paragraphOffset } },
|
|
|
|
|
},
|
|
|
|
|
scrollDOM: { scrollTop: 24 },
|
|
|
|
|
dispatch: vi.fn(),
|
|
|
|
|
focus: vi.fn(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { result, rerender } = renderHook(
|
|
|
|
|
({ rawMode }) => {
|
|
|
|
|
useEditorModePositionSync({
|
|
|
|
|
activeTabPath: 'note.md',
|
|
|
|
|
editor: editor as never,
|
|
|
|
|
pendingRawRestoreRef,
|
|
|
|
|
pendingRoundTripRawRestoreRef,
|
|
|
|
|
pendingRichRestoreRef,
|
|
|
|
|
rawMode,
|
|
|
|
|
})
|
|
|
|
|
return { pendingRawRestoreRef, pendingRoundTripRawRestoreRef, pendingRichRestoreRef }
|
|
|
|
|
},
|
|
|
|
|
{ initialProps: { rawMode: true } },
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
result.current.pendingRichRestoreRef.current = captureRawEditorPositionSnapshot(document)
|
|
|
|
|
})
|
|
|
|
|
rerender({ rawMode: false })
|
2026-04-25 21:21:41 +02:00
|
|
|
await act(async () => {
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
})
|
2026-04-15 23:33:30 +02:00
|
|
|
act(() => {
|
|
|
|
|
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', {
|
|
|
|
|
detail: { path: 'note.md' },
|
|
|
|
|
}))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('details', 'end')
|
|
|
|
|
expect(editor.focus).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
})
|