Files
tolaria/src/hooks/useEditorTabSwap.ts
lucaronin 15b11d118b fix: decouple H1 from title sync + non-blocking TitleField rename
H1 headings are body content, not the title source of truth. Remove
useHeadingTitleSync hook and frontmatter-title-from-H1 logic in the
editor. Rust rename_note no longer modifies H1 in file content — only
updates the frontmatter title: field.

TitleField now uses optimistic UI: the new title displays immediately
after commit while the async file rename runs in background, preventing
UI freezes on slow filesystems or large vaults.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:42:48 +01:00

300 lines
12 KiB
TypeScript

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'
import { compactMarkdown } from '../utils/compact-markdown'
interface Tab {
entry: VaultEntry
content: string
}
interface UseEditorTabSwapOptions {
tabs: Tab[]
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
onContentChange?: (path: string, content: string) => void
/** When true, the BlockNote editor is hidden (raw/CodeMirror mode active). */
rawMode?: 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, rawMode }: 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)
const prevRawModeRef = useRef(!!rawMode)
// Guard: prevents a subsequent effect run from re-caching stale blocks
// while a raw-mode swap is still pending in a microtask/pendingSwap.
const rawSwapPendingRef = useRef(false)
// 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 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 = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
// Reconstruct full file: frontmatter + body (which now includes H1 if present)
const [frontmatter] = splitFrontmatter(tab.content)
const fullContent = `${frontmatter}${bodyMarkdown}`
onContentChangeRef.current?.(path, fullContent)
}, [editor])
// 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
// Detect raw mode transition: true → false means we need to re-parse
// from tab.content since the cached blocks are stale.
const rawModeJustEnded = prevRawModeRef.current && !rawMode
prevRawModeRef.current = !!rawMode
// While raw mode is active the BlockNote editor is hidden — skip all
// swap logic to avoid touching the invisible editor.
if (rawMode) return
// 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
if (!pathChanged) {
if (rawModeJustEnded && activeTabPath) {
// Raw mode just ended — invalidate stale cached blocks so we
// re-parse from the latest tab.content below.
cache.delete(activeTabPath)
rawSwapPendingRef.current = true
} else {
// While a raw-mode swap is pending (scheduled via microtask), a second
// effect run can fire due to the tabs prop updating. Skip re-caching
// stale editor.document to avoid poisoning the cache before doSwap runs.
if (rawSwapPendingRef.current) return
// 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 (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
rawSwapPendingRef.current = false
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, rawMode])
// 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 }
}