import { useCallback, useEffect, useRef, type MutableRefObject } 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 } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays type EditorBlocks = any[] type CachedTabState = { blocks: EditorBlocks; scrollTop: number } interface UseEditorTabSwapOptions { tabs: Tab[] activeTabPath: string | null editor: ReturnType onContentChange?: (path: string, content: string) => void /** When true, the BlockNote editor is hidden (raw/CodeMirror mode active). */ rawMode?: boolean } function signalEditorTabSwapped(path: string): void { window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', { detail: { path }, })) } /** 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 { const first = blocks?.[0] as { type?: string props?: { level?: number } content?: Array<{ type?: string; text?: string }> } | undefined const content = first?.type === 'heading' && first.props?.level === 1 && Array.isArray(first.content) ? first.content : null if (!content) return null const text = 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}`) } function readEditorScrollTop(): number { const scrollEl = document.querySelector('.editor__blocknote-container') return scrollEl?.scrollTop ?? 0 } function cacheEditorState( cache: Map, path: string, blocks: EditorBlocks, ) { cache.set(path, { blocks, scrollTop: readEditorScrollTop(), }) } function buildFastPathBlocks(preprocessed: string): EditorBlocks | null { if (!preprocessed.trim()) { return [{ type: 'paragraph', content: [] }] } const h1OnlyMatch = preprocessed.trim().match(/^# (.+)$/) if (!h1OnlyMatch) return null return [ { type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] }, { type: 'paragraph', content: [], children: [] }, ] } function isBlankBodyContent(content: string): boolean { return extractEditorBody(content).trim() === '' } function blankParagraphBlocks(): EditorBlocks { return [{ type: 'paragraph', content: [], children: [] }] } async function parseMarkdownBlocks( editor: ReturnType, preprocessed: string, ): Promise { const result = editor.tryParseMarkdownToBlocks(preprocessed) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tryParseMarkdownToBlocks returns sync or async BlockNote blocks if (result && typeof (result as any).then === 'function') { return (result as unknown as Promise) } return result as EditorBlocks } async function resolveBlocksForTarget( editor: ReturnType, cache: Map, targetPath: string, content: string, ): Promise { const cached = cache.get(targetPath) if (cached) return cached const body = extractEditorBody(content) const preprocessed = preProcessWikilinks(body) const fastPathBlocks = buildFastPathBlocks(preprocessed) if (fastPathBlocks) { const nextState = { blocks: fastPathBlocks, scrollTop: 0 } cache.set(targetPath, nextState) return nextState } const parsed = await parseMarkdownBlocks(editor, preprocessed) const withWikilinks = injectWikilinks(parsed) if (withWikilinks.length > 0) { cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 }) } return { blocks: withWikilinks, scrollTop: 0 } } function applyBlocksToEditor( editor: ReturnType, blocks: EditorBlocks, scrollTop: number, suppressChangeRef: MutableRefObject, ) { 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 { queueMicrotask(() => { suppressChangeRef.current = false }) } requestAnimationFrame(() => { const scrollEl = document.querySelector('.editor__blocknote-container') if (scrollEl) scrollEl.scrollTop = scrollTop }) } function applyBlankStateToEditor( editor: ReturnType, suppressChangeRef: MutableRefObject, ) { suppressChangeRef.current = true try { editor._tiptapEditor.commands.setContent('

') } catch (err) { console.error('applyBlankStateToEditor failed, falling back to replaceBlocks:', err) applyBlocksToEditor(editor, blankParagraphBlocks(), 0, suppressChangeRef) return } queueMicrotask(() => { suppressChangeRef.current = false }) requestAnimationFrame(() => { const scrollEl = document.querySelector('.editor__blocknote-container') if (scrollEl) scrollEl.scrollTop = 0 }) } function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined { return activeTabPath ? tabs.find(tab => tab.entry.path === activeTabPath) : undefined } function useLatestRef(value: T): MutableRefObject { const ref = useRef(value) useEffect(() => { ref.current = value }, [value]) return ref } function useEditorMountState( editor: ReturnType, editorMountedRef: MutableRefObject, pendingSwapRef: MutableRefObject<(() => void) | null>, ) { useEffect(() => { if (editor.prosemirrorView) { editorMountedRef.current = true } const cleanup = editor.onMount(() => { editorMountedRef.current = true if (pendingSwapRef.current) { const swap = pendingSwapRef.current pendingSwapRef.current = null queueMicrotask(swap) } }) return cleanup }, [editor, editorMountedRef, pendingSwapRef]) } function useEditorChangeHandler(options: { editor: ReturnType tabsRef: MutableRefObject onContentChangeRef: MutableRefObject<((path: string, content: string) => void) | undefined> prevActivePathRef: MutableRefObject suppressChangeRef: MutableRefObject }) { const { editor, tabsRef, onContentChangeRef, prevActivePathRef, suppressChangeRef, } = options return 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 const blocks = editor.document const restored = restoreWikilinksInBlocks(blocks) const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks)) const [frontmatter] = splitFrontmatter(tab.content) onContentChangeRef.current?.(path, `${frontmatter}${bodyMarkdown}`) }, [editor, onContentChangeRef, prevActivePathRef, suppressChangeRef, tabsRef]) } function consumeRawModeTransition( prevRawModeRef: MutableRefObject, rawMode: boolean | undefined, ) { const rawModeJustEnded = prevRawModeRef.current && !rawMode prevRawModeRef.current = !!rawMode return rawModeJustEnded } function cachePreviousTabOnPathChange(options: { prevPath: string | null pathChanged: boolean editorMountedRef: MutableRefObject cache: Map editor: ReturnType }) { const { prevPath, pathChanged, editorMountedRef, cache, editor } = options if (!prevPath || !pathChanged || !editorMountedRef.current) return cacheEditorState(cache, prevPath, editor.document) } function rememberPendingTabArrival( activeTabPath: string | null, activeTab: Tab | undefined, pendingTabArrivalPathRef: MutableRefObject, ) { if (!activeTabPath) { pendingTabArrivalPathRef.current = null return false } if (activeTab) { pendingTabArrivalPathRef.current = null return true } pendingTabArrivalPathRef.current = activeTabPath return false } function handleStableActivePath(options: { pathChanged: boolean rawModeJustEnded: boolean activeTabPath: string | null activeTab: Tab | undefined pendingTabArrival: boolean cache: Map editor: ReturnType editorMountedRef: MutableRefObject rawSwapPendingRef: MutableRefObject }) { const { pathChanged, rawModeJustEnded, activeTabPath, activeTab, pendingTabArrival, cache, editor, editorMountedRef, rawSwapPendingRef, } = options if (pathChanged) return false if (rawModeJustEnded && activeTabPath) { cache.delete(activeTabPath) rawSwapPendingRef.current = true return false } if (pendingTabArrival) return false if (rawSwapPendingRef.current) return true if (activeTabPath && activeTab && editorMountedRef.current) { cacheEditorState(cache, activeTabPath, editor.document) } return true } function scheduleTabSwap(options: { editor: ReturnType cache: Map targetPath: string activeTab: Tab pendingSwapRef: MutableRefObject<(() => void) | null> prevActivePathRef: MutableRefObject rawSwapPendingRef: MutableRefObject suppressChangeRef: MutableRefObject }) { const { editor, cache, targetPath, activeTab, pendingSwapRef, prevActivePathRef, rawSwapPendingRef, suppressChangeRef, } = options const doSwap = () => { if (prevActivePathRef.current !== targetPath) return rawSwapPendingRef.current = false if (isBlankBodyContent(activeTab.content)) { cache.set(targetPath, { blocks: blankParagraphBlocks(), scrollTop: 0 }) applyBlankStateToEditor(editor, suppressChangeRef) requestAnimationFrame(() => signalEditorTabSwapped(targetPath)) return } void resolveBlocksForTarget(editor, cache, targetPath, activeTab.content) .then(({ blocks, scrollTop }) => { if (prevActivePathRef.current !== targetPath) return applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef) requestAnimationFrame(() => signalEditorTabSwapped(targetPath)) }) .catch((err: unknown) => { console.error('Failed to parse/swap editor content:', err) }) } if (editor.prosemirrorView) { queueMicrotask(doSwap) return } pendingSwapRef.current = doSwap } function useTabSwapEffect(options: { tabs: Tab[] activeTabPath: string | null editor: ReturnType rawMode?: boolean tabCacheRef: MutableRefObject> prevActivePathRef: MutableRefObject editorMountedRef: MutableRefObject pendingSwapRef: MutableRefObject<(() => void) | null> pendingTabArrivalPathRef: MutableRefObject prevRawModeRef: MutableRefObject rawSwapPendingRef: MutableRefObject suppressChangeRef: MutableRefObject }) { const { tabs, activeTabPath, editor, rawMode, tabCacheRef, prevActivePathRef, editorMountedRef, pendingSwapRef, pendingTabArrivalPathRef, prevRawModeRef, rawSwapPendingRef, suppressChangeRef, } = options useEffect(() => { const cache = tabCacheRef.current const prevPath = prevActivePathRef.current const pathChanged = prevPath !== activeTabPath const activeTab = findActiveTab(tabs, activeTabPath) const pendingTabArrival = activeTabPath !== null && pendingTabArrivalPathRef.current === activeTabPath const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode) if (rawMode) return cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor }) prevActivePathRef.current = activeTabPath if (handleStableActivePath({ pathChanged, rawModeJustEnded, activeTabPath, activeTab, pendingTabArrival, cache, editor, editorMountedRef, rawSwapPendingRef, })) { return } if (!rememberPendingTabArrival(activeTabPath, activeTab, pendingTabArrivalPathRef)) { return } const targetPath = activeTabPath const readyActiveTab = activeTab if (!targetPath || !readyActiveTab) return scheduleTabSwap({ editor, cache, targetPath, activeTab: readyActiveTab, pendingSwapRef, prevActivePathRef, rawSwapPendingRef, suppressChangeRef, }) }, [ activeTabPath, editor, editorMountedRef, pendingSwapRef, pendingTabArrivalPathRef, prevActivePathRef, prevRawModeRef, rawMode, rawSwapPendingRef, suppressChangeRef, tabCacheRef, tabs, ]) } function useTabCacheCleanup( tabs: Tab[], tabCacheRef: MutableRefObject>, ) { const tabPathsRef = useRef>(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, tabCacheRef]) } /** * 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) { const tabCacheRef = useRef>(new Map()) const prevActivePathRef = useRef(null) const editorMountedRef = useRef(false) const pendingSwapRef = useRef<(() => void) | null>(null) const pendingTabArrivalPathRef = useRef(null) const prevRawModeRef = useRef(!!rawMode) const rawSwapPendingRef = useRef(false) const suppressChangeRef = useRef(false) const onContentChangeRef = useLatestRef(onContentChange) const tabsRef = useLatestRef(tabs) const handleEditorChange = useEditorChangeHandler({ editor, tabsRef, onContentChangeRef, prevActivePathRef, suppressChangeRef, }) useEditorMountState(editor, editorMountedRef, pendingSwapRef) useTabSwapEffect({ tabs, activeTabPath, editor, rawMode, tabCacheRef, prevActivePathRef, editorMountedRef, pendingSwapRef, pendingTabArrivalPathRef, prevRawModeRef, rawSwapPendingRef, suppressChangeRef, }) useTabCacheCleanup(tabs, tabCacheRef) return { handleEditorChange, editorMountedRef } }