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:
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user