diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 67018ec4..cfc22d49 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -222,7 +222,10 @@ import { ResourceActionMenu, type NoteLinkHintPosition, } from "./editor/EditorPaneChrome"; -import { resolveEditorDraftState } from "./editor/editor-draft-state"; +import { + resolveEditorDraftState, + shouldReplaceEditorDocument, +} from "./editor/editor-draft-state"; import type { EdgeEverPluginHost, PluginEditorAdapter } from "@/lib/plugins/plugin-host"; import { useEditorResourceActions, @@ -2212,10 +2215,14 @@ const RichEditorPane = ({ } = resolvedDraft; const alreadyHydratedSameMemo = sameMemo && hydratedMemoIdRef.current === memo.id; + const currentEditorDocument = isEditorReady(currentEditor) + ? currentEditor.getJSON() as TiptapDoc + : null; + const shouldReplaceDocument = shouldReplaceEditorDocument(currentEditorDocument, nextContent); const editorMarkdownMatches = Boolean( alreadyHydratedSameMemo && - isEditorReady(currentEditor) && - docToMarkdown(currentEditor.getJSON() as TiptapDoc) === nextMarkdown && + currentEditorDocument && + docToMarkdown(currentEditorDocument) === nextMarkdown && title === nextTitle && tagsText === nextTagsText ); @@ -2275,7 +2282,7 @@ const RichEditorPane = ({ setMarkdownSource(nextMarkdown); setMobilePlainTextElementValue(mobileTextAreaRef.current, nextMarkdown); - if (isEditorReady(currentEditor)) { + if (isEditorReady(currentEditor) && shouldReplaceDocument) { try { currentEditor.commands.setContent(nextContent); } catch (err) { diff --git a/apps/web/src/components/editor/editor-draft-state.test.mjs b/apps/web/src/components/editor/editor-draft-state.test.mjs index 47380337..8568b843 100644 --- a/apps/web/src/components/editor/editor-draft-state.test.mjs +++ b/apps/web/src/components/editor/editor-draft-state.test.mjs @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { resolveEditorDraftState } from "./editor-draft-state.ts"; +import { + resolveEditorDraftState, + shouldReplaceEditorDocument, +} from "./editor-draft-state.ts"; const doc = (text) => ({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] }); @@ -125,3 +128,28 @@ describe("editor draft source resolution", () => { expect(state.hasUnsavedChanges).toBe(false); }); }); + +describe("editor document hydration", () => { + test("does not replace a newly mounted empty note with the same empty document", () => { + const emptyDocument = { type: "doc", content: [{ type: "paragraph" }] }; + expect(shouldReplaceEditorDocument(emptyDocument, emptyDocument)).toBe(false); + }); + + test("replaces the mounted memo when a recovered local draft has different content", () => { + expect(shouldReplaceEditorDocument(doc("remote body"), doc("recovered draft"))).toBe(true); + }); + + test("does not treat rich documents with the same markdown text as identical", () => { + const plainDocument = doc("same body"); + const themedDocument = { + type: "doc", + content: [{ type: "edgeeverThemeBlock", attrs: { theme: "paper" }, content: plainDocument.content }], + }; + + expect(shouldReplaceEditorDocument(plainDocument, themedDocument)).toBe(true); + }); + + test("hydrates when the current editor document cannot be read", () => { + expect(shouldReplaceEditorDocument(null, doc("remote body"))).toBe(true); + }); +}); diff --git a/apps/web/src/components/editor/editor-draft-state.ts b/apps/web/src/components/editor/editor-draft-state.ts index e2962ec3..46c1be7a 100644 --- a/apps/web/src/components/editor/editor-draft-state.ts +++ b/apps/web/src/components/editor/editor-draft-state.ts @@ -24,6 +24,17 @@ export type EditorDraftState = { hasUnsavedChanges: boolean; }; +/** + * TipTap is created with the memo snapshot before the asynchronous local-draft + * lookup finishes. Replacing that already-identical document during hydration + * resets the ProseMirror view and selection, which is visible in slower desktop + * runtimes as a second editor paint. + */ +export const shouldReplaceEditorDocument = ( + currentDocument: TiptapDoc | null, + nextDocument: TiptapDoc, +) => currentDocument === null || JSON.stringify(currentDocument) !== JSON.stringify(nextDocument); + type ResolveEditorDraftStateInput = { memo: MemoDetail; draft?: LocalDraft | null;