Merge branch 'main' into main
This commit is contained in:
@@ -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:
|
||||
|
||||
11
src/App.tsx
11
src/App.tsx
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
84
src/components/editorContentFlushRegistration.ts
Normal file
84
src/components/editorContentFlushRegistration.ts
Normal 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])
|
||||
}
|
||||
75
src/hooks/editorChangeDebounce.ts
Normal file
75
src/hooks/editorChangeDebounce.ts
Normal 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()
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
101
tests/smoke/long-note-editor-scaling.spec.ts
Normal file
101
tests/smoke/long-note-editor-scaling.spec.ts
Normal 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())
|
||||
})
|
||||
Reference in New Issue
Block a user