Files
tolaria/src/hooks/useEditorTabSwap.ts
2026-05-01 05:44:22 +02:00

1182 lines
34 KiB
TypeScript

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'
import { injectMathInBlocks, preProcessMathMarkdown } from '../utils/mathMarkdown'
import { injectMermaidInBlocks, preProcessMermaidMarkdown, serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown'
import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance'
import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages'
import { getResolvedCachedNoteContent, NOTE_CONTENT_CACHE_RESOLVED_EVENT } from './useTabManagement'
import { useEditorMountState, useLatestRef } from './editorTabSwapLifecycle'
import { usePreparedNotePreload } from './usePreparedNotePreload'
import {
applyBlankStateToEditor,
applyBlocksToEditor,
type EditorContentPathRef,
} from './editorContentSwapApply'
import {
consumeRawModeTransition,
flushBeforePathChange,
flushBeforeRawMode,
useDebouncedEditorChange,
} from './editorChangeDebounce'
import { repairMalformedEditorBlocks } from './editorBlockRepair'
import {
blankParagraphBlocks,
extractEditorBody,
getH1TextFromBlocks,
isUntitledPath,
pathStem,
slugifyPathStem,
} from './editorTabContent'
import { clearEditorDomSelection, EDITOR_CONTAINER_SELECTOR } from './editorDomSelection'
import {
parseMarkdownBlocksWithFallback,
type MarkdownParseResult,
} from './editorMarkdownParseFallback'
export { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './editorTabContent'
export { RICH_EDITOR_CHANGE_DEBOUNCE_MS } from './editorChangeDebounce'
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; sourceContent: string }
type PendingLocalContent = { path: string; content: string }
const TAB_STATE_CACHE_LIMIT = 24
interface TabSwapState {
cache: Map<string, CachedTabState>
prevPath: string | null
pathChanged: boolean
activeTab: Tab | undefined
previousTab: Tab | undefined
rawModeJustEnded: boolean
}
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
vaultPath?: string
}
interface RunTabSwapEffectOptions {
tabs: Tab[]
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
rawMode?: boolean
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
prevActivePathRef: MutableRefObject<string | null>
editorMountedRef: MutableRefObject<boolean>
pendingSwapRef: MutableRefObject<(() => void) | null>
prevRawModeRef: MutableRefObject<boolean>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
editorContentPathRef: EditorContentPathRef
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
flushPendingEditorChange: () => boolean
vaultPath?: string
}
interface UseTabSwapEffectOptions extends Omit<RunTabSwapEffectOptions, 'vaultPath'> {
vaultPathRef: MutableRefObject<string | undefined>
}
function signalEditorTabSwapped(path: string): void {
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', {
detail: { path },
}))
finishNoteOpenTrace(path)
}
function readEditorScrollTop(): number {
const scrollEl = document.querySelector(EDITOR_CONTAINER_SELECTOR)
return scrollEl?.scrollTop ?? 0
}
function cacheEditorState(
cache: Map<string, CachedTabState>,
path: string,
nextState: CachedTabState,
) {
if (cache.has(path)) cache.delete(path)
cache.set(path, nextState)
while (cache.size > TAB_STATE_CACHE_LIMIT) {
const oldestPath = cache.keys().next().value
if (!oldestPath) return
cache.delete(oldestPath)
}
}
function buildFastPathBlocks(options: { preprocessed: string }): EditorBlocks | null {
const { preprocessed } = options
const trimmed = preprocessed.trim()
if (!trimmed) {
return [{ type: 'paragraph', content: [] }]
}
if (trimmed === '#') {
return [
{ type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [], children: [] },
{ type: 'paragraph', content: [], children: [] },
]
}
const h1OnlyMatch = trimmed.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 emptyHeadingBlock(): Record<string, unknown> {
return {
type: 'heading',
props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' },
content: [],
children: [],
}
}
function isBlankBodyContent(options: { content: string }): boolean {
const { content } = options
return extractEditorBody(content).trim() === ''
}
function extractBodyRemainderAfterEmptyH1(options: { content: string }): string | null {
const { content } = options
const body = extractEditorBody(content)
const [firstLine, secondLine, ...rest] = body.split('\n')
if (!firstLine) return null
const normalizedFirstLine = firstLine.trimEnd()
if (normalizedFirstLine !== '#' && normalizedFirstLine !== '# ') return null
if (secondLine === '') {
return rest.join('\n').trimStart()
}
return [secondLine, ...rest].join('\n').trimStart()
}
async function parseMarkdownBlocks(
editor: ReturnType<typeof useCreateBlockNote>,
preprocessed: string,
): Promise<EditorBlocks> {
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<EditorBlocks>)
}
return result as EditorBlocks
}
function preProcessEditorMarkdown(markdown: string, vaultPath?: string): string {
const withMermaid = preProcessMermaidMarkdown({ markdown })
const withImages = vaultPath ? resolveImageUrls(withMermaid, vaultPath) : withMermaid
const withWikilinks = preProcessWikilinks(withImages)
return preProcessMathMarkdown({ markdown: withWikilinks })
}
function injectEditorMarkdownBlocks(blocks: EditorBlocks): EditorBlocks {
const withWikilinks = injectWikilinks(blocks)
const withMath = injectMathInBlocks(withWikilinks)
return injectMermaidInBlocks(withMath) as EditorBlocks
}
function repairParsedMarkdownBlocks(parsed: MarkdownParseResult): EditorBlocks {
const parseSafeBlocks = repairMalformedEditorBlocks(parsed.blocks) as EditorBlocks
if (parsed.usedSourceFallback) return parseSafeBlocks
return repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parseSafeBlocks)) as EditorBlocks
}
async function resolveBlocksForTarget(
options: {
editor: ReturnType<typeof useCreateBlockNote>
cache: Map<string, CachedTabState>
targetPath: string
content: string
vaultPath?: string
},
): Promise<CachedTabState> {
const { editor, cache, targetPath, content, vaultPath } = options
const cached = cache.get(targetPath)
if (cached?.sourceContent === content) return cached
const body = extractEditorBody(content)
const preprocessed = preProcessEditorMarkdown(body, vaultPath)
const fastPathBlocks = buildFastPathBlocks({ preprocessed })
if (fastPathBlocks) {
const nextState = {
blocks: repairMalformedEditorBlocks(fastPathBlocks) as EditorBlocks,
scrollTop: 0,
sourceContent: content,
}
cacheEditorState(cache, targetPath, nextState)
return nextState
}
const parsed = await parseMarkdownBlocksWithFallback({
parseMarkdownBlocks: markdown => parseMarkdownBlocks(editor, markdown),
preprocessed,
sourceMarkdown: body,
context: targetPath,
})
const nextState = {
blocks: repairParsedMarkdownBlocks(parsed),
scrollTop: 0,
sourceContent: content,
}
cacheEditorState(cache, targetPath, nextState)
return nextState
}
function shouldPrepareCachedContent(cache: Map<string, CachedTabState>, path: string, content: string): boolean {
return cache.get(path)?.sourceContent !== content
}
async function prepareCachedNoteContent(options: {
editor: ReturnType<typeof useCreateBlockNote>
cache: Map<string, CachedTabState>
path: string
vaultPath?: string
}): Promise<void> {
const { editor, cache, path, vaultPath } = options
const cached = getResolvedCachedNoteContent(path)
if (!cached || !shouldPrepareCachedContent(cache, path, cached.content)) return
await resolveBlocksForTarget({ editor, cache, targetPath: path, content: cached.content, vaultPath })
}
async function resolveEmptyHeadingBlocks(
editor: ReturnType<typeof useCreateBlockNote>,
content: string,
vaultPath?: string,
targetPath = 'empty heading note',
): Promise<EditorBlocks | null> {
const remainder = extractBodyRemainderAfterEmptyH1({ content })
if (remainder === null) return null
if (!remainder.trim()) return [emptyHeadingBlock(), ...blankParagraphBlocks()] as EditorBlocks
const parsed = await parseMarkdownBlocksWithFallback({
parseMarkdownBlocks: markdown => parseMarkdownBlocks(editor, markdown),
preprocessed: preProcessEditorMarkdown(remainder, vaultPath),
sourceMarkdown: remainder,
context: targetPath,
})
return [emptyHeadingBlock(), ...repairParsedMarkdownBlocks(parsed)] as EditorBlocks
}
function findActiveTab(options: {
tabs: Tab[]
activeTabPath: string | null
}): Tab | undefined {
const { tabs, activeTabPath } = options
return activeTabPath
? tabs.find(tab => tab.entry.path === activeTabPath)
: undefined
}
function serializeEditorBody(editor: ReturnType<typeof useCreateBlockNote>): string {
const restored = restoreWikilinksInBlocks(editor.document)
return compactMarkdown(serializeMermaidAwareBlocks(editor, restored))
}
function trySerializeEditorBody(
editor: ReturnType<typeof useCreateBlockNote>,
reason: string,
): string | null {
try {
return serializeEditorBody(editor)
} catch (error) {
console.warn(`[editor] Skipped ${reason} because BlockNote document could not be serialized:`, error)
return null
}
}
function normalizeTabBody(options: { content: string }): string {
const { content } = options
return compactMarkdown(extractEditorBody(content))
}
function renameBodiesOverlap(options: {
currentBody: string
nextBody: string
}): boolean {
const { currentBody, nextBody } = options
const current = currentBody.trimEnd()
const next = nextBody.trimEnd()
return current === next
|| current.startsWith(next)
|| next.startsWith(current)
}
function isUntitledRenameTransition(
prevPath: string | null,
nextPath: string | null,
activeTab: Tab | undefined,
editor: ReturnType<typeof useCreateBlockNote>,
): boolean {
if (!prevPath || !nextPath || !activeTab || !isUntitledPath(prevPath)) return false
const currentHeading = getH1TextFromBlocks(editor.document)
if (!currentHeading || slugifyPathStem(currentHeading) !== pathStem(nextPath)) return false
const currentBody = trySerializeEditorBody(editor, 'untitled rename comparison')
if (currentBody === null) return false
return renameBodiesOverlap({
currentBody,
nextBody: normalizeTabBody({ content: activeTab.content }),
})
}
function useEditorChangeHandler(options: {
editor: ReturnType<typeof useCreateBlockNote>
tabsRef: MutableRefObject<Tab[]>
onContentChangeRef: MutableRefObject<((path: string, content: string) => void) | undefined>
prevActivePathRef: MutableRefObject<string | null>
editorContentPathRef: EditorContentPathRef
suppressChangeRef: MutableRefObject<boolean>
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
vaultPathRef: MutableRefObject<string | undefined>
}) {
const {
editor,
tabsRef,
onContentChangeRef,
prevActivePathRef,
editorContentPathRef,
suppressChangeRef,
tabCacheRef,
pendingLocalContentRef,
vaultPathRef,
} = options
const propagateEditorChange = useCallback(() => {
const path = prevActivePathRef.current
if (!path) return
if (editorContentPathRef.current !== path) return
const tab = tabsRef.current.find(t => t.entry.path === path)
if (!tab) return
const blocks = editor.document
const rawBodyMarkdown = trySerializeEditorBody(editor, 'editor change')
if (rawBodyMarkdown === null) return
const bodyMarkdown = vaultPathRef.current
? portableImageUrls(rawBodyMarkdown, vaultPathRef.current)
: rawBodyMarkdown
const [frontmatter] = splitFrontmatter(tab.content)
const nextContent = `${frontmatter}${bodyMarkdown}`
pendingLocalContentRef.current = { path, content: nextContent }
cacheEditorState(tabCacheRef.current, path, {
blocks,
scrollTop: readEditorScrollTop(),
sourceContent: nextContent,
})
onContentChangeRef.current?.(path, nextContent)
}, [editor, editorContentPathRef, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, tabCacheRef, tabsRef, vaultPathRef])
return useDebouncedEditorChange({ onFlush: propagateEditorChange, suppressChangeRef })
}
function cachePreviousTabOnPathChange(options: {
prevPath: string | null
previousTab: Tab | undefined
pathChanged: boolean
editorMountedRef: MutableRefObject<boolean>
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
editorContentPathRef: EditorContentPathRef
}) {
const { prevPath, previousTab, pathChanged, editorMountedRef, cache, editor, editorContentPathRef } = options
if (!prevPath || !previousTab || !pathChanged || !editorMountedRef.current) return
if (editorContentPathRef.current !== prevPath) return
cacheEditorState(cache, prevPath, {
blocks: editor.document,
scrollTop: readEditorScrollTop(),
sourceContent: previousTab.content,
})
}
function shouldWaitForActiveTab(options: {
pathChanged: boolean
activeTabPath: string | null
activeTab: Tab | undefined
}) {
const { pathChanged, activeTabPath, activeTab } = options
return pathChanged && !!activeTabPath && !activeTab
}
function syncActivePathTransition(options: {
prevPath: string | null
pathChanged: boolean
activeTabPath: string | null
activeTab: Tab | undefined
previousTab: Tab | undefined
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
prevActivePathRef: MutableRefObject<string | null>
editorContentPathRef: EditorContentPathRef
}) {
const {
prevPath,
pathChanged,
activeTabPath,
activeTab,
previousTab,
cache,
editor,
editorMountedRef,
prevActivePathRef,
editorContentPathRef,
} = options
cachePreviousTabOnPathChange({
prevPath,
previousTab,
pathChanged,
editorMountedRef,
cache,
editor,
editorContentPathRef,
})
if (shouldWaitForActiveTab({ pathChanged, activeTabPath, activeTab })) return true
if (!preserveUntitledRenameState({
prevPath,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
editorContentPathRef,
})) {
prevActivePathRef.current = activeTabPath
return false
}
prevActivePathRef.current = activeTabPath
return true
}
function markRawModeReswapPending(options: {
activeTabPath: string | null
cache: Map<string, CachedTabState>
rawSwapPendingRef: MutableRefObject<boolean>
}) {
const { activeTabPath, cache, rawSwapPendingRef } = options
if (!activeTabPath) return false
cache.delete(activeTabPath)
rawSwapPendingRef.current = true
return true
}
function currentEditorMatchesActiveTab(options: {
activeTabPath: string | null
activeTab: Tab | undefined
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
}) {
const {
activeTabPath,
activeTab,
editor,
editorMountedRef,
} = options
if (!activeTabPath || !activeTab || !editorMountedRef.current) return false
if (typeof editor.blocksToMarkdownLossy !== 'function') return false
const bodyMarkdown = trySerializeEditorBody(editor, 'active tab comparison')
return bodyMarkdown === normalizeTabBody({ content: activeTab.content })
}
function cacheStableActiveTabAndClearPending(options: {
cache: Map<string, CachedTabState>
activeTabPath: string | null
activeTab: Tab | undefined
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
editorContentPathRef: EditorContentPathRef
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
}) {
const {
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
editorContentPathRef,
pendingLocalContentRef,
} = options
cacheStableActivePath({
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
editorContentPathRef,
})
pendingLocalContentRef.current = null
return true
}
function shouldKeepPendingLocalContent(options: {
activeTabPath: string | null
activeTab: Tab | undefined
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
}) {
const {
activeTabPath,
activeTab,
pendingLocalContentRef,
} = options
const pendingLocalContent = pendingLocalContentRef.current
if (!activeTabPath || !activeTab || pendingLocalContent?.path !== activeTabPath) return false
return true
}
function consumePendingLocalContent(options: {
cache: Map<string, CachedTabState>
activeTabPath: string | null
activeTab: Tab | undefined
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
editorContentPathRef: EditorContentPathRef
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
}) {
const {
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
editorContentPathRef,
pendingLocalContentRef,
} = options
const pendingLocalContent = pendingLocalContentRef.current
if (!pendingLocalContent || pendingLocalContent.content !== activeTab?.content) return true
return cacheStableActiveTabAndClearPending({
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
editorContentPathRef,
pendingLocalContentRef,
})
}
function handleStableActivePath(options: {
pathChanged: boolean
rawModeJustEnded: boolean
activeTabPath: string | null
activeTab: Tab | undefined
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
editorContentPathRef: EditorContentPathRef
rawSwapPendingRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
}) {
const {
pathChanged,
rawModeJustEnded,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
editorContentPathRef,
rawSwapPendingRef,
pendingLocalContentRef,
} = options
if (pathChanged) return false
if (rawModeJustEnded) {
return !markRawModeReswapPending({ activeTabPath, cache, rawSwapPendingRef })
}
if (currentEditorMatchesActiveTab({ activeTabPath, activeTab, editor, editorMountedRef })) {
return cacheStableActiveTabAndClearPending({
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
editorContentPathRef,
pendingLocalContentRef,
})
}
if (shouldKeepPendingLocalContent({ activeTabPath, activeTab, pendingLocalContentRef })) {
return consumePendingLocalContent({
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
editorContentPathRef,
pendingLocalContentRef,
})
}
if (shouldRefreshStableActivePath({ activeTabPath, activeTab, cache })) return false
if (rawSwapPendingRef.current) return true
cacheStableActivePath({
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
editorContentPathRef,
})
return true
}
function shouldRefreshStableActivePath(options: {
activeTabPath: string | null
activeTab: Tab | undefined
cache: Map<string, CachedTabState>
}): boolean {
const {
activeTabPath,
activeTab,
cache,
} = options
if (!activeTabPath || !activeTab) return false
const cachedState = cache.get(activeTabPath)
return !cachedState || cachedState.sourceContent !== activeTab.content
}
function cacheStableActivePath(options: {
cache: Map<string, CachedTabState>
activeTabPath: string | null
activeTab: Tab | undefined
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
editorContentPathRef: EditorContentPathRef
}) {
const {
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
editorContentPathRef,
} = options
if (!activeTabPath || !activeTab || !editorMountedRef.current) return
editorContentPathRef.current = activeTabPath
cacheEditorState(cache, activeTabPath, {
blocks: editor.document,
scrollTop: readEditorScrollTop(),
sourceContent: activeTab.content,
})
}
function preserveUntitledRenameState(options: {
prevPath: string | null
activeTabPath: string | null
activeTab: Tab | undefined
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
editorContentPathRef: EditorContentPathRef
}) {
const {
prevPath,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
editorContentPathRef,
} = options
if (!prevPath || !activeTabPath) return false
if (!isUntitledRenameTransition(prevPath, activeTabPath, activeTab, editor)) return false
cache.delete(prevPath)
cacheStableActivePath({
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
editorContentPathRef,
})
requestAnimationFrame(() => signalEditorTabSwapped(activeTabPath))
return true
}
function signalTabSwap(options: { path: string }) {
const { path } = options
requestAnimationFrame(() => signalEditorTabSwapped(path))
}
function clearStaleSwap(options: {
targetPath: string
prevActivePathRef: MutableRefObject<string | null>,
suppressChangeRef: MutableRefObject<boolean>,
}): boolean {
const {
targetPath,
prevActivePathRef,
suppressChangeRef,
} = options
if (prevActivePathRef.current === targetPath) return false
suppressChangeRef.current = false
return true
}
function applyBlankTabState(options: {
cache: Map<string, CachedTabState>
targetPath: string
content: string
editor: ReturnType<typeof useCreateBlockNote>
suppressChangeRef: MutableRefObject<boolean>
editorContentPathRef: EditorContentPathRef
}) {
const {
cache,
targetPath,
content,
editor,
suppressChangeRef,
editorContentPathRef,
} = options
cacheEditorState(cache, targetPath, {
blocks: blankParagraphBlocks(),
scrollTop: 0,
sourceContent: content,
})
applyBlankStateToEditor({ editor, suppressChangeRef, editorContentPathRef, targetPath })
signalTabSwap({ path: targetPath })
}
function scheduleEmptyHeadingSwap(options: {
editor: ReturnType<typeof useCreateBlockNote>
targetPath: string
content: string
prevActivePathRef: MutableRefObject<string | null>
suppressChangeRef: MutableRefObject<boolean>
editorContentPathRef: EditorContentPathRef
vaultPath?: string
}) {
const {
editor,
targetPath,
content,
prevActivePathRef,
suppressChangeRef,
editorContentPathRef,
vaultPath,
} = options
if (extractBodyRemainderAfterEmptyH1({ content }) === null) return false
void resolveEmptyHeadingBlocks(editor, content, vaultPath, targetPath)
.then((blocks) => {
if (prevActivePathRef.current !== targetPath || !blocks) return
applyBlocksToEditor({ editor, blocks, scrollTop: 0, suppressChangeRef, editorContentPathRef, targetPath })
signalTabSwap({ path: targetPath })
})
.catch((err: unknown) => {
suppressChangeRef.current = false
console.error('Failed to render empty heading state:', err)
failNoteOpenTrace(targetPath, 'empty-heading-swap-failed')
})
return true
}
function scheduleParsedBlockSwap(options: {
editor: ReturnType<typeof useCreateBlockNote>
cache: Map<string, CachedTabState>
targetPath: string
content: string
prevActivePathRef: MutableRefObject<string | null>
suppressChangeRef: MutableRefObject<boolean>
editorContentPathRef: EditorContentPathRef
vaultPath?: string
}) {
const {
editor,
cache,
targetPath,
content,
prevActivePathRef,
suppressChangeRef,
editorContentPathRef,
vaultPath,
} = options
void resolveBlocksForTarget({ editor, cache, targetPath, content, vaultPath })
.then(({ blocks, scrollTop }) => {
if (prevActivePathRef.current !== targetPath) return
applyBlocksToEditor({ editor, blocks, scrollTop, suppressChangeRef, editorContentPathRef, targetPath })
signalTabSwap({ path: targetPath })
})
.catch((err: unknown) => {
suppressChangeRef.current = false
console.error('Failed to parse/swap editor content:', err)
failNoteOpenTrace(targetPath, 'parsed-swap-failed')
})
}
function scheduleTabSwap(options: {
editor: ReturnType<typeof useCreateBlockNote>
cache: Map<string, CachedTabState>
targetPath: string
activeTab: Tab
clearDomSelection: boolean
pendingSwapRef: MutableRefObject<(() => void) | null>
prevActivePathRef: MutableRefObject<string | null>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
editorContentPathRef: EditorContentPathRef
vaultPath?: string
}) {
const {
editor,
cache,
targetPath,
activeTab,
clearDomSelection,
pendingSwapRef,
prevActivePathRef,
rawSwapPendingRef,
suppressChangeRef,
editorContentPathRef,
vaultPath,
} = options
suppressChangeRef.current = true
const doSwap = () => {
if (clearStaleSwap({ targetPath, prevActivePathRef, suppressChangeRef })) return
rawSwapPendingRef.current = false
if (clearDomSelection) clearEditorDomSelection()
if (isBlankBodyContent({ content: activeTab.content })) {
applyBlankTabState({
cache,
targetPath,
content: activeTab.content,
editor,
suppressChangeRef,
editorContentPathRef,
})
return
}
if (scheduleEmptyHeadingSwap({
editor,
targetPath,
content: activeTab.content,
prevActivePathRef,
suppressChangeRef,
editorContentPathRef,
vaultPath,
})) {
return
}
scheduleParsedBlockSwap({
editor,
cache,
targetPath,
content: activeTab.content,
prevActivePathRef,
suppressChangeRef,
editorContentPathRef,
vaultPath,
})
}
if (editor.prosemirrorView) {
queueMicrotask(doSwap)
return
}
pendingSwapRef.current = doSwap
}
function resolveTabSwapState(options: {
tabs: Tab[]
activeTabPath: string | null
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
prevActivePathRef: MutableRefObject<string | null>
rawModeJustEnded: boolean
}): TabSwapState {
const {
tabs,
activeTabPath,
tabCacheRef,
prevActivePathRef,
rawModeJustEnded,
} = options
const prevPath = prevActivePathRef.current
return {
cache: tabCacheRef.current,
prevPath,
pathChanged: prevPath !== activeTabPath,
activeTab: findActiveTab({ tabs, activeTabPath }),
previousTab: findActiveTab({ tabs, activeTabPath: prevPath }),
rawModeJustEnded,
}
}
function shouldSkipScheduledTabSwap(options: {
state: TabSwapState
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
prevActivePathRef: MutableRefObject<string | null>
editorContentPathRef: EditorContentPathRef
rawSwapPendingRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
}) {
const {
state,
activeTabPath,
editor,
editorMountedRef,
prevActivePathRef,
editorContentPathRef,
rawSwapPendingRef,
pendingLocalContentRef,
} = options
if (state.pathChanged) {
pendingLocalContentRef.current = null
}
if (syncActivePathTransition({
prevPath: state.prevPath,
pathChanged: state.pathChanged,
activeTabPath,
activeTab: state.activeTab,
previousTab: state.previousTab,
cache: state.cache,
editor,
editorMountedRef,
prevActivePathRef,
editorContentPathRef,
})) {
return true
}
return handleStableActivePath({
pathChanged: state.pathChanged,
rawModeJustEnded: state.rawModeJustEnded,
activeTabPath,
activeTab: state.activeTab,
cache: state.cache,
editor,
editorMountedRef,
editorContentPathRef,
rawSwapPendingRef,
pendingLocalContentRef,
})
}
function runTabSwapEffect(options: RunTabSwapEffectOptions) {
const {
tabs,
activeTabPath,
editor,
rawMode,
tabCacheRef,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
editorContentPathRef,
pendingLocalContentRef,
flushPendingEditorChange,
vaultPath,
} = options
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
if (flushBeforeRawMode({ rawMode, flushPendingEditorChange })) return
const state = resolveTabSwapState({
tabs,
activeTabPath,
tabCacheRef,
prevActivePathRef,
rawModeJustEnded,
})
flushBeforePathChange({ pathChanged: state.pathChanged, flushPendingEditorChange })
if (shouldSkipScheduledTabSwap({
state,
activeTabPath,
editor,
editorMountedRef,
prevActivePathRef,
editorContentPathRef,
rawSwapPendingRef,
pendingLocalContentRef,
})) {
return
}
if (!activeTabPath || !state.activeTab) return
scheduleTabSwap({
editor,
cache: state.cache,
targetPath: activeTabPath,
activeTab: state.activeTab,
clearDomSelection: state.pathChanged,
pendingSwapRef,
prevActivePathRef,
rawSwapPendingRef,
suppressChangeRef,
editorContentPathRef,
vaultPath,
})
}
function useTabSwapEffect(options: UseTabSwapEffectOptions) {
const {
tabs,
activeTabPath,
editor,
rawMode,
tabCacheRef,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
editorContentPathRef,
pendingLocalContentRef,
vaultPathRef,
flushPendingEditorChange,
} = options
useEffect(() => {
runTabSwapEffect({
tabs,
activeTabPath,
editor,
rawMode,
tabCacheRef,
editorMountedRef,
prevActivePathRef,
pendingSwapRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
editorContentPathRef,
pendingLocalContentRef,
flushPendingEditorChange,
vaultPath: vaultPathRef.current,
})
}, [
activeTabPath,
editor,
editorMountedRef,
pendingSwapRef,
prevActivePathRef,
prevRawModeRef,
rawMode,
rawSwapPendingRef,
suppressChangeRef,
editorContentPathRef,
tabCacheRef,
tabs,
pendingLocalContentRef,
vaultPathRef,
flushPendingEditorChange,
])
}
/**
* 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 the onChange callback for SingleEditorView and a flush hook for
* save/navigation paths that need the latest rich-editor content immediately.
*/
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode, vaultPath }: UseEditorTabSwapOptions) {
const tabCacheRef = useRef<Map<string, CachedTabState>>(new Map())
const pendingLocalContentRef = useRef<PendingLocalContent | null>(null)
const prevActivePathRef = useRef<string | null>(null)
const editorContentPathRef = useRef<string | null>(null)
const editorMountedRef = useRef(false)
const pendingSwapRef = useRef<(() => void) | null>(null)
const prevRawModeRef = useRef(!!rawMode)
const rawSwapPendingRef = useRef(false)
const suppressChangeRef = useRef(false)
const onContentChangeRef = useLatestRef(onContentChange)
const tabsRef = useLatestRef(tabs)
const vaultPathRef = useLatestRef(vaultPath)
const { handleEditorChange, flushPendingEditorChange } = useEditorChangeHandler({
editor,
tabsRef,
onContentChangeRef,
prevActivePathRef,
editorContentPathRef,
suppressChangeRef,
tabCacheRef,
pendingLocalContentRef,
vaultPathRef,
})
const preparePreloadedPath = useCallback((path: string) => prepareCachedNoteContent({ editor, cache: tabCacheRef.current, path, vaultPath: vaultPathRef.current }), [editor, tabCacheRef, vaultPathRef])
useEditorMountState(editor, editorMountedRef, pendingSwapRef)
useTabSwapEffect({
tabs,
activeTabPath,
editor,
rawMode,
tabCacheRef,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
editorContentPathRef,
pendingLocalContentRef,
vaultPathRef,
flushPendingEditorChange,
})
usePreparedNotePreload({
eventName: NOTE_CONTENT_CACHE_RESOLVED_EVENT,
editorMountedRef,
rawMode,
preparePath: preparePreloadedPath,
})
return { handleEditorChange, flushPendingEditorChange, editorMountedRef }
}