Cache scrollTop alongside blocks in the tab swap cache. On tab leave, capture the scroll container position; on tab restore, apply it via requestAnimationFrame after blocks are rendered. Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
import type React from 'react'
|
|
import { useCallback, useEffect, useRef } from 'react'
|
|
import type { useCreateBlockNote } from '@blocknote/react'
|
|
import type { VaultEntry } from '../types'
|
|
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks'
|
|
|
|
interface Tab {
|
|
entry: VaultEntry
|
|
content: string
|
|
}
|
|
|
|
interface UseEditorTabSwapOptions {
|
|
tabs: Tab[]
|
|
activeTabPath: string | null
|
|
editor: ReturnType<typeof useCreateBlockNote>
|
|
onContentChange?: (path: string, content: string) => void
|
|
/** Called on every editor change with the H1 text (null if no H1 first block). */
|
|
onH1Change?: (h1Text: string | null) => void
|
|
/** When .current is false, handleEditorChange won't update frontmatter title from H1. */
|
|
syncActiveRef?: React.MutableRefObject<boolean>
|
|
}
|
|
|
|
/** Strip the YAML frontmatter from raw file content, returning the body
|
|
* (including any H1 heading) that should appear in the editor. */
|
|
export function extractEditorBody(rawFileContent: string): string {
|
|
const [, rawBody] = splitFrontmatter(rawFileContent)
|
|
return rawBody.trimStart()
|
|
}
|
|
|
|
/** Extract H1 text from the editor's first block, or null if not an H1. */
|
|
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
|
|
if (!blocks?.length) return null
|
|
const first = blocks[0] as {
|
|
type?: string
|
|
props?: { level?: number }
|
|
content?: Array<{ type?: string; text?: string }>
|
|
}
|
|
if (first.type !== 'heading' || first.props?.level !== 1) return null
|
|
if (!Array.isArray(first.content)) return null
|
|
const text = first.content
|
|
.filter(item => item.type === 'text')
|
|
.map(item => item.text || '')
|
|
.join('')
|
|
return text.trim() || null
|
|
}
|
|
|
|
/** Replace the title: line in YAML frontmatter with a new title value. */
|
|
export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string): string {
|
|
return frontmatter.replace(/^(title:\s*).+$/m, `$1${newTitle}`)
|
|
}
|
|
|
|
/**
|
|
* Manages the tab content-swap machinery for the BlockNote editor.
|
|
*
|
|
* Owns all refs and effects related to:
|
|
* - Tracking editor mount state (editorMountedRef, pendingSwapRef)
|
|
* - Swapping document content when the active tab changes (with caching)
|
|
* - Cleaning up the block cache when tabs are closed
|
|
* - Serializing editor blocks → markdown on change (suppressChangeRef)
|
|
*
|
|
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
|
|
*/
|
|
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef }: UseEditorTabSwapOptions) {
|
|
// 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<Map<string, { blocks: any[]; scrollTop: number }>>(new Map())
|
|
const prevActivePathRef = useRef<string | null>(null)
|
|
const editorMountedRef = useRef(false)
|
|
const pendingSwapRef = useRef<(() => void) | null>(null)
|
|
|
|
// Suppress onChange during programmatic content swaps (tab switching / initial load)
|
|
const suppressChangeRef = useRef(false)
|
|
|
|
// Keep refs to callbacks for the onChange handler
|
|
const onContentChangeRef = useRef(onContentChange)
|
|
onContentChangeRef.current = onContentChange
|
|
const onH1ChangeRef = useRef(onH1Change)
|
|
onH1ChangeRef.current = onH1Change
|
|
const tabsRef = useRef(tabs)
|
|
tabsRef.current = tabs
|
|
|
|
// Track editor mount state
|
|
useEffect(() => {
|
|
// Check if already mounted (prosemirrorView exists)
|
|
if (editor.prosemirrorView) {
|
|
editorMountedRef.current = true
|
|
}
|
|
const cleanup = editor.onMount(() => {
|
|
editorMountedRef.current = true
|
|
// Execute any pending content swap that was queued before mount.
|
|
// Defer via queueMicrotask so BlockNote's internal flushSync calls
|
|
// don't collide with React's commit phase.
|
|
if (pendingSwapRef.current) {
|
|
const swap = pendingSwapRef.current
|
|
pendingSwapRef.current = null
|
|
queueMicrotask(swap)
|
|
}
|
|
})
|
|
return cleanup
|
|
}, [editor])
|
|
|
|
// onChange handler: serialize editor blocks → markdown, reconstruct full file, call save
|
|
const handleEditorChange = useCallback(() => {
|
|
if (suppressChangeRef.current) return
|
|
const path = prevActivePathRef.current
|
|
if (!path) return
|
|
|
|
const tab = tabsRef.current.find(t => t.entry.path === path)
|
|
if (!tab) return
|
|
|
|
// Convert blocks → markdown, restoring wikilinks first
|
|
const blocks = editor.document
|
|
const restored = restoreWikilinksInBlocks(blocks)
|
|
const bodyMarkdown = editor.blocksToMarkdownLossy(restored as typeof blocks)
|
|
|
|
// Reconstruct full file: frontmatter + body (which now includes H1 if present)
|
|
const [frontmatter] = splitFrontmatter(tab.content)
|
|
const h1Text = getH1TextFromBlocks(blocks)
|
|
|
|
// Keep frontmatter title: in sync with H1 when sync is active
|
|
const isSyncActive = syncActiveRef?.current !== false
|
|
const fm = (isSyncActive && h1Text)
|
|
? replaceTitleInFrontmatter(frontmatter, h1Text)
|
|
: frontmatter
|
|
const fullContent = `${fm}${bodyMarkdown}`
|
|
|
|
onContentChangeRef.current?.(path, fullContent)
|
|
onH1ChangeRef.current?.(h1Text)
|
|
}, [editor, syncActiveRef])
|
|
|
|
// Swap document content when active tab changes.
|
|
// Uses queueMicrotask to defer BlockNote mutations outside React's commit phase,
|
|
// avoiding flushSync-inside-lifecycle errors that silently prevent content from rendering.
|
|
useEffect(() => {
|
|
const cache = tabCacheRef.current
|
|
const prevPath = prevActivePathRef.current
|
|
const pathChanged = prevPath !== activeTabPath
|
|
|
|
// Save current editor state + scroll position for the tab we're leaving
|
|
if (prevPath && pathChanged && editorMountedRef.current) {
|
|
const scrollEl = document.querySelector('.editor__blocknote-container')
|
|
cache.set(prevPath, {
|
|
blocks: editor.document,
|
|
scrollTop: scrollEl?.scrollTop ?? 0,
|
|
})
|
|
}
|
|
prevActivePathRef.current = activeTabPath
|
|
|
|
// When tab content updates but the active tab stays the same (e.g. after
|
|
// Cmd+S save), refresh the cache with the current editor blocks so a later
|
|
// tab switch doesn't revert to stale content. Do NOT re-apply blocks —
|
|
// the editor already shows the user's edits.
|
|
if (!pathChanged) {
|
|
if (activeTabPath && editorMountedRef.current) {
|
|
const scrollEl = document.querySelector('.editor__blocknote-container')
|
|
cache.set(activeTabPath, {
|
|
blocks: editor.document,
|
|
scrollTop: scrollEl?.scrollTop ?? 0,
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
if (!activeTabPath) return
|
|
|
|
const tab = tabs.find(t => t.entry.path === activeTabPath)
|
|
if (!tab) return
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote's PartialBlock generic is extremely complex
|
|
const applyBlocks = (blocks: any[], scrollTop = 0) => {
|
|
suppressChangeRef.current = true
|
|
try {
|
|
const current = editor.document
|
|
if (current.length > 0 && blocks.length > 0) {
|
|
editor.replaceBlocks(current, blocks)
|
|
} else if (blocks.length > 0) {
|
|
editor.insertBlocks(blocks, current[0], 'before')
|
|
}
|
|
} catch (err) {
|
|
console.error('applyBlocks failed, trying fallback:', err)
|
|
try {
|
|
const html = editor.blocksToHTMLLossy(blocks)
|
|
editor._tiptapEditor.commands.setContent(html)
|
|
} catch (err2) {
|
|
console.error('Fallback also failed:', err2)
|
|
}
|
|
} finally {
|
|
// Re-enable change detection on next microtask, after BlockNote
|
|
// 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
|
|
|
|
const doSwap = () => {
|
|
// Guard: bail if user switched tabs since this swap was scheduled
|
|
if (prevActivePathRef.current !== targetPath) return
|
|
|
|
if (cache.has(targetPath)) {
|
|
const cached = cache.get(targetPath)!
|
|
applyBlocks(cached.blocks, cached.scrollTop)
|
|
return
|
|
}
|
|
|
|
const body = extractEditorBody(tab.content)
|
|
const preprocessed = preProcessWikilinks(body)
|
|
|
|
// Fast path: empty body (e.g. newly created notes). Skip the
|
|
// potentially-async markdown parser and set a single empty paragraph
|
|
// so the editor is immediately interactive.
|
|
if (!preprocessed.trim()) {
|
|
const emptyDoc = [{ type: 'paragraph', content: [] }]
|
|
cache.set(targetPath, { blocks: emptyDoc, scrollTop: 0 })
|
|
applyBlocks(emptyDoc)
|
|
return
|
|
}
|
|
|
|
// Fast path: H1-only content (e.g. newly created notes that just have
|
|
// the title heading). Build blocks directly to stay instant.
|
|
const h1OnlyMatch = preprocessed.trim().match(/^# (.+)$/)
|
|
if (h1OnlyMatch) {
|
|
const h1Doc = [
|
|
{ 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, { blocks: h1Doc, scrollTop: 0 })
|
|
applyBlocks(h1Doc)
|
|
return
|
|
}
|
|
|
|
try {
|
|
const result = editor.tryParseMarkdownToBlocks(preprocessed)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
|
|
const handleBlocks = (blocks: any[]) => {
|
|
if (prevActivePathRef.current !== targetPath) return
|
|
const withWikilinks = injectWikilinks(blocks)
|
|
// Only cache non-empty results to avoid poisoning the cache
|
|
if (withWikilinks.length > 0) {
|
|
cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 })
|
|
}
|
|
applyBlocks(withWikilinks)
|
|
}
|
|
/* eslint-disable @typescript-eslint/no-explicit-any -- tryParseMarkdownToBlocks returns sync or async BlockNote blocks */
|
|
if (result && typeof (result as any).then === 'function') {
|
|
(result as unknown as Promise<any[]>).then(handleBlocks).catch((err: unknown) => {
|
|
console.error('Async markdown parse failed:', err)
|
|
})
|
|
} else {
|
|
handleBlocks(result as any[])
|
|
}
|
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
} catch (err) {
|
|
console.error('Failed to parse/swap editor content:', err)
|
|
}
|
|
}
|
|
|
|
if (editor.prosemirrorView) {
|
|
// Defer the swap outside React's commit phase so BlockNote's internal
|
|
// flushSync calls don't collide with React's rendering lifecycle.
|
|
queueMicrotask(doSwap)
|
|
} else {
|
|
pendingSwapRef.current = doSwap
|
|
}
|
|
}, [activeTabPath, tabs, editor])
|
|
|
|
// Clean up cache entries when tabs are closed
|
|
const tabPathsRef = useRef<Set<string>>(new Set())
|
|
useEffect(() => {
|
|
const currentPaths = new Set(tabs.map(t => t.entry.path))
|
|
for (const path of tabPathsRef.current) {
|
|
if (!currentPaths.has(path)) {
|
|
tabCacheRef.current.delete(path)
|
|
}
|
|
}
|
|
tabPathsRef.current = currentPaths
|
|
}, [tabs])
|
|
|
|
return { handleEditorChange, editorMountedRef }
|
|
}
|