diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx
index e0af4ed7..06a333e1 100644
--- a/src/components/Editor.test.tsx
+++ b/src/components/Editor.test.tsx
@@ -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(
+ ,
+ )
+
+ 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', () => {
diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx
index 25267cc0..134fb6ae 100644
--- a/src/components/Editor.tsx
+++ b/src/components/Editor.tsx
@@ -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({
diff --git a/src/components/editorRawModeSync.arrowLigatures.test.ts b/src/components/editorRawModeSync.arrowLigatures.test.ts
index 66c7e6d1..1e1a613d 100644
--- a/src/components/editorRawModeSync.arrowLigatures.test.ts
+++ b/src/components/editorRawModeSync.arrowLigatures.test.ts
@@ -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')
diff --git a/src/components/editorRawModeSync.ts b/src/components/editorRawModeSync.ts
index eee80ce9..b0ef69eb 100644
--- a/src/components/editorRawModeSync.ts
+++ b/src/components/editorRawModeSync.ts
@@ -78,12 +78,22 @@ export function syncActiveTabIntoRawBuffer(options: {
activeTabPath: string | null
activeTabContent: string | null
rawLatestContentRef: React.MutableRefObject
+ 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
}
diff --git a/src/components/useRawModeWithFlush.ts b/src/components/useRawModeWithFlush.ts
index 75d7956b..423e6e72 100644
--- a/src/components/useRawModeWithFlush.ts
+++ b/src/components/useRawModeWithFlush.ts
@@ -132,6 +132,7 @@ function useHandleFlushPending({
rawInitialContentRef,
rawLatestContentRef,
rawSourceContentRef,
+ flushPendingEditorChangeRef,
pendingRawRestoreRef,
pendingRoundTripRawRestoreRef,
setRawModeContentOverride,
@@ -143,6 +144,7 @@ function useHandleFlushPending({
rawInitialContentRef: React.MutableRefObject
rawLatestContentRef: React.MutableRefObject
rawSourceContentRef: React.MutableRefObject
+ flushPendingEditorChangeRef?: React.MutableRefObject<(() => boolean) | null>
pendingRawRestoreRef: React.MutableRefObject
pendingRoundTripRawRestoreRef: React.MutableRefObject
setRawModeContentOverride: React.Dispatch>
@@ -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(null)
const rawInitialContentRef = useRef(null)
@@ -301,6 +307,7 @@ export function useRawModeWithFlush(
rawInitialContentRef,
rawLatestContentRef,
rawSourceContentRef,
+ flushPendingEditorChangeRef,
pendingRawRestoreRef,
pendingRoundTripRawRestoreRef,
setRawModeContentOverride,
diff --git a/tests/smoke/raw-mode-pasted-text-roundtrip.spec.ts b/tests/smoke/raw-mode-pasted-text-roundtrip.spec.ts
new file mode 100644
index 00000000..0baa148d
--- /dev/null
+++ b/tests/smoke/raw-mode-pasted-text-roundtrip.spec.ts
@@ -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 {
+ 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,}/)
+})