From 33c4daaa8d4bf34f4c0d27c111f0a575788cb9e4 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 11 Mar 2026 22:14:20 +0100 Subject: [PATCH] fix: sync raw editor (CodeMirror) content to BlockNote on mode switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When toggling from raw mode back to BlockNote, the editor now correctly re-parses content from tab.content instead of using stale cached blocks. Key changes: - useEditorTabSwap: detect rawMode true→false transition, invalidate block cache, and re-parse from tab.content. Added rawSwapPendingRef guard to prevent a second effect run from re-caching stale blocks before the deferred doSwap microtask runs. - useRawMode: added onBeforeRawEnd callback to flush debounced raw editor content synchronously before toggling off. - Editor.tsx: wired rawLatestContentRef and handleBeforeRawEnd to ensure the latest raw content reaches tab.content before the swap. - RawEditorView: exposed latestContentRef so parent can read the latest keystroke content without waiting for the 500ms debounce. - EditorContent: threaded rawLatestContentRef through to RawEditorView. Co-Authored-By: Claude Opus 4.6 --- src/components/Editor.tsx | 20 ++- src/components/EditorContent.tsx | 16 +- src/components/RawEditorView.tsx | 11 +- src/hooks/useEditorTabSwap.test.ts | 170 +++++++++++++++--- src/hooks/useEditorTabSwap.ts | 54 ++++-- src/hooks/useRawMode.test.ts | 28 +++ src/hooks/useRawMode.ts | 8 +- .../raw-editor-sync-to-blocknote.spec.ts | 151 ++++++++++++++++ 8 files changed, 408 insertions(+), 50 deletions(-) create mode 100644 tests/smoke/raw-editor-sync-to-blocknote.spec.ts diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index f8c9c025..d1e6647e 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -150,9 +150,24 @@ export const Editor = memo(function Editor({ onTitleSync: onTitleSync ?? (() => {}), }) + // Ref updated by RawEditorView on every keystroke — used to flush + // debounced content synchronously before leaving raw mode. + const rawLatestContentRef = useRef(null) + + const handleBeforeRawEnd = useCallback(() => { + if (rawLatestContentRef.current != null && activeTabPath) { + onContentChange?.(activeTabPath, rawLatestContentRef.current) + } + rawLatestContentRef.current = null + }, [activeTabPath, onContentChange]) + + const { rawMode, handleToggleRaw } = useRawMode({ + activeTabPath, onBeforeRawEnd: handleBeforeRawEnd, + }) + const { handleEditorChange, editorMountedRef } = useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, - onH1Change: onH1Changed, syncActiveRef, + onH1Change: onH1Changed, syncActiveRef, rawMode, }) useEditorFocus(editor, editorMountedRef) @@ -166,8 +181,6 @@ export const Editor = memo(function Editor({ activeTabPath, onLoadDiff, onLoadDiffAtCommit, }) - const { rawMode, handleToggleRaw } = useRawMode({ activeTabPath }) - const { handleToggleDiffExclusive, handleToggleRawExclusive } = useEditorModeExclusion({ diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef, }) @@ -224,6 +237,7 @@ export const Editor = memo(function Editor({ onUnarchiveNote={onUnarchiveNote} vaultPath={vaultPath} isDarkTheme={isDarkTheme} + rawLatestContentRef={rawLatestContentRef} /> } {(showAIChat || !inspectorCollapsed) && } diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index 5bc66657..c8864701 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -1,3 +1,4 @@ +import type React from 'react' import type { VaultEntry, NoteStatus } from '../types' import type { useCreateBlockNote } from '@blocknote/react' import { DiffView } from './DiffView' @@ -41,6 +42,8 @@ interface EditorContentProps { onUnarchiveNote?: (path: string) => void vaultPath?: string isDarkTheme?: boolean + /** Ref updated by RawEditorView on every keystroke with the latest doc. */ + rawLatestContentRef?: React.MutableRefObject } function EditorLoadingSkeleton() { @@ -72,7 +75,7 @@ function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | nul } function RawModeEditorSection({ - rawMode, activeTab, entries, onContentChange, onSave, isDark, + rawMode, activeTab, entries, onContentChange, onSave, isDark, latestContentRef, }: { rawMode: boolean activeTab: Tab | null @@ -80,6 +83,7 @@ function RawModeEditorSection({ onContentChange?: (path: string, content: string) => void onSave?: () => void isDark?: boolean + latestContentRef?: React.MutableRefObject }) { if (!rawMode || !activeTab) return null return ( @@ -91,6 +95,7 @@ function RawModeEditorSection({ onContentChange={onContentChange ?? (() => {})} onSave={onSave ?? (() => {})} isDark={isDark} + latestContentRef={latestContentRef} /> ) } @@ -129,19 +134,20 @@ function ActiveTabBreadcrumb({ activeTab, props }: { ) } -function EditorBody({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, rawMode, onRawContentChange, onSave, onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, isTrashed }: { +function EditorBody({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, rawMode, onRawContentChange, onSave, onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, isTrashed, rawLatestContentRef }: { activeTab: Tab | null; isLoadingNewTab: boolean; entries: VaultEntry[] editor: ReturnType diffMode: boolean; diffContent: string | null; onToggleDiff: () => void rawMode: boolean; onRawContentChange?: (path: string, content: string) => void; onSave?: () => void onNavigateWikilink: (target: string) => void; onEditorChange?: () => void vaultPath?: string; isDarkTheme?: boolean; isTrashed: boolean + rawLatestContentRef?: React.MutableRefObject }) { const showEditor = !diffMode && !rawMode return ( <> {diffMode && } - + {showEditor && activeTab && (
@@ -157,7 +163,7 @@ export function EditorContent({ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, onRawContentChange, onSave, onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, - onDeleteNote, + onDeleteNote, rawLatestContentRef, ...breadcrumbProps }: EditorContentProps) { const isTrashed = activeTab?.entry.trashed ?? false @@ -179,7 +185,7 @@ export function EditorContent({ {activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && ( breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} /> )} - +
) } diff --git a/src/components/RawEditorView.tsx b/src/components/RawEditorView.tsx index 4af978fe..ed51d2a8 100644 --- a/src/components/RawEditorView.tsx +++ b/src/components/RawEditorView.tsx @@ -23,6 +23,9 @@ export interface RawEditorViewProps { onContentChange: (path: string, content: string) => void onSave: () => void isDark?: boolean + /** Mutable ref updated on every keystroke with the latest doc string. + * Allows the parent to flush debounced content before unmount. */ + latestContentRef?: React.MutableRefObject } const DEBOUNCE_MS = 500 @@ -35,7 +38,7 @@ function getCursorCoords(view: EditorView): { top: number; left: number } | null return { top: coords.bottom, left: coords.left } } -export function RawEditorView({ content, path, entries, onContentChange, onSave, isDark = false }: RawEditorViewProps) { +export function RawEditorView({ content, path, entries, onContentChange, onSave, isDark = false, latestContentRef }: RawEditorViewProps) { const containerRef = useRef(null) const debounceRef = useRef | null>(null) const pathRef = useRef(path) @@ -43,6 +46,8 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, const onSaveRef = useRef(onSave) const latestDocRef = useRef(content) useEffect(() => { pathRef.current = path }, [path]) + // Expose latest doc content to parent via ref + useEffect(() => { if (latestContentRef) latestContentRef.current = content }, [latestContentRef, content]) useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange]) useEffect(() => { onSaveRef.current = onSave }, [onSave]) @@ -64,8 +69,12 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, const insertWikilinkRef = useRef<(entryTitle: string) => void>(() => {}) + const latestContentRefStable = useRef(latestContentRef) + useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef]) + const handleDocChange = useCallback((doc: string) => { latestDocRef.current = doc + if (latestContentRefStable.current) latestContentRefStable.current.current = doc setYamlError(detectYamlError(doc)) if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => { diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index 49632636..75e55210 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -158,33 +158,151 @@ describe('replaceTitleInFrontmatter', () => { }) }) +const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] +const blocksB = [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] + +function makeTab(path: string, title: string) { + return { + entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never, + content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody of ${title}.`, + } +} + +function makeMockEditor(docRef: { current: unknown[] }) { + return { + document: docRef.current, + get prosemirrorView() { return {} }, + onMount: (cb: () => void) => { cb(); return () => {} }, + replaceBlocks: vi.fn((_old, newBlocks) => { docRef.current = newBlocks }), + insertBlocks: vi.fn(), + blocksToMarkdownLossy: vi.fn(() => ''), + blocksToHTMLLossy: vi.fn(() => ''), + tryParseMarkdownToBlocks: vi.fn(() => blocksA), + _tiptapEditor: { commands: { setContent: vi.fn() } }, + _docRef: docRef, + } +} + +describe('useEditorTabSwap raw mode sync', () => { + afterEach(() => { vi.restoreAllMocks() }) + + 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 }) + + 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 { rerender } = renderHook( + ({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({ + tabs, activeTabPath, editor: mockEditor as never, rawMode, + }), + { initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } }, + ) + + // Initial load — parses and caches blocks + await act(() => new Promise(r => setTimeout(r, 0))) + + // Enter raw mode + rerender({ tabs: [tabA], activeTabPath: 'a.md', rawMode: true }) + await act(() => new Promise(r => setTimeout(r, 0))) + + // Simulate raw editing: tab content was updated externally + const updatedTab = { + ...tabA, + content: '---\ntitle: Updated Title\n---\n\n# Updated Title\n\nNew body content.', + } + mockEditor.tryParseMarkdownToBlocks.mockClear() + mockEditor.replaceBlocks.mockClear() + + // Exit raw mode with updated content + rerender({ tabs: [updatedTab], activeTabPath: 'a.md', rawMode: false }) + await act(() => new Promise(r => setTimeout(r, 0))) + + // Verify re-parse happened with updated body content + expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith( + expect.stringContaining('Updated Title'), + ) + expect(mockEditor.replaceBlocks).toHaveBeenCalled() + }) + + it('does not skip swap when rawMode is on (editor hidden)', 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 { rerender } = renderHook( + ({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({ + tabs, activeTabPath, editor: mockEditor as never, rawMode, + }), + { initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } }, + ) + + await act(() => new Promise(r => setTimeout(r, 0))) + mockEditor.replaceBlocks.mockClear() + + // Enter raw mode and update content + const updatedTab = { ...tabA, content: '---\ntitle: Changed\n---\n\n# Changed\n\nEdited.' } + rerender({ tabs: [updatedTab], activeTabPath: 'a.md', rawMode: true }) + await act(() => new Promise(r => setTimeout(r, 0))) + + // While in raw mode, the editor should NOT be updated + expect(mockEditor.replaceBlocks).not.toHaveBeenCalled() + }) + + it('preserves content through multiple BlockNote→raw→BlockNote cycles', 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 { 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))) + + // Cycle 1: raw mode on → edit → raw mode off + rerender({ tabs: [tabA], activeTabPath: 'a.md', rawMode: true }) + await act(() => new Promise(r => setTimeout(r, 0))) + + const edit1 = { ...tabA, content: '---\ntitle: Edit 1\n---\n\n# Edit 1\n\nFirst edit.' } + mockEditor.tryParseMarkdownToBlocks.mockClear() + rerender({ tabs: [edit1], activeTabPath: 'a.md', rawMode: false }) + await act(() => new Promise(r => setTimeout(r, 0))) + expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith( + expect.stringContaining('Edit 1'), + ) + + // Cycle 2: raw mode on → edit → raw mode off + rerender({ tabs: [edit1], activeTabPath: 'a.md', rawMode: true }) + await act(() => new Promise(r => setTimeout(r, 0))) + + const edit2 = { ...tabA, content: '---\ntitle: Edit 2\n---\n\n# Edit 2\n\nSecond edit.' } + mockEditor.tryParseMarkdownToBlocks.mockClear() + rerender({ tabs: [edit2], activeTabPath: 'a.md', rawMode: false }) + await act(() => new Promise(r => setTimeout(r, 0))) + expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith( + expect.stringContaining('Edit 2'), + ) + }) +}) + describe('useEditorTabSwap scroll position', () => { - const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] - const blocksB = [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] - - function makeTab(path: string, title: string) { - return { - entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never, - content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody of ${title}.`, - } - } - - function makeMockEditor(docRef: { current: unknown[] }) { - const mountCallbacks: Array<() => void> = [] - return { - document: docRef.current, - get prosemirrorView() { return {} }, - onMount: (cb: () => void) => { mountCallbacks.push(cb); return () => {} }, - replaceBlocks: vi.fn((_old, newBlocks) => { docRef.current = newBlocks }), - insertBlocks: vi.fn(), - blocksToMarkdownLossy: vi.fn(() => ''), - blocksToHTMLLossy: vi.fn(() => ''), - tryParseMarkdownToBlocks: vi.fn(() => blocksA), - _tiptapEditor: { commands: { setContent: vi.fn() } }, - // Make document getter dynamic - _docRef: docRef, - } - } afterEach(() => { vi.restoreAllMocks() }) diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index 033c99c1..a53acd70 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -19,6 +19,8 @@ interface UseEditorTabSwapOptions { onH1Change?: (h1Text: string | null) => void /** When .current is false, handleEditorChange won't update frontmatter title from H1. */ syncActiveRef?: React.MutableRefObject + /** When true, the BlockNote editor is hidden (raw/CodeMirror mode active). */ + rawMode?: boolean } /** Strip the YAML frontmatter from raw file content, returning the body @@ -61,13 +63,17 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string) * * Returns `handleEditorChange`, the onChange callback for SingleEditorView. */ -export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef }: UseEditorTabSwapOptions) { +export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef, rawMode }: UseEditorTabSwapOptions) { // Cache parsed blocks + scroll position per tab path for instant switching // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays const tabCacheRef = useRef>(new Map()) const prevActivePathRef = useRef(null) const editorMountedRef = useRef(false) const pendingSwapRef = useRef<(() => void) | null>(null) + const prevRawModeRef = useRef(!!rawMode) + // Guard: prevents a subsequent effect run from re-caching stale blocks + // while a raw-mode swap is still pending in a microtask/pendingSwap. + const rawSwapPendingRef = useRef(false) // Suppress onChange during programmatic content swaps (tab switching / initial load) const suppressChangeRef = useRef(false) @@ -137,6 +143,15 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, const prevPath = prevActivePathRef.current const pathChanged = prevPath !== activeTabPath + // Detect raw mode transition: true → false means we need to re-parse + // from tab.content since the cached blocks are stale. + const rawModeJustEnded = prevRawModeRef.current && !rawMode + prevRawModeRef.current = !!rawMode + + // While raw mode is active the BlockNote editor is hidden — skip all + // swap logic to avoid touching the invisible editor. + if (rawMode) return + // Save current editor state + scroll position for the tab we're leaving if (prevPath && pathChanged && editorMountedRef.current) { const scrollEl = document.querySelector('.editor__blocknote-container') @@ -147,19 +162,31 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, } prevActivePathRef.current = activeTabPath - // When tab content updates but the active tab stays the same (e.g. after - // Cmd+S save), refresh the cache with the current editor blocks so a later - // tab switch doesn't revert to stale content. Do NOT re-apply blocks — - // the editor already shows the user's edits. if (!pathChanged) { - if (activeTabPath && editorMountedRef.current) { - const scrollEl = document.querySelector('.editor__blocknote-container') - cache.set(activeTabPath, { - blocks: editor.document, - scrollTop: scrollEl?.scrollTop ?? 0, - }) + if (rawModeJustEnded && activeTabPath) { + // Raw mode just ended — invalidate stale cached blocks so we + // re-parse from the latest tab.content below. + cache.delete(activeTabPath) + rawSwapPendingRef.current = true + } else { + // While a raw-mode swap is pending (scheduled via microtask), a second + // effect run can fire due to the tabs prop updating. Skip re-caching + // stale editor.document to avoid poisoning the cache before doSwap runs. + if (rawSwapPendingRef.current) return + + // When tab content updates but the active tab stays the same (e.g. after + // Cmd+S save), refresh the cache with the current editor blocks so a later + // tab switch doesn't revert to stale content. Do NOT re-apply blocks — + // the editor already shows the user's edits. + if (activeTabPath && editorMountedRef.current) { + const scrollEl = document.querySelector('.editor__blocknote-container') + cache.set(activeTabPath, { + blocks: editor.document, + scrollTop: scrollEl?.scrollTop ?? 0, + }) + } + return } - return } if (!activeTabPath) return @@ -202,6 +229,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, const doSwap = () => { // Guard: bail if user switched tabs since this swap was scheduled if (prevActivePathRef.current !== targetPath) return + rawSwapPendingRef.current = false if (cache.has(targetPath)) { const cached = cache.get(targetPath)! @@ -268,7 +296,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, } else { pendingSwapRef.current = doSwap } - }, [activeTabPath, tabs, editor]) + }, [activeTabPath, tabs, editor, rawMode]) // Clean up cache entries when tabs are closed const tabPathsRef = useRef>(new Set()) diff --git a/src/hooks/useRawMode.test.ts b/src/hooks/useRawMode.test.ts index 6b207293..053e1f32 100644 --- a/src/hooks/useRawMode.test.ts +++ b/src/hooks/useRawMode.test.ts @@ -84,4 +84,32 @@ describe('useRawMode', () => { // Cannot activate raw mode without an active tab path expect(result.current.rawMode).toBe(false) }) + + it('calls onBeforeRawEnd when deactivating raw mode', async () => { + const onBeforeRawEnd = vi.fn() + const { result } = renderHook( + ({ path }) => useRawMode({ activeTabPath: path, onFlushPending, onBeforeRawEnd }), + { initialProps: { path: '/note.md' } }, + ) + + await act(async () => { await result.current.handleToggleRaw() }) + expect(result.current.rawMode).toBe(true) + + await act(async () => { await result.current.handleToggleRaw() }) + + expect(onBeforeRawEnd).toHaveBeenCalledOnce() + expect(result.current.rawMode).toBe(false) + }) + + it('does not call onBeforeRawEnd when activating raw mode', async () => { + const onBeforeRawEnd = vi.fn() + const { result } = renderHook( + ({ path }) => useRawMode({ activeTabPath: path, onFlushPending, onBeforeRawEnd }), + { initialProps: { path: '/note.md' } }, + ) + + await act(async () => { await result.current.handleToggleRaw() }) + + expect(onBeforeRawEnd).not.toHaveBeenCalled() + }) }) diff --git a/src/hooks/useRawMode.ts b/src/hooks/useRawMode.ts index 21e37276..d66ae0fd 100644 --- a/src/hooks/useRawMode.ts +++ b/src/hooks/useRawMode.ts @@ -4,6 +4,9 @@ interface UseRawModeParams { activeTabPath: string | null /** Flush pending WYSIWYG edits to disk before entering raw mode. */ onFlushPending?: () => Promise + /** Called synchronously before raw mode is deactivated, so the caller can + * flush any debounced raw-editor content into tab state. */ + onBeforeRawEnd?: () => void } /** @@ -11,19 +14,20 @@ interface UseRawModeParams { * Raw mode is automatically inactive when the active tab changes, * because rawMode is derived from whether the stored path matches the current tab. */ -export function useRawMode({ activeTabPath, onFlushPending }: UseRawModeParams) { +export function useRawMode({ activeTabPath, onFlushPending, onBeforeRawEnd }: UseRawModeParams) { // Track which path has raw mode active — automatically deactivates on tab switch const [rawActivePath, setRawActivePath] = useState(null) const rawMode = rawActivePath !== null && rawActivePath === activeTabPath const handleToggleRaw = useCallback(async () => { if (rawMode) { + onBeforeRawEnd?.() setRawActivePath(null) } else { await onFlushPending?.() setRawActivePath(activeTabPath) } - }, [rawMode, activeTabPath, onFlushPending]) + }, [rawMode, activeTabPath, onFlushPending, onBeforeRawEnd]) return { rawMode, handleToggleRaw } } diff --git a/tests/smoke/raw-editor-sync-to-blocknote.spec.ts b/tests/smoke/raw-editor-sync-to-blocknote.spec.ts new file mode 100644 index 00000000..0219e45b --- /dev/null +++ b/tests/smoke/raw-editor-sync-to-blocknote.spec.ts @@ -0,0 +1,151 @@ +import { test, expect, type Page } from '@playwright/test' +import { openCommandPalette, executeCommand } from './helpers' + +/** + * Smoke test: editing in raw (CodeMirror) mode and switching back to + * BlockNote must show the updated content — the two editors stay in sync. + */ + +async function openFirstNote(page: Page) { + const noteList = page.locator('[data-testid="note-list-container"]') + await noteList.waitFor({ timeout: 5000 }) + await noteList.locator('.cursor-pointer').first().click() + await page.waitForTimeout(500) + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 }) +} + +async function toggleRawMode(page: Page) { + await openCommandPalette(page) + await executeCommand(page, 'Toggle Raw') + await page.waitForTimeout(500) +} + +/** Get the full text content from the CodeMirror raw editor. */ +async function getRawEditorContent(page: Page): Promise { + return page.evaluate(() => { + const el = document.querySelector('.cm-content') + if (!el) return '' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const view = (el as any).cmTile?.view + if (view) return view.state.doc.toString() as string + return el.textContent ?? '' + }) +} + +/** Replace the entire raw editor content via CodeMirror dispatch (reliable). */ +async function setRawEditorContent(page: Page, content: string) { + await page.evaluate((newContent) => { + const el = document.querySelector('.cm-content') + if (!el) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const view = (el as any).cmTile?.view + if (!view) return + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: newContent }, + }) + }, content) +} + +test.describe('Raw editor ↔ BlockNote sync', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('editing in raw mode and switching to BlockNote shows updated content', async ({ page }) => { + await openFirstNote(page) + + // Read the original H1 from the BlockNote editor + const h1Locator = page.locator('.bn-editor h1.bn-inline-content').first() + await expect(h1Locator).toBeVisible({ timeout: 5000 }) + const originalH1 = await h1Locator.textContent() + expect(originalH1).toBeTruthy() + + // Toggle to raw mode + await toggleRawMode(page) + await expect(page.locator('.cm-content')).toBeVisible() + + // Read raw content and verify it contains the original title + const rawContent = await getRawEditorContent(page) + expect(rawContent).toContain(originalH1!) + + // Replace the H1 line with a new heading via CodeMirror dispatch + const newTitle = 'Updated By Raw Editor' + const updatedContent = rawContent.replace( + new RegExp(`# ${originalH1!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + `# ${newTitle}`, + ) + await setRawEditorContent(page, updatedContent) + await page.waitForTimeout(600) // Wait for debounce (500ms) + + // Toggle back to BlockNote + await toggleRawMode(page) + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 }) + await page.waitForTimeout(500) + + // Verify the BlockNote editor shows the updated heading + const updatedH1 = page.locator('.bn-editor h1.bn-inline-content').first() + await expect(updatedH1).toContainText(newTitle, { timeout: 5000 }) + }) + + test('switching BlockNote → raw → BlockNote multiple times preserves content', async ({ page }) => { + await openFirstNote(page) + + // Cycle 1: toggle to raw, edit, toggle back + await toggleRawMode(page) + await expect(page.locator('.cm-content')).toBeVisible() + + const rawContent1 = await getRawEditorContent(page) + const edit1 = rawContent1.replace(/# .+/, '# Cycle One Title') + await setRawEditorContent(page, edit1) + await page.waitForTimeout(600) + + await toggleRawMode(page) + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 }) + await page.waitForTimeout(500) + + await expect( + page.locator('.bn-editor h1.bn-inline-content').first(), + ).toContainText('Cycle One Title', { timeout: 5000 }) + + // Cycle 2: toggle to raw again, verify content persisted, edit again + await toggleRawMode(page) + await expect(page.locator('.cm-content')).toBeVisible() + + const rawContent2 = await getRawEditorContent(page) + expect(rawContent2).toContain('Cycle One Title') + + const edit2 = rawContent2.replace(/# .+/, '# Cycle Two Title') + await setRawEditorContent(page, edit2) + await page.waitForTimeout(600) + + await toggleRawMode(page) + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 }) + await page.waitForTimeout(500) + + await expect( + page.locator('.bn-editor h1.bn-inline-content').first(), + ).toContainText('Cycle Two Title', { timeout: 5000 }) + }) + + test('appended text in raw mode appears in BlockNote after switch', async ({ page }) => { + await openFirstNote(page) + + // Toggle to raw and append text via CodeMirror dispatch + await toggleRawMode(page) + await expect(page.locator('.cm-content')).toBeVisible() + + const content = await getRawEditorContent(page) + await setRawEditorContent(page, content + '\n\nAppended by raw editor test') + await page.waitForTimeout(600) // Wait for debounce + + // Toggle back to BlockNote + await toggleRawMode(page) + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 }) + await page.waitForTimeout(500) + + // Verify the appended text shows up in BlockNote + await expect(page.locator('.bn-editor')).toContainText('Appended by raw editor test', { timeout: 5000 }) + }) +})