diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index e6a90ef..1fa6154 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -514,7 +514,7 @@ interface PostEditorProps { postId: string; } -const PostEditor: React.FC = ({ postId }) => { +export const PostEditor: React.FC = ({ postId }) => { const { updatePost, markDirty, diff --git a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx index 076b485..53d3981 100644 --- a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx +++ b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx @@ -55,8 +55,40 @@ interface MilkdownEditorProps { placeholder?: string; } +interface MilkdownChangePropagationInput { + markdown: string; + prevMarkdown: string; + externalContent: string; + hasUserInteracted: boolean; +} + +export const shouldPropagateMilkdownChange = ({ + markdown, + prevMarkdown, + externalContent, + hasUserInteracted, +}: MilkdownChangePropagationInput): boolean => { + const unescaped = unescapeMacroSyntax(markdown); + const prevUnescaped = unescapeMacroSyntax(prevMarkdown); + const externalUnescaped = unescapeMacroSyntax(externalContent); + + if (unescaped === prevUnescaped) { + return false; + } + + if (!hasUserInteracted) { + return false; + } + + return unescaped !== externalUnescaped; +}; + +interface EditorToolbarProps { + onUserInteraction: () => void; +} + // Toolbar component that uses the editor instance -const EditorToolbar: React.FC = () => { +const EditorToolbar: React.FC = ({ onUserInteraction }) => { const [loading, getEditor] = useInstance(); const [insertMode, setInsertMode] = useState(null); const [selectedText, setSelectedText] = useState(''); @@ -67,14 +99,16 @@ const EditorToolbar: React.FC = () => { if (loading) return; const editor = getEditor(); if (editor) { + onUserInteraction(); editor.action(callCommand(commandKey, payload)); } - }, [loading, getEditor]); + }, [loading, getEditor, onUserInteraction]); const insertHeading = useCallback((level: number) => { if (loading) return; const editor = getEditor(); if (!editor) return; + onUserInteraction(); editor.action((ctx) => { const view = ctx.get(editorViewCtx); @@ -95,7 +129,7 @@ const EditorToolbar: React.FC = () => { ); dispatch(tr); }); - }, [loading, getEditor]); + }, [loading, getEditor, onUserInteraction]); // Get current selection text from editor const getSelectionText = useCallback(() => { @@ -114,15 +148,17 @@ const EditorToolbar: React.FC = () => { }, [getEditor]); const openLinkModal = useCallback(() => { + onUserInteraction(); const text = getSelectionText(); setSelectedText(text); setInsertMode('link'); - }, [getSelectionText]); + }, [getSelectionText, onUserInteraction]); const openImageModal = useCallback(() => { + onUserInteraction(); setSelectedText(''); setInsertMode('image'); - }, []); + }, [onUserInteraction]); // Add keyboard shortcut listener for Ctrl/Cmd+K (link) useEffect(() => { @@ -141,6 +177,7 @@ const EditorToolbar: React.FC = () => { const handleInsertLink = useCallback((url: string, text?: string) => { const editor = getEditor(); if (!editor) return; + onUserInteraction(); editor.action((ctx) => { const view = ctx.get(editorViewCtx); @@ -166,13 +203,14 @@ const EditorToolbar: React.FC = () => { }); setInsertMode(null); - }, [getEditor]); + }, [getEditor, onUserInteraction]); // Handle image insertion from modal const handleInsertImage = useCallback((url: string, alt: string) => { + onUserInteraction(); runCommand(insertImageCommand.key, { src: url, alt }); setInsertMode(null); - }, [runCommand]); + }, [onUserInteraction, runCommand]); if (loading) return null; @@ -257,9 +295,7 @@ const MilkdownProviderInner: React.FC = ({ const isInternalChange = useRef(false); const onChangeRef = useRef(onChange); const contentRef = useRef(content); - // Store the normalized markdown after Milkdown's initial round-trip - // Only trigger onChange if the markdown differs from this baseline - const normalizedBaseline = useRef(null); + const hasUserInteractedRef = useRef(false); // Keep refs updated useEffect(() => { @@ -284,24 +320,16 @@ const MilkdownProviderInner: React.FC = ({ // Add custom remark plugin to force tight lists ctx.set(remarkPluginsCtx, [remarkTightLists]); ctx.get(listenerCtx).markdownUpdated((_ctx: Ctx, markdown: string, prevMarkdown: string) => { - // Unescape brackets and underscores to preserve macro syntax like [[photo_gallery]] - const unescaped = unescapeMacroSyntax(markdown); - const prevUnescaped = unescapeMacroSyntax(prevMarkdown); - - if (unescaped !== prevUnescaped) { - // On first update after load, store the normalized baseline - // This captures Milkdown's round-trip formatting - if (normalizedBaseline.current === null) { - normalizedBaseline.current = unescaped; - return; // Don't trigger onChange for initial normalization - } - // Only trigger onChange if content differs from the baseline - // (meaning the user actually edited something) - if (unescaped !== normalizedBaseline.current) { - isInternalChange.current = true; - normalizedBaseline.current = unescaped; // Update baseline - onChangeRef.current(unescaped); - } + const shouldEmit = shouldPropagateMilkdownChange({ + markdown, + prevMarkdown, + externalContent: contentRef.current, + hasUserInteracted: hasUserInteractedRef.current, + }); + + if (shouldEmit) { + isInternalChange.current = true; + onChangeRef.current(unescapeMacroSyntax(markdown)); } }); }) @@ -324,8 +352,7 @@ const MilkdownProviderInner: React.FC = ({ if (!editor) return; if (content !== lastExternalContent.current && !isInternalChange.current) { - // Reset baseline so next markdownUpdated captures new normalized content - normalizedBaseline.current = null; + hasUserInteractedRef.current = false; editor.action(replaceAll(content)); lastExternalContent.current = content; } else if (isInternalChange.current) { @@ -335,9 +362,19 @@ const MilkdownProviderInner: React.FC = ({ isInternalChange.current = false; }, [content, loading, getEditor]); + const markUserInteraction = useCallback(() => { + hasUserInteractedRef.current = true; + }, []); + return ( -
- +
+
diff --git a/tests/renderer/components/EditorVisualModePersistence.test.tsx b/tests/renderer/components/EditorVisualModePersistence.test.tsx new file mode 100644 index 0000000..c22486e --- /dev/null +++ b/tests/renderer/components/EditorVisualModePersistence.test.tsx @@ -0,0 +1,203 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { render, act } from '@testing-library/react'; + +let markdownUpdatedHandler: ((ctx: unknown, markdown: string, prevMarkdown: string) => void) | null = null; + +vi.mock('@monaco-editor/react', () => ({ + default: () =>
, +})); + +vi.mock('@milkdown/kit/core', () => { + const makeChain = () => { + const chain = { + config: (callback: (ctx: { set: () => void; get: () => { markdownUpdated: (cb: typeof markdownUpdatedHandler) => void } }) => void) => { + callback({ + set: () => {}, + get: () => ({ + markdownUpdated: (cb) => { + markdownUpdatedHandler = cb; + }, + }), + }); + return chain; + }, + use: () => chain, + }; + return chain; + }; + + return { + Editor: { + make: makeChain, + }, + defaultValueCtx: Symbol('defaultValueCtx'), + editorViewCtx: Symbol('editorViewCtx'), + rootCtx: Symbol('rootCtx'), + remarkStringifyOptionsCtx: Symbol('remarkStringifyOptionsCtx'), + remarkPluginsCtx: Symbol('remarkPluginsCtx'), + }; +}); + +vi.mock('@milkdown/kit/preset/commonmark', () => ({ + commonmark: {}, + toggleStrongCommand: { key: 'toggleStrong' }, + toggleEmphasisCommand: { key: 'toggleEmphasis' }, + wrapInBlockquoteCommand: { key: 'wrapInBlockquote' }, + wrapInBulletListCommand: { key: 'wrapInBulletList' }, + wrapInOrderedListCommand: { key: 'wrapInOrderedList' }, + insertHrCommand: { key: 'insertHr' }, + toggleInlineCodeCommand: { key: 'toggleInlineCode' }, + insertImageCommand: { key: 'insertImage' }, + toggleLinkCommand: { key: 'toggleLink' }, +})); + +vi.mock('@milkdown/kit/preset/gfm', () => ({ + gfm: {}, + toggleStrikethroughCommand: { key: 'toggleStrike' }, +})); + +vi.mock('@milkdown/kit/plugin/history', () => ({ + history: {}, + undoCommand: { key: 'undo' }, + redoCommand: { key: 'redo' }, +})); + +vi.mock('@milkdown/kit/plugin/listener', () => ({ + listener: {}, + listenerCtx: Symbol('listenerCtx'), +})); + +vi.mock('@milkdown/kit/plugin/clipboard', () => ({ clipboard: {} })); +vi.mock('@milkdown/kit/plugin/trailing', () => ({ trailing: {} })); +vi.mock('@milkdown/kit/plugin/indent', () => ({ indent: {} })); +vi.mock('@milkdown/kit/plugin/cursor', () => ({ cursor: {} })); + +vi.mock('@milkdown/kit/utils', () => ({ + $node: () => ({}), + $inputRule: () => ({}), + $remark: () => ({}), + $prose: () => ({}), + replaceAll: (content: string) => () => { + const normalized = content.replace('\n', '\n\n'); + markdownUpdatedHandler?.({}, normalized, ''); + }, + callCommand: () => () => {}, +})); + +vi.mock('@milkdown/react', () => ({ + Milkdown: () =>
, + MilkdownProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + useInstance: () => [false, () => ({ action: (action: unknown) => { + if (typeof action === 'function') { + action({ get: () => ({}) }); + } + } })] as const, + useEditor: (factory: (root: Node) => unknown) => { + factory(document.createElement('div')); + }, +})); + +vi.mock('../../../src/renderer/components/Lightbox', () => ({ + Lightbox: () => null, + useMarkdownImages: () => [], +})); +vi.mock('../../../src/renderer/components/PostLinks', () => ({ PostLinks: () => null })); +vi.mock('../../../src/renderer/components/LinkedMediaPanel', () => ({ LinkedMediaPanel: () => null })); +vi.mock('../../../src/renderer/components/ErrorModal', () => ({ ErrorModal: () => null })); +vi.mock('../../../src/renderer/components/ConfirmDeleteModal', () => ({ ConfirmDeleteModal: () => null })); +vi.mock('../../../src/renderer/components/SettingsView', () => ({ SettingsView: () => null })); +vi.mock('../../../src/renderer/components/TagsView', () => ({ TagsView: () => null })); +vi.mock('../../../src/renderer/components/TagInput', () => ({ TagInput: () => null })); +vi.mock('../../../src/renderer/components/ChatPanel', () => ({ ChatPanel: () => null })); +vi.mock('../../../src/renderer/components/ImportAnalysisView', () => ({ ImportAnalysisView: () => null })); +vi.mock('../../../src/renderer/components/MetadataDiffPanel', () => ({ MetadataDiffPanel: () => null })); +vi.mock('../../../src/renderer/components/GitDiffView/GitDiffView', () => ({ GitDiffView: () => null })); +vi.mock('../../../src/renderer/components/InsertModal', () => ({ InsertModal: () => null })); +vi.mock('../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal', () => ({ + AISuggestionsModal: () => null, +})); +vi.mock('../../../src/renderer/components/Toast', () => ({ + showToast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +import { PostEditor } from '../../../src/renderer/components/Editor/Editor'; +import { useAppStore } from '../../../src/renderer/store'; + +const createPost = () => ({ + id: 'post-1', + title: 'Test Post', + content: 'Line one\nLine two', + excerpt: '', + slug: 'test-post', + status: 'draft' as const, + tags: [], + categories: ['article'], + featuredImage: null, + publishedAt: null, + createdAt: new Date('2026-02-16T12:00:00.000Z'), + updatedAt: new Date('2026-02-16T12:00:00.000Z'), + author: undefined, + metadata: {}, + seoTitle: undefined, + seoDescription: undefined, + canonicalUrl: undefined, + projectId: 'project-1', + filePath: 'posts/test-post.md', +}); + +describe('Editor visual mode persistence', () => { + beforeEach(() => { + markdownUpdatedHandler = null; + vi.clearAllMocks(); + const neverSettles = new Promise(() => {}); + + (window as any).addEventListener = vi.fn(); + (window as any).removeEventListener = vi.fn(); + + (window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost()); + (window as any).electronAPI.posts.hasPublishedVersion = vi.fn().mockReturnValue(neverSettles); + (window as any).electronAPI.posts.update = vi.fn().mockResolvedValue(null); + (window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles); + + useAppStore.setState({ + preferredEditorMode: 'wysiwyg', + posts: [], + media: [], + dirtyPosts: new Set(), + isLoading: false, + }); + }); + + afterEach(() => { + useAppStore.setState({ + dirtyPosts: new Set(), + }); + }); + + it('does not mark post dirty when Milkdown emits formatting-only update on load', async () => { + let unmount: (() => void) | undefined; + + await act(async () => { + ({ unmount } = render()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect((window as any).electronAPI.posts.get).toHaveBeenCalledWith('post-1'); + + expect(useAppStore.getState().isDirty('post-1')).toBe(false); + + await act(async () => { + unmount?.(); + }); + }); +}); diff --git a/tests/renderer/components/MilkdownEditor.test.ts b/tests/renderer/components/MilkdownEditor.test.ts new file mode 100644 index 0000000..fd3eb82 --- /dev/null +++ b/tests/renderer/components/MilkdownEditor.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { shouldPropagateMilkdownChange } from '../../../src/renderer/components/MilkdownEditor/MilkdownEditor'; + +describe('shouldPropagateMilkdownChange', () => { + it('does not propagate when markdown is unchanged', () => { + const result = shouldPropagateMilkdownChange({ + markdown: 'Same content', + prevMarkdown: 'Same content', + externalContent: 'Same content', + hasUserInteracted: true, + }); + + expect(result).toBe(false); + }); + + it('does not propagate normalization changes without user interaction', () => { + const result = shouldPropagateMilkdownChange({ + markdown: 'Line one\n\nLine two', + prevMarkdown: '', + externalContent: 'Line one\nLine two', + hasUserInteracted: false, + }); + + expect(result).toBe(false); + }); + + it('propagates real edits when user has interacted', () => { + const result = shouldPropagateMilkdownChange({ + markdown: '[Example](https://example.com)', + prevMarkdown: 'Hello world', + externalContent: 'Hello world', + hasUserInteracted: true, + }); + + expect(result).toBe(true); + }); + + it('does not propagate duplicate updates that match external content', () => { + const result = shouldPropagateMilkdownChange({ + markdown: 'External content', + prevMarkdown: 'Different previous', + externalContent: 'External content', + hasUserInteracted: true, + }); + + expect(result).toBe(false); + }); +});