diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 671d071e..2ef4fb8f 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -256,6 +256,7 @@ import { isCreatedMemoEditorFocused, isEditorReady, MemoSaveRequestError, + resetEditorDocument, shouldRetryCreatedMemoFocus, MOBILE_DRAFT_PERSIST_DELAY_MS, MOBILE_EDITOR_QUERY, @@ -1199,10 +1200,9 @@ const RichEditorPane = ({ }, }, }, [ - // A ProseMirror undo history belongs to exactly one logical memo. A newly - // created memo keeps the same instance while its local id is remapped to a - // durable id; an actual memo switch still receives a fresh undo history. - editorInstanceMemoKey, + // Keep one TipTap view for the pane lifetime. A newly created memo already + // reuses this instance across local→durable id remaps; switching notes now + // also reuses it and resets undo history via resetEditorDocument. ]); const { @@ -2077,7 +2077,27 @@ const RichEditorPane = ({ hydratedMemoIdRef.current = null; appliedEditorSourceKeyRef.current = null; clearMarkdownSnapshot(); - setHydratedEditorMemoId(null); + const immediateDraft = resolveEditorDraftState({ memo, draft: null, queuedUpdate: null }); + editingMemoIdRef.current = memo.id; + setHasUnsavedChanges(false); + setSaveState("idle"); + setSaveConflictInfo(null); + setTitle(immediateDraft.title); + setTagsText(immediateDraft.tagsText); + setMobilePlainText(immediateDraft.contentMarkdown); + setMobilePlainTextElementValue(mobileTextAreaRef.current, immediateDraft.contentMarkdown); + hydrateMarkdownSource(memo.id, immediateDraft.contentJson, immediateDraft.contentMarkdown); + if (isEditorReady(currentEditor)) { + try { + resetEditorDocument(currentEditor, immediateDraft.contentJson); + } catch (err) { + console.error("Failed to reset TipTap document, falling back to setContent:", err); + currentEditor.commands.setContent(immediateDraft.contentJson); + } + } + appliedEditorSourceKeyRef.current = immediateDraft.sourceKey; + hydratedMemoIdRef.current = memo.id; + setHydratedEditorMemoId(memo.id); } // While the user still has unsaved keystrokes, ignore memo prop churn entirely @@ -2180,7 +2200,7 @@ const RichEditorPane = ({ title === nextTitle && tagsText === nextTagsText ); - const sourceAlreadyApplied = alreadyHydratedSameMemo && appliedEditorSourceKeyRef.current === sourceKey; + const sourceAlreadyApplied = appliedEditorSourceKeyRef.current === sourceKey; // Skip a full document replace when content already matches — setContent // always resets the selection and feels like a line jump / jump-to-end. @@ -2238,7 +2258,11 @@ const RichEditorPane = ({ if (isEditorReady(currentEditor) && shouldReplaceDocument && !keptLiveMarkdown) { try { - currentEditor.commands.setContent(nextContent); + if (sameMemo) { + currentEditor.commands.setContent(nextContent); + } else { + resetEditorDocument(currentEditor, nextContent); + } } catch (err) { console.error("Failed to set TipTap contentJson, falling back to markdownToDoc:", err); currentEditor.commands.setContent(markdownToDoc(nextMarkdown)); diff --git a/apps/web/src/components/MemoCard.tsx b/apps/web/src/components/MemoCard.tsx index e7359d1d..7a9d8069 100644 --- a/apps/web/src/components/MemoCard.tsx +++ b/apps/web/src/components/MemoCard.tsx @@ -51,6 +51,7 @@ export const MemoCard = ({ sortMode, multiSelectKeyDown, onOpen, + onPrefetch, onRestore, onDelete, onOpenContextMenu, @@ -69,6 +70,7 @@ export const MemoCard = ({ sortMode: MemoSortMode; multiSelectKeyDown: boolean; onOpen: () => void; + onPrefetch?: () => void; onRestore: () => void; onDelete: () => void; onOpenContextMenu: (event: MouseEvent) => void; @@ -180,7 +182,12 @@ export const MemoCard = ({ }; const handlePointerDown = (event: ReactPointerEvent) => { - if (event.pointerType !== "touch" || selectionMode) { + if (event.pointerType !== "touch") { + onPrefetch?.(); + return; + } + + if (selectionMode) { return; } diff --git a/apps/web/src/components/MemoListPane.test.mjs b/apps/web/src/components/MemoListPane.test.mjs index 796ad1ab..26c5ab14 100644 --- a/apps/web/src/components/MemoListPane.test.mjs +++ b/apps/web/src/components/MemoListPane.test.mjs @@ -10,6 +10,14 @@ describe("memo context menu", () => { expect(source).toContain(" { + const source = readFileSync(new URL("./MemoListPane.tsx", import.meta.url), "utf8"); + + expect(source).toContain(" event.preventDefault()}"); + }); }); describe("desktop memo list spacing", () => { diff --git a/apps/web/src/components/MemoListPane.tsx b/apps/web/src/components/MemoListPane.tsx index eb549214..6ed2cb72 100644 --- a/apps/web/src/components/MemoListPane.tsx +++ b/apps/web/src/components/MemoListPane.tsx @@ -380,6 +380,7 @@ export const MemoListPane = ({ onSortModeChange, onLoadMoreMemos, onOpenMemo, + onPrefetchMemo, onDeleteMemo, onRestoreMemo, onTogglePinMemo, @@ -458,6 +459,7 @@ export const MemoListPane = ({ onSortModeChange: (sortMode: MemoSortMode) => void; onLoadMoreMemos: () => void; onOpenMemo: (memoId: string) => void; + onPrefetchMemo?: (memoId: string) => void; onDeleteMemo: (memoId: string) => void; onRestoreMemo: (memoId: string) => void; onTogglePinMemo: (memo: MemoSummary) => void; @@ -1495,6 +1497,7 @@ export const MemoListPane = ({ sortMode={view === "trash" ? "updated-desc" : sortMode} multiSelectKeyDown={multiSelectKeyDown} onOpen={() => onOpenMemo(memo.id)} + onPrefetch={onPrefetchMemo ? () => onPrefetchMemo(memo.id) : undefined} onRestore={() => onRestoreMemo(memo.id)} onDelete={() => onDeleteMemo(memo.id)} onOpenContextMenu={(event) => handleOpenMemoContextMenu(memo, event)} @@ -1518,13 +1521,14 @@ export const MemoListPane = ({ are not offset by the memo pane's backdrop-filter containing block. */} {memoContextMenu && typeof document !== "undefined" ? createPortal(
- { if (!open) setMemoContextMenu(null); }}> + { if (!open) setMemoContextMenu(null); }}> event.preventDefault()} + className="max-h-[calc(100dvh-1.5rem)] w-56 max-w-[calc(100vw-1.5rem)] overflow-y-auto bg-card border border-slate-200 rounded-md py-1 shadow-md duration-0 data-[state=open]:animate-none data-[state=closed]:animate-none" data-memo-actions-menu > { expect(editorSource).toContain("editorInstanceKey: editorInstanceMemoKey"); }); + test("reuses the TipTap view when switching memos and resets the document instead of remounting", () => { + expect(editorSource).toContain("resetEditorDocument"); + expect(editorSource).toContain("switching notes now"); + expect(editorSource).toContain("also reuses it and resets undo history via resetEditorDocument"); + expect(editorSource).not.toContain("an actual memo switch still receives a fresh undo history."); + }); + + test("prefetches memo detail when a list card is pressed", () => { + expect(workspaceSource).toContain("prefetchMemoDetail"); + expect(workspaceSource).toContain("onPrefetchMemo={prefetchMemoDetail}"); + expect(workspaceSource).toContain("queryClient.prefetchQuery"); + }); + test("keeps retrying create-note autofocus until the editor is editable and focused", () => { expect(editorSource).toContain("shouldRetryCreatedMemoFocus"); expect(editorSource).toContain("isCreatedMemoEditorFocused"); diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 78264246..b57b947d 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -1120,6 +1120,12 @@ export const WorkspaceApp = ({ queryFn: () => repository.getMemo(detailMemoId as string, memoView === "trash"), enabled: Boolean(detailMemoId), }); + const prefetchMemoDetail = useCallback((memoId: string) => { + void queryClient.prefetchQuery({ + queryKey: memoDetailQueryKey(memoId, memoView), + queryFn: () => repository.getMemo(memoId, memoView === "trash"), + }); + }, [memoView, queryClient, repository]); useEffect(() => { const handleMemoDetailRefreshed = (event: Event) => { @@ -2995,6 +3001,7 @@ export const WorkspaceApp = ({ setSelectedMemoId(memoId); setActivePane("editor"); }} + onPrefetchMemo={prefetchMemoDetail} onToggleMemo={(memoId, rangeMemoIds) => { setMemoSelectionMode(true); setSelectedMemoIds((current) => { diff --git a/apps/web/src/components/editor/editor-pane-helpers.test.mjs b/apps/web/src/components/editor/editor-pane-helpers.test.mjs index 6b905944..9352c8cf 100644 --- a/apps/web/src/components/editor/editor-pane-helpers.test.mjs +++ b/apps/web/src/components/editor/editor-pane-helpers.test.mjs @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; import { shouldRetryCreatedMemoFocus } from "./editor-pane-helpers.ts"; describe("created memo focus retry", () => { @@ -43,3 +44,14 @@ describe("created memo focus retry", () => { })).toBe(false); }); }); + +describe("editor document reset", () => { + test("rebuilds EditorState from JSON so undo history does not follow a memo switch", () => { + const source = readFileSync(new URL("./editor-pane-helpers.ts", import.meta.url), "utf8"); + + expect(source).toContain("export const resetEditorDocument"); + expect(source).toContain("editor.view.updateState(EditorState.create({"); + expect(source).toContain("doc: editor.schema.nodeFromJSON(content)"); + expect(source).toContain("plugins: editor.state.plugins"); + }); +}); diff --git a/apps/web/src/components/editor/editor-pane-helpers.ts b/apps/web/src/components/editor/editor-pane-helpers.ts index e84469e7..b4b8b312 100644 --- a/apps/web/src/components/editor/editor-pane-helpers.ts +++ b/apps/web/src/components/editor/editor-pane-helpers.ts @@ -1,5 +1,6 @@ +import { EditorState } from "@tiptap/pm/state"; import type { Editor } from "@tiptap/react"; -import type { MemoDetail, MemoEditSession } from "@edgeever/shared"; +import type { MemoDetail, MemoEditSession, TiptapDoc } from "@edgeever/shared"; import { isDesktopResourceRuntime } from "@/lib/desktop-resources"; import { isBrowserOffline } from "@/lib/network-status"; import { isLocalMemoId } from "@/lib/local-mirror"; @@ -77,6 +78,18 @@ export type MobilePlainTextElement = HTMLTextAreaElement | HTMLDivElement; export const isEditorReady = (editor: Editor | null | undefined): editor is Editor => Boolean(editor && !editor.isDestroyed && (editor as { extensionManager?: unknown }).extensionManager); +/** + * Replace the document with a fresh EditorState so undo/redo cannot leak + * across memos. Cheaper than destroying the TipTap view on every switch. + */ +export const resetEditorDocument = (editor: Editor, content: TiptapDoc) => { + editor.view.updateState(EditorState.create({ + schema: editor.schema, + doc: editor.schema.nodeFromJSON(content), + plugins: editor.state.plugins, + })); +}; + export const CREATED_MEMO_FOCUS_MAX_ATTEMPTS = 120; export const isCreatedMemoEditorFocused = (editor: Editor | null | undefined) =>