From 330aed23eb5edbf7bb5e82a8feff1d0435e55241 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 18 Sep 2026 15:51:38 +0800 Subject: [PATCH] fix(web): speed up memo context menu and note switching Reuse the TipTap view across memo changes, skip the modal scroll lock on the card menu, and prefetch note details on pointer down so short notes no longer pay a full editor remount. --- apps/web/src/components/EditorPane.tsx | 38 +++++++++++++++---- apps/web/src/components/MemoCard.tsx | 9 ++++- apps/web/src/components/MemoListPane.test.mjs | 8 ++++ apps/web/src/components/MemoListPane.tsx | 8 +++- .../WorkspaceApp.create-memo.test.mjs | 13 +++++++ apps/web/src/components/WorkspaceApp.tsx | 7 ++++ .../editor/editor-pane-helpers.test.mjs | 12 ++++++ .../components/editor/editor-pane-helpers.ts | 15 +++++++- 8 files changed, 99 insertions(+), 11 deletions(-) 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) =>