diff --git a/src/hooks/editorTabContent.ts b/src/hooks/editorTabContent.ts index fa1a625d..86c8bf66 100644 --- a/src/hooks/editorTabContent.ts +++ b/src/hooks/editorTabContent.ts @@ -68,6 +68,10 @@ export function isUntitledPath(path: FilePath): boolean { return pathStem(path).startsWith('untitled-') } +export function blankParagraphBlocks(): unknown[] { + return [{ type: 'paragraph', content: [], children: [] }] +} + function isParsedBlock(value: unknown): value is ParsedBlock { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/src/hooks/editorTiptapSelection.ts b/src/hooks/editorTiptapSelection.ts new file mode 100644 index 00000000..ddf1bd88 --- /dev/null +++ b/src/hooks/editorTiptapSelection.ts @@ -0,0 +1,31 @@ +type TiptapEditorBridge = { + state?: { + doc?: { content?: { size?: unknown } } + } + commands?: { + setTextSelection?: (position: number) => unknown + } +} + +function getTiptapEditorBridge(editor: unknown): TiptapEditorBridge | null { + const editorWithBridge = editor as { _tiptapEditor?: TiptapEditorBridge } + return editorWithBridge._tiptapEditor ?? null +} + +function getSafeTextSelectionPosition(tiptapEditor: TiptapEditorBridge): number { + const size = tiptapEditor.state?.doc?.content?.size + if (typeof size !== 'number' || !Number.isFinite(size)) return 0 + return size > 0 ? Math.min(1, size) : 0 +} + +export function resetTextSelectionBeforeContentSwap(editor: unknown): void { + const tiptapEditor = getTiptapEditorBridge(editor) + const setTextSelection = tiptapEditor?.commands?.setTextSelection + if (!tiptapEditor || typeof setTextSelection !== 'function') return + + try { + setTextSelection(getSafeTextSelectionPosition(tiptapEditor)) + } catch (err) { + console.warn('Failed to reset editor selection before content swap:', err) + } +} diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index d2856042..ae4fed44 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -229,7 +229,13 @@ function makeMockEditor(docRef: { current: unknown[] }) { blocksToMarkdownLossy: vi.fn(() => ''), blocksToHTMLLossy: vi.fn(() => ''), tryParseMarkdownToBlocks: vi.fn(() => blocksA), - _tiptapEditor: { commands: { setContent: vi.fn() } }, + _tiptapEditor: { + state: { doc: { content: { size: 8 } } }, + commands: { + setContent: vi.fn(), + setTextSelection: vi.fn(), + }, + }, _docRef: docRef, } Object.defineProperty(editor, 'document', { get: () => docRef.current }) @@ -454,6 +460,24 @@ describe('useEditorTabSwap raw mode sync', () => { } }) + it('resets stale TipTap selection before applying a switched note', async () => { + const tabA = makeTab('a.md', 'Note A') + const tabB = makeTab('b.md', 'Note B') + + const { mockEditor, rerenderWith } = await createSwapHarness({ + initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, + setupEditor: (editor) => { + editor._tiptapEditor.state.doc.content.size = 3 + }, + }) + mockEditor._tiptapEditor.commands.setTextSelection.mockClear() + + await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' }) + + expect(mockEditor._tiptapEditor.commands.setTextSelection).toHaveBeenCalledWith(1) + expect(mockEditor.replaceBlocks).toHaveBeenCalled() + }) + it('re-parses when the active tab content changes without a path change', async () => { const tabA = makeTab('a.md', 'Note A') const refreshedTabA = { diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index 2db791fd..e56578e4 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -9,6 +9,7 @@ import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerform import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages' import { repairMalformedEditorBlocks } from './editorBlockRepair' import { + blankParagraphBlocks, extractEditorBody, getH1TextFromBlocks, isUntitledPath, @@ -17,6 +18,7 @@ import { slugifyPathStem, } from './editorTabContent' import { clearEditorDomSelection, EDITOR_CONTAINER_SELECTOR } from './editorDomSelection' +import { resetTextSelectionBeforeContentSwap } from './editorTiptapSelection' export { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './editorTabContent' interface Tab { @@ -120,10 +122,6 @@ function extractBodyRemainderAfterEmptyH1(options: { content: string }): string return [secondLine, ...rest].join('\n').trimStart() } -function blankParagraphBlocks(): EditorBlocks { - return [{ type: 'paragraph', content: [], children: [] }] -} - async function parseMarkdownBlocks( editor: ReturnType, preprocessed: string, @@ -194,6 +192,7 @@ function applyBlocksToEditor( const safeBlocks = repairMalformedEditorBlocks(blocks) as EditorBlocks suppressChangeRef.current = true try { + resetTextSelectionBeforeContentSwap(editor) const current = editor.document if (current.length > 0 && safeBlocks.length > 0) { editor.replaceBlocks(current, safeBlocks) @@ -224,6 +223,7 @@ function applyBlankStateToEditor( ) { suppressChangeRef.current = true try { + resetTextSelectionBeforeContentSwap(editor) editor._tiptapEditor.commands.setContent('

') } catch (err) { console.error('applyBlankStateToEditor failed, falling back to replaceBlocks:', err) @@ -245,6 +245,7 @@ function applyHtmlStateToEditor( ) { suppressChangeRef.current = true try { + resetTextSelectionBeforeContentSwap(editor) editor._tiptapEditor.commands.setContent(html) } catch (err) { console.error('applyHtmlStateToEditor failed:', err)