fix: swap note content after pending navigation
This commit is contained in:
@@ -185,6 +185,41 @@ function makeMockEditor(docRef: { current: unknown[] }) {
|
||||
describe('useEditorTabSwap raw mode sync', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('swaps in the new note when the path updates before tabs catch up', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
const tabB = makeTab('b.md', 'March 2024')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
rerender({ tabs: [tabA], activeTabPath: 'b.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
|
||||
expect.stringContaining('March 2024'),
|
||||
)
|
||||
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-parses from tab.content when rawMode transitions from true to false', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
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'
|
||||
@@ -9,6 +9,10 @@ interface Tab {
|
||||
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
|
||||
@@ -27,15 +31,17 @@ export function extractEditorBody(rawFileContent: string): string {
|
||||
|
||||
/** 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 {
|
||||
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
|
||||
} | 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('')
|
||||
@@ -47,6 +53,392 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: 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<string, CachedTabState>,
|
||||
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: [] },
|
||||
]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function resolveBlocksForTarget(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
cache: Map<string, CachedTabState>,
|
||||
targetPath: string,
|
||||
content: string,
|
||||
): Promise<CachedTabState> {
|
||||
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<typeof useCreateBlockNote>,
|
||||
blocks: EditorBlocks,
|
||||
scrollTop: number,
|
||||
suppressChangeRef: MutableRefObject<boolean>,
|
||||
) {
|
||||
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 findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined {
|
||||
return activeTabPath
|
||||
? tabs.find(tab => tab.entry.path === activeTabPath)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function useLatestRef<T>(value: T): MutableRefObject<T> {
|
||||
const ref = useRef(value)
|
||||
useEffect(() => {
|
||||
ref.current = value
|
||||
}, [value])
|
||||
return ref
|
||||
}
|
||||
|
||||
function useEditorMountState(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
editorMountedRef: MutableRefObject<boolean>,
|
||||
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<typeof useCreateBlockNote>
|
||||
tabsRef: MutableRefObject<Tab[]>
|
||||
onContentChangeRef: MutableRefObject<((path: string, content: string) => void) | undefined>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
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<boolean>,
|
||||
rawMode: boolean | undefined,
|
||||
) {
|
||||
const rawModeJustEnded = prevRawModeRef.current && !rawMode
|
||||
prevRawModeRef.current = !!rawMode
|
||||
return rawModeJustEnded
|
||||
}
|
||||
|
||||
function cachePreviousTabOnPathChange(options: {
|
||||
prevPath: string | null
|
||||
pathChanged: boolean
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
cache: Map<string, CachedTabState>
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
}) {
|
||||
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<string | null>,
|
||||
) {
|
||||
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<string, CachedTabState>
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
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<typeof useCreateBlockNote>
|
||||
cache: Map<string, CachedTabState>
|
||||
targetPath: string
|
||||
activeTab: Tab
|
||||
pendingSwapRef: MutableRefObject<(() => void) | null>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
const {
|
||||
editor,
|
||||
cache,
|
||||
targetPath,
|
||||
activeTab,
|
||||
pendingSwapRef,
|
||||
prevActivePathRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
|
||||
const doSwap = () => {
|
||||
if (prevActivePathRef.current !== targetPath) return
|
||||
rawSwapPendingRef.current = false
|
||||
void resolveBlocksForTarget(editor, cache, targetPath, activeTab.content)
|
||||
.then(({ blocks, scrollTop }) => {
|
||||
if (prevActivePathRef.current !== targetPath) return
|
||||
applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef)
|
||||
})
|
||||
.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<typeof useCreateBlockNote>
|
||||
rawMode?: boolean
|
||||
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
pendingSwapRef: MutableRefObject<(() => void) | null>
|
||||
pendingTabArrivalPathRef: MutableRefObject<string | null>
|
||||
prevRawModeRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
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
|
||||
}
|
||||
|
||||
scheduleTabSwap({
|
||||
editor,
|
||||
cache,
|
||||
targetPath: activeTabPath,
|
||||
activeTab: activeTab!,
|
||||
pendingSwapRef,
|
||||
prevActivePathRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
})
|
||||
}, [
|
||||
activeTabPath,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingSwapRef,
|
||||
pendingTabArrivalPathRef,
|
||||
prevActivePathRef,
|
||||
prevRawModeRef,
|
||||
rawMode,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
tabCacheRef,
|
||||
tabs,
|
||||
])
|
||||
}
|
||||
|
||||
function useTabCacheCleanup(
|
||||
tabs: Tab[],
|
||||
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>,
|
||||
) {
|
||||
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, tabCacheRef])
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the tab content-swap machinery for the BlockNote editor.
|
||||
*
|
||||
@@ -59,241 +451,40 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string)
|
||||
* 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 tabCacheRef = useRef<Map<string, CachedTabState>>(new Map())
|
||||
const prevActivePathRef = useRef<string | null>(null)
|
||||
const editorMountedRef = useRef(false)
|
||||
const pendingSwapRef = useRef<(() => void) | null>(null)
|
||||
const pendingTabArrivalPathRef = useRef<string | 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)
|
||||
const onContentChangeRef = useLatestRef(onContentChange)
|
||||
const tabsRef = useLatestRef(tabs)
|
||||
const handleEditorChange = useEditorChangeHandler({
|
||||
editor,
|
||||
tabsRef,
|
||||
onContentChangeRef,
|
||||
prevActivePathRef,
|
||||
suppressChangeRef,
|
||||
})
|
||||
|
||||
// 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])
|
||||
useEditorMountState(editor, editorMountedRef, pendingSwapRef)
|
||||
useTabSwapEffect({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
editor,
|
||||
rawMode,
|
||||
tabCacheRef,
|
||||
prevActivePathRef,
|
||||
editorMountedRef,
|
||||
pendingSwapRef,
|
||||
pendingTabArrivalPathRef,
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
})
|
||||
useTabCacheCleanup(tabs, tabCacheRef)
|
||||
|
||||
return { handleEditorChange, editorMountedRef }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user