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\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()
+ })
})
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' },