chore(editor): update focus and save workflows

- preserve grid layout while the editor is in focus mode\n- extract save orchestration into a dedicated lifecycle hook\n- simplify code formatting controls in the toolbar
This commit is contained in:
boojack
2026-07-12 17:23:36 +08:00
parent 42ad4105c1
commit 9203a22ed1
6 changed files with 177 additions and 99 deletions
+9
View File
@@ -36,6 +36,8 @@ MemoEditor/
│ ├── EditorToolbar.tsx # Toolbar
│ └── ...
├── hooks/ # React hooks (utilities)
│ ├── useMemoSave.ts # Save transaction, cache invalidation, and reset
│ └── useFocusMode.ts # Scroll lock and layout-stable focus presentation
├── Editor/ # The CodeMirror 6 decorated-source editor
│ ├── index.tsx # React wrapper: mounts the EditorView, owns the
│ │ # controller refs, syncs initialContent in/out
@@ -90,6 +92,13 @@ Uses `useReducer` + Context for predictable state transitions. All state changes
Pure TypeScript functions containing business logic. No React hooks, easy to test.
### Lifecycle hooks
Cross-cutting React workflows stay outside the editor shell. `useMemoSave`
coordinates validation, persistence, query invalidation, and post-save reducer
state. `useFocusMode` owns focus mode's DOM lifecycle, including restoring the
previous body scroll style and preserving the editor's place in grid layouts.
### Components
Thin presentation components that dispatch actions and render UI.
@@ -7,7 +7,6 @@ import {
ListOrderedIcon,
ListTodoIcon,
type LucideIcon,
SquareCodeIcon,
StrikethroughIcon,
} from "lucide-react";
import type { Translations } from "@/utils/i18n";
@@ -110,12 +109,6 @@ export const EDITOR_COMMANDS: EditorCommand[] = [
icon: StrikethroughIcon,
group: "mark",
},
{
id: "code",
labelKey: "editor.format.code",
icon: CodeIcon,
group: "mark",
},
{
id: "bulletList",
labelKey: "editor.format.bullet-list",
@@ -137,7 +130,7 @@ export const EDITOR_COMMANDS: EditorCommand[] = [
{
id: "codeBlock",
labelKey: "editor.format.code-block",
icon: SquareCodeIcon,
icon: CodeIcon,
group: "block",
},
{
@@ -11,3 +11,4 @@ export { useFocusMode } from "./useFocusMode";
export { useLinkMemo } from "./useLinkMemo";
export { useLocation } from "./useLocation";
export { useMemoInit } from "./useMemoInit";
export { useMemoSave } from "./useMemoSave";
@@ -1,10 +1,40 @@
import { useEffect } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
/**
* Manages the DOM-only parts of focus mode: body scroll locking and a measured
* placeholder height that keeps masonry/grid layouts stable while the editor
* itself is fixed above the page.
*/
export function useFocusMode(isFocusMode: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const normalModeHeightRef = useRef(0);
export function useFocusMode(isFocusMode: boolean): void {
useEffect(() => {
document.body.style.overflow = isFocusMode ? "hidden" : "";
if (!isFocusMode) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
document.body.style.overflow = previousOverflow;
};
}, [isFocusMode]);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container || isFocusMode) return;
const updateHeight = () => {
normalModeHeightRef.current = container.getBoundingClientRect().height;
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, [isFocusMode]);
return {
containerRef,
placeholderHeight: normalModeHeightRef.current,
};
}
@@ -0,0 +1,113 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { toast } from "react-hot-toast";
import { useNewMemo } from "@/contexts/NewMemoContext";
import { memoKeys } from "@/hooks/useMemoQueries";
import { userKeys } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import type { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { errorService, memoService, validationService } from "../services";
import { useEditorContext } from "../state";
interface UseMemoSaveOptions {
memoName?: string;
parentMemoName?: string;
defaultVisibility?: Visibility;
defaultCreateTime?: Date;
discardDraft: () => void;
onConfirm?: (memoName: string) => void;
onCancel?: () => void;
}
/**
* Owns the editor's save transaction and its post-save cache/state updates.
* Keeping this workflow outside the shell makes saving identical whether it is
* triggered by the toolbar or the editor keyboard shortcut.
*/
export function useMemoSave({
memoName,
parentMemoName,
defaultVisibility,
defaultCreateTime,
discardDraft,
onConfirm,
onCancel,
}: UseMemoSaveOptions): () => Promise<void> {
const t = useTranslate();
const queryClient = useQueryClient();
const { markNewMemo } = useNewMemo();
const { actions, dispatch, getState } = useEditorContext();
return useCallback(async () => {
const state = getState();
const { valid, reason } = validationService.canSave(state);
if (!valid) {
toast.error(reason || "Cannot save");
return;
}
dispatch(actions.setLoading("saving", true));
try {
const result = await memoService.save(state, { memoName, parentMemoName });
if (!result.hasChanges) {
toast.error(t("editor.no-changes-detected"));
onCancel?.();
return;
}
// Prevent the autosave unmount flush from restoring the saved draft.
discardDraft();
const invalidationPromises = [
queryClient.invalidateQueries({ queryKey: memoKeys.lists() }),
queryClient.invalidateQueries({ queryKey: userKeys.stats() }),
];
if (memoName) {
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.detail(memoName) }));
}
if (parentMemoName) {
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.comments(parentMemoName) }));
}
await Promise.all(invalidationPromises);
dispatch(actions.reset());
if (!memoName && defaultVisibility) {
dispatch(actions.setMetadata({ visibility: defaultVisibility }));
}
// Reset creates a fresh editor state, so restore calendar-derived values
// for the next memo created without remounting this composer.
if (!memoName && defaultCreateTime) {
dispatch(actions.setTimestamps({ createTime: defaultCreateTime, updateTime: defaultCreateTime }));
}
if (!memoName && !parentMemoName) {
markNewMemo(result.memoName);
}
onConfirm?.(result.memoName);
} catch (error) {
handleError(error, toast.error, {
context: "Failed to save memo",
fallbackMessage: errorService.getErrorMessage(error),
});
} finally {
dispatch(actions.setLoading("saving", false));
}
}, [
actions,
defaultCreateTime,
defaultVisibility,
discardDraft,
dispatch,
getState,
markNewMemo,
memoName,
onCancel,
onConfirm,
parentMemoName,
queryClient,
t,
]);
}
+19 -87
View File
@@ -1,22 +1,17 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import { useNewMemo } from "@/contexts/NewMemoContext";
import { useLocalStorage } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys } from "@/hooks/useMemoQueries";
import { userKeys } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils";
import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString } from "@/utils/memo";
import { AudioRecorderPanel, EditorContent, EditorMetadata, FocusModeOverlay, TimestampPopover } from "./components";
import { FOCUS_MODE_STYLES, FORMATTING_TOOLBAR_STORAGE_KEY } from "./constants";
import { useAudioRecorder, useAutoSave, useFocusMode, useMemoInit } from "./hooks";
import { errorService, memoService, transcriptionService, validationService } from "./services";
import { useAudioRecorder, useAutoSave, useFocusMode, useMemoInit, useMemoSave } from "./hooks";
import { errorService, transcriptionService } from "./services";
import { EditorProvider, useEditorContext, useEditorSelector } from "./state";
import { EditorToolbar, FormattingToolbar } from "./Toolbar";
import type { MemoEditorProps } from "./types";
@@ -41,10 +36,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
onCancel,
}) => {
const t = useTranslate();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const editorRef = useRef<EditorController>(null);
const { actions, dispatch, getState } = useEditorContext();
const { actions, dispatch } = useEditorContext();
// Subscribe only to the low-frequency slices this component renders from, so
// typing (which changes content) does not re-render the editor shell and its
// toolbar/metadata children.
@@ -52,7 +46,6 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
const hasTimestamp = useEditorSelector((s) => Boolean(s.timestamps.createTime));
const { userGeneralSetting } = useAuth();
const { aiSetting, fetchSetting } = useInstance();
const { markNewMemo } = useNewMemo();
const [isAudioRecorderOpen, setIsAudioRecorderOpen] = useState(false);
const [isTranscribingAudio, setIsTranscribingAudio] = useState(false);
// Persisted preference: also show the formatting toolbar in normal mode. Focus
@@ -84,8 +77,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
// Auto-save content to localStorage (subscribes to the store internally).
const { discardDraft } = useAutoSave(currentUser?.name ?? "", cacheKey, isInitialized && isDraftCacheEnabled);
// Focus mode management with body scroll lock
useFocusMode(isFocusMode);
const { containerRef: editorContainerRef, placeholderHeight } = useFocusMode(isFocusMode);
// Live-sync the draft's createTime/updateTime to the calendar-derived prop.
// Only applies in create mode; edit mode owns its own timestamps. Runs after
@@ -227,80 +219,15 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
}
};
async function handleSave() {
// Read the latest state imperatively — this component no longer subscribes
// to content, so the closure can't rely on a per-render `state` snapshot.
const state = getState();
// Validate before saving
const { valid, reason } = validationService.canSave(state);
if (!valid) {
toast.error(reason || "Cannot save");
return;
}
dispatch(actions.setLoading("saving", true));
try {
const result = await memoService.save(state, { memoName, parentMemoName });
if (!result.hasChanges) {
toast.error(t("editor.no-changes-detected"));
onCancel?.();
return;
}
// Clear localStorage cache on successful save and prevent the unmount
// flush from writing the just-saved content back as a stale draft.
discardDraft();
// Invalidate React Query cache to refresh memo lists across the app
const invalidationPromises = [
queryClient.invalidateQueries({ queryKey: memoKeys.lists() }),
queryClient.invalidateQueries({ queryKey: userKeys.stats() }),
];
// Ensure memo detail pages don't keep stale cached content after edits.
if (memoName) {
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.detail(memoName) }));
}
// If this was a comment, also invalidate the comments query for the parent memo
if (parentMemoName) {
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.comments(parentMemoName) }));
}
await Promise.all(invalidationPromises);
// Reset editor state to initial values
dispatch(actions.reset());
if (!memoName && defaultVisibility) {
dispatch(actions.setMetadata({ visibility: defaultVisibility }));
}
// Re-seed the calendar-derived timestamps so the popover stays visible
// and subsequent memos in the same filter session keep the prefilled date.
// Without this, the live-sync effect won't re-fire (its deps don't change
// across reset), and memo #2 onward would silently fall back to "now".
if (!memoName && defaultCreateTime) {
dispatch(actions.setTimestamps({ createTime: defaultCreateTime, updateTime: defaultCreateTime }));
}
// Surface a freshly created top-level memo at the top of the list so it
// stays visible even when pinned memos would otherwise push it down.
if (!memoName && !parentMemoName) {
markNewMemo(result.memoName);
}
// Notify parent component of successful save
onConfirm?.(result.memoName);
} catch (error) {
handleError(error, toast.error, {
context: "Failed to save memo",
fallbackMessage: errorService.getErrorMessage(error),
});
} finally {
dispatch(actions.setLoading("saving", false));
}
}
const handleSave = useMemoSave({
memoName,
parentMemoName,
defaultVisibility,
defaultCreateTime,
discardDraft,
onConfirm,
onCancel,
});
return (
<>
@@ -312,12 +239,17 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
- In focus mode: becomes fixed with specific spacing, editor grows to fill space
- In normal mode: stays relative with max-height constraint
*/}
{isFocusMode && placeholderHeight > 0 && (
<div aria-hidden className={cn("w-full", className)} style={{ height: placeholderHeight }} />
)}
<div
ref={editorContainerRef}
className={cn(
"group relative w-full flex flex-col justify-between items-start bg-card px-4 pt-3 pb-1 rounded-lg border border-border gap-2",
FOCUS_MODE_STYLES.transition,
isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
className,
!isFocusMode && className,
)}
>
{/* Formatting toolbar. Always shown in focus mode (with an exit button);