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

View file

@ -36,6 +36,8 @@ MemoEditor/
│ ├── EditorToolbar.tsx # Toolbar │ ├── EditorToolbar.tsx # Toolbar
│ └── ... │ └── ...
├── hooks/ # React hooks (utilities) ├── 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 ├── Editor/ # The CodeMirror 6 decorated-source editor
│ ├── index.tsx # React wrapper: mounts the EditorView, owns the │ ├── index.tsx # React wrapper: mounts the EditorView, owns the
│ │ # controller refs, syncs initialContent in/out │ │ # 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. 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 ### Components
Thin presentation components that dispatch actions and render UI. Thin presentation components that dispatch actions and render UI.

View file

@ -7,7 +7,6 @@ import {
ListOrderedIcon, ListOrderedIcon,
ListTodoIcon, ListTodoIcon,
type LucideIcon, type LucideIcon,
SquareCodeIcon,
StrikethroughIcon, StrikethroughIcon,
} from "lucide-react"; } from "lucide-react";
import type { Translations } from "@/utils/i18n"; import type { Translations } from "@/utils/i18n";
@ -110,12 +109,6 @@ export const EDITOR_COMMANDS: EditorCommand[] = [
icon: StrikethroughIcon, icon: StrikethroughIcon,
group: "mark", group: "mark",
}, },
{
id: "code",
labelKey: "editor.format.code",
icon: CodeIcon,
group: "mark",
},
{ {
id: "bulletList", id: "bulletList",
labelKey: "editor.format.bullet-list", labelKey: "editor.format.bullet-list",
@ -137,7 +130,7 @@ export const EDITOR_COMMANDS: EditorCommand[] = [
{ {
id: "codeBlock", id: "codeBlock",
labelKey: "editor.format.code-block", labelKey: "editor.format.code-block",
icon: SquareCodeIcon, icon: CodeIcon,
group: "block", group: "block",
}, },
{ {

View file

@ -11,3 +11,4 @@ export { useFocusMode } from "./useFocusMode";
export { useLinkMemo } from "./useLinkMemo"; export { useLinkMemo } from "./useLinkMemo";
export { useLocation } from "./useLocation"; export { useLocation } from "./useLocation";
export { useMemoInit } from "./useMemoInit"; export { useMemoInit } from "./useMemoInit";
export { useMemoSave } from "./useMemoSave";

View file

@ -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(() => { useEffect(() => {
document.body.style.overflow = isFocusMode ? "hidden" : ""; if (!isFocusMode) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { return () => {
document.body.style.overflow = ""; document.body.style.overflow = previousOverflow;
}; };
}, [isFocusMode]); }, [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,
};
} }

View file

@ -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,
]);
}

View file

@ -1,22 +1,17 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import { useNewMemo } from "@/contexts/NewMemoContext";
import { useLocalStorage } from "@/hooks"; import { useLocalStorage } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser"; 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 { cn } from "@/lib/utils";
import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb"; import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString } from "@/utils/memo"; import { convertVisibilityFromString } from "@/utils/memo";
import { AudioRecorderPanel, EditorContent, EditorMetadata, FocusModeOverlay, TimestampPopover } from "./components"; import { AudioRecorderPanel, EditorContent, EditorMetadata, FocusModeOverlay, TimestampPopover } from "./components";
import { FOCUS_MODE_STYLES, FORMATTING_TOOLBAR_STORAGE_KEY } from "./constants"; import { FOCUS_MODE_STYLES, FORMATTING_TOOLBAR_STORAGE_KEY } from "./constants";
import { useAudioRecorder, useAutoSave, useFocusMode, useMemoInit } from "./hooks"; import { useAudioRecorder, useAutoSave, useFocusMode, useMemoInit, useMemoSave } from "./hooks";
import { errorService, memoService, transcriptionService, validationService } from "./services"; import { errorService, transcriptionService } from "./services";
import { EditorProvider, useEditorContext, useEditorSelector } from "./state"; import { EditorProvider, useEditorContext, useEditorSelector } from "./state";
import { EditorToolbar, FormattingToolbar } from "./Toolbar"; import { EditorToolbar, FormattingToolbar } from "./Toolbar";
import type { MemoEditorProps } from "./types"; import type { MemoEditorProps } from "./types";
@ -41,10 +36,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
onCancel, onCancel,
}) => { }) => {
const t = useTranslate(); const t = useTranslate();
const queryClient = useQueryClient();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const editorRef = useRef<EditorController>(null); 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 // 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 // typing (which changes content) does not re-render the editor shell and its
// toolbar/metadata children. // toolbar/metadata children.
@ -52,7 +46,6 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
const hasTimestamp = useEditorSelector((s) => Boolean(s.timestamps.createTime)); const hasTimestamp = useEditorSelector((s) => Boolean(s.timestamps.createTime));
const { userGeneralSetting } = useAuth(); const { userGeneralSetting } = useAuth();
const { aiSetting, fetchSetting } = useInstance(); const { aiSetting, fetchSetting } = useInstance();
const { markNewMemo } = useNewMemo();
const [isAudioRecorderOpen, setIsAudioRecorderOpen] = useState(false); const [isAudioRecorderOpen, setIsAudioRecorderOpen] = useState(false);
const [isTranscribingAudio, setIsTranscribingAudio] = useState(false); const [isTranscribingAudio, setIsTranscribingAudio] = useState(false);
// Persisted preference: also show the formatting toolbar in normal mode. Focus // 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). // Auto-save content to localStorage (subscribes to the store internally).
const { discardDraft } = useAutoSave(currentUser?.name ?? "", cacheKey, isInitialized && isDraftCacheEnabled); const { discardDraft } = useAutoSave(currentUser?.name ?? "", cacheKey, isInitialized && isDraftCacheEnabled);
// Focus mode management with body scroll lock const { containerRef: editorContainerRef, placeholderHeight } = useFocusMode(isFocusMode);
useFocusMode(isFocusMode);
// Live-sync the draft's createTime/updateTime to the calendar-derived prop. // 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 // 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() { const handleSave = useMemoSave({
// Read the latest state imperatively — this component no longer subscribes memoName,
// to content, so the closure can't rely on a per-render `state` snapshot. parentMemoName,
const state = getState(); defaultVisibility,
// Validate before saving defaultCreateTime,
const { valid, reason } = validationService.canSave(state); discardDraft,
if (!valid) { onConfirm,
toast.error(reason || "Cannot save"); onCancel,
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));
}
}
return ( return (
<> <>
@ -312,12 +239,17 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
- In focus mode: becomes fixed with specific spacing, editor grows to fill space - In focus mode: becomes fixed with specific spacing, editor grows to fill space
- In normal mode: stays relative with max-height constraint - In normal mode: stays relative with max-height constraint
*/} */}
{isFocusMode && placeholderHeight > 0 && (
<div aria-hidden className={cn("w-full", className)} style={{ height: placeholderHeight }} />
)}
<div <div
ref={editorContainerRef}
className={cn( 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", "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, FOCUS_MODE_STYLES.transition,
isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing), 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); {/* Formatting toolbar. Always shown in focus mode (with an exit button);