diff --git a/src/components/editor/DocumentEditor.tsx b/src/components/editor/DocumentEditor.tsx index 0ebf08a0..5bfa1cca 100644 --- a/src/components/editor/DocumentEditor.tsx +++ b/src/components/editor/DocumentEditor.tsx @@ -910,11 +910,8 @@ export function DocumentEditor({ }, }), ], - ...(contentType === "markdown" && typeof content === "string" - ? { content, contentType: "markdown" as const } - : content - ? { content } - : {}), + // NOTE: initial content intentionally omitted — content is set via the + // useEffect below after first paint to avoid blocking modal-open animation. onUpdate: ({ editor }) => { const md = typeof editor.getMarkdown === "function" ? editor.getMarkdown() : ""; @@ -930,11 +927,25 @@ export function DocumentEditor({ }, }); - // Sync incoming content only when it actually differs from the live editor state. + const hasLoadedInitialContentRef = useRef(false); + const autofocusRef = useRef(autofocus); + autofocusRef.current = autofocus; + + // Reset the initial-load flag when the editor instance itself changes so a + // recreated editor still benefits from the deferred-paint optimization. + useEffect(() => { + hasLoadedInitialContentRef.current = false; + }, [editor]); + + // Sync incoming content only when it actually differs from the live editor + // state. The first content load is deferred to after the modal's open paint + // to avoid blocking the open animation with markdown parsing + KaTeX renders. useEffect(() => { if (!editor) return; - queueMicrotask(() => { + const applyContent = () => { + if (editor.isDestroyed) return; + if (contentType === "markdown" && typeof content === "string") { if (editor.getMarkdown?.() === content) return; editor.commands.setContent(content, { @@ -949,7 +960,39 @@ export function DocumentEditor({ return; editor.commands.setContent(nextContent, { emitUpdate: false }); + }; + + if (hasLoadedInitialContentRef.current) { + // External update (e.g., ZeroDB sync from another tab). Apply immediately. + applyContent(); + return; + } + + // Initial mount: defer content load until after the modal's open paint to + // avoid blocking the animation. Two rAFs ensures we run after at least one + // committed frame is on screen. + let rafA: number | null = null; + let rafB: number | null = null; + rafA = requestAnimationFrame(() => { + rafB = requestAnimationFrame(() => { + if (editor.isDestroyed) return; + applyContent(); + hasLoadedInitialContentRef.current = true; + // setContent uses tr.replaceWith over the full doc range, which makes + // ProseMirror's selection mapping land at the END of the replacement. + // Move the cursor to the start to match Tiptap's native autofocus:true. + if (autofocusRef.current) { + editor.commands.focus("start"); + } else { + editor.commands.setTextSelection(0); + } + }); }); + + return () => { + if (rafA != null) cancelAnimationFrame(rafA); + if (rafB != null) cancelAnimationFrame(rafB); + }; }, [editor, content, contentType]); return (