Preserve original markdown style when editing in the rich editor (#8862)
* Preserve original markdown style when editing in the rich editor The rich markdown editor re-serialized the whole document into a canonical style on the first edit, so a one-character change rewrote every non-canonical construct (`_x_`->`*x*`, `__x__`->`**x**`, `* item`->`- item`, dropped trailing newline) and produced a large, unexpected diff. Reconcile the canonical getMarkdown() output back toward the original source bytes at every disk-bound serialize site (debounced autosave, flush, Cmd/Ctrl+S) so untouched regions keep their markup and only the edited region changes. A divergent-base fuzzy patch (diff-match-patch) carries the user's edit onto the original style; a safety re-parse requires the result to render-equal what the editor shows, else it falls back to today's canonical output — so content semantics can never change. The rich editor stays the default for markdown. Fixes #6080 * Bound markdown reconciliation diff work
This commit is contained in:
parent
a2d3451efd
commit
ed2135d248
|
|
@ -131,6 +131,7 @@
|
|||
"@electron/rebuild": "^4.2.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@sanity/diff-match-patch": "^3.2.0",
|
||||
"@stablyai/playwright-test": "^2.1.14",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
|
|
|
|||
|
|
@ -110,6 +110,9 @@ importers:
|
|||
'@playwright/test':
|
||||
specifier: ^1.59.1
|
||||
version: 1.59.1
|
||||
'@sanity/diff-match-patch':
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
'@stablyai/playwright-test':
|
||||
specifier: ^2.1.14
|
||||
version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3)
|
||||
|
|
@ -2604,6 +2607,10 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@sanity/diff-match-patch@3.2.0':
|
||||
resolution: {integrity: sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
|
|
@ -8799,6 +8806,8 @@ snapshots:
|
|||
'@rollup/rollup-win32-x64-msvc@4.60.3':
|
||||
optional: true
|
||||
|
||||
'@sanity/diff-match-patch@3.2.0': {}
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@sindresorhus/is@4.6.0': {}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import { RichMarkdownEditorSurface } from './RichMarkdownEditorSurface'
|
|||
import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance'
|
||||
import { useRichMarkdownMenuController } from './useRichMarkdownMenuController'
|
||||
import { useRichMarkdownProgrammaticSync } from './useRichMarkdownProgrammaticSync'
|
||||
import { useRichMarkdownReconcileRoundTrip } from './useRichMarkdownReconcileRoundTrip'
|
||||
import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit'
|
||||
import { useRichMarkdownReviewController } from './useRichMarkdownReviewController'
|
||||
import { useRichMarkdownReviewEditorEffects } from './useRichMarkdownReviewEditorEffects'
|
||||
import {
|
||||
|
|
@ -99,6 +101,11 @@ export default function RichMarkdownEditor({
|
|||
const menu = useRichMarkdownMenuController({ markdownDocuments })
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const lastCommittedMarkdownRef = useRef(content)
|
||||
// Why: three-way source-preserving reconciliation baseline — the raw on-disk
|
||||
// bytes and their canonical serialization — so edits patch onto the original
|
||||
// style rather than re-canonicalizing untouched regions (#6080).
|
||||
const originalSourceRef = useRef(content)
|
||||
const baseCanonicalRef = useRef('')
|
||||
const onContentChangeRef = useRef(onContentChange)
|
||||
const onDirtyStateHintRef = useRef(onDirtyStateHint)
|
||||
const onSaveRef = useRef(onSave)
|
||||
|
|
@ -159,6 +166,13 @@ export default function RichMarkdownEditor({
|
|||
onSaveRef.current = onSave
|
||||
onOpenDocLinkRef.current = onOpenDocLink
|
||||
isEditingLinkRef.current = isEditingLink
|
||||
const reconcileRoundTripRef = useRichMarkdownReconcileRoundTrip({
|
||||
htmlSuperscriptLinkContext,
|
||||
filePath,
|
||||
runtimeEnvironmentId,
|
||||
worktreeId,
|
||||
worktreeRoot
|
||||
})
|
||||
|
||||
const flushPendingSerialization = useCallback(() => {
|
||||
if (serializeTimerRef.current === null) {
|
||||
|
|
@ -167,16 +181,19 @@ export default function RichMarkdownEditor({
|
|||
window.clearTimeout(serializeTimerRef.current)
|
||||
serializeTimerRef.current = null
|
||||
try {
|
||||
const markdown = editorRef.current?.getMarkdown()
|
||||
if (markdown !== undefined) {
|
||||
lastCommittedMarkdownRef.current = markdown
|
||||
const { markdown, didSerialize } = commitRichMarkdownSerialization(
|
||||
editorRef.current,
|
||||
{ originalSourceRef, baseCanonicalRef, lastCommittedMarkdownRef },
|
||||
reconcileRoundTripRef.current
|
||||
)
|
||||
if (didSerialize) {
|
||||
onContentChangeRef.current(markdown)
|
||||
}
|
||||
} catch {
|
||||
// Why: save/restart flows should never crash the UI just because the
|
||||
// editor was torn down between scheduling and flushing a debounced sync.
|
||||
}
|
||||
}, [])
|
||||
}, [reconcileRoundTripRef])
|
||||
|
||||
useEffect(() => {
|
||||
// Why: autosave/restart paths live outside the editor component tree, so a
|
||||
|
|
@ -216,6 +233,9 @@ export default function RichMarkdownEditor({
|
|||
rootRef,
|
||||
editorRef,
|
||||
lastCommittedMarkdownRef,
|
||||
originalSourceRef,
|
||||
baseCanonicalRef,
|
||||
reconcileRoundTripRef,
|
||||
onContentChangeRef,
|
||||
onDirtyStateHintRef,
|
||||
onSaveRef,
|
||||
|
|
@ -288,6 +308,8 @@ export default function RichMarkdownEditor({
|
|||
filePath,
|
||||
isApplyingProgrammaticUpdateRef,
|
||||
lastCommittedMarkdownRef,
|
||||
originalSourceRef,
|
||||
baseCanonicalRef,
|
||||
markdownDocuments,
|
||||
rootRef,
|
||||
runtimeEnvironmentId,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ function createConfigParams(overrides: Partial<EditorConfigParams> = {}): Editor
|
|||
rootRef: ref<HTMLDivElement | null>(null),
|
||||
editorRef: ref<Editor | null>(null),
|
||||
lastCommittedMarkdownRef: ref(''),
|
||||
originalSourceRef: ref(''),
|
||||
baseCanonicalRef: ref(''),
|
||||
reconcileRoundTripRef: ref<(markdown: string) => string | null>(() => null),
|
||||
onContentChangeRef: ref(vi.fn()),
|
||||
onDirtyStateHintRef: ref(vi.fn()),
|
||||
onSaveRef: ref(vi.fn()),
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
type RichMarkdownRuntimeSettings
|
||||
} from './rich-markdown-editor-click-routing'
|
||||
import { createRichMarkdownKeyHandler } from './rich-markdown-key-handler'
|
||||
import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit'
|
||||
import {
|
||||
createRichMarkdownImageResolverContext,
|
||||
setRichMarkdownImageResolverContext
|
||||
|
|
@ -51,6 +52,9 @@ export type EditorConfigParams = {
|
|||
rootRef: MutableRefObject<HTMLDivElement | null>
|
||||
editorRef: MutableRefObject<Editor | null>
|
||||
lastCommittedMarkdownRef: MutableRefObject<string>
|
||||
originalSourceRef: MutableRefObject<string>
|
||||
baseCanonicalRef: MutableRefObject<string>
|
||||
reconcileRoundTripRef: MutableRefObject<(markdown: string) => string | null>
|
||||
onContentChangeRef: MutableRefObject<(content: string) => void>
|
||||
onDirtyStateHintRef: MutableRefObject<(dirty: boolean) => void>
|
||||
onSaveRef: MutableRefObject<(content: string) => void>
|
||||
|
|
@ -100,6 +104,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
|||
rootRef,
|
||||
editorRef,
|
||||
lastCommittedMarkdownRef,
|
||||
originalSourceRef,
|
||||
baseCanonicalRef,
|
||||
reconcileRoundTripRef,
|
||||
onContentChangeRef,
|
||||
onDirtyStateHintRef,
|
||||
onSaveRef,
|
||||
|
|
@ -170,6 +177,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
|||
editorRef,
|
||||
rootRef,
|
||||
lastCommittedMarkdownRef,
|
||||
originalSourceRef,
|
||||
baseCanonicalRef,
|
||||
reconcileRoundTripRef,
|
||||
onContentChangeRef,
|
||||
onSaveRef,
|
||||
isEditingLinkRef,
|
||||
|
|
@ -236,6 +246,11 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
|||
// split on load.
|
||||
normalizeEmptyListItems(nextEditor)
|
||||
lastCommittedMarkdownRef.current = content
|
||||
// Why: seed the source-preserving reconciliation baseline — the raw loaded
|
||||
// bytes and their canonical serialization — so the first edit patches onto
|
||||
// the original style instead of re-canonicalizing the whole file.
|
||||
originalSourceRef.current = content
|
||||
baseCanonicalRef.current = nextEditor.getMarkdown()
|
||||
isInitializingRef.current = false
|
||||
cancelAutoFocusRef.current?.()
|
||||
cancelAutoFocusRef.current = autoFocusRichEditor(nextEditor, rootRef.current)
|
||||
|
|
@ -268,9 +283,14 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
|||
serializeTimerRef.current = window.setTimeout(() => {
|
||||
serializeTimerRef.current = null
|
||||
try {
|
||||
const markdown = nextEditor.getMarkdown()
|
||||
lastCommittedMarkdownRef.current = markdown
|
||||
onContentChangeRef.current(markdown)
|
||||
const { markdown, didSerialize } = commitRichMarkdownSerialization(
|
||||
nextEditor,
|
||||
{ originalSourceRef, baseCanonicalRef, lastCommittedMarkdownRef },
|
||||
reconcileRoundTripRef.current
|
||||
)
|
||||
if (didSerialize) {
|
||||
onContentChangeRef.current(markdown)
|
||||
}
|
||||
} catch {
|
||||
// Why: save/restart flows should never crash the UI just because
|
||||
// the editor was torn down between scheduling and serializing.
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext
|
|||
editorRef: { current: editor },
|
||||
rootRef: { current: null },
|
||||
lastCommittedMarkdownRef: { current: '' },
|
||||
originalSourceRef: { current: '' },
|
||||
baseCanonicalRef: { current: '' },
|
||||
reconcileRoundTripRef: { current: () => null },
|
||||
onContentChangeRef: { current: vi.fn() },
|
||||
onSaveRef: { current: vi.fn() },
|
||||
isEditingLinkRef: { current: false },
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { Editor } from '@tiptap/react'
|
|||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
|
||||
import { editorShortcutMatches } from './editor-shortcuts'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands'
|
||||
import {
|
||||
|
|
@ -21,12 +20,16 @@ import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-de
|
|||
import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
|
||||
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
|
||||
import { handleRichMarkdownSaveShortcut } from './rich-markdown-save-shortcut'
|
||||
|
||||
export type KeyHandlerContext = {
|
||||
isMac: boolean
|
||||
editorRef: MutableRefObject<Editor | null>
|
||||
rootRef: MutableRefObject<HTMLDivElement | null>
|
||||
lastCommittedMarkdownRef: MutableRefObject<string>
|
||||
originalSourceRef: MutableRefObject<string>
|
||||
baseCanonicalRef: MutableRefObject<string>
|
||||
reconcileRoundTripRef: MutableRefObject<(markdown: string) => string | null>
|
||||
onContentChangeRef: MutableRefObject<(content: string) => void>
|
||||
onSaveRef: MutableRefObject<(content: string) => void>
|
||||
isEditingLinkRef: MutableRefObject<boolean>
|
||||
|
|
@ -127,15 +130,7 @@ export function createRichMarkdownKeyHandler(
|
|||
ctx.openSearchRef.current()
|
||||
return true
|
||||
}
|
||||
if (editorShortcutMatches('editor.save', event)) {
|
||||
event.preventDefault()
|
||||
// Why: flush any pending debounced serialization so the save
|
||||
// captures the very latest editor content, not a stale snapshot.
|
||||
ctx.flushPendingSerialization()
|
||||
const markdown = ctx.editorRef.current?.getMarkdown() ?? ctx.lastCommittedMarkdownRef.current
|
||||
ctx.lastCommittedMarkdownRef.current = markdown
|
||||
ctx.onContentChangeRef.current(markdown)
|
||||
ctx.onSaveRef.current(markdown)
|
||||
if (handleRichMarkdownSaveShortcut(ctx, event)) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { Editor } from '@tiptap/core'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import {
|
||||
createRichMarkdownHtmlSuperscriptLinkContext,
|
||||
type RichMarkdownHtmlSuperscriptLinkContext
|
||||
} from './rich-markdown-html-superscript-link-context'
|
||||
import {
|
||||
setRichMarkdownImageResolverContext,
|
||||
type RichMarkdownImageResolverContext
|
||||
} from './rich-markdown-image-context'
|
||||
import { normalizeEmptyListItems } from './rich-markdown-normalize'
|
||||
|
||||
export type RichMarkdownReconcileSerializerContext = {
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
imageResolverContext: RichMarkdownImageResolverContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-serializes markdown through a throwaway editor that REPLICATES the mounted
|
||||
* editor's post-load state: same extension set, the file's own superscript-link
|
||||
* classification context, its image resolver context, and normalizeEmptyListItems.
|
||||
* Reconciliation's safety re-parse must match the live getMarkdown() output, or
|
||||
* docs with empty list items / local images / superscript links would spuriously
|
||||
* mismatch and silently fall back to canonical. Returns null if serialization throws.
|
||||
*/
|
||||
export function serializeRichMarkdownForReconcile(
|
||||
content: string,
|
||||
{ htmlSuperscriptLinkContext, imageResolverContext }: RichMarkdownReconcileSerializerContext
|
||||
): string | null {
|
||||
try {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
// Why: mirror the live editor's link classification (sourceOwner/paths drive
|
||||
// whether a link serializes as a superscript link) via a fresh context built
|
||||
// from the live snapshot, without subscribing to the live editor's context.
|
||||
const { version: _version, ...snapshot } = htmlSuperscriptLinkContext.getSnapshot()
|
||||
const context = createRichMarkdownHtmlSuperscriptLinkContext(snapshot)
|
||||
const editor = new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions({
|
||||
codec,
|
||||
htmlSuperscriptLinks: true,
|
||||
htmlSuperscriptLinkContext: context
|
||||
}),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec, { htmlSuperscriptLinks: true }),
|
||||
contentType: 'markdown',
|
||||
onBeforeCreate: ({ editor: nextEditor }) => {
|
||||
setRichMarkdownImageResolverContext(nextEditor, imageResolverContext)
|
||||
}
|
||||
})
|
||||
try {
|
||||
normalizeEmptyListItems(editor)
|
||||
return editor.getMarkdown()
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import type { KeyHandlerContext } from './rich-markdown-key-handler'
|
||||
import { editorShortcutMatches } from './editor-shortcuts'
|
||||
import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit'
|
||||
|
||||
/**
|
||||
* Cmd/Ctrl+S: flush the debounced serialization, then reconcile toward the
|
||||
* original source style before saving so untouched regions keep their bytes.
|
||||
*/
|
||||
export function handleRichMarkdownSaveShortcut(
|
||||
ctx: KeyHandlerContext,
|
||||
event: KeyboardEvent
|
||||
): boolean {
|
||||
if (!editorShortcutMatches('editor.save', event)) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
// Why: flush pending debounced serialization so the save captures the very
|
||||
// latest editor content, not a stale snapshot.
|
||||
ctx.flushPendingSerialization()
|
||||
// Why: the flush already reconciled + updated refs, so this re-serialize is
|
||||
// idempotent (edited === baseCanonical → returns the reconciled bytes). On a
|
||||
// torn-down editor it falls back to the last committed bytes without patching.
|
||||
const { markdown } = commitRichMarkdownSerialization(
|
||||
ctx.editorRef.current,
|
||||
ctx,
|
||||
ctx.reconcileRoundTripRef.current
|
||||
)
|
||||
ctx.onContentChangeRef.current(markdown)
|
||||
ctx.onSaveRef.current(markdown)
|
||||
return true
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import {
|
||||
commitRichMarkdownSerialization,
|
||||
type RichMarkdownReconcileRefs
|
||||
} from './rich-markdown-serialization-commit'
|
||||
import { handleRichMarkdownSaveShortcut } from './rich-markdown-save-shortcut'
|
||||
import type { KeyHandlerContext } from './rich-markdown-key-handler'
|
||||
|
||||
// Style-only canonicalizer mirroring the #6080 rewrites; stands in for the live
|
||||
// editor's getMarkdown so these tests need no DOM. Strips the trailing newline
|
||||
// like the real getMarkdown, so the branch-6 exact re-parse comparison behaves as
|
||||
// in production (the reconcile roundTrip and the edited getMarkdown agree on EOF).
|
||||
function fakeCanonicalize(md: string): string {
|
||||
return md
|
||||
.replace(/__([^_]+)__/g, '**$1**')
|
||||
.replace(/_([^_]+)_/g, '*$1*')
|
||||
.replace(/\n+$/, '')
|
||||
}
|
||||
const roundTrip = (md: string): string => fakeCanonicalize(md)
|
||||
|
||||
function refs(originalSource: string, baseCanonical: string): RichMarkdownReconcileRefs {
|
||||
return {
|
||||
originalSourceRef: { current: originalSource },
|
||||
baseCanonicalRef: { current: baseCanonical },
|
||||
lastCommittedMarkdownRef: { current: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function fakeEditor(getMarkdown: () => string): Editor {
|
||||
return { getMarkdown } as unknown as Editor
|
||||
}
|
||||
|
||||
describe('commitRichMarkdownSerialization (shared disk-bound serialize chokepoint)', () => {
|
||||
it('persists SOURCE-PRESERVING bytes, not raw getMarkdown (regression gate for #6080)', () => {
|
||||
const r = refs('# Title\n\n_word_\n', '# Title\n\n*word*')
|
||||
const editor = fakeEditor(() => '# Title!\n\n*word*') // canonical edit
|
||||
|
||||
const { markdown, didSerialize } = commitRichMarkdownSerialization(editor, r, roundTrip)
|
||||
|
||||
// Fails if this site reverted to raw getMarkdown() (would emit *word*).
|
||||
expect(markdown).toBe('# Title!\n\n_word_\n')
|
||||
expect(didSerialize).toBe(true)
|
||||
})
|
||||
|
||||
it('advances all three refs so the next incremental edit patches onto the reconciled source', () => {
|
||||
const r = refs('# Title\n\n_word_\n', '# Title\n\n*word*')
|
||||
const editor = fakeEditor(() => '# Title!\n\n*word*')
|
||||
|
||||
commitRichMarkdownSerialization(editor, r, roundTrip)
|
||||
|
||||
expect(r.originalSourceRef.current).toBe('# Title!\n\n_word_\n') // reconciled bytes
|
||||
expect(r.baseCanonicalRef.current).toBe('# Title!\n\n*word*') // canonical of reconciled
|
||||
expect(r.lastCommittedMarkdownRef.current).toBe('# Title!\n\n_word_\n') // exact disk bytes
|
||||
})
|
||||
|
||||
it('falls back to the last committed bytes when the editor is torn down (null)', () => {
|
||||
const r = refs('# Title\n\n_word_\n', '# Title\n\n*word*')
|
||||
r.lastCommittedMarkdownRef.current = '# Title\n\n_word_\n'
|
||||
|
||||
const { markdown, didSerialize } = commitRichMarkdownSerialization(null, r, roundTrip)
|
||||
|
||||
expect(didSerialize).toBe(false)
|
||||
expect(markdown).toBe('# Title\n\n_word_\n')
|
||||
// Refs untouched on a torn-down editor.
|
||||
expect(r.originalSourceRef.current).toBe('# Title\n\n_word_\n')
|
||||
})
|
||||
|
||||
it('does not crash when getMarkdown throws (editor destroyed mid-flush)', () => {
|
||||
const r = refs('src', 'src')
|
||||
r.lastCommittedMarkdownRef.current = 'safe'
|
||||
const editor = fakeEditor(() => {
|
||||
throw new Error('editor destroyed')
|
||||
})
|
||||
|
||||
const { markdown, didSerialize } = commitRichMarkdownSerialization(editor, r, roundTrip)
|
||||
|
||||
expect(didSerialize).toBe(false)
|
||||
expect(markdown).toBe('safe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleRichMarkdownSaveShortcut (Cmd/Ctrl+S persistence site)', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function saveEvent(): KeyboardEvent & { preventDefault: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
key: 's',
|
||||
code: 'KeyS',
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
isComposing: false,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as KeyboardEvent & { preventDefault: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
function saveContext(editor: Editor | null): {
|
||||
ctx: KeyHandlerContext
|
||||
onSave: ReturnType<typeof vi.fn>
|
||||
onContentChange: ReturnType<typeof vi.fn>
|
||||
flush: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const onSave = vi.fn()
|
||||
const onContentChange = vi.fn()
|
||||
const flush = vi.fn()
|
||||
const ctx = {
|
||||
editorRef: { current: editor },
|
||||
originalSourceRef: { current: '# Title\n\n_word_\n' },
|
||||
baseCanonicalRef: { current: '# Title\n\n*word*' },
|
||||
lastCommittedMarkdownRef: { current: '' },
|
||||
reconcileRoundTripRef: { current: roundTrip },
|
||||
onContentChangeRef: { current: onContentChange },
|
||||
onSaveRef: { current: onSave },
|
||||
flushPendingSerialization: flush
|
||||
} as unknown as KeyHandlerContext
|
||||
return { ctx, onSave, onContentChange, flush }
|
||||
}
|
||||
|
||||
it('flushes then saves SOURCE-PRESERVING bytes on Cmd+S (mac)', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
const editor = fakeEditor(() => '# Title!\n\n*word*')
|
||||
const { ctx, onSave, onContentChange, flush } = saveContext(editor)
|
||||
const event = saveEvent()
|
||||
|
||||
expect(handleRichMarkdownSaveShortcut(ctx, event)).toBe(true)
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(flush).toHaveBeenCalledTimes(1)
|
||||
// onSave/onContentChange receive reconciled bytes, not raw *word*.
|
||||
expect(onSave).toHaveBeenCalledWith('# Title!\n\n_word_\n')
|
||||
expect(onContentChange).toHaveBeenCalledWith('# Title!\n\n_word_\n')
|
||||
})
|
||||
|
||||
it('ignores non-save keystrokes and touches nothing', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
const editor = fakeEditor(() => '# Title!\n\n*word*')
|
||||
const { ctx, onSave, flush } = saveContext(editor)
|
||||
const event = { ...saveEvent(), key: 'a', code: 'KeyA' } as KeyboardEvent & {
|
||||
preventDefault: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
expect(handleRichMarkdownSaveShortcut(ctx, event)).toBe(false)
|
||||
expect(flush).not.toHaveBeenCalled()
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { MutableRefObject } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { reconcileSerializedMarkdown } from './rich-markdown-source-reconcile'
|
||||
|
||||
export type RichMarkdownReconcileRefs = {
|
||||
/** Current on-disk source bytes; updated to the reconciled output each commit. */
|
||||
originalSourceRef: MutableRefObject<string>
|
||||
/** Canonical serialization of `originalSourceRef` (getMarkdown of the unedited doc). */
|
||||
baseCanonicalRef: MutableRefObject<string>
|
||||
/** Exact bytes last handed to disk; gates the external-change reload. */
|
||||
lastCommittedMarkdownRef: MutableRefObject<string>
|
||||
}
|
||||
|
||||
export type RichMarkdownSerializationCommit = {
|
||||
/** Bytes to persist to disk (reconciled, or last committed on a torn-down editor). */
|
||||
markdown: string
|
||||
/** False when the editor was torn down before serializing (refs left untouched). */
|
||||
didSerialize: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Single place where reconciliation and ref updates happen for every disk-bound
|
||||
* serialize site. Computes edited=getMarkdown(), reconciles toward the original
|
||||
* source style, and advances the refs for the next incremental edit.
|
||||
*/
|
||||
export function commitRichMarkdownSerialization(
|
||||
editor: Editor | null,
|
||||
refs: RichMarkdownReconcileRefs,
|
||||
roundTrip: (markdown: string) => string | null
|
||||
): RichMarkdownSerializationCommit {
|
||||
let edited: string | undefined
|
||||
try {
|
||||
edited = editor?.getMarkdown()
|
||||
} catch {
|
||||
// Why: the editor can be destroyed between scheduling and serializing; a
|
||||
// save/restart flush must never crash here.
|
||||
edited = undefined
|
||||
}
|
||||
if (edited === undefined) {
|
||||
// Torn-down fallback: reuse the already-reconciled bytes without patching.
|
||||
return { markdown: refs.lastCommittedMarkdownRef.current, didSerialize: false }
|
||||
}
|
||||
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource: refs.originalSourceRef.current,
|
||||
baseCanonical: refs.baseCanonicalRef.current,
|
||||
edited,
|
||||
roundTrip
|
||||
})
|
||||
|
||||
refs.originalSourceRef.current = reconciled
|
||||
// Why: reconciled ≡ edited semantically, so its canonical form is `edited`
|
||||
// (also correct in every fallback branch, which returns `edited` verbatim).
|
||||
refs.baseCanonicalRef.current = edited
|
||||
// Why: the external-change guard short-circuits on lastCommittedMarkdownRef, so
|
||||
// it must hold the exact reconciled bytes that reach disk, not the canonical form.
|
||||
refs.lastCommittedMarkdownRef.current = reconciled
|
||||
return { markdown: reconciled, didSerialize: true }
|
||||
}
|
||||
|
|
@ -0,0 +1,516 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { reconcileSerializedMarkdown } from './rich-markdown-source-reconcile'
|
||||
import { serializeRichMarkdownForReconcile } from './rich-markdown-reconcile-serializer'
|
||||
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
import type { RichMarkdownImageResolverContext } from './rich-markdown-image-context'
|
||||
|
||||
// A deterministic stand-in for the live editor's canonicalization, covering the
|
||||
// exact style rewrites #6080 reports. Used both to derive baseCanonical/edited
|
||||
// and as the injected safety `roundTrip`, so the fake stays self-consistent.
|
||||
function fakeCanonicalize(md: string): string {
|
||||
return md
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/^(\s*)\* /, '$1- ')) // `* bullet` -> `- bullet`
|
||||
.join('\n')
|
||||
.replace(/__([^_]+)__/g, '**$1**') // `__strong__` -> `**strong**` (before emphasis)
|
||||
.replace(/_([^_]+)_/g, '*$1*') // `_emphasis_` -> `*emphasis*`
|
||||
}
|
||||
|
||||
const roundTrip = (md: string): string => fakeCanonicalize(md)
|
||||
|
||||
/** Reconcile using the fake canonicalizer for both the base and the safety re-parse. */
|
||||
function reconcileWithFake(originalSource: string, edited: string): string {
|
||||
return reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical: fakeCanonicalize(originalSource),
|
||||
edited,
|
||||
roundTrip
|
||||
})
|
||||
}
|
||||
|
||||
describe('reconcileSerializedMarkdown', () => {
|
||||
it('preserves untouched non-canonical regions on a 1-char edit', () => {
|
||||
const originalSource = '# Title\n\n_emphasis_ and __strong__\n\n* one\n* two\n'
|
||||
const baseCanonical = fakeCanonicalize(originalSource)
|
||||
const edited = baseCanonical.replace('# Title', '# Title!')
|
||||
|
||||
const reconciled = reconcileWithFake(originalSource, edited)
|
||||
|
||||
// Untouched markup keeps its original bytes.
|
||||
expect(reconciled).toContain('_emphasis_')
|
||||
expect(reconciled).toContain('__strong__')
|
||||
expect(reconciled).toContain('* one')
|
||||
expect(reconciled).toContain('* two')
|
||||
// The edited region reflects the change.
|
||||
expect(reconciled).toContain('# Title!')
|
||||
// Never re-canonicalized.
|
||||
expect(reconciled).not.toContain('*emphasis*')
|
||||
expect(reconciled).not.toContain('- one')
|
||||
})
|
||||
|
||||
it('preserves the trailing newline of the original source', () => {
|
||||
const originalSource = '# H\n\n_word_\n'
|
||||
const edited = fakeCanonicalize(originalSource).replace('# H', '# H!')
|
||||
|
||||
const reconciled = reconcileWithFake(originalSource, edited)
|
||||
|
||||
expect(reconciled.endsWith('\n')).toBe(true)
|
||||
expect(reconciled).toContain('_word_')
|
||||
})
|
||||
|
||||
it('keeps newly-typed content canonical while preserving the original style', () => {
|
||||
const originalSource = '_old_\n'
|
||||
const edited = '*old* and *new*\n'
|
||||
|
||||
const reconciled = reconcileWithFake(originalSource, edited)
|
||||
|
||||
expect(reconciled).toContain('_old_') // original preserved
|
||||
expect(reconciled).toContain('*new*') // new content stays canonical
|
||||
})
|
||||
|
||||
it('returns edited when the source is already canonical (branch 2)', () => {
|
||||
const originalSource = '*word* here\n'
|
||||
const edited = '*word* there\n'
|
||||
|
||||
// baseCanonical === originalSource, so there is nothing to preserve.
|
||||
expect(reconcileWithFake(originalSource, edited)).toBe(edited)
|
||||
})
|
||||
|
||||
it('returns the source verbatim when there is no semantic change (branch 1)', () => {
|
||||
const originalSource = '_word_ and * bullet\n'
|
||||
const baseCanonical = fakeCanonicalize(originalSource)
|
||||
|
||||
// edited === baseCanonical: e.g. cursor moved, selection changed, no edit.
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical,
|
||||
edited: baseCanonical,
|
||||
roundTrip
|
||||
})
|
||||
|
||||
// Exact bytes, including trailing newline — zero disk churn.
|
||||
expect(reconciled).toBe(originalSource)
|
||||
})
|
||||
|
||||
it('falls back to canonical when either string exceeds the size cap (branch 3)', () => {
|
||||
const big = 'line of text\n'.repeat(9000) // ~117 KB, above the size cap
|
||||
const originalSource = `${big}_x_\n`
|
||||
const edited = `${big}*x* y\n`
|
||||
|
||||
// Non-canonical source + real semantic change, but oversize → canonical fallback.
|
||||
expect(reconcileWithFake(originalSource, edited)).toBe(edited)
|
||||
})
|
||||
|
||||
it('bounds diff work for replacement-heavy edits instead of using the 1s library default', () => {
|
||||
let now = 0
|
||||
const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => {
|
||||
now += 10
|
||||
return now
|
||||
})
|
||||
const baseCanonical = 'a'.repeat(20_000)
|
||||
const edited = 'b'.repeat(20_000)
|
||||
|
||||
try {
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource: `_${baseCanonical.slice(1)}`,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip: (markdown) => markdown
|
||||
})
|
||||
|
||||
expect(reconciled).toBe(edited)
|
||||
// The 10ms budget expires almost immediately; the dependency's default
|
||||
// one-second budget takes ~102 reads with this deterministic clock.
|
||||
expect(dateNow.mock.calls.length).toBeLessThan(10)
|
||||
} finally {
|
||||
dateNow.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips the dependency half-match path when its long seed repeats', () => {
|
||||
const dateNow = vi.spyOn(Date, 'now')
|
||||
const baseCanonical = 'ab'.repeat(24_500)
|
||||
const edited = 'ba'.repeat(24_500)
|
||||
|
||||
try {
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource: `_${baseCanonical.slice(1)}`,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip: (markdown) => markdown
|
||||
})
|
||||
|
||||
expect(reconciled).toBe(edited)
|
||||
// The dependency reads the clock when diffing; zero reads proves the
|
||||
// repetitive-input preflight returned before its unbounded half-match scan.
|
||||
expect(dateNow).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
dateNow.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('still preserves a small edit inside a highly repetitive document', () => {
|
||||
const dateNow = vi.spyOn(Date, 'now')
|
||||
const baseCanonical = 'ab'.repeat(24_500)
|
||||
const editIndex = Math.floor(baseCanonical.length / 2)
|
||||
const edited = `${baseCanonical.slice(0, editIndex)}X${baseCanonical.slice(editIndex + 1)}`
|
||||
|
||||
try {
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource: `_${baseCanonical.slice(1)}`,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip: (markdown) => `a${markdown.slice(1)}`
|
||||
})
|
||||
|
||||
expect(reconciled).toBe(`_${edited.slice(1)}`)
|
||||
expect(dateNow).toHaveBeenCalled()
|
||||
} finally {
|
||||
dateNow.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to canonical when a hunk fails to apply (branch 5)', () => {
|
||||
const baseCanonical = 'The quick brown fox jumps over the lazy dog every morning.\n'
|
||||
const edited = 'The quick brown fox LEAPS over the lazy dog every morning.\n'
|
||||
const originalSource = 'Zzz totally unrelated content sharing nothing at all here.\n'
|
||||
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip
|
||||
})
|
||||
|
||||
expect(reconciled).toBe(edited)
|
||||
})
|
||||
|
||||
it('falls back to canonical when the safety re-parse mismatches (branch 6)', () => {
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource: '_a_\n',
|
||||
baseCanonical: '*a*\n',
|
||||
edited: '*a* b\n',
|
||||
// Simulates a fuzzy misplacement whose render differs from `edited`.
|
||||
roundTrip: () => 'something entirely different'
|
||||
})
|
||||
|
||||
expect(reconciled).toBe('*a* b\n')
|
||||
})
|
||||
|
||||
it('falls back to canonical when the safety serializer returns null (branch 6)', () => {
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource: '_a_\n',
|
||||
baseCanonical: '*a*\n',
|
||||
edited: '*a* b\n',
|
||||
roundTrip: () => null
|
||||
})
|
||||
|
||||
expect(reconciled).toBe('*a* b\n')
|
||||
})
|
||||
|
||||
it('preserves non-canonical regions across an incremental two-edit ref chain', () => {
|
||||
const originalSource = '# Title\n\n_emphasis_\n\n* item\n'
|
||||
const base = fakeCanonicalize(originalSource)
|
||||
const edited1 = base.replace('# Title', '# Title A')
|
||||
|
||||
const reconciled1 = reconcileWithFake(originalSource, edited1)
|
||||
expect(reconciled1).toContain('_emphasis_')
|
||||
expect(reconciled1).toContain('* item')
|
||||
|
||||
// Simulate the commit helper advancing the refs: originalSource := reconciled1,
|
||||
// baseCanonical := edited1. The second edit must still preserve the markup.
|
||||
const edited2 = edited1.replace('# Title A', '# Title AB')
|
||||
const reconciled2 = reconcileSerializedMarkdown({
|
||||
originalSource: reconciled1,
|
||||
baseCanonical: edited1,
|
||||
edited: edited2,
|
||||
roundTrip
|
||||
})
|
||||
|
||||
expect(reconciled2).toContain('_emphasis_')
|
||||
expect(reconciled2).toContain('* item')
|
||||
expect(reconciled2).toContain('# Title AB')
|
||||
})
|
||||
|
||||
it('skips the safety re-parse for a canonical LF file with a trailing newline (branch 2)', () => {
|
||||
// The common case: file is already canonical but ends in \n, so getMarkdown
|
||||
// (which strips the trailing newline) never equals it byte-for-byte. This
|
||||
// must NOT pay the expensive re-parse — the whole point of branch 2.
|
||||
const originalSource = '# Title\n\n*word*\n\n- one\n- two\n'
|
||||
const baseCanonical = '# Title\n\n*word*\n\n- one\n- two' // getMarkdown: no trailing \n
|
||||
const edited = '# Title!\n\n*word*\n\n- one\n- two'
|
||||
const roundTrip = vi.fn(() => edited)
|
||||
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip
|
||||
})
|
||||
|
||||
expect(roundTrip).not.toHaveBeenCalled()
|
||||
// Edit applied, and the source's trailing newline is preserved.
|
||||
expect(reconciled).toBe('# Title!\n\n*word*\n\n- one\n- two\n')
|
||||
})
|
||||
|
||||
it('preserves CRLF + trailing newline for a canonical CRLF file without re-parsing (branch 2)', () => {
|
||||
const originalSource = '# H\r\n\r\n*word*\r\n'
|
||||
const baseCanonical = '# H\n\n*word*' // getMarkdown: LF, no trailing
|
||||
const edited = '# H!\n\n*word*'
|
||||
const roundTrip = vi.fn(() => edited)
|
||||
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip
|
||||
})
|
||||
|
||||
expect(roundTrip).not.toHaveBeenCalled()
|
||||
expect(reconciled).toBe('# H!\r\n\r\n*word*\r\n')
|
||||
// No mixed endings.
|
||||
expect(reconciled.replace(/\r\n/g, '')).not.toContain('\n')
|
||||
})
|
||||
|
||||
it('restores CRLF on the oversize fallback so a uniform-CRLF file never flips to LF (branch 3)', () => {
|
||||
const big = 'line of text\r\n'.repeat(9000) // ~130 KB, above the cap, all CRLF
|
||||
const originalSource = `${big}_x_\r\n` // non-canonical emphasis, oversize
|
||||
const editedLf = `${big.replace(/\r\n/g, '\n')}*x* y\n`
|
||||
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical: `${big.replace(/\r\n/g, '\n')}*x*\n`,
|
||||
edited: editedLf,
|
||||
roundTrip: () => null
|
||||
})
|
||||
|
||||
// Canonical fallback content, but the source's CRLF endings are kept.
|
||||
expect(reconciled).toContain('\r\n')
|
||||
expect(reconciled.replace(/\r\n/g, '')).not.toContain('\n')
|
||||
})
|
||||
|
||||
it('restores CRLF on the hunk-apply fallback (branch 5)', () => {
|
||||
const originalSource = 'Zzz unrelated content sharing nothing here.\r\n'
|
||||
const baseCanonical = 'The quick brown fox jumps over the lazy dog.\n'
|
||||
const edited = 'The quick brown fox LEAPS over the lazy dog.\n'
|
||||
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip
|
||||
})
|
||||
|
||||
// Falls back to canonical content, but in the source's CRLF ending.
|
||||
expect(reconciled).toBe('The quick brown fox LEAPS over the lazy dog.\r\n')
|
||||
})
|
||||
|
||||
it('preserves CRLF endings on disk without mixing (branch 4 EOL restore)', () => {
|
||||
const originalSource = '# H\r\n\r\n_word_\r\n\r\n* one\r\n'
|
||||
// getMarkdown always emits LF, so base/edited are LF.
|
||||
const baseCanonical = '# H\n\n*word*\n\n- one\n'
|
||||
const edited = '# H!\n\n*word*\n\n- one\n'
|
||||
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip
|
||||
})
|
||||
|
||||
expect(reconciled).toContain('\r\n')
|
||||
expect(reconciled).toContain('_word_')
|
||||
expect(reconciled).toContain('# H!')
|
||||
// No mixed endings: stripping CRLF leaves no lone LF.
|
||||
expect(reconciled.replace(/\r\n/g, '')).not.toContain('\n')
|
||||
})
|
||||
|
||||
it('lands a repeated-substring edit on the correct occurrence or falls back', () => {
|
||||
const originalSource = ['- _alpha_', '- _beta_', '', '- _alpha_', '- _beta_', ''].join('\n')
|
||||
const baseCanonical = fakeCanonicalize(originalSource)
|
||||
// Edit only the FIRST "alpha".
|
||||
const edited = baseCanonical.replace('*alpha*', '*alpha edited*')
|
||||
|
||||
const reconciled = reconcileWithFake(originalSource, edited)
|
||||
|
||||
// Invariant: the reconciled bytes render exactly to `edited` — the change is
|
||||
// never silently relocated to the wrong occurrence or dropped.
|
||||
expect(fakeCanonicalize(reconciled).trimEnd()).toBe(edited.trimEnd())
|
||||
// Either the source-preserving path landed it, or it cleanly fell back.
|
||||
const landedInStyle =
|
||||
reconciled.includes('_alpha edited_') && reconciled.split('_beta_').length === 3
|
||||
expect(landedInStyle || reconciled === edited).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeRichMarkdownForReconcile (real editor pipeline)', () => {
|
||||
const serializerContext = {
|
||||
htmlSuperscriptLinkContext: createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '',
|
||||
worktreeId: '',
|
||||
worktreeRoot: null,
|
||||
sourceOwner: { kind: 'unknown' as const }
|
||||
}),
|
||||
imageResolverContext: {
|
||||
filePath: '',
|
||||
runtimeContext: undefined
|
||||
} satisfies RichMarkdownImageResolverContext
|
||||
}
|
||||
|
||||
const serialize = (md: string): string | null =>
|
||||
serializeRichMarkdownForReconcile(md, serializerContext)
|
||||
|
||||
it('applies normalizeEmptyListItems so empty list items round-trip stably', () => {
|
||||
// `3. ` immediately before a heading parses as an empty list item; without the
|
||||
// normalize step the safety re-parse would spuriously mismatch and no-op.
|
||||
const doc = '3. \n# Heading\n'
|
||||
const once = serialize(doc)
|
||||
expect(once).not.toBeNull()
|
||||
// Idempotent: re-serializing the output is stable (the live editor's steady state).
|
||||
expect(serialize(once!)?.trimEnd()).toBe(once!.trimEnd())
|
||||
})
|
||||
|
||||
it('reconciles a non-canonical doc end-to-end with the real serializer, preserving style', () => {
|
||||
const originalSource = '# Title\n\n_emphasis_ text\n'
|
||||
const baseCanonical = serialize(originalSource)!
|
||||
const edited = baseCanonical.replace('# Title', '# Title!')
|
||||
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip: (md) => serialize(md)
|
||||
})
|
||||
|
||||
expect(reconciled).toContain('_emphasis_') // original style preserved
|
||||
expect(reconciled).toContain('# Title!') // edit applied
|
||||
// Safety invariant: reconciled renders exactly to the editor's canonical output.
|
||||
expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd())
|
||||
})
|
||||
|
||||
it('minimizes the diff for the exact #6080 repro (real serializer)', () => {
|
||||
// The reported case: a 1-char H1 edit must not rewrite untouched markup.
|
||||
const originalSource =
|
||||
'# Title\n\n_emphasis_ and __strong__\n\n* one\n* two\n* three\n\n_more emphasis_\n'
|
||||
const baseCanonical = serialize(originalSource)!
|
||||
const edited = baseCanonical.replace('# Title', '# Title!')
|
||||
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip: (md) => serialize(md)
|
||||
})
|
||||
|
||||
// Every untouched non-canonical construct keeps its original bytes.
|
||||
for (const preserved of [
|
||||
'_emphasis_',
|
||||
'__strong__',
|
||||
'* one',
|
||||
'* two',
|
||||
'* three',
|
||||
'_more emphasis_'
|
||||
]) {
|
||||
expect(reconciled).toContain(preserved)
|
||||
}
|
||||
// The edit landed, and nothing else was re-canonicalized.
|
||||
expect(reconciled).toContain('# Title!')
|
||||
expect(reconciled).not.toContain('*emphasis*')
|
||||
expect(reconciled).not.toContain('- one')
|
||||
// Safety invariant: reconciled renders exactly to the editor's canonical output.
|
||||
expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd())
|
||||
})
|
||||
|
||||
it('branch-2 fast path output still renders to the canonical edit (real serializer)', () => {
|
||||
// Guards the re-parse-skipping fast path: even though branch 2 does NOT run
|
||||
// the safety re-parse, its output must satisfy the same invariant the full
|
||||
// path proves — reconciled renders exactly to the editor's canonical output.
|
||||
const originalSource = '# Title\n\n*emphasis* text\n\n- one\n- two\n' // canonical + trailing \n
|
||||
const baseCanonical = serialize(originalSource)!
|
||||
expect(originalSource).not.toBe(baseCanonical) // differs only by the trailing \n
|
||||
const edited = baseCanonical.replace('# Title', '# Title!')
|
||||
let roundTripCalls = 0
|
||||
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip: (md) => {
|
||||
roundTripCalls += 1
|
||||
return serialize(md)
|
||||
}
|
||||
})
|
||||
|
||||
expect(roundTripCalls).toBe(0) // took the cheap path
|
||||
expect(reconciled).toBe('# Title!\n\n*emphasis* text\n\n- one\n- two\n')
|
||||
expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd())
|
||||
})
|
||||
|
||||
it('does not drop a user-added trailing empty paragraph (branch 2 edited-side guard)', () => {
|
||||
// getMarkdown keeps `\n\n` for a real trailing empty paragraph, so the fast
|
||||
// path must NOT strip it: a canonical doc with a single trailing newline
|
||||
// (branch-2 eligible on the source side) edited to add a trailing empty
|
||||
// paragraph must persist that block, not silently drop it on save.
|
||||
const originalSource = '# H\n\ntext\n'
|
||||
const baseCanonical = serialize(originalSource)! // '# H\n\ntext'
|
||||
const edited = '# H\n\ntext\n\n' // user pressed Enter at EOF
|
||||
let roundTripCalls = 0
|
||||
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip: (md) => {
|
||||
roundTripCalls += 1
|
||||
return serialize(md)
|
||||
}
|
||||
})
|
||||
|
||||
expect(roundTripCalls).toBeGreaterThan(0) // deferred to the safety-verified path
|
||||
// Exact reload equality (NOT trimEnd): the empty paragraph survives.
|
||||
expect(serialize(reconciled)).toBe(serialize(edited))
|
||||
})
|
||||
|
||||
it('does not introduce a spurious when a trailing-blank-line doc is edited (branch 2 guard)', () => {
|
||||
// A canonical heading-ending doc with 3 trailing blank lines: the fast path
|
||||
// must NOT preserve that long trailing run while skipping the re-parse, or a
|
||||
// reload materializes a literal paragraph (a content change). The
|
||||
// >1-trailing-newline guard defers this to the safety-checked path.
|
||||
const originalSource = '# Notes\n\nSome text here.\n\n## TODO\n\n\n\n'
|
||||
const baseCanonical = serialize(originalSource)!
|
||||
const edited = baseCanonical.replace('## TODO', '## TODO\n\nA new note.')
|
||||
let roundTripCalls = 0
|
||||
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip: (md) => {
|
||||
roundTripCalls += 1
|
||||
return serialize(md)
|
||||
}
|
||||
})
|
||||
|
||||
expect(roundTripCalls).toBeGreaterThan(0) // took the safety-verified path
|
||||
expect(reconciled).not.toContain(' ')
|
||||
// Safety invariant: reconciled renders exactly to the editor's canonical edit.
|
||||
expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd())
|
||||
})
|
||||
|
||||
it('falls back cleanly when an empty-list doc cannot be source-preserved', () => {
|
||||
// Combines an empty list item with non-canonical emphasis; whatever the fuzzy
|
||||
// patch does, the output must render to `edited` (no corruption).
|
||||
const originalSource = '_lead_\n\n3. \n# Heading\n'
|
||||
const baseCanonical = serialize(originalSource)!
|
||||
const edited = baseCanonical.replace('Heading', 'Heading!')
|
||||
|
||||
const reconciled = reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip: (md) => serialize(md)
|
||||
})
|
||||
|
||||
expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd())
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import {
|
||||
applyPatches,
|
||||
cleanupEfficiency,
|
||||
cleanupSemantic,
|
||||
makeDiff,
|
||||
makePatches
|
||||
} from '@sanity/diff-match-patch'
|
||||
|
||||
// Why: bound the safety re-parse cost — it builds a throwaway TipTap editor per
|
||||
// commit, whose parse time scales with document length (UTF-16 code units, the
|
||||
// unit `.length` returns and the closest cheap proxy for parse cost — not UTF-8
|
||||
// bytes). Measured ~50-67ms at the cap on fast HW (higher on low-end/SSH), under
|
||||
// the 300ms serialize debounce; a higher cap risks a main-thread stall on
|
||||
// slow/SSH hosts. Above the cap we use today's canonical output (no regression).
|
||||
const RECONCILE_SIZE_CAP_CODE_UNITS = 50_000
|
||||
|
||||
// Why: diff-match-patch defaults to a one-second search, which freezes the
|
||||
// renderer on replacement-heavy paste/edit paths. A timed-out coarse diff is
|
||||
// still safe because the round-trip proof below rejects any bad placement.
|
||||
const RECONCILE_DIFF_TIMEOUT_SECONDS = 0.01
|
||||
|
||||
export type ReconcileSerializedMarkdownParams = {
|
||||
/** Current on-disk source bytes (possibly non-canonical, possibly CRLF). */
|
||||
originalSource: string
|
||||
/** Canonical serialization of `originalSource` (what getMarkdown returns unedited). */
|
||||
baseCanonical: string
|
||||
/** Canonical serialization after the user's edit (getMarkdown, always LF). */
|
||||
edited: string
|
||||
/**
|
||||
* Re-serializes reconciled bytes through the live editor's pipeline. Injected
|
||||
* so the reconcile logic is unit-testable without a DOM. Returns null when the
|
||||
* throwaway serializer fails (treated as a safety mismatch → canonical fallback).
|
||||
*/
|
||||
roundTrip: (markdown: string) => string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries the user's edit into the original source style so untouched regions
|
||||
* keep their non-canonical bytes. Falls back to the canonical `edited` output
|
||||
* (today's behavior) whenever the source-preserving transform cannot be proven
|
||||
* render-equivalent — so it can never corrupt or relocate content.
|
||||
*/
|
||||
export function reconcileSerializedMarkdown({
|
||||
originalSource,
|
||||
baseCanonical,
|
||||
edited,
|
||||
roundTrip
|
||||
}: ReconcileSerializedMarkdownParams): string {
|
||||
// Branch 1: no semantic change vs the unedited doc → preserve the original
|
||||
// bytes verbatim (incl. trailing newline / EOL); zero disk churn.
|
||||
if (edited === baseCanonical) {
|
||||
return originalSource
|
||||
}
|
||||
|
||||
// Work in LF space: getMarkdown emits LF while `originalSource` may be CRLF, so
|
||||
// patching LF-context hunks against raw CRLF would fuzzy-match poorly and mix
|
||||
// endings. The detected EOL is restored on EVERY non-verbatim return below so a
|
||||
// uniform-CRLF file never silently flips to LF, even on a canonical fallback.
|
||||
const eol = detectDominantEol(originalSource)
|
||||
const originalSourceLf = toLf(originalSource)
|
||||
const baseLf = toLf(baseCanonical)
|
||||
const editedLf = toLf(edited)
|
||||
|
||||
// Branch 2: the source body equals the canonical `edited` body apart from its
|
||||
// EOL/trailing newlines, so nothing non-canonical remains to preserve. Skip the
|
||||
// expensive safety re-parse and carry the source's EOL + trailing newline onto
|
||||
// the edit. Two guards keep the re-parse skip provably drift-free:
|
||||
// - source trailing run ≤ 1 newline: a longer trailing-blank run can
|
||||
// materialize a spurious ` ` empty paragraph on reload when the edit
|
||||
// turns the EOF block into a paragraph;
|
||||
// - `edited` has no trailing newline: getMarkdown keeps a trailing `\n\n` for a
|
||||
// real trailing empty paragraph, so stripping it here would silently drop a
|
||||
// block the user added.
|
||||
// Both cases (rare) defer to the branch-6-verified path below instead.
|
||||
const originalTrailingNewlines = originalSourceLf.match(/\n+$/)?.[0] ?? ''
|
||||
if (
|
||||
originalTrailingNewlines.length <= 1 &&
|
||||
!editedLf.endsWith('\n') &&
|
||||
stripTrailingNewlines(originalSourceLf) === stripTrailingNewlines(baseLf)
|
||||
) {
|
||||
return restoreEol(editedLf + originalTrailingNewlines, eol)
|
||||
}
|
||||
|
||||
// Branch 3: oversize → bounded-cost canonical fallback (today's behavior).
|
||||
if (
|
||||
Math.max(originalSource.length, baseCanonical.length, edited.length) >
|
||||
RECONCILE_SIZE_CAP_CODE_UNITS
|
||||
) {
|
||||
return restoreEol(editedLf, eol)
|
||||
}
|
||||
|
||||
// Branch 4: run the divergent-base patch entirely in LF space.
|
||||
// Why: the dependency's half-match accelerator ignores its diff deadline and
|
||||
// can spend 100ms+ scanning repeated long seeds. Returning canonical is safer
|
||||
// than entering that unbounded path for highly repetitive replacements.
|
||||
if (hasRepeatedHalfMatchSeed(baseLf, editedLf)) {
|
||||
return restoreEol(editedLf, eol)
|
||||
}
|
||||
let diffs = makeDiff(baseLf, editedLf, {
|
||||
checkLines: true,
|
||||
timeout: RECONCILE_DIFF_TIMEOUT_SECONDS
|
||||
})
|
||||
// Match makePatches(textA, textB)'s cleanup behavior while supplying the
|
||||
// bounded diff ourselves instead of inheriting the library's 1s timeout.
|
||||
if (diffs.length > 2) {
|
||||
diffs = cleanupSemantic(diffs)
|
||||
diffs = cleanupEfficiency(diffs)
|
||||
}
|
||||
const patches = makePatches(baseLf, diffs)
|
||||
const [reconciledLf, results] = applyPatches(patches, originalSourceLf)
|
||||
|
||||
// Branch 5: a hunk that failed to locate in the non-canonical source → the
|
||||
// fuzzy match is unreliable here, so fall back to canonical.
|
||||
if (results.some((applied) => !applied)) {
|
||||
return restoreEol(editedLf, eol)
|
||||
}
|
||||
|
||||
// Branch 6: prove the reconciled bytes render-equal the editor's document.
|
||||
// Any fuzzy misplacement (e.g. onto a repeated substring) changes canonical
|
||||
// output and is caught here → canonical fallback. Compared under norm so only
|
||||
// style/EOL/EOF differences are tolerated.
|
||||
const reparsed = roundTrip(reconciledLf)
|
||||
if (reparsed === null || normalizeForSafety(reparsed) !== normalizeForSafety(editedLf)) {
|
||||
return restoreEol(editedLf, eol)
|
||||
}
|
||||
|
||||
// Restore the detected EOL as the final step so reconciled CRLF stays CRLF.
|
||||
return restoreEol(reconciledLf, eol)
|
||||
}
|
||||
|
||||
function stripTrailingNewlines(lfText: string): string {
|
||||
return lfText.replace(/\n+$/, '')
|
||||
}
|
||||
|
||||
function detectDominantEol(text: string): '\n' | '\r\n' {
|
||||
const totalLf = (text.match(/\n/g) ?? []).length
|
||||
const crlf = (text.match(/\r\n/g) ?? []).length
|
||||
const lfOnly = totalLf - crlf
|
||||
return crlf > 0 && crlf >= lfOnly ? '\r\n' : '\n'
|
||||
}
|
||||
|
||||
function toLf(text: string): string {
|
||||
return text.replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
function restoreEol(lfText: string, eol: '\n' | '\r\n'): string {
|
||||
// lfText is pure LF, so a blind LF→CRLF replace produces no mixed endings.
|
||||
return eol === '\r\n' ? lfText.replace(/\n/g, '\r\n') : lfText
|
||||
}
|
||||
|
||||
function normalizeForSafety(text: string): string {
|
||||
// Why: both operands are canonical getMarkdown outputs, so an equal-render
|
||||
// reconciliation is byte-identical here apart from EOL. Compare exactly (only
|
||||
// CRLF-normalized) — a trailing `\n\n` empty paragraph is semantic, so a lenient
|
||||
// trimEnd would mask exactly the trailing-block drift branch 6 must catch.
|
||||
return text.replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
function hasRepeatedHalfMatchSeed(textA: string, textB: string): boolean {
|
||||
// Match diff-match-patch's own prefix/suffix trimming so a small edit in a
|
||||
// repetitive document is not mistaken for a repetitive replacement.
|
||||
const minimumLength = Math.min(textA.length, textB.length)
|
||||
let prefixLength = 0
|
||||
while (
|
||||
prefixLength < minimumLength &&
|
||||
textA.charCodeAt(prefixLength) === textB.charCodeAt(prefixLength)
|
||||
) {
|
||||
prefixLength += 1
|
||||
}
|
||||
let suffixLength = 0
|
||||
while (
|
||||
suffixLength < minimumLength - prefixLength &&
|
||||
textA.charCodeAt(textA.length - suffixLength - 1) ===
|
||||
textB.charCodeAt(textB.length - suffixLength - 1)
|
||||
) {
|
||||
suffixLength += 1
|
||||
}
|
||||
const middleA = textA.slice(prefixLength, textA.length - suffixLength)
|
||||
const middleB = textB.slice(prefixLength, textB.length - suffixLength)
|
||||
const longText = middleA.length > middleB.length ? middleA : middleB
|
||||
const shortText = middleA.length > middleB.length ? middleB : middleA
|
||||
if (longText.length < 4 || shortText.length * 2 < longText.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const seedLength = Math.floor(longText.length / 4)
|
||||
for (const start of [Math.ceil(longText.length / 4), Math.ceil(longText.length / 2)]) {
|
||||
const seed = longText.slice(start, start + seedLength)
|
||||
const firstMatch = shortText.indexOf(seed)
|
||||
if (firstMatch !== -1 && shortText.includes(seed, firstMatch + 1)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -60,6 +60,9 @@ function createContext(editor: Editor): KeyHandlerContext {
|
|||
editorRef: { current: editor },
|
||||
rootRef: { current: null },
|
||||
lastCommittedMarkdownRef: { current: '' },
|
||||
originalSourceRef: { current: '' },
|
||||
baseCanonicalRef: { current: '' },
|
||||
reconcileRoundTripRef: { current: () => null },
|
||||
onContentChangeRef: { current: vi.fn() },
|
||||
onSaveRef: { current: vi.fn() },
|
||||
isEditingLinkRef: { current: false },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { useRichMarkdownProgrammaticSync } from './useRichMarkdownProgrammaticSync'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import {
|
||||
createRichMarkdownEditorCodec,
|
||||
type RichMarkdownEditorCodec
|
||||
} from './rich-markdown-source-transport'
|
||||
import {
|
||||
createRichMarkdownHtmlSuperscriptLinkContext,
|
||||
type RichMarkdownHtmlSuperscriptLinkContext
|
||||
} from './rich-markdown-html-superscript-link-context'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
|
||||
function buildEditor(
|
||||
codec: RichMarkdownEditorCodec,
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext,
|
||||
markdown: string
|
||||
): Editor {
|
||||
return new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions({
|
||||
codec,
|
||||
htmlSuperscriptLinks: true,
|
||||
htmlSuperscriptLinkContext
|
||||
}),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(markdown, codec, { htmlSuperscriptLinks: true }),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
}
|
||||
|
||||
describe('useRichMarkdownProgrammaticSync external-reload baseline adoption (#6080)', () => {
|
||||
it('adopts externally-canonicalized bytes as the reconciliation baseline without a reload', () => {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
const htmlSuperscriptLinkContext = createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '',
|
||||
worktreeId: '',
|
||||
worktreeRoot: null,
|
||||
sourceOwner: { kind: 'unknown' as const }
|
||||
})
|
||||
// The editor currently shows emphasis loaded from the original `_old_` source;
|
||||
// its canonical (getMarkdown) form is `*old*` with no trailing newline.
|
||||
const canonical = '# T\n\n*old*'
|
||||
const editor = buildEditor(codec, htmlSuperscriptLinkContext, canonical)
|
||||
expect(editor.getMarkdown()).toBe(canonical)
|
||||
|
||||
// Refs still reflect the ORIGINAL non-canonical source bytes on disk.
|
||||
const lastCommittedMarkdownRef = { current: '# T\n\n_old_' }
|
||||
const originalSourceRef = { current: '# T\n\n_old_' }
|
||||
const baseCanonicalRef = { current: canonical }
|
||||
const isApplyingProgrammaticUpdateRef = { current: false }
|
||||
|
||||
try {
|
||||
// An external tool rewrites disk to the canonical bytes (same semantics,
|
||||
// new byte-level style). The doc already renders them, so no reload — but
|
||||
// the reconciliation baseline must still be refreshed to the new bytes.
|
||||
renderHook(() =>
|
||||
useRichMarkdownProgrammaticSync({
|
||||
codec,
|
||||
content: canonical,
|
||||
docLinkMenuSetter: vi.fn(),
|
||||
editor,
|
||||
fileId: 'f1',
|
||||
filePath: '/repo/README.md',
|
||||
isApplyingProgrammaticUpdateRef,
|
||||
lastCommittedMarkdownRef,
|
||||
originalSourceRef,
|
||||
baseCanonicalRef,
|
||||
markdownDocuments: undefined,
|
||||
rootRef: { current: null },
|
||||
runtimeEnvironmentId: null,
|
||||
settings: null,
|
||||
slashMenuSetter: vi.fn(),
|
||||
worktreeId: 'w1',
|
||||
worktreeRoot: null
|
||||
})
|
||||
)
|
||||
|
||||
// Without the fix these stay stale at `_old_`, and the next edit would
|
||||
// rewrite the file back to `_old_`, silently undoing the external change.
|
||||
expect(lastCommittedMarkdownRef.current).toBe(canonical)
|
||||
expect(originalSourceRef.current).toBe(canonical)
|
||||
expect(baseCanonicalRef.current).toBe(canonical)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -22,6 +22,8 @@ type RichMarkdownProgrammaticSyncOptions = {
|
|||
filePath: string
|
||||
isApplyingProgrammaticUpdateRef: MutableRefObject<boolean>
|
||||
lastCommittedMarkdownRef: MutableRefObject<string>
|
||||
originalSourceRef: MutableRefObject<string>
|
||||
baseCanonicalRef: MutableRefObject<string>
|
||||
markdownDocuments?: MarkdownDocument[]
|
||||
rootRef: MutableRefObject<HTMLDivElement | null>
|
||||
runtimeEnvironmentId?: string | null
|
||||
|
|
@ -46,6 +48,8 @@ export function useRichMarkdownProgrammaticSync({
|
|||
filePath,
|
||||
isApplyingProgrammaticUpdateRef,
|
||||
lastCommittedMarkdownRef,
|
||||
originalSourceRef,
|
||||
baseCanonicalRef,
|
||||
markdownDocuments,
|
||||
rootRef,
|
||||
runtimeEnvironmentId,
|
||||
|
|
@ -101,12 +105,29 @@ export function useRichMarkdownProgrammaticSync({
|
|||
if (!editor) {
|
||||
return
|
||||
}
|
||||
if (content === lastCommittedMarkdownRef.current || editor.getMarkdown() === content) {
|
||||
if (content === lastCommittedMarkdownRef.current) {
|
||||
return
|
||||
}
|
||||
if (editor.getMarkdown() === content) {
|
||||
// Why: disk bytes changed but already render-equal to the current doc (e.g.
|
||||
// an external tool canonicalized byte-level style). Skip the disruptive
|
||||
// reload, but adopt the new bytes as the reconciliation baseline so the next
|
||||
// edit patches onto the fresh source, not the stale pre-change source.
|
||||
lastCommittedMarkdownRef.current = content
|
||||
originalSourceRef.current = content
|
||||
baseCanonicalRef.current = content
|
||||
return
|
||||
}
|
||||
isApplyingProgrammaticUpdateRef.current = true
|
||||
try {
|
||||
applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef, codec)
|
||||
applyExternalRichMarkdownContent(
|
||||
editor,
|
||||
content,
|
||||
lastCommittedMarkdownRef,
|
||||
originalSourceRef,
|
||||
baseCanonicalRef,
|
||||
codec
|
||||
)
|
||||
} finally {
|
||||
isApplyingProgrammaticUpdateRef.current = false
|
||||
}
|
||||
|
|
@ -120,6 +141,8 @@ export function useRichMarkdownProgrammaticSync({
|
|||
fileId,
|
||||
isApplyingProgrammaticUpdateRef,
|
||||
lastCommittedMarkdownRef,
|
||||
originalSourceRef,
|
||||
baseCanonicalRef,
|
||||
rootRef,
|
||||
slashMenuSetter
|
||||
])
|
||||
|
|
@ -129,6 +152,8 @@ function applyExternalRichMarkdownContent(
|
|||
editor: Editor,
|
||||
content: string,
|
||||
lastCommittedMarkdownRef: MutableRefObject<string>,
|
||||
originalSourceRef: MutableRefObject<string>,
|
||||
baseCanonicalRef: MutableRefObject<string>,
|
||||
codec: RichMarkdownEditorCodec
|
||||
): void {
|
||||
try {
|
||||
|
|
@ -145,6 +170,10 @@ function applyExternalRichMarkdownContent(
|
|||
// external content, matching onCreate's single-paragraph reflow behavior.
|
||||
normalizeEmptyListItems(editor)
|
||||
lastCommittedMarkdownRef.current = content
|
||||
// Why: reset the reconciliation baseline to the freshly loaded external bytes
|
||||
// so subsequent edits preserve the new source style, not the pre-reload one.
|
||||
originalSourceRef.current = content
|
||||
baseCanonicalRef.current = editor.getMarkdown()
|
||||
if (hadFocus) {
|
||||
const docSize = editor.state.doc.content.size
|
||||
editor
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { useRef, type MutableRefObject } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { serializeRichMarkdownForReconcile } from './rich-markdown-reconcile-serializer'
|
||||
import { createRichMarkdownImageResolverContext } from './rich-markdown-image-context'
|
||||
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
|
||||
type ReconcileRoundTripParams = {
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
filePath: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the reconciliation safety serializer as a render-updated ref. It
|
||||
* mirrors the live editor's codec/link/image context so the step-6 re-parse
|
||||
* matches getMarkdown(), and only runs on commit — so rebuilding the closure
|
||||
* each render is cheap and always reflects the latest context.
|
||||
*/
|
||||
export function useRichMarkdownReconcileRoundTrip({
|
||||
htmlSuperscriptLinkContext,
|
||||
filePath,
|
||||
runtimeEnvironmentId,
|
||||
worktreeId,
|
||||
worktreeRoot
|
||||
}: ReconcileRoundTripParams): MutableRefObject<(markdown: string) => string | null> {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const ref = useRef<(markdown: string) => string | null>(() => null)
|
||||
ref.current = (markdown) =>
|
||||
serializeRichMarkdownForReconcile(markdown, {
|
||||
htmlSuperscriptLinkContext,
|
||||
imageResolverContext: createRichMarkdownImageResolverContext({
|
||||
filePath,
|
||||
runtimeEnvironmentId,
|
||||
settings,
|
||||
worktreeId,
|
||||
worktreeRoot
|
||||
})
|
||||
})
|
||||
return ref
|
||||
}
|
||||
Loading…
Reference in New Issue