1182 lines
34 KiB
TypeScript
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 }
|
|
}
|