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.
This commit is contained in:
1 parent
7117a8bcde
commit
330aed23eb
8 files changed
+99
-11
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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<HTMLElement>) => void;
|
||||
@@ -180,7 +182,12 @@ export const MemoCard = ({
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
if (event.pointerType !== "touch" || selectionMode) {
|
||||
if (event.pointerType !== "touch") {
|
||||
onPrefetch?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,14 @@ describe("memo context menu", () => {
|
||||
expect(source).toContain("<DropdownMenuSubContent");
|
||||
expect(source).not.toContain("contextMoveOpen");
|
||||
});
|
||||
|
||||
test("opens the card menu without a modal scroll lock so the list does not hitch", () => {
|
||||
const source = readFileSync(new URL("./MemoListPane.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(source).toContain("<DropdownMenu modal={false} open={true}");
|
||||
expect(source).toContain("data-[state=open]:animate-none");
|
||||
expect(source).toContain("onCloseAutoFocus={(event) => event.preventDefault()}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop memo list spacing", () => {
|
||||
|
||||
@@ -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(
|
||||
<div style={{ position: "fixed", left: memoContextMenu.x, top: memoContextMenu.y, zIndex: 100 }}>
|
||||
<DropdownMenu open={true} onOpenChange={(open) => { if (!open) setMemoContextMenu(null); }}>
|
||||
<DropdownMenu modal={false} open={true} onOpenChange={(open) => { if (!open) setMemoContextMenu(null); }}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<span className="sr-only" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
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"
|
||||
onCloseAutoFocus={(event) => 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
|
||||
>
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -24,6 +24,19 @@ describe("desktop create-note wiring", () => {
|
||||
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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user