diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index fa755317..49632636 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest' -import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './useEditorTabSwap' +import { describe, it, expect, vi, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, useEditorTabSwap } from './useEditorTabSwap' describe('extractEditorBody', () => { it('strips frontmatter and preserves H1 heading for new note content', () => { @@ -156,3 +157,150 @@ describe('replaceTitleInFrontmatter', () => { expect(replaceTitleInFrontmatter('', 'Title')).toBe('') }) }) + +describe('useEditorTabSwap scroll position', () => { + const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] + const blocksB = [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] + + function makeTab(path: string, title: string) { + return { + entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never, + content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody of ${title}.`, + } + } + + function makeMockEditor(docRef: { current: unknown[] }) { + const mountCallbacks: Array<() => void> = [] + return { + document: docRef.current, + get prosemirrorView() { return {} }, + onMount: (cb: () => void) => { mountCallbacks.push(cb); return () => {} }, + replaceBlocks: vi.fn((_old, newBlocks) => { docRef.current = newBlocks }), + insertBlocks: vi.fn(), + blocksToMarkdownLossy: vi.fn(() => ''), + blocksToHTMLLossy: vi.fn(() => ''), + tryParseMarkdownToBlocks: vi.fn(() => blocksA), + _tiptapEditor: { commands: { setContent: vi.fn() } }, + // Make document getter dynamic + _docRef: docRef, + } + } + + afterEach(() => { vi.restoreAllMocks() }) + + it('saves scroll position when switching tabs and restores it when switching back', async () => { + const scrollEl = { scrollTop: 0 } + vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element) + const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) + + const docRef = { current: blocksA as unknown[] } + const mockEditor = makeMockEditor(docRef) + // Override document to be dynamic + Object.defineProperty(mockEditor, 'document', { get: () => docRef.current }) + + const tabA = makeTab('a.md', 'Note A') + const tabB = makeTab('b.md', 'Note B') + + const { rerender } = renderHook( + ({ tabs, activeTabPath }) => useEditorTabSwap({ + tabs, + activeTabPath, + editor: mockEditor as never, + }), + { initialProps: { tabs: [tabA, tabB], activeTabPath: 'a.md' } }, + ) + + // Flush the microtask for initial content swap + await act(() => new Promise(r => setTimeout(r, 0))) + + // Simulate scrolling in tab A + scrollEl.scrollTop = 350 + + // Switch to tab B + rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md' }) + await act(() => new Promise(r => setTimeout(r, 0))) + + // rAF should have been called to set scroll to 0 (new tab, no cached scroll) + expect(rAF).toHaveBeenCalled() + + // Switch back to tab A + docRef.current = blocksB // simulate B's content in editor + scrollEl.scrollTop = 0 // B is at top + rerender({ tabs: [tabA, tabB], activeTabPath: 'a.md' }) + await act(() => new Promise(r => setTimeout(r, 0))) + + // The last rAF call should restore A's scroll position (350) + const lastRAFCall = rAF.mock.calls[rAF.mock.calls.length - 1] + expect(lastRAFCall).toBeDefined() + // Execute the callback to verify scrollTop is set + scrollEl.scrollTop = 0 + ;(lastRAFCall[0] as (n: number) => void)(0) + expect(scrollEl.scrollTop).toBe(350) + }) + + it('defaults to scroll top 0 for newly opened tabs', async () => { + const scrollEl = { scrollTop: 0 } + vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element) + const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) + + const docRef = { current: blocksA as unknown[] } + const mockEditor = makeMockEditor(docRef) + Object.defineProperty(mockEditor, 'document', { get: () => docRef.current }) + + const tabA = makeTab('a.md', 'Note A') + + renderHook( + ({ tabs, activeTabPath }) => useEditorTabSwap({ + tabs, + activeTabPath, + editor: mockEditor as never, + }), + { initialProps: { tabs: [tabA], activeTabPath: 'a.md' } }, + ) + + await act(() => new Promise(r => setTimeout(r, 0))) + + // For a fresh tab, scroll should go to 0 + expect(rAF).toHaveBeenCalled() + expect(scrollEl.scrollTop).toBe(0) + }) + + it('cleans up scroll cache when a tab is closed', async () => { + const scrollEl = { scrollTop: 100 } + vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) + + const docRef = { current: blocksA as unknown[] } + const mockEditor = makeMockEditor(docRef) + Object.defineProperty(mockEditor, 'document', { get: () => docRef.current }) + + const tabA = makeTab('a.md', 'Note A') + const tabB = makeTab('b.md', 'Note B') + + const { rerender } = renderHook( + ({ tabs, activeTabPath }) => useEditorTabSwap({ + tabs, + activeTabPath, + editor: mockEditor as never, + }), + { initialProps: { tabs: [tabA, tabB], activeTabPath: 'a.md' } }, + ) + await act(() => new Promise(r => setTimeout(r, 0))) + + // Switch to B (caches A's scroll at 100) + rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md' }) + await act(() => new Promise(r => setTimeout(r, 0))) + + // Close tab A (only tab B remains) + rerender({ tabs: [tabB], activeTabPath: 'b.md' }) + await act(() => new Promise(r => setTimeout(r, 0))) + + // Reopen tab A — should start at scroll 0, not the cached 100 + const tabANew = makeTab('a.md', 'Note A') + scrollEl.scrollTop = 0 + rerender({ tabs: [tabB, tabANew], activeTabPath: 'a.md' }) + await act(() => new Promise(r => setTimeout(r, 0))) + + expect(scrollEl.scrollTop).toBe(0) + }) +}) diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index df937fce..8f98fec9 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -61,9 +61,9 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string) * Returns `handleEditorChange`, the onChange callback for SingleEditorView. */ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef }: UseEditorTabSwapOptions) { - // Cache parsed blocks per tab path for instant switching + // Cache parsed blocks + scroll position per tab path for instant switching // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays - const tabCacheRef = useRef>(new Map()) + const tabCacheRef = useRef>(new Map()) const prevActivePathRef = useRef(null) const editorMountedRef = useRef(false) const pendingSwapRef = useRef<(() => void) | null>(null) @@ -136,9 +136,13 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, const prevPath = prevActivePathRef.current const pathChanged = prevPath !== activeTabPath - // Save current editor state for the tab we're leaving + // Save current editor state + scroll position for the tab we're leaving if (prevPath && pathChanged && editorMountedRef.current) { - cache.set(prevPath, editor.document) + const scrollEl = document.querySelector('.editor__blocknote-container') + cache.set(prevPath, { + blocks: editor.document, + scrollTop: scrollEl?.scrollTop ?? 0, + }) } prevActivePathRef.current = activeTabPath @@ -148,7 +152,11 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, // the editor already shows the user's edits. if (!pathChanged) { if (activeTabPath && editorMountedRef.current) { - cache.set(activeTabPath, editor.document) + const scrollEl = document.querySelector('.editor__blocknote-container') + cache.set(activeTabPath, { + blocks: editor.document, + scrollTop: scrollEl?.scrollTop ?? 0, + }) } return } @@ -159,7 +167,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, if (!tab) return // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote's PartialBlock generic is extremely complex - const applyBlocks = (blocks: any[]) => { + const applyBlocks = (blocks: any[], scrollTop = 0) => { suppressChangeRef.current = true try { const current = editor.document @@ -181,6 +189,11 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, // finishes its internal state updates from the content swap queueMicrotask(() => { suppressChangeRef.current = false }) } + // Restore scroll position after layout updates from the content swap + requestAnimationFrame(() => { + const scrollEl = document.querySelector('.editor__blocknote-container') + if (scrollEl) scrollEl.scrollTop = scrollTop + }) } const targetPath = activeTabPath @@ -190,7 +203,8 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, if (prevActivePathRef.current !== targetPath) return if (cache.has(targetPath)) { - applyBlocks(cache.get(targetPath)!) + const cached = cache.get(targetPath)! + applyBlocks(cached.blocks, cached.scrollTop) return } @@ -202,7 +216,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, // so the editor is immediately interactive. if (!preprocessed.trim()) { const emptyDoc = [{ type: 'paragraph', content: [] }] - cache.set(targetPath, emptyDoc) + cache.set(targetPath, { blocks: emptyDoc, scrollTop: 0 }) applyBlocks(emptyDoc) return } @@ -215,7 +229,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, { type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] }, { type: 'paragraph', content: [], children: [] }, ] - cache.set(targetPath, h1Doc) + cache.set(targetPath, { blocks: h1Doc, scrollTop: 0 }) applyBlocks(h1Doc) return } @@ -228,7 +242,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, const withWikilinks = injectWikilinks(blocks) // Only cache non-empty results to avoid poisoning the cache if (withWikilinks.length > 0) { - cache.set(targetPath, withWikilinks) + cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 }) } applyBlocks(withWikilinks) }