Merge branch 'main' into main

This commit is contained in:
github-actions[bot]
2026-04-29 16:32:08 +00:00
committed by GitHub
10 changed files with 478 additions and 67 deletions

View File

@@ -584,6 +584,8 @@ flowchart LR
style E fill:#d4edda,stroke:#28a745,color:#000
```
Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path.
### Wikilink Navigation
Two navigation mechanisms:

View File

@@ -555,8 +555,10 @@ function App() {
onToast: (msg) => setToastMessage(msg),
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
})
const flushPendingEditorContentRef = useRef<((path: string) => void) | null>(null)
const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null)
const flushEditorStateBeforeAction = async (path: string) => {
flushPendingEditorContentRef.current?.(path)
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
}
@@ -980,10 +982,14 @@ function App() {
}, [handleAppContentChange, recordAutoGitActivity])
const handleTrackedSave = useCallback(async (...args: Parameters<typeof handleAppSave>) => {
if (notes.activeTabPath) {
flushPendingEditorContentRef.current?.(notes.activeTabPath)
flushPendingRawContentRef.current?.(notes.activeTabPath)
}
const result = await handleAppSave(...args)
recordAutoGitActivity()
return result
}, [handleAppSave, recordAutoGitActivity])
}, [handleAppSave, notes.activeTabPath, recordAutoGitActivity])
const seedAutoGitSavedChange = useCallback(async () => {
if (isTauri()) {
@@ -1029,7 +1035,7 @@ function App() {
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
createTypeEntry: notes.createTypeEntrySilent,
onBeforeAction: appSave.flushBeforeAction,
onBeforeAction: flushEditorStateBeforeAction,
})
const deleteActions = useDeleteActions({
@@ -1673,6 +1679,7 @@ function App() {
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingEditorContentRef={flushPendingEditorContentRef}
flushPendingRawContentRef={flushPendingRawContentRef}
locale={appLocale}
/>

View File

@@ -43,6 +43,9 @@ const mockEditor = vi.hoisted(() => ({
const blockNoteCreation = vi.hoisted(() => ({
options: [] as unknown[],
}))
const blockNoteViewState = vi.hoisted(() => ({
onChange: null as (() => void) | null,
}))
// Mock BlockNote components
vi.mock('@blocknote/core', () => ({
@@ -81,16 +84,23 @@ vi.mock('@blocknote/react', () => ({
ComponentsContext: {
Provider: ({ children }: PropsWithChildren) => <>{children}</>,
},
BlockNoteViewRaw: ({ children, editable }: PropsWithChildren<{ editable?: boolean }>) => (
<div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>
<div
contentEditable={editable !== false}
data-testid="blocknote-editable"
suppressContentEditableWarning
/>
{children}
</div>
),
BlockNoteViewRaw: ({
children,
editable,
onChange,
}: PropsWithChildren<{ editable?: boolean; onChange?: () => void }>) => {
blockNoteViewState.onChange = onChange ?? null
return (
<div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>
<div
contentEditable={editable !== false}
data-testid="blocknote-editable"
suppressContentEditableWarning
/>
{children}
</div>
)
},
FormattingToolbarController: () => null,
LinkToolbarController: () => null,
EditLinkButton: () => null,
@@ -224,6 +234,7 @@ function renderEditor(overrides: Partial<EditorComponentProps> = {}) {
describe('Editor', () => {
beforeEach(() => {
blockNoteCreation.options = []
blockNoteViewState.onChange = null
})
it('shows empty state when no tabs are open', () => {
@@ -387,6 +398,46 @@ describe('Editor', () => {
})
})
it('registers a rich-editor flush hook for pending BlockNote changes', async () => {
const onContentChange = vi.fn()
const flushPendingEditorContentRef = { current: null as ((path: string) => void) | null }
const originalMarkdownSerializer = mockEditor.blocksToMarkdownLossy.getMockImplementation()
mockEditor.replaceBlocks.mockClear()
try {
renderEditor({
tabs: [mockTab],
activeTabPath: mockEntry.path,
onContentChange,
flushPendingEditorContentRef,
})
await vi.waitFor(() => {
expect(blockNoteViewState.onChange).toEqual(expect.any(Function))
expect(flushPendingEditorContentRef.current).toEqual(expect.any(Function))
})
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) })
mockEditor.blocksToMarkdownLossy.mockReturnValueOnce('# Test Project\n\nEdited rich body.\n')
act(() => {
blockNoteViewState.onChange?.()
})
expect(onContentChange).not.toHaveBeenCalled()
act(() => {
flushPendingEditorContentRef.current?.(mockEntry.path)
})
expect(onContentChange).toHaveBeenCalledWith(
mockEntry.path,
expect.stringContaining('Edited rich body.'),
)
} finally {
mockEditor.blocksToMarkdownLossy.mockImplementation(originalMarkdownSerializer)
}
})
it('disables native text assistance on the rich editor editable surface', () => {
renderEditor({
tabs: [mockTab],

View File

@@ -25,6 +25,7 @@ import {
resolvePendingRawExitContent,
resolveRawModeContent,
} from './editorRawModeSync'
import { useRegisterEditorContentFlushes } from './editorContentFlushRegistration'
import { useRawModeWithFlush } from './useRawModeWithFlush'
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
import { createMathInputExtension } from './mathInputExtension'
@@ -104,6 +105,8 @@ interface EditorProps {
onKeepMine?: (path: string) => void
/** Resolve conflict by keeping the remote version. */
onKeepTheirs?: (path: string) => void
/** Registers a hook that flushes pending rich-editor changes into app state before external actions. */
flushPendingEditorContentRef?: React.MutableRefObject<((path: string) => void) | null>
/** Registers a hook that flushes the raw editor buffer into app state before external actions. */
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
locale?: AppLocale
@@ -219,7 +222,7 @@ function useEditorSetup({
}))
}, [activeTabPath, setPendingRawExitContent, tabs])
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
const { handleEditorChange, flushPendingEditorChange, editorMountedRef } = useEditorTabSwap({
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode, vaultPath,
})
useEditorFocus(editor, editorMountedRef)
@@ -244,45 +247,11 @@ function useEditorSetup({
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
handleEditorChange, flushPendingEditorChange, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
}
}
function useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
}: {
activeTab: Tab | null
rawLatestContentRef: React.MutableRefObject<string | null>
rawMode: boolean
onContentChange?: (path: string, content: string) => void
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
}) {
const flushPendingRawContent = useCallback((path: string) => {
if (!rawMode || !activeTab || activeTab.entry.path !== path) return
const latestContent = rawLatestContentRef.current
if (latestContent === null || latestContent === activeTab.content) return
onContentChange?.(path, latestContent)
}, [activeTab, onContentChange, rawLatestContentRef, rawMode])
useEffect(() => {
if (!flushPendingRawContentRef) return
flushPendingRawContentRef.current = flushPendingRawContent
return () => {
if (flushPendingRawContentRef.current === flushPendingRawContent) {
flushPendingRawContentRef.current = null
}
}
}, [flushPendingRawContent, flushPendingRawContentRef])
}
function useEditorFindCommand({
activeTab,
findInNoteRef,
@@ -558,7 +527,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
noteLayout, onToggleNoteLayout,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
flushPendingRawContentRef, findInNoteRef,
flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef,
locale,
} = props
@@ -566,7 +535,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
handleEditorChange, flushPendingEditorChange, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
} = useEditorSetup({
tabs, activeTabPath, vaultPath, onContentChange,
@@ -584,8 +553,10 @@ export const Editor = memo(function Editor(props: EditorProps) {
rawMode,
})
useRegisterRawContentFlush({
useRegisterEditorContentFlushes({
activeTab,
flushPendingEditorChange,
flushPendingEditorContentRef,
rawLatestContentRef,
rawMode,
onContentChange,

View File

@@ -0,0 +1,84 @@
import { useCallback, useEffect } from 'react'
import type React from 'react'
type Tab = {
entry: { path: string }
content: string
}
export function useRegisterEditorContentFlushes({
activeTab,
flushPendingEditorChange,
flushPendingEditorContentRef,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
}: {
activeTab: Tab | null
flushPendingEditorChange: () => boolean
flushPendingEditorContentRef?: React.MutableRefObject<((path: string) => void) | null>
rawLatestContentRef: React.MutableRefObject<string | null>
rawMode: boolean
onContentChange?: (path: string, content: string) => void
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
}) {
useRegisterRichContentFlush({ activeTab, flushPendingEditorChange, flushPendingEditorContentRef })
useRegisterRawContentFlush({ activeTab, rawLatestContentRef, rawMode, onContentChange, flushPendingRawContentRef })
}
function useRegisterRichContentFlush({
activeTab,
flushPendingEditorChange,
flushPendingEditorContentRef,
}: {
activeTab: Tab | null
flushPendingEditorChange: () => boolean
flushPendingEditorContentRef?: React.MutableRefObject<((path: string) => void) | null>
}) {
const flushPendingEditorContent = useCallback((path: string) => {
if (!activeTab || activeTab.entry.path !== path) return
flushPendingEditorChange()
}, [activeTab, flushPendingEditorChange])
useRegisteredFlushRef(flushPendingEditorContentRef, flushPendingEditorContent)
}
function useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
}: {
activeTab: Tab | null
rawLatestContentRef: React.MutableRefObject<string | null>
rawMode: boolean
onContentChange?: (path: string, content: string) => void
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
}) {
const flushPendingRawContent = useCallback((path: string) => {
if (!rawMode || !activeTab || activeTab.entry.path !== path) return
const latestContent = rawLatestContentRef.current
if (latestContent === null || latestContent === activeTab.content) return
onContentChange?.(path, latestContent)
}, [activeTab, onContentChange, rawLatestContentRef, rawMode])
useRegisteredFlushRef(flushPendingRawContentRef, flushPendingRawContent)
}
function useRegisteredFlushRef(
ref: React.MutableRefObject<((path: string) => void) | null> | undefined,
flush: (path: string) => void,
) {
useEffect(() => {
if (!ref) return
ref.current = flush
return () => {
if (ref.current === flush) ref.current = null
}
}, [flush, ref])
}

View File

@@ -0,0 +1,75 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
export const RICH_EDITOR_CHANGE_DEBOUNCE_MS = 150
export function useDebouncedEditorChange({
onFlush,
suppressChangeRef,
}: {
onFlush: () => void
suppressChangeRef: MutableRefObject<boolean>
}) {
const pendingRef = useRef(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearTimer = useCallback(() => {
if (!timerRef.current) return
clearTimeout(timerRef.current)
timerRef.current = null
}, [])
const flushPendingEditorChange = useCallback(() => {
if (!pendingRef.current) return false
clearTimer()
pendingRef.current = false
onFlush()
return true
}, [clearTimer, onFlush])
const handleEditorChange = useCallback(() => {
if (suppressChangeRef.current) return
pendingRef.current = true
clearTimer()
timerRef.current = setTimeout(() => {
timerRef.current = null
void flushPendingEditorChange()
}, RICH_EDITOR_CHANGE_DEBOUNCE_MS)
}, [clearTimer, flushPendingEditorChange, suppressChangeRef])
useEffect(() => {
return () => {
void flushPendingEditorChange()
clearTimer()
}
}, [clearTimer, flushPendingEditorChange])
return { handleEditorChange, flushPendingEditorChange }
}
export function consumeRawModeTransition(
prevRawModeRef: MutableRefObject<boolean>,
rawMode: boolean | undefined,
) {
const rawModeJustEnded = prevRawModeRef.current && !rawMode
prevRawModeRef.current = !!rawMode
return rawModeJustEnded
}
export function flushBeforeRawMode(options: {
rawMode?: boolean
flushPendingEditorChange: () => boolean
}) {
const { rawMode, flushPendingEditorChange } = options
if (!rawMode) return false
flushPendingEditorChange()
return true
}
export function flushBeforePathChange(options: {
pathChanged: boolean
flushPendingEditorChange: () => boolean
}) {
const { pathChanged, flushPendingEditorChange } = options
if (pathChanged) flushPendingEditorChange()
}

View File

@@ -105,6 +105,9 @@ async function expectRenameSessionContinues(options: { renamedTabArrivesLate: bo
act(() => {
result.current.handleEditorChange()
})
act(() => {
result.current.flushPendingEditorChange()
})
expect(onContentChange).toHaveBeenCalledWith(
'fresh-title.md',
@@ -171,6 +174,9 @@ describe('useEditorTabSwap untitled rename continuity', () => {
act(() => {
result.current.handleEditorChange()
})
act(() => {
result.current.flushPendingEditorChange()
})
const syncedContent = onContentChange.mock.calls.at(-1)?.[1]
expect(typeof syncedContent).toBe('string')
@@ -211,6 +217,9 @@ describe('useEditorTabSwap untitled rename continuity', () => {
act(() => {
result.current.handleEditorChange()
})
act(() => {
result.current.flushPendingEditorChange()
})
const queryContent = onContentChange.mock.calls.at(-1)?.[1]
expect(typeof queryContent).toBe('string')
@@ -218,6 +227,9 @@ describe('useEditorTabSwap untitled rename continuity', () => {
act(() => {
result.current.handleEditorChange()
})
act(() => {
result.current.flushPendingEditorChange()
})
const insertedContent = onContentChange.mock.calls.at(-1)?.[1]
expect(typeof insertedContent).toBe('string')

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, useEditorTabSwap } from './useEditorTabSwap'
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, RICH_EDITOR_CHANGE_DEBOUNCE_MS, useEditorTabSwap } from './useEditorTabSwap'
import { normalizeParsedImageBlocks } from './editorTabContent'
import { cacheNoteContent, clearPrefetchCache } from './useTabManagement'
@@ -254,6 +254,19 @@ function makeHeadingBlocks(
}]
}
function makeLongNoteBlocks(wordCount: number) {
const words = Array.from({ length: wordCount }, (_, index) => `word${index}`)
const paragraphs: unknown[] = []
for (let index = 0; index < words.length; index += 20) {
paragraphs.push({
type: 'paragraph',
content: [{ type: 'text', text: words.slice(index, index + 20).join(' '), styles: {} }],
children: [],
})
}
return paragraphs
}
async function flushEditorTick() {
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)))
}
@@ -299,6 +312,7 @@ async function createSwapHarness(options: {
return {
...rendered,
docRef,
mockEditor,
async rerenderWith(nextProps: Partial<SwapHarnessProps>) {
currentProps = { ...currentProps, ...nextProps }
@@ -701,12 +715,98 @@ describe('useEditorTabSwap raw mode sync', () => {
result.current.handleEditorChange()
})
expect(onContentChange).not.toHaveBeenCalled()
act(() => {
result.current.flushPendingEditorChange()
})
expect(onContentChange).toHaveBeenCalledWith(
'a.md',
'---\ntitle: Note A\n---\nInline $x^2$\n',
)
})
it('coalesces rich-editor serialization for a 4000-word note', async () => {
const tabA = makeTab('long.md', 'Long Note')
const onContentChange = vi.fn()
const { docRef, mockEditor, result } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'long.md', rawMode: false },
onContentChange,
})
const longBlocks = makeLongNoteBlocks(4200)
docRef.current = longBlocks
mockEditor.blocksToMarkdownLossy.mockImplementation((blocks: unknown[]) => (
(blocks as Array<{ content?: Array<{ text?: string }> }>)
.map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '')
.join('\n\n')
))
mockEditor.blocksToMarkdownLossy.mockClear()
vi.useFakeTimers()
try {
act(() => {
result.current.handleEditorChange()
result.current.handleEditorChange()
result.current.handleEditorChange()
})
expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled()
expect(onContentChange).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(RICH_EDITOR_CHANGE_DEBOUNCE_MS - 1)
})
expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(1)
})
expect(mockEditor.blocksToMarkdownLossy).toHaveBeenCalledTimes(1)
expect(onContentChange).toHaveBeenCalledTimes(1)
expect(onContentChange.mock.calls[0][1].split(/\s+/).length).toBeGreaterThan(4000)
} finally {
vi.useRealTimers()
}
})
it('flushes pending rich-editor content before switching notes', async () => {
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'Note B')
const onContentChange = vi.fn()
const { docRef, mockEditor, rerender, result } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
onContentChange,
})
docRef.current = [{
type: 'paragraph',
content: [{ type: 'text', text: 'Changed before switch', styles: {} }],
children: [],
}]
mockEditor.blocksToMarkdownLossy.mockReturnValue('Changed before switch\n')
vi.useFakeTimers()
try {
act(() => {
result.current.handleEditorChange()
})
expect(onContentChange).not.toHaveBeenCalled()
act(() => {
rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md', rawMode: false })
})
await act(async () => { await Promise.resolve() })
expect(onContentChange).toHaveBeenCalledWith(
'a.md',
'---\ntitle: Note A\n---\nChanged before switch\n',
)
} finally {
vi.useRealTimers()
}
})
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 })

View File

@@ -10,6 +10,12 @@ 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 {
consumeRawModeTransition,
flushBeforePathChange,
flushBeforeRawMode,
useDebouncedEditorChange,
} from './editorChangeDebounce'
import { repairMalformedEditorBlocks } from './editorBlockRepair'
import {
blankParagraphBlocks,
@@ -23,6 +29,7 @@ import {
import { clearEditorDomSelection, EDITOR_CONTAINER_SELECTOR } from './editorDomSelection'
import { resetTextSelectionBeforeContentSwap } from './editorTiptapSelection'
export { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './editorTabContent'
export { RICH_EDITOR_CHANGE_DEBOUNCE_MS } from './editorChangeDebounce'
interface Tab {
entry: VaultEntry
@@ -365,8 +372,7 @@ function useEditorChangeHandler(options: {
vaultPathRef,
} = options
return useCallback(() => {
if (suppressChangeRef.current) return
const propagateEditorChange = useCallback(() => {
const path = prevActivePathRef.current
if (!path) return
@@ -387,16 +393,9 @@ function useEditorChangeHandler(options: {
sourceContent: nextContent,
})
onContentChangeRef.current?.(path, nextContent)
}, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, suppressChangeRef, tabCacheRef, tabsRef, vaultPathRef])
}
}, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, tabCacheRef, tabsRef, vaultPathRef])
function consumeRawModeTransition(
prevRawModeRef: MutableRefObject<boolean>,
rawMode: boolean | undefined,
) {
const rawModeJustEnded = prevRawModeRef.current && !rawMode
prevRawModeRef.current = !!rawMode
return rawModeJustEnded
return useDebouncedEditorChange({ onFlush: propagateEditorChange, suppressChangeRef })
}
function cachePreviousTabOnPathChange(options: {
@@ -983,6 +982,7 @@ function runTabSwapEffect(options: {
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
flushPendingEditorChange: () => boolean
vaultPath?: string
}) {
const {
@@ -998,11 +998,12 @@ function runTabSwapEffect(options: {
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
flushPendingEditorChange,
vaultPath,
} = options
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
if (rawMode) return
if (flushBeforeRawMode({ rawMode, flushPendingEditorChange })) return
const state = resolveTabSwapState({
tabs,
activeTabPath,
@@ -1010,6 +1011,7 @@ function runTabSwapEffect(options: {
prevActivePathRef,
rawModeJustEnded,
})
flushBeforePathChange({ pathChanged: state.pathChanged, flushPendingEditorChange })
if (shouldSkipScheduledTabSwap({
state,
@@ -1053,6 +1055,7 @@ function useTabSwapEffect(options: {
suppressChangeRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
vaultPathRef: MutableRefObject<string | undefined>
flushPendingEditorChange: () => boolean
}) {
const {
tabs,
@@ -1068,6 +1071,7 @@ function useTabSwapEffect(options: {
suppressChangeRef,
pendingLocalContentRef,
vaultPathRef,
flushPendingEditorChange,
} = options
useEffect(() => {
@@ -1084,6 +1088,7 @@ function useTabSwapEffect(options: {
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
flushPendingEditorChange,
vaultPath: vaultPathRef.current,
})
}, [
@@ -1100,6 +1105,7 @@ function useTabSwapEffect(options: {
tabs,
pendingLocalContentRef,
vaultPathRef,
flushPendingEditorChange,
])
}
@@ -1112,7 +1118,8 @@ function useTabSwapEffect(options: {
* - Cleaning up the block cache when tabs are closed
* - Serializing editor blocks → markdown on change (suppressChangeRef)
*
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
* 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())
@@ -1126,7 +1133,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
const onContentChangeRef = useLatestRef(onContentChange)
const tabsRef = useLatestRef(tabs)
const vaultPathRef = useLatestRef(vaultPath)
const handleEditorChange = useEditorChangeHandler({
const { handleEditorChange, flushPendingEditorChange } = useEditorChangeHandler({
editor,
tabsRef,
onContentChangeRef,
@@ -1153,6 +1160,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
suppressChangeRef,
pendingLocalContentRef,
vaultPathRef,
flushPendingEditorChange,
})
usePreparedNotePreload({
eventName: NOTE_CONTENT_CACHE_RESOLVED_EVENT,
@@ -1161,5 +1169,5 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
preparePath: preparePreloadedPath,
})
return { handleEditorChange, editorMountedRef }
return { handleEditorChange, flushPendingEditorChange, editorMountedRef }
}

View File

@@ -0,0 +1,101 @@
import fs from 'fs'
import path from 'path'
import { test, expect, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVaultDesktopHarness,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { sendShortcut } from './helpers'
const LONG_NOTE_TITLE = 'Long Note Scaling QA'
const LONG_NOTE_RELATIVE_PATH = path.join('note', 'long-note-scaling-qa.md')
const PARAGRAPH_COUNT = 82
const WORDS_PER_PARAGRAPH = 50
let tempVaultDir: string
let longNotePath: string
function segmentLabel(index: number): string {
return `Segment ${String(index).padStart(3, '0')}`
}
function makeLongNoteMarkdown(): string {
const paragraphs = Array.from({ length: PARAGRAPH_COUNT }, (_, paragraphIndex) => {
const segment = segmentLabel(paragraphIndex + 1)
const words = Array.from({ length: WORDS_PER_PARAGRAPH }, (_, wordIndex) =>
`scale${String(paragraphIndex + 1).padStart(3, '0')}_${String(wordIndex + 1).padStart(2, '0')}`,
)
return `${segment} ${words.join(' ')}`
})
return `---\ntype: note\n---\n\n# ${LONG_NOTE_TITLE}\n\n${paragraphs.join('\n\n')}\n`
}
async function openNote(page: Page, title: string): Promise<void> {
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.getByText(title, { exact: true }).click()
await expect(page.locator('.bn-editor h1').first()).toHaveText(title, { timeout: 8_000 })
}
async function appendTextToSegment(page: Page, segment: string, text: string): Promise<void> {
const paragraph = page.locator('.bn-editor p').filter({ hasText: segment }).first()
await paragraph.scrollIntoViewIfNeeded()
await paragraph.click()
await page.keyboard.press('End')
await page.keyboard.type(text)
await expect(page.locator('.bn-editor')).toContainText(text.trim(), { timeout: 5_000 })
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(120_000)
tempVaultDir = createFixtureVaultCopy()
longNotePath = path.join(tempVaultDir, LONG_NOTE_RELATIVE_PATH)
fs.writeFileSync(longNotePath, makeLongNoteMarkdown(), 'utf8')
await openFixtureVaultDesktopHarness(page, tempVaultDir, { expectedReadyTitle: LONG_NOTE_TITLE })
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('long rich-editor notes stay editable across typing, wikilinks, save, and note switches', async ({ page }) => {
const beginningEdit = ` beginning edit ${Date.now()}`
const middleEdit = ` middle edit ${Date.now()}`
const endEdit = ` end edit ${Date.now()}`
await openNote(page, LONG_NOTE_TITLE)
await appendTextToSegment(page, segmentLabel(1), beginningEdit)
await appendTextToSegment(page, segmentLabel(41), middleEdit)
await appendTextToSegment(page, segmentLabel(82), endEdit)
const wikilinkParagraph = page.locator('.bn-editor p').filter({ hasText: segmentLabel(41) }).first()
await wikilinkParagraph.click()
await page.keyboard.press('End')
await page.keyboard.type(' [[Alpha')
const wikilinkMenu = page.locator('.wikilink-menu')
await expect(wikilinkMenu).toBeVisible({ timeout: 5_000 })
await expect(wikilinkMenu).toContainText('Alpha Project')
await page.keyboard.press('Enter')
await expect(page.locator('.bn-editor .wikilink').filter({ hasText: 'Alpha Project' }).last()).toBeVisible()
await page.keyboard.press('PageUp')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('PageDown')
await sendShortcut(page, 's', ['Control'])
await openNote(page, 'Note B')
await openNote(page, LONG_NOTE_TITLE)
const editor = page.locator('.bn-editor')
await expect(editor).toContainText(beginningEdit.trim(), { timeout: 5_000 })
await expect(editor).toContainText(middleEdit.trim(), { timeout: 5_000 })
await expect(editor).toContainText(endEdit.trim(), { timeout: 5_000 })
await expect(editor.locator('.wikilink').filter({ hasText: 'Alpha Project' }).last()).toBeVisible()
await expect.poll(() => fs.readFileSync(longNotePath, 'utf8'), { timeout: 5_000 }).toContain(beginningEdit.trim())
await expect.poll(() => fs.readFileSync(longNotePath, 'utf8'), { timeout: 5_000 }).toContain(middleEdit.trim())
await expect.poll(() => fs.readFileSync(longNotePath, 'utf8'), { timeout: 5_000 }).toContain(endEdit.trim())
})