fix(editor): preserve pasted markdown raw toggles
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
86
tests/smoke/raw-mode-pasted-text-roundtrip.spec.ts
Normal file
86
tests/smoke/raw-mode-pasted-text-roundtrip.spec.ts
Normal 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,}/)
|
||||
})
|
||||
Reference in New Issue
Block a user