fix: keep note switches instant
This commit is contained in:
@@ -60,11 +60,7 @@ describe('extractEditorBody', () => {
|
||||
|
||||
describe('getH1TextFromBlocks', () => {
|
||||
it('returns text from H1 heading block', () => {
|
||||
const blocks = [{
|
||||
type: 'heading',
|
||||
props: { level: 1 },
|
||||
content: [{ type: 'text', text: 'My Title', styles: {} }],
|
||||
}]
|
||||
const blocks = makeHeadingBlocks([{ type: 'text', text: 'My Title', styles: {} }])
|
||||
expect(getH1TextFromBlocks(blocks)).toBe('My Title')
|
||||
})
|
||||
|
||||
@@ -81,41 +77,25 @@ describe('getH1TextFromBlocks', () => {
|
||||
})
|
||||
|
||||
it('returns null for H2 heading', () => {
|
||||
const blocks = [{
|
||||
type: 'heading',
|
||||
props: { level: 2 },
|
||||
content: [{ type: 'text', text: 'Subtitle' }],
|
||||
}]
|
||||
const blocks = makeHeadingBlocks([{ type: 'text', text: 'Subtitle' }], 2)
|
||||
expect(getH1TextFromBlocks(blocks)).toBeNull()
|
||||
})
|
||||
|
||||
it('concatenates multiple text spans', () => {
|
||||
const blocks = [{
|
||||
type: 'heading',
|
||||
props: { level: 1 },
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello ' },
|
||||
{ type: 'text', text: 'World' },
|
||||
],
|
||||
}]
|
||||
const blocks = makeHeadingBlocks([
|
||||
{ type: 'text', text: 'Hello ' },
|
||||
{ type: 'text', text: 'World' },
|
||||
])
|
||||
expect(getH1TextFromBlocks(blocks)).toBe('Hello World')
|
||||
})
|
||||
|
||||
it('returns null for empty H1 content', () => {
|
||||
const blocks = [{
|
||||
type: 'heading',
|
||||
props: { level: 1 },
|
||||
content: [],
|
||||
}]
|
||||
const blocks = makeHeadingBlocks([])
|
||||
expect(getH1TextFromBlocks(blocks)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for whitespace-only H1', () => {
|
||||
const blocks = [{
|
||||
type: 'heading',
|
||||
props: { level: 1 },
|
||||
content: [{ type: 'text', text: ' ' }],
|
||||
}]
|
||||
const blocks = makeHeadingBlocks([{ type: 'text', text: ' ' }])
|
||||
expect(getH1TextFromBlocks(blocks)).toBeNull()
|
||||
})
|
||||
|
||||
@@ -125,14 +105,10 @@ describe('getH1TextFromBlocks', () => {
|
||||
})
|
||||
|
||||
it('filters non-text inline content', () => {
|
||||
const blocks = [{
|
||||
type: 'heading',
|
||||
props: { level: 1 },
|
||||
content: [
|
||||
{ type: 'text', text: 'Title' },
|
||||
{ type: 'wikilink', props: { target: 'linked' } },
|
||||
],
|
||||
}]
|
||||
const blocks = makeHeadingBlocks([
|
||||
{ type: 'text', text: 'Title' },
|
||||
{ type: 'wikilink', props: { target: 'linked' } },
|
||||
])
|
||||
expect(getH1TextFromBlocks(blocks)).toBe('Title')
|
||||
})
|
||||
})
|
||||
@@ -187,7 +163,7 @@ function makeBlankBodyTab(path: string, title = 'Untitled Note 1') {
|
||||
}
|
||||
|
||||
function makeMockEditor(docRef: { current: unknown[] }) {
|
||||
return {
|
||||
const editor = {
|
||||
document: docRef.current,
|
||||
get prosemirrorView() { return {} },
|
||||
onMount: (cb: () => void) => { cb(); return () => {} },
|
||||
@@ -199,39 +175,92 @@ function makeMockEditor(docRef: { current: unknown[] }) {
|
||||
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
||||
_docRef: docRef,
|
||||
}
|
||||
Object.defineProperty(editor, 'document', { get: () => docRef.current })
|
||||
return editor
|
||||
}
|
||||
|
||||
function makeHeadingBlocks(
|
||||
content: Array<Record<string, unknown>>,
|
||||
level = 1,
|
||||
) {
|
||||
return [{
|
||||
type: 'heading',
|
||||
props: { level },
|
||||
content,
|
||||
}]
|
||||
}
|
||||
|
||||
async function flushEditorTick() {
|
||||
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)))
|
||||
}
|
||||
|
||||
function installEditorDomSpies(scrollTop = 0) {
|
||||
const scrollEl = { scrollTop }
|
||||
const frameSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0)
|
||||
return 0
|
||||
})
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
|
||||
return { scrollEl, frameSpy }
|
||||
}
|
||||
|
||||
type SwapHarnessProps = {
|
||||
tabs: ReturnType<typeof makeTab>[]
|
||||
activeTabPath: string | null
|
||||
rawMode?: boolean
|
||||
}
|
||||
|
||||
async function createSwapHarness(options: {
|
||||
initialProps: SwapHarnessProps
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
setupEditor?: (editor: ReturnType<typeof makeMockEditor>) => void
|
||||
}) {
|
||||
installEditorDomSpies()
|
||||
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
options.setupEditor?.(mockEditor)
|
||||
|
||||
let currentProps = options.initialProps
|
||||
const rendered = renderHook(
|
||||
(props: SwapHarnessProps) => useEditorTabSwap({
|
||||
...props,
|
||||
editor: mockEditor as never,
|
||||
onContentChange: options.onContentChange,
|
||||
}),
|
||||
{ initialProps: currentProps },
|
||||
)
|
||||
|
||||
await flushEditorTick()
|
||||
|
||||
return {
|
||||
...rendered,
|
||||
mockEditor,
|
||||
async rerenderWith(nextProps: Partial<SwapHarnessProps>) {
|
||||
currentProps = { ...currentProps, ...nextProps }
|
||||
rendered.rerender(currentProps)
|
||||
await flushEditorTick()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
const { mockEditor, rerenderWith } = await createSwapHarness({
|
||||
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
|
||||
})
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
rerender({ tabs: [tabA], activeTabPath: 'b.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
await rerenderWith({ tabs: [tabA], activeTabPath: 'b.md' })
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
await rerenderWith({ tabs: [tabB] })
|
||||
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
|
||||
expect.stringContaining('March 2024'),
|
||||
@@ -240,31 +269,18 @@ describe('useEditorTabSwap raw mode sync', () => {
|
||||
})
|
||||
|
||||
it('signals when the target tab content has been applied', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const swapListener = vi.fn()
|
||||
window.addEventListener('laputa:editor-tab-swapped', swapListener)
|
||||
|
||||
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)))
|
||||
const { rerenderWith } = await createSwapHarness({
|
||||
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
|
||||
})
|
||||
swapListener.mockClear()
|
||||
|
||||
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
|
||||
|
||||
expect(swapListener).toHaveBeenCalledTimes(1)
|
||||
const event = swapListener.mock.calls[0][0] as CustomEvent
|
||||
@@ -274,59 +290,33 @@ describe('useEditorTabSwap raw mode sync', () => {
|
||||
})
|
||||
|
||||
it('hard-resets the editor when the target note body is blank', 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 populatedTab = makeTab('a.md', 'Note A')
|
||||
const untitledTab = makeBlankBodyTab('untitled.md')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
const { mockEditor, rerenderWith } = await createSwapHarness({
|
||||
initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
|
||||
})
|
||||
mockEditor._tiptapEditor.commands.setContent.mockClear()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' })
|
||||
|
||||
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<p></p>')
|
||||
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders empty H1 untitled notes via TipTap HTML content', 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 populatedTab = makeTab('a.md', 'Note A')
|
||||
const untitledTab = makeUntitledTab('untitled.md')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
const { mockEditor, rerenderWith } = await createSwapHarness({
|
||||
initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
|
||||
})
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
mockEditor._tiptapEditor.commands.setContent.mockClear()
|
||||
|
||||
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' })
|
||||
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
|
||||
@@ -334,35 +324,43 @@ describe('useEditorTabSwap raw mode sync', () => {
|
||||
})
|
||||
|
||||
it('renders empty H1 typed notes with template content under the title', 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)
|
||||
mockEditor.blocksToHTMLLossy.mockReturnValue('<h2>Objective</h2><p></p>')
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const populatedTab = makeTab('a.md', 'Note A')
|
||||
const typedUntitledTab = makeUntitledTab('untitled.md', 'Untitled Project 1', '## Objective\n\n')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
const { mockEditor, rerenderWith } = await createSwapHarness({
|
||||
initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
|
||||
setupEditor: (editor) => {
|
||||
editor.blocksToHTMLLossy.mockReturnValue('<h2>Objective</h2><p></p>')
|
||||
},
|
||||
})
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
mockEditor._tiptapEditor.commands.setContent.mockClear()
|
||||
|
||||
rerender({ tabs: [typedUntitledTab], activeTabPath: 'untitled.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
await rerenderWith({ tabs: [typedUntitledTab], activeTabPath: 'untitled.md' })
|
||||
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith('## Objective\n\n')
|
||||
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<h1></h1><h2>Objective</h2><p></p>')
|
||||
})
|
||||
|
||||
it('reuses cached editor blocks when reopening a recently visited note', async () => {
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
const tabB = makeTab('b.md', 'Note B')
|
||||
|
||||
const { mockEditor, rerenderWith } = await createSwapHarness({
|
||||
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
|
||||
})
|
||||
|
||||
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
|
||||
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
await rerenderWith({ tabs: [tabA], activeTabPath: 'a.md' })
|
||||
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores editor change events before the pending tab swap applies a new untitled note', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
@@ -11,7 +11,17 @@ interface Tab {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
|
||||
type EditorBlocks = any[]
|
||||
type CachedTabState = { blocks: EditorBlocks; scrollTop: number }
|
||||
type CachedTabState = { blocks: EditorBlocks; scrollTop: number; sourceContent: 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[]
|
||||
@@ -93,15 +103,19 @@ function readEditorScrollTop(): number {
|
||||
function cacheEditorState(
|
||||
cache: Map<string, CachedTabState>,
|
||||
path: string,
|
||||
blocks: EditorBlocks,
|
||||
nextState: CachedTabState,
|
||||
) {
|
||||
cache.set(path, {
|
||||
blocks,
|
||||
scrollTop: readEditorScrollTop(),
|
||||
})
|
||||
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(preprocessed: string): EditorBlocks | null {
|
||||
function buildFastPathBlocks(options: { preprocessed: string }): EditorBlocks | null {
|
||||
const { preprocessed } = options
|
||||
const trimmed = preprocessed.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
@@ -124,11 +138,13 @@ function buildFastPathBlocks(preprocessed: string): EditorBlocks | null {
|
||||
]
|
||||
}
|
||||
|
||||
function isBlankBodyContent(content: string): boolean {
|
||||
function isBlankBodyContent(options: { content: string }): boolean {
|
||||
const { content } = options
|
||||
return extractEditorBody(content).trim() === ''
|
||||
}
|
||||
|
||||
function extractBodyRemainderAfterEmptyH1(content: string): string | null {
|
||||
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
|
||||
@@ -166,23 +182,22 @@ async function resolveBlocksForTarget(
|
||||
content: string,
|
||||
): Promise<CachedTabState> {
|
||||
const cached = cache.get(targetPath)
|
||||
if (cached) return cached
|
||||
if (cached?.sourceContent === content) return cached
|
||||
|
||||
const body = extractEditorBody(content)
|
||||
const preprocessed = preProcessWikilinks(body)
|
||||
const fastPathBlocks = buildFastPathBlocks(preprocessed)
|
||||
const fastPathBlocks = buildFastPathBlocks({ preprocessed })
|
||||
if (fastPathBlocks) {
|
||||
const nextState = { blocks: fastPathBlocks, scrollTop: 0 }
|
||||
cache.set(targetPath, nextState)
|
||||
const nextState = { blocks: fastPathBlocks, scrollTop: 0, sourceContent: content }
|
||||
cacheEditorState(cache, 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 }
|
||||
const nextState = { blocks: withWikilinks, scrollTop: 0, sourceContent: content }
|
||||
cacheEditorState(cache, targetPath, nextState)
|
||||
return nextState
|
||||
}
|
||||
|
||||
function applyBlocksToEditor(
|
||||
@@ -262,7 +277,7 @@ async function resolveEmptyHeadingHtml(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
content: string,
|
||||
): Promise<string | null> {
|
||||
const remainder = extractBodyRemainderAfterEmptyH1(content)
|
||||
const remainder = extractBodyRemainderAfterEmptyH1({ content })
|
||||
if (remainder === null) return null
|
||||
if (!remainder.trim()) return '<h1></h1><p></p>'
|
||||
|
||||
@@ -271,7 +286,11 @@ async function resolveEmptyHeadingHtml(
|
||||
return `<h1></h1>${editor.blocksToHTMLLossy(withWikilinks as typeof parsed)}`
|
||||
}
|
||||
|
||||
function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined {
|
||||
function findActiveTab(options: {
|
||||
tabs: Tab[]
|
||||
activeTabPath: string | null
|
||||
}): Tab | undefined {
|
||||
const { tabs, activeTabPath } = options
|
||||
return activeTabPath
|
||||
? tabs.find(tab => tab.entry.path === activeTabPath)
|
||||
: undefined
|
||||
@@ -282,11 +301,16 @@ function serializeEditorBody(editor: ReturnType<typeof useCreateBlockNote>): str
|
||||
return compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof editor.document))
|
||||
}
|
||||
|
||||
function normalizeTabBody(content: string): string {
|
||||
function normalizeTabBody(options: { content: string }): string {
|
||||
const { content } = options
|
||||
return compactMarkdown(extractEditorBody(content))
|
||||
}
|
||||
|
||||
function renameBodiesOverlap(currentBody: string, nextBody: string): boolean {
|
||||
function renameBodiesOverlap(options: {
|
||||
currentBody: string
|
||||
nextBody: string
|
||||
}): boolean {
|
||||
const { currentBody, nextBody } = options
|
||||
const current = currentBody.trimEnd()
|
||||
const next = nextBody.trimEnd()
|
||||
return current === next
|
||||
@@ -305,10 +329,10 @@ function isUntitledRenameTransition(
|
||||
const currentHeading = getH1TextFromBlocks(editor.document)
|
||||
if (!currentHeading || slugifyPathStem(currentHeading) !== pathStem(nextPath)) return false
|
||||
|
||||
return renameBodiesOverlap(
|
||||
serializeEditorBody(editor),
|
||||
normalizeTabBody(activeTab.content),
|
||||
)
|
||||
return renameBodiesOverlap({
|
||||
currentBody: serializeEditorBody(editor),
|
||||
nextBody: normalizeTabBody({ content: activeTab.content }),
|
||||
})
|
||||
}
|
||||
|
||||
function useLatestRef<T>(value: T): MutableRefObject<T> {
|
||||
@@ -382,21 +406,27 @@ function consumeRawModeTransition(
|
||||
|
||||
function cachePreviousTabOnPathChange(options: {
|
||||
prevPath: string | null
|
||||
previousTab: Tab | undefined
|
||||
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)
|
||||
const { prevPath, previousTab, pathChanged, editorMountedRef, cache, editor } = options
|
||||
if (!prevPath || !previousTab || !pathChanged || !editorMountedRef.current) return
|
||||
cacheEditorState(cache, prevPath, {
|
||||
blocks: editor.document,
|
||||
scrollTop: readEditorScrollTop(),
|
||||
sourceContent: previousTab.content,
|
||||
})
|
||||
}
|
||||
|
||||
function shouldWaitForActiveTab(
|
||||
pathChanged: boolean,
|
||||
activeTabPath: string | null,
|
||||
activeTab: Tab | undefined,
|
||||
) {
|
||||
function shouldWaitForActiveTab(options: {
|
||||
pathChanged: boolean
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
}) {
|
||||
const { pathChanged, activeTabPath, activeTab } = options
|
||||
return pathChanged && !!activeTabPath && !activeTab
|
||||
}
|
||||
|
||||
@@ -405,6 +435,7 @@ function syncActivePathTransition(options: {
|
||||
pathChanged: boolean
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
previousTab: Tab | undefined
|
||||
cache: Map<string, CachedTabState>
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
@@ -415,14 +446,22 @@ function syncActivePathTransition(options: {
|
||||
pathChanged,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
previousTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
} = options
|
||||
|
||||
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
|
||||
if (shouldWaitForActiveTab(pathChanged, activeTabPath, activeTab)) return true
|
||||
cachePreviousTabOnPathChange({
|
||||
prevPath,
|
||||
previousTab,
|
||||
pathChanged,
|
||||
editorMountedRef,
|
||||
cache,
|
||||
editor,
|
||||
})
|
||||
if (shouldWaitForActiveTab({ pathChanged, activeTabPath, activeTab })) return true
|
||||
|
||||
if (!preserveUntitledRenameState({
|
||||
prevPath,
|
||||
@@ -495,7 +534,11 @@ function cacheStableActivePath(options: {
|
||||
} = options
|
||||
|
||||
if (!activeTabPath || !activeTab || !editorMountedRef.current) return
|
||||
cacheEditorState(cache, activeTabPath, editor.document)
|
||||
cacheEditorState(cache, activeTabPath, {
|
||||
blocks: editor.document,
|
||||
scrollTop: readEditorScrollTop(),
|
||||
sourceContent: activeTab.content,
|
||||
})
|
||||
}
|
||||
|
||||
function preserveUntitledRenameState(options: {
|
||||
@@ -530,15 +573,21 @@ function preserveUntitledRenameState(options: {
|
||||
return true
|
||||
}
|
||||
|
||||
function signalTabSwap(path: string) {
|
||||
function signalTabSwap(options: { path: string }) {
|
||||
const { path } = options
|
||||
requestAnimationFrame(() => signalEditorTabSwapped(path))
|
||||
}
|
||||
|
||||
function clearStaleSwap(
|
||||
targetPath: string,
|
||||
function clearStaleSwap(options: {
|
||||
targetPath: string
|
||||
prevActivePathRef: MutableRefObject<string | null>,
|
||||
suppressChangeRef: MutableRefObject<boolean>,
|
||||
): boolean {
|
||||
}): boolean {
|
||||
const {
|
||||
targetPath,
|
||||
prevActivePathRef,
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
if (prevActivePathRef.current === targetPath) return false
|
||||
suppressChangeRef.current = false
|
||||
return true
|
||||
@@ -547,19 +596,25 @@ function clearStaleSwap(
|
||||
function applyBlankTabState(options: {
|
||||
cache: Map<string, CachedTabState>
|
||||
targetPath: string
|
||||
content: string
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
const {
|
||||
cache,
|
||||
targetPath,
|
||||
content,
|
||||
editor,
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
|
||||
cache.set(targetPath, { blocks: blankParagraphBlocks(), scrollTop: 0 })
|
||||
cacheEditorState(cache, targetPath, {
|
||||
blocks: blankParagraphBlocks(),
|
||||
scrollTop: 0,
|
||||
sourceContent: content,
|
||||
})
|
||||
applyBlankStateToEditor(editor, suppressChangeRef)
|
||||
signalTabSwap(targetPath)
|
||||
signalTabSwap({ path: targetPath })
|
||||
}
|
||||
|
||||
function scheduleEmptyHeadingSwap(options: {
|
||||
@@ -577,13 +632,13 @@ function scheduleEmptyHeadingSwap(options: {
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
|
||||
if (extractBodyRemainderAfterEmptyH1(content) === null) return false
|
||||
if (extractBodyRemainderAfterEmptyH1({ content }) === null) return false
|
||||
|
||||
void resolveEmptyHeadingHtml(editor, content)
|
||||
.then((html) => {
|
||||
if (prevActivePathRef.current !== targetPath || !html) return
|
||||
applyHtmlStateToEditor(editor, html, suppressChangeRef)
|
||||
signalTabSwap(targetPath)
|
||||
signalTabSwap({ path: targetPath })
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
suppressChangeRef.current = false
|
||||
@@ -614,7 +669,7 @@ function scheduleParsedBlockSwap(options: {
|
||||
.then(({ blocks, scrollTop }) => {
|
||||
if (prevActivePathRef.current !== targetPath) return
|
||||
applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef)
|
||||
signalTabSwap(targetPath)
|
||||
signalTabSwap({ path: targetPath })
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
suppressChangeRef.current = false
|
||||
@@ -646,11 +701,17 @@ function scheduleTabSwap(options: {
|
||||
suppressChangeRef.current = true
|
||||
|
||||
const doSwap = () => {
|
||||
if (clearStaleSwap(targetPath, prevActivePathRef, suppressChangeRef)) return
|
||||
if (clearStaleSwap({ targetPath, prevActivePathRef, suppressChangeRef })) return
|
||||
rawSwapPendingRef.current = false
|
||||
|
||||
if (isBlankBodyContent(activeTab.content)) {
|
||||
applyBlankTabState({ cache, targetPath, editor, suppressChangeRef })
|
||||
if (isBlankBodyContent({ content: activeTab.content })) {
|
||||
applyBlankTabState({
|
||||
cache,
|
||||
targetPath,
|
||||
content: activeTab.content,
|
||||
editor,
|
||||
suppressChangeRef,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -681,6 +742,75 @@ function scheduleTabSwap(options: {
|
||||
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>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
activeTabPath,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
rawSwapPendingRef,
|
||||
} = options
|
||||
|
||||
if (syncActivePathTransition({
|
||||
prevPath: state.prevPath,
|
||||
pathChanged: state.pathChanged,
|
||||
activeTabPath,
|
||||
activeTab: state.activeTab,
|
||||
previousTab: state.previousTab,
|
||||
cache: state.cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
})) {
|
||||
return true
|
||||
}
|
||||
|
||||
return handleStableActivePath({
|
||||
pathChanged: state.pathChanged,
|
||||
rawModeJustEnded: state.rawModeJustEnded,
|
||||
activeTabPath,
|
||||
activeTab: state.activeTab,
|
||||
cache: state.cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
rawSwapPendingRef,
|
||||
})
|
||||
}
|
||||
|
||||
function runTabSwapEffect(options: {
|
||||
tabs: Tab[]
|
||||
activeTabPath: string | null
|
||||
@@ -708,46 +838,34 @@ function runTabSwapEffect(options: {
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
|
||||
const cache = tabCacheRef.current
|
||||
const prevPath = prevActivePathRef.current
|
||||
const pathChanged = prevPath !== activeTabPath
|
||||
const activeTab = findActiveTab(tabs, activeTabPath)
|
||||
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
|
||||
|
||||
if (rawMode) return
|
||||
if (syncActivePathTransition({
|
||||
prevPath,
|
||||
pathChanged,
|
||||
const state = resolveTabSwapState({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
tabCacheRef,
|
||||
prevActivePathRef,
|
||||
rawModeJustEnded,
|
||||
})
|
||||
|
||||
if (shouldSkipScheduledTabSwap({
|
||||
state,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleStableActivePath({
|
||||
pathChanged,
|
||||
rawModeJustEnded,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
rawSwapPendingRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeTabPath || !activeTab) return
|
||||
if (!activeTabPath || !state.activeTab) return
|
||||
|
||||
scheduleTabSwap({
|
||||
editor,
|
||||
cache,
|
||||
cache: state.cache,
|
||||
targetPath: activeTabPath,
|
||||
activeTab,
|
||||
activeTab: state.activeTab,
|
||||
pendingSwapRef,
|
||||
prevActivePathRef,
|
||||
rawSwapPendingRef,
|
||||
@@ -811,23 +929,6 @@ function useTabSwapEffect(options: {
|
||||
])
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -871,7 +972,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
})
|
||||
useTabCacheCleanup(tabs, tabCacheRef)
|
||||
|
||||
return { handleEditorChange, editorMountedRef }
|
||||
}
|
||||
|
||||
@@ -266,6 +266,20 @@ describe('useTabManagement (single-note model)', () => {
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('activates a warmed note immediately while reusing the cached content', async () => {
|
||||
cacheNoteContent('/vault/note/warm.md', '# Warm content')
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
act(() => {
|
||||
void result.current.handleSelectNote(makeEntry({ path: '/vault/note/warm.md', title: 'Warm' }))
|
||||
})
|
||||
|
||||
expect(result.current.activeTabPath).toBe('/vault/note/warm.md')
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].content).toBe('# Warm content')
|
||||
})
|
||||
|
||||
it('reuses cached content when reopening a recently loaded note', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke)
|
||||
|
||||
@@ -14,14 +14,15 @@ type NotePath = VaultEntry['path']
|
||||
// Stores in-flight or recently loaded note content promises, keyed by path.
|
||||
// Cleared on vault reload to prevent stale content after external edits.
|
||||
// Latency profile: deduplicates rapid note switches and keeps revisits instant.
|
||||
const prefetchCache = new Map<string, Promise<string>>()
|
||||
const NOTE_CONTENT_CACHE_LIMIT = 48
|
||||
|
||||
interface NoteContentCacheEntry {
|
||||
path: NotePath
|
||||
promise: Promise<string>
|
||||
value: string | null
|
||||
}
|
||||
|
||||
const prefetchCache = new Map<string, NoteContentCacheEntry>()
|
||||
const NOTE_CONTENT_CACHE_LIMIT = 48
|
||||
|
||||
function trimPrefetchCache(): void {
|
||||
while (prefetchCache.size > NOTE_CONTENT_CACHE_LIMIT) {
|
||||
const oldestPath = prefetchCache.keys().next().value
|
||||
@@ -30,23 +31,35 @@ function trimPrefetchCache(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function rememberNoteContent({ path, promise }: NoteContentCacheEntry): Promise<string> {
|
||||
function rememberNoteContent(entry: NoteContentCacheEntry): NoteContentCacheEntry {
|
||||
const { path } = entry
|
||||
if (prefetchCache.has(path)) prefetchCache.delete(path)
|
||||
prefetchCache.set(path, promise)
|
||||
prefetchCache.set(path, entry)
|
||||
trimPrefetchCache()
|
||||
return promise
|
||||
return entry
|
||||
}
|
||||
|
||||
function requestNoteContent({ path }: Pick<NoteContentCacheEntry, 'path'>): Promise<string> {
|
||||
function requestNoteContent({ path }: Pick<NoteContentCacheEntry, 'path'>): NoteContentCacheEntry {
|
||||
const cacheEntry: NoteContentCacheEntry = {
|
||||
path,
|
||||
promise: Promise.resolve(''),
|
||||
value: null,
|
||||
}
|
||||
const promise = (isTauri()
|
||||
? invoke<string>('get_note_content', { path })
|
||||
: mockInvoke<string>('get_note_content', { path })
|
||||
).catch((err) => {
|
||||
prefetchCache.delete(path)
|
||||
throw err
|
||||
})
|
||||
)
|
||||
.then((content) => {
|
||||
cacheEntry.value = content
|
||||
return content
|
||||
})
|
||||
.catch((err) => {
|
||||
prefetchCache.delete(path)
|
||||
throw err
|
||||
})
|
||||
|
||||
return rememberNoteContent({ path, promise })
|
||||
cacheEntry.promise = promise
|
||||
return rememberNoteContent(cacheEntry)
|
||||
}
|
||||
|
||||
/** Prefetch a note's content into the in-memory cache.
|
||||
@@ -58,7 +71,11 @@ export function prefetchNoteContent(path: string): void {
|
||||
}
|
||||
|
||||
export function cacheNoteContent(path: string, content: string): void {
|
||||
rememberNoteContent({ path, promise: Promise.resolve(content) })
|
||||
rememberNoteContent({
|
||||
path,
|
||||
promise: Promise.resolve(content),
|
||||
value: content,
|
||||
})
|
||||
}
|
||||
|
||||
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
|
||||
@@ -66,8 +83,12 @@ export function clearPrefetchCache(): void {
|
||||
prefetchCache.clear()
|
||||
}
|
||||
|
||||
function getCachedNoteContent(path: string): string | null {
|
||||
return prefetchCache.get(path)?.value ?? null
|
||||
}
|
||||
|
||||
async function loadNoteContent(path: string): Promise<string> {
|
||||
return prefetchCache.get(path) ?? requestNoteContent({ path })
|
||||
return prefetchCache.get(path)?.promise ?? requestNoteContent({ path }).promise
|
||||
}
|
||||
|
||||
export type { Tab }
|
||||
@@ -102,6 +123,76 @@ function isAlreadyViewingPath(
|
||||
return activeTabPathRef.current === path || tabsRef.current.some((tab) => tab.entry.path === path)
|
||||
}
|
||||
|
||||
function startEntryNavigation(options: {
|
||||
entry: VaultEntry
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
tabsRef: React.MutableRefObject<Tab[]>
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
||||
}) {
|
||||
const {
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
} = options
|
||||
|
||||
const seq = ++navSeqRef.current
|
||||
const cachedContent = getCachedNoteContent(entry.path)
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
if (cachedContent !== null) {
|
||||
setSingleTab(tabsRef, setTabs, { entry, content: cachedContent })
|
||||
}
|
||||
|
||||
return { seq, cachedContent }
|
||||
}
|
||||
|
||||
function shouldApplyLoadedEntry(options: {
|
||||
seq: number
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
cachedContent: string | null
|
||||
content: string
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
path: string
|
||||
}) {
|
||||
const {
|
||||
seq,
|
||||
navSeqRef,
|
||||
cachedContent,
|
||||
content,
|
||||
activeTabPathRef,
|
||||
path,
|
||||
} = options
|
||||
|
||||
if (navSeqRef.current !== seq) return false
|
||||
return cachedContent !== content || activeTabPathRef.current !== path
|
||||
}
|
||||
|
||||
function handleEntryLoadFailure(options: {
|
||||
entry: VaultEntry
|
||||
seq: number
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
tabsRef: React.MutableRefObject<Tab[]>
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
error: unknown
|
||||
}) {
|
||||
const {
|
||||
entry,
|
||||
seq,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
setTabs,
|
||||
error,
|
||||
} = options
|
||||
|
||||
console.warn('Failed to load note content:', error)
|
||||
if (navSeqRef.current !== seq) return
|
||||
setSingleTab(tabsRef, setTabs, { entry, content: '' })
|
||||
}
|
||||
|
||||
async function navigateToEntry(options: {
|
||||
entry: VaultEntry
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
@@ -125,17 +216,35 @@ async function navigateToEntry(options: {
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++navSeqRef.current
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
const { seq, cachedContent } = startEntryNavigation({
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
})
|
||||
|
||||
try {
|
||||
const content = await loadNoteContent(entry.path)
|
||||
if (navSeqRef.current !== seq) return
|
||||
if (!shouldApplyLoadedEntry({
|
||||
seq,
|
||||
navSeqRef,
|
||||
cachedContent,
|
||||
content,
|
||||
activeTabPathRef,
|
||||
path: entry.path,
|
||||
})) return
|
||||
setSingleTab(tabsRef, setTabs, { entry, content })
|
||||
} catch (err) {
|
||||
console.warn('Failed to load note content:', err)
|
||||
if (navSeqRef.current !== seq) return
|
||||
setSingleTab(tabsRef, setTabs, { entry, content: '' })
|
||||
handleEntryLoadFailure({
|
||||
entry,
|
||||
seq,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
setTabs,
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user