diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx index b370cfbd..4f279d29 100644 --- a/src/components/SingleEditorView.test.tsx +++ b/src/components/SingleEditorView.test.tsx @@ -492,6 +492,34 @@ describe('SingleEditorView', () => { 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( + , + ) + + 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('routes the custom link-toolbar open action through openExternalUrl', () => { render( 0) { - editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end') + const targetBlock = findNearestTextCursorBlock(blocks, blocks.length - 1) + if (targetBlock) { + try { + editor.setTextCursorPosition(targetBlock.id, 'end') + } catch { + // Ignore transient BlockNote selection errors and at least restore focus. + } + } } editor.focus() }, [editor, editable]) diff --git a/src/components/blockNoteCursorTarget.ts b/src/components/blockNoteCursorTarget.ts new file mode 100644 index 00000000..1cb00a62 --- /dev/null +++ b/src/components/blockNoteCursorTarget.ts @@ -0,0 +1,45 @@ +interface CursorTargetBlockLike { + id: string + content?: unknown +} + +function blockSupportsTextCursor(block: CursorTargetBlockLike | undefined): block is CursorTargetBlockLike { + return Array.isArray(block?.content) +} + +export function findNearestTextCursorBlock( + blocks: CursorTargetBlockLike[], + targetIndex: number, +): CursorTargetBlockLike | null { + if (blocks.length === 0) return null + + const clampedTargetIndex = Math.min(Math.max(targetIndex, 0), blocks.length - 1) + const targetBlock = blocks[clampedTargetIndex] + if (blockSupportsTextCursor(targetBlock)) { + return targetBlock + } + + for (let distance = 1; distance < blocks.length; distance += 1) { + const forwardBlock = blocks[clampedTargetIndex + distance] + if (blockSupportsTextCursor(forwardBlock)) { + return forwardBlock + } + + const backwardBlock = blocks[clampedTargetIndex - distance] + if (blockSupportsTextCursor(backwardBlock)) { + return backwardBlock + } + } + + return null +} + +export function findNearestTextCursorBlockById( + blocks: CursorTargetBlockLike[], + targetBlockId: string, +): CursorTargetBlockLike | null { + const targetIndex = blocks.findIndex((block) => block.id === targetBlockId) + if (targetIndex === -1) return null + + return findNearestTextCursorBlock(blocks, targetIndex) +} diff --git a/src/components/editorModePosition.test.ts b/src/components/editorModePosition.test.ts index 8325b483..0428fe9f 100644 --- a/src/components/editorModePosition.test.ts +++ b/src/components/editorModePosition.test.ts @@ -12,13 +12,14 @@ import { 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' }, - { id: 'details', markdown: 'Paragraph one' }, - { id: 'tail', markdown: '## Tail' }, + { id: 'title', markdown: '# Title', content: [] }, + { id: 'details', markdown: 'Paragraph one', content: [] }, + { id: 'tail', markdown: '## Tail', content: [] }, ] function makeEditor(blocks: MockBlock[]): BlockNotePositionEditor { @@ -153,4 +154,36 @@ describe('editorModePosition', () => { 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![media](media.png)\n\nParagraph tail' + const mediaBlocks: MockBlock[] = [ + { id: 'title', markdown: '# Title', content: [] }, + { id: 'image', markdown: '![media](media.png)' }, + { 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() + }) }) diff --git a/src/components/editorModePosition.ts b/src/components/editorModePosition.ts index a654d5ac..0c77cffa 100644 --- a/src/components/editorModePosition.ts +++ b/src/components/editorModePosition.ts @@ -1,8 +1,10 @@ import { compactMarkdown } from '../utils/compact-markdown' import { restoreWikilinksInBlocks, splitFrontmatter } from '../utils/wikilinks' +import { findNearestTextCursorBlockById } from './blockNoteCursorTarget' interface BlockLike { id: string + content?: unknown } interface BlockSelectionLike { @@ -234,9 +236,19 @@ function buildBlockNoteRestoreState( const headIndex = findNearestBlockIndex({ ranges, targetLine: headLine }) const startIndex = Math.min(anchorIndex, headIndex) const endIndex = Math.max(anchorIndex, headIndex) + const startBlockId = findNearestTextCursorBlockById( + editor.document, + editor.document[startIndex].id, + )?.id + const endBlockId = findNearestTextCursorBlockById( + editor.document, + editor.document[endIndex].id, + )?.id + if (!startBlockId || !endBlockId) return null + return { - startBlockId: editor.document[startIndex].id, - endBlockId: editor.document[endIndex].id, + startBlockId, + endBlockId, } } @@ -337,10 +349,14 @@ export function restoreBlockNoteView( const state = buildBlockNoteRestoreState(editor, snapshot) if (!state) return false - if (state.startBlockId === state.endBlockId) { - editor.setTextCursorPosition(state.endBlockId, 'end') - } else { - editor.setSelection(state.startBlockId, state.endBlockId) + try { + if (state.startBlockId === state.endBlockId) { + editor.setTextCursorPosition(state.endBlockId, 'end') + } else { + editor.setSelection(state.startBlockId, state.endBlockId) + } + } catch { + return false } editor.focus() documentObject diff --git a/src/components/useEditorModePositionSync.test.tsx b/src/components/useEditorModePositionSync.test.tsx index d3b837f9..0ad024d3 100644 --- a/src/components/useEditorModePositionSync.test.tsx +++ b/src/components/useEditorModePositionSync.test.tsx @@ -14,13 +14,14 @@ import { useEditorModePositionSync } from './useEditorModePositionSync' 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' }, - { id: 'details', markdown: 'Paragraph one' }, - { id: 'tail', markdown: '## Tail' }, + { id: 'title', markdown: '# Title', content: [] }, + { id: 'details', markdown: 'Paragraph one', content: [] }, + { id: 'tail', markdown: '## Tail', content: [] }, ] function makeEditor(): BlockNotePositionEditor { @@ -114,7 +115,7 @@ describe('useEditorModePositionSync', () => { expect(focus).toHaveBeenCalled() }) - it('restores the BlockNote cursor after toggling back from raw mode', () => { + it('restores the BlockNote cursor after toggling back from raw mode', async () => { const editor = makeEditor() const paragraphOffset = content.indexOf('Paragraph one') + 5 const pendingRawRestoreRef = { current: null as CodeMirrorRestoreState | null } @@ -149,6 +150,9 @@ describe('useEditorModePositionSync', () => { result.current.pendingRichRestoreRef.current = captureRawEditorPositionSnapshot(document) }) rerender({ rawMode: false }) + await act(async () => { + await Promise.resolve() + }) act(() => { window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', { detail: { path: 'note.md' },