From 44e28706970a9687b7db2c12c78a6d146e56b60c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 2 May 2026 00:41:42 +0200 Subject: [PATCH] fix(editor): preserve pasted markdown raw toggles --- src/components/Editor.test.tsx | 51 +++++++++++ src/components/Editor.tsx | 10 +++ .../editorRawModeSync.arrowLigatures.test.ts | 18 ++++ src/components/editorRawModeSync.ts | 14 ++- src/components/useRawModeWithFlush.ts | 7 ++ .../raw-mode-pasted-text-roundtrip.spec.ts | 86 +++++++++++++++++++ 6 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 tests/smoke/raw-mode-pasted-text-roundtrip.spec.ts 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,}/) +})