fix(editor): preserve pasted markdown raw toggles

This commit is contained in:
lucaronin
2026-05-02 00:41:42 +02:00
parent 802b6b77d9
commit 44e2870697
6 changed files with 184 additions and 2 deletions

View File

@@ -724,6 +724,57 @@ describe('Editor', () => {
resetVaultConfigStore()
})
it('opens raw mode from unchanged rich content without rewriting pasted markdown source', async () => {
resetVaultConfigStore()
bindVaultConfigStore(
{
zoom: null,
view_mode: null,
editor_mode: null,
tag_colors: null,
status_colors: null,
property_display_modes: null,
inbox: null,
},
vi.fn(),
)
const rawToggleRef = { current: (() => {}) as () => void }
const sourceContent = '---\ntitle: Pasted\n---\nFirst pasted line\nSecond pasted line\n'
const pastedTab = { entry: mockEntry, content: sourceContent }
const originalMarkdownSerializer = mockEditor.blocksToMarkdownLossy.getMockImplementation()
mockEditor.blocksToMarkdownLossy.mockReturnValue('First pasted line\\\\\n\\\\\nSecond pasted line\n')
try {
render(
<Editor
{...defaultProps}
tabs={[pastedTab]}
activeTabPath={mockEntry.path}
entries={[mockEntry]}
rawToggleRef={rawToggleRef}
/>,
)
await vi.waitFor(() => {
expect(typeof rawToggleRef.current).toBe('function')
})
await act(async () => {
await rawToggleRef.current()
})
await vi.waitFor(() => {
expect(screen.getByTestId('raw-editor-codemirror').textContent).toContain('First pasted line')
})
expect(screen.getByTestId('raw-editor-codemirror').textContent).toContain('Second pasted line')
expect(screen.getByTestId('raw-editor-codemirror').textContent).not.toContain('\\\\')
} finally {
mockEditor.blocksToMarkdownLossy.mockImplementation(originalMarkdownSerializer)
resetVaultConfigStore()
}
})
})
describe('applyPendingRawExitContent', () => {

View File

@@ -189,6 +189,7 @@ function useEditorSetup({
rawToggleRef, diffToggleRef,
}: EditorSetupParams) {
const vaultPathRef = useRef(vaultPath)
const flushPendingEditorChangeRef = useRef<(() => boolean) | null>(null)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
const editor = useCreateBlockNote({
@@ -212,6 +213,7 @@ function useEditorSetup({
activeTab?.content ?? null,
onContentChange,
vaultPath,
flushPendingEditorChangeRef,
)
const tabsForEditorSwap = applyPendingRawExitContent(tabs, pendingRawExitContent)
const rawModeContent = resolveRawModeContent({ activeTab, rawModeContentOverride })
@@ -227,6 +229,14 @@ function useEditorSetup({
const { handleEditorChange, flushPendingEditorChange, editorMountedRef } = useEditorTabSwap({
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode, vaultPath,
})
useEffect(() => {
flushPendingEditorChangeRef.current = flushPendingEditorChange
return () => {
if (flushPendingEditorChangeRef.current === flushPendingEditorChange) {
flushPendingEditorChangeRef.current = null
}
}
}, [flushPendingEditorChange])
useEditorFocus(editor, editorMountedRef)
const { diffMode, diffContent, diffLoading, handleToggleDiff, handleViewCommitDiff } = useDiffMode({

View File

@@ -11,6 +11,23 @@ const mockEditor = {
}
describe('editorRawModeSync arrow ligatures', () => {
it('preserves source markdown when raw mode opens without pending rich-editor edits', () => {
const rawLatestContentRef = { current: null as string | null }
const sourceContent = '---\ntitle: Pasted\n---\nFirst pasted line\nSecond pasted line\n'
const result = syncActiveTabIntoRawBuffer({
editor: mockEditor as never,
activeTabPath: '/vault/pasted.md',
activeTabContent: sourceContent,
rawLatestContentRef,
serializeRichEditorContent: false,
})
expect(result).toBe(sourceContent)
expect(rawLatestContentRef.current).toBe(sourceContent)
expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled()
})
it('keeps unicode arrows intact when rich-editor content enters raw mode', () => {
mockEditor.blocksToMarkdownLossy.mockReturnValueOnce('Flow → left ← both ↔\n')
const rawLatestContentRef = { current: null as string | null }
@@ -20,6 +37,7 @@ describe('editorRawModeSync arrow ligatures', () => {
activeTabPath: '/vault/flows.md',
activeTabContent: '---\ntitle: Flows\n---\n\nFlow -> left <- both <->\n',
rawLatestContentRef,
serializeRichEditorContent: true,
})
expect(result).toBe('---\ntitle: Flows\n---\nFlow → left ← both ↔\n')

View File

@@ -78,12 +78,22 @@ export function syncActiveTabIntoRawBuffer(options: {
activeTabPath: string | null
activeTabContent: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
serializeRichEditorContent?: boolean
vaultPath?: string
}) {
const { editor, activeTabPath, activeTabContent, rawLatestContentRef, vaultPath } = options
const {
editor,
activeTabPath,
activeTabContent,
rawLatestContentRef,
serializeRichEditorContent = true,
vaultPath,
} = options
if (!activeTabPath || activeTabContent === null) return null
const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent, vaultPath)
const syncedContent = serializeRichEditorContent
? serializeEditorDocumentToMarkdown(editor, activeTabContent, vaultPath)
: activeTabContent
rawLatestContentRef.current = syncedContent
return syncedContent
}

View File

@@ -132,6 +132,7 @@ function useHandleFlushPending({
rawInitialContentRef,
rawLatestContentRef,
rawSourceContentRef,
flushPendingEditorChangeRef,
pendingRawRestoreRef,
pendingRoundTripRawRestoreRef,
setRawModeContentOverride,
@@ -143,6 +144,7 @@ function useHandleFlushPending({
rawInitialContentRef: React.MutableRefObject<string | null>
rawLatestContentRef: React.MutableRefObject<string | null>
rawSourceContentRef: React.MutableRefObject<string | null>
flushPendingEditorChangeRef?: React.MutableRefObject<(() => boolean) | null>
pendingRawRestoreRef: React.MutableRefObject<CodeMirrorRestoreState | null>
pendingRoundTripRawRestoreRef: React.MutableRefObject<PendingRoundTripRawRestore | null>
setRawModeContentOverride: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
@@ -150,11 +152,13 @@ function useHandleFlushPending({
}) {
return useCallback(async () => {
rawSourceContentRef.current = activeTabContent
const serializeRichEditorContent = flushPendingEditorChangeRef?.current?.() ?? true
const syncedContent = syncActiveTabIntoRawBuffer({
editor,
activeTabPath,
activeTabContent,
rawLatestContentRef,
serializeRichEditorContent,
vaultPath,
})
rawInitialContentRef.current = syncedContent ?? activeTabContent
@@ -173,6 +177,7 @@ function useHandleFlushPending({
activeTabContent,
activeTabPath,
editor,
flushPendingEditorChangeRef,
pendingRawRestoreRef,
pendingRoundTripRawRestoreRef,
rawInitialContentRef,
@@ -269,6 +274,7 @@ export function useRawModeWithFlush(
activeTabContent: string | null,
onContentChange?: (path: string, content: string) => void,
vaultPath?: string,
flushPendingEditorChangeRef?: React.MutableRefObject<(() => boolean) | null>,
) {
const rawLatestContentRef = useRef<string | null>(null)
const rawInitialContentRef = useRef<string | null>(null)
@@ -301,6 +307,7 @@ export function useRawModeWithFlush(
rawInitialContentRef,
rawLatestContentRef,
rawSourceContentRef,
flushPendingEditorChangeRef,
pendingRawRestoreRef,
pendingRoundTripRawRestoreRef,
setRawModeContentOverride,

View File

@@ -0,0 +1,86 @@
import { expect, test, type Page } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import {
createFixtureVaultCopy,
openFixtureVault,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette } from './helpers'
let tempVaultDir: string
const pastedNoteSource = [
'---',
'title: Pasted Toggle',
'---',
'First pasted line with punctuation: a*b [brackets].',
'Second pasted line from an external editor.',
'Third pasted line should stay adjacent.',
'',
].join('\n')
async function openNote(page: Page, title: string) {
await page.locator('[data-testid="note-list-container"]').getByText(title, { exact: true }).click()
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function openRawMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
}
async function openBlockNoteMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function getRawEditorContent(page: Page): Promise<string> {
return page.evaluate(() => {
type CodeMirrorHost = Element & {
cmTile?: {
view?: {
state: {
doc: {
toString(): string
}
}
}
}
}
const el = document.querySelector('.cm-content')
if (!el) return ''
const view = (el as CodeMirrorHost).cmTile?.view
if (view) return view.state.doc.toString() as string
return el.textContent ?? ''
})
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
fs.writeFileSync(path.join(tempVaultDir, 'Pasted Toggle.md'), pastedNoteSource, 'utf8')
await openFixtureVault(page, tempVaultDir)
})
test.afterEach(async () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('pasted multiline note source stays unchanged through raw and visual view toggles', async ({ page }) => {
await openNote(page, 'Pasted Toggle')
await openRawMode(page)
await expect.poll(() => getRawEditorContent(page)).toBe(pastedNoteSource)
await openBlockNoteMode(page)
await openRawMode(page)
const rawAfterRoundTrip = await getRawEditorContent(page)
expect(rawAfterRoundTrip).toBe(pastedNoteSource)
expect(rawAfterRoundTrip).not.toContain('\\\\\n')
expect(rawAfterRoundTrip).not.toMatch(/\n{3,}/)
})