diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 2f499ba6b..dc2b9885a 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -178,9 +178,9 @@ export default function RichMarkdownEditor({ 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. + } catch (error) { + // Why: teardown and reconcile failures are handled above; other failures must stay observable. + console.error('[editor] rich markdown serialize (flush) failed', error) } }, [reconcileRoundTripRef]) diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index 5877d9eb3..09f1479e9 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -254,9 +254,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE 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. + } catch (error) { + // Why: teardown and reconcile failures are handled above; other failures must stay observable. + console.error('[editor] rich markdown serialize (debounced) failed', error) } }, 300) }, diff --git a/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts b/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts index e08b7a08b..008f751b5 100644 --- a/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts @@ -78,6 +78,33 @@ describe('commitRichMarkdownSerialization (shared disk-bound serialize chokepoin expect(didSerialize).toBe(false) expect(markdown).toBe('safe') }) + + it('preserves source EOL and advances refs when reconciliation throws', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const r = refs('# 이전\r\n\r\n_강조_\r\n', '# 이전\n\n*강조*') + const editor = fakeEditor(() => '# 변경\n\n*강조*') + const throwingRoundTrip = vi.fn(() => { + throw new Error('round-trip failed') + }) + + try { + const { markdown, didSerialize } = commitRichMarkdownSerialization( + editor, + r, + throwingRoundTrip + ) + + expect(markdown).toBe('# 변경\r\n\r\n*강조*') + expect(didSerialize).toBe(true) + expect(r.originalSourceRef.current).toBe(markdown) + expect(r.baseCanonicalRef.current).toBe('# 변경\n\n*강조*') + expect(r.lastCommittedMarkdownRef.current).toBe(markdown) + expect(throwingRoundTrip).toHaveBeenCalledTimes(1) + expect(consoleError).toHaveBeenCalledTimes(1) + } finally { + consoleError.mockRestore() + } + }) }) describe('handleRichMarkdownSaveShortcut (Cmd/Ctrl+S persistence site)', () => { diff --git a/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts b/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts index d35e9bde4..dcebdf60d 100644 --- a/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts +++ b/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts @@ -1,6 +1,9 @@ import type { MutableRefObject } from 'react' import type { Editor } from '@tiptap/react' -import { reconcileSerializedMarkdown } from './rich-markdown-source-reconcile' +import { + reconcileSerializedMarkdown, + restoreMarkdownSourceEol +} from './rich-markdown-source-reconcile' export type RichMarkdownReconcileRefs = { /** Current on-disk source bytes; updated to the reconciled output each commit. */ @@ -41,12 +44,19 @@ export function commitRichMarkdownSerialization( return { markdown: refs.lastCommittedMarkdownRef.current, didSerialize: false } } - const reconciled = reconcileSerializedMarkdown({ - originalSource: refs.originalSourceRef.current, - baseCanonical: refs.baseCanonicalRef.current, - edited, - roundTrip - }) + let reconciled: string + try { + reconciled = reconcileSerializedMarkdown({ + originalSource: refs.originalSourceRef.current, + baseCanonical: refs.baseCanonicalRef.current, + edited, + roundTrip + }) + } catch (error) { + // Why: style reconciliation is best-effort; preserve content and source EOL when it fails. + console.error('[editor] markdown reconcile failed; falling back to canonical output', error) + reconciled = restoreMarkdownSourceEol(edited, refs.originalSourceRef.current) + } refs.originalSourceRef.current = reconciled // Why: reconciled ≡ edited semantically, so its canonical form is `edited` diff --git a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts index 9a047992b..f04134100 100644 --- a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts +++ b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts @@ -26,6 +26,10 @@ export type ReconcileSerializedMarkdownParams = { roundTrip: (markdown: string) => string | null } +export function restoreMarkdownSourceEol(markdown: string, source: string): string { + return restoreEol(toLf(markdown), detectDominantEol(source)) +} + /** * Carries the user's edit into the original source style so untouched regions keep their non-canonical bytes. * Falls back to canonical `edited` when the transform can't be proven render-equivalent, so it never corrupts or relocates content.