diff --git a/src/components/useEditorModePositionSync.test.tsx b/src/components/useEditorModePositionSync.test.tsx index 0ad024d3..bcb9254c 100644 --- a/src/components/useEditorModePositionSync.test.tsx +++ b/src/components/useEditorModePositionSync.test.tsx @@ -162,4 +162,66 @@ describe('useEditorModePositionSync', () => { expect(editor.setTextCursorPosition).toHaveBeenCalledWith('details', 'end') expect(editor.focus).toHaveBeenCalled() }) + + it('cancels a pending BlockNote restore when raw mode starts again before the frame runs', () => { + const editor = makeEditor() + const paragraphOffset = content.indexOf('Paragraph one') + 5 + const pendingRawRestoreRef = { current: null as CodeMirrorRestoreState | null } + const pendingRoundTripRawRestoreRef = { current: null as { path: string; state: CodeMirrorRestoreState } | null } + const pendingRichRestoreRef = { current: null as RawEditorPositionSnapshot | null } + const frames = new Map() + let nextFrame = 1 + vi.mocked(window.requestAnimationFrame).mockImplementation((callback: FrameRequestCallback) => { + const id = nextFrame + nextFrame += 1 + frames.set(id, callback) + return id + }) + installRawView({ + state: { + doc: { toString: () => content }, + selection: { main: { anchor: paragraphOffset, head: paragraphOffset } }, + }, + scrollDOM: { scrollTop: 24 }, + dispatch: vi.fn(), + focus: vi.fn(), + }) + + const { result, rerender } = renderHook( + ({ rawMode }) => { + useEditorModePositionSync({ + activeTabPath: 'note.md', + editor: editor as never, + pendingRawRestoreRef, + pendingRoundTripRawRestoreRef, + pendingRichRestoreRef, + rawMode, + }) + return { pendingRawRestoreRef, pendingRoundTripRawRestoreRef, pendingRichRestoreRef } + }, + { initialProps: { rawMode: true } }, + ) + + act(() => { + result.current.pendingRichRestoreRef.current = captureRawEditorPositionSnapshot(document) + }) + rerender({ rawMode: false }) + act(() => { + window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', { + detail: { path: 'note.md' }, + })) + }) + const restoreFrame = frames.get(1) + expect(restoreFrame).toBeDefined() + + rerender({ rawMode: true }) + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(1) + + act(() => { + restoreFrame?.(0) + }) + + expect(editor.setTextCursorPosition).not.toHaveBeenCalled() + expect(editor.focus).not.toHaveBeenCalled() + }) }) diff --git a/src/components/useEditorModePositionSync.ts b/src/components/useEditorModePositionSync.ts index fcc19555..cd3d4bc1 100644 --- a/src/components/useEditorModePositionSync.ts +++ b/src/components/useEditorModePositionSync.ts @@ -62,6 +62,17 @@ function useBlockNoteRestoreEffect({ useEffect(() => { if (rawMode) return + let restoreFrame = 0 + let canceled = false + + const cancelPendingRestore = () => { + canceled = true + if (restoreFrame === 0) return + + window.cancelAnimationFrame(restoreFrame) + restoreFrame = 0 + } + const handleEditorTabSwapped = (event: Event) => { const pendingSnapshot = pendingRichRestoreRef.current if (!activeTabPath || !pendingSnapshot) return @@ -69,7 +80,14 @@ function useBlockNoteRestoreEffect({ const customEvent = event as CustomEvent<{ path: string }> if (customEvent.detail.path !== activeTabPath) return - window.requestAnimationFrame(() => { + if (restoreFrame !== 0) { + window.cancelAnimationFrame(restoreFrame) + } + + restoreFrame = window.requestAnimationFrame(() => { + restoreFrame = 0 + if (canceled) return + restoreBlockNoteView(editor, pendingSnapshot, document) pendingRoundTripRawRestoreRef.current = null pendingRichRestoreRef.current = null @@ -78,6 +96,7 @@ function useBlockNoteRestoreEffect({ window.addEventListener('laputa:editor-tab-swapped', handleEditorTabSwapped) return () => { + cancelPendingRestore() window.removeEventListener('laputa:editor-tab-swapped', handleEditorTabSwapped) } }, [activeTabPath, editor, pendingRichRestoreRef, pendingRoundTripRawRestoreRef, rawMode]) diff --git a/src/components/useRawModeWithFlush.test.tsx b/src/components/useRawModeWithFlush.test.tsx new file mode 100644 index 00000000..26c418b4 --- /dev/null +++ b/src/components/useRawModeWithFlush.test.tsx @@ -0,0 +1,71 @@ +import { renderHook, act } from '@testing-library/react' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { useRawModeWithFlush } from './useRawModeWithFlush' +import * as store from '../utils/vaultConfigStore' + +const notePath = '/vault/project/test.md' +const originalContent = '# Test\n\nOriginal body\n' +const rawEditedContent = '# Test\n\nEdited in raw mode\n' + +function makeEditor() { + return { + document: [], + blocksToMarkdownLossy: vi.fn(() => originalContent), + } +} + +describe('useRawModeWithFlush', () => { + beforeEach(() => { + store.resetVaultConfigStore() + store.bindVaultConfigStore( + { + zoom: null, + view_mode: null, + editor_mode: null, + tag_colors: null, + status_colors: null, + property_display_modes: null, + }, + vi.fn(), + ) + }) + + afterEach(() => { + store.resetVaultConfigStore() + }) + + it('re-enters raw mode with pending raw edits while tab state is still stale', async () => { + const onContentChange = vi.fn() + const flushPendingEditorChangeRef = { current: vi.fn(() => false) } + const editor = makeEditor() + const { result } = renderHook(() => useRawModeWithFlush( + editor as never, + notePath, + originalContent, + onContentChange, + undefined, + flushPendingEditorChangeRef, + )) + + await act(async () => { + await result.current.handleToggleRaw() + }) + act(() => { + result.current.rawLatestContentRef.current = rawEditedContent + }) + + await act(async () => { + await result.current.handleToggleRaw() + }) + await act(async () => { + await result.current.handleToggleRaw() + }) + + expect(onContentChange).toHaveBeenCalledWith(notePath, rawEditedContent) + expect(result.current.rawLatestContentRef.current).toBe(rawEditedContent) + expect(result.current.rawModeContentOverride).toEqual({ + path: notePath, + content: rawEditedContent, + }) + }) +}) diff --git a/src/components/useRawModeWithFlush.ts b/src/components/useRawModeWithFlush.ts index 423e6e72..19cb1b16 100644 --- a/src/components/useRawModeWithFlush.ts +++ b/src/components/useRawModeWithFlush.ts @@ -73,6 +73,21 @@ function capturePendingRoundTripRawRestore(activeTabPath: string | null): Pendin : null } +function resolveActiveTabContent({ + activeTabContent, + activeTabPath, + pendingRawExitContent, +}: { + activeTabContent: string | null + activeTabPath: string | null + pendingRawExitContent: PendingRawExitContent | null +}) { + if (activeTabPath && pendingRawExitContent?.path === activeTabPath) { + return pendingRawExitContent.content + } + return activeTabContent +} + function useTrackRawBuffer({ activeTabContent, activeTabPath, @@ -285,16 +300,21 @@ export function useRawModeWithFlush( const pendingRoundTripRawRestoreRef = useRef(null) const [pendingRawExitContent, setPendingRawExitContent] = useState(null) const [rawModeContentOverride, setRawModeContentOverride] = useState(null) - useTrackRawBuffer({ + const effectiveActiveTabContent = resolveActiveTabContent({ activeTabContent, activeTabPath, + pendingRawExitContent, + }) + useTrackRawBuffer({ + activeTabContent: effectiveActiveTabContent, + activeTabPath, rawInitialContentRef, rawBufferPathRef, rawLatestContentRef, rawSourceContentRef, }) useSyncRawModeContentOverride({ - activeTabContent, + activeTabContent: effectiveActiveTabContent, activeTabPath, rawSourceContentRef, setRawModeContentOverride, @@ -303,7 +323,7 @@ export function useRawModeWithFlush( const handleFlushPending = useHandleFlushPending({ editor, activeTabPath, - activeTabContent, + activeTabContent: effectiveActiveTabContent, rawInitialContentRef, rawLatestContentRef, rawSourceContentRef, @@ -315,7 +335,7 @@ export function useRawModeWithFlush( }) const handleBeforeRawEnd = useHandleBeforeRawEnd({ activeTabPath, - activeTabContent, + activeTabContent: effectiveActiveTabContent, onContentChange, rawInitialContentRef, rawBufferPathRef, diff --git a/tests/smoke/h1-untitled-auto-rename.spec.ts b/tests/smoke/h1-untitled-auto-rename.spec.ts index 51179728..bd97c0ba 100644 --- a/tests/smoke/h1-untitled-auto-rename.spec.ts +++ b/tests/smoke/h1-untitled-auto-rename.spec.ts @@ -1,7 +1,8 @@ import fs from 'fs' import { test, expect, type Page } from '@playwright/test' +import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog' import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault' -import { triggerMenuCommand } from './testBridge' +import { triggerMenuCommand, triggerShortcutCommand } from './testBridge' function markdownFiles(vaultPath: string): string[] { return fs.readdirSync(vaultPath).filter((name) => name.endsWith('.md')).sort() @@ -118,13 +119,53 @@ async function expectStableEmptyTitleHeading(page: Page): Promise { async function activeSelectionBlockType(page: Page): Promise { return page.evaluate(() => { - const selection = window.getSelection() - const anchorNode = selection?.anchorNode ?? null - const anchorElement = anchorNode instanceof Element ? anchorNode : anchorNode?.parentElement ?? null - return anchorElement?.closest('.bn-block-content')?.getAttribute('data-content-type') ?? null + const anchorNode = window.getSelection()?.anchorNode + if (!anchorNode) return null + + const anchorElement = anchorNode instanceof Element ? anchorNode : anchorNode.parentElement + if (!anchorElement) return null + + return anchorElement.closest('.bn-block-content')?.getAttribute('data-content-type') ?? null }) } +async function getRawEditorContent(page: Page): Promise { + return page.evaluate(() => { + const host = document.querySelector('[data-testid="raw-editor-codemirror"]') as (Element & { + __cmView?: { state: { doc: { toString: () => string } } } + }) | null + const view = host?.__cmView + if (!view) throw new Error('CodeMirror view is missing') + return view.state.doc.toString() + }) +} + +async function setRawEditorContent(page: Page, content: string): Promise { + await page.evaluate((nextContent) => { + const host = document.querySelector('[data-testid="raw-editor-codemirror"]') as (Element & { + __cmView?: { + state: { doc: { toString: () => string } } + dispatch: (spec: { changes: { from: number; to: number; insert: string } }) => void + } + }) | null + const view = host?.__cmView + if (!view) throw new Error('CodeMirror view is missing') + view.dispatch({ + changes: { from: 0, to: view.state.doc.toString().length, insert: nextContent }, + }) + }, content) +} + +async function openRawEditor(page: Page): Promise { + await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor) + await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 }) +} + +async function openRichEditor(page: Page): Promise { + await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor) + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) +} + async function expectTitleHeadingText(page: Page, title: string): Promise { await expect(page.locator('.bn-editor [data-content-type="heading"]').first()).toContainText(title, { timeout: 5_000, @@ -248,6 +289,44 @@ test('@smoke new-note typing stays focused through initial save settlement', asy }) }) +test('@smoke new-note editor mode roundtrip stays editable after auto-rename', async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', (err) => { + errors.push(err.message) + }) + const title = 'Mode Switch Crash Guard' + const filename = `${slugifyTitle(title)}.md` + const rawLine = 'Raw mode edit after auto-rename.' + const richLine = 'Rich editor still accepts typing after the raw-mode return.' + + await createUntitledNote(page) + await writeNewHeadingAndBody(page, title, 'Initial body before the raw-mode round trip.') + await expectActiveFilename(page, slugifyTitle(title)) + await expectRenamedFile({ vaultPath: tempVaultDir, filename }) + + await openRawEditor(page) + const rawContent = await getRawEditorContent(page) + await setRawEditorContent(page, `${rawContent}\n\n${rawLine}`) + await page.waitForTimeout(650) + + await openRichEditor(page) + await expect(page.locator('.bn-editor')).toContainText(rawLine, { timeout: 5_000 }) + await page.locator('.bn-editor').click() + await page.keyboard.type(richLine) + + await expectFileContentContains({ + vaultPath: tempVaultDir, + filename, + text: rawLine, + }) + await expectFileContentContains({ + vaultPath: tempVaultDir, + filename, + text: richLine, + }) + expect(errors).toEqual([]) +}) + test('@smoke new-note H1 auto-rename preserves body typing and cursor while rename lands', async ({ page }) => { const errors: string[] = [] page.on('pageerror', (err) => {