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:
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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() })
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
151
tests/smoke/raw-editor-sync-to-blocknote.spec.ts
Normal file
151
tests/smoke/raw-editor-sync-to-blocknote.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user