fix: sync raw editor (CodeMirror) content to BlockNote on mode switch

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 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-11 22:14:20 +01:00
parent ab02aa5e96
commit fafa4e394b
8 changed files with 408 additions and 50 deletions

View File

@@ -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<string | null>(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) && <ResizeHandle onResize={onInspectorResize} />}

View File

@@ -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<string | null>
}
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<string | null>
}) {
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<typeof useCreateBlockNote>
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<string | null>
}) {
const showEditor = !diffMode && !rawMode
return (
<>
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} isDark={isDarkTheme} />
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} isDark={isDarkTheme} latestContentRef={rawLatestContentRef} />
{showEditor && activeTab && (
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} editable={!isTrashed} />
@@ -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 && (
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
)}
<EditorBody activeTab={activeTab} isLoadingNewTab={isLoadingNewTab} entries={entries} editor={editor} diffMode={diffMode} diffContent={diffContent} onToggleDiff={onToggleDiff} rawMode={rawMode} onRawContentChange={onRawContentChange} onSave={onSave} onNavigateWikilink={onNavigateWikilink} onEditorChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} isTrashed={isTrashed} />
<EditorBody activeTab={activeTab} isLoadingNewTab={isLoadingNewTab} entries={entries} editor={editor} diffMode={diffMode} diffContent={diffContent} onToggleDiff={onToggleDiff} rawMode={rawMode} onRawContentChange={onRawContentChange} onSave={onSave} onNavigateWikilink={onNavigateWikilink} onEditorChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} isTrashed={isTrashed} rawLatestContentRef={rawLatestContentRef} />
</div>
)
}

View File

@@ -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<string | null>
}
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<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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(() => {

View File

@@ -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() })

View File

@@ -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<boolean>
/** 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<Map<string, { blocks: any[]; scrollTop: number }>>(new Map())
const prevActivePathRef = useRef<string | null>(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<Set<string>>(new Set())

View File

@@ -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()
})
})

View File

@@ -4,6 +4,9 @@ interface UseRawModeParams {
activeTabPath: string | null
/** Flush pending WYSIWYG edits to disk before entering raw mode. */
onFlushPending?: () => Promise<boolean>
/** 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<string | null>(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 }
}

View File

@@ -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<string> {
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 })
})
})