memos/web/src/components/MemoEditor/index.tsx
Steven b2e2b6426c perf(react-query): fix context re-renders and improve type safety
Optimizes React Query migration with performance and consistency improvements:

Performance:
- Memoize AuthContext and InstanceContext provider values to prevent unnecessary re-renders
- Convert InstanceContext getter functions to useMemo hooks
- Fix refetchSettings to avoid state dependency that caused frequent recreations

Type Safety:
- Replace 'any' types in useAttachmentQueries with proper protobuf types
- Add Attachment and ListAttachmentsRequest type imports

Query Key Consistency:
- Replace hardcoded ["users", "stats"] with userKeys.stats() factory function
- Ensures consistent cache key management across mutations

Developer Experience:
- Rename unused useCurrentUser to useCurrentUserQuery to avoid confusion
- Add documentation explaining AuthContext-based vs React Query current user hooks
- Update internal references in useNotifications and useTagCounts

All changes verified with TypeScript compilation and build tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 08:29:34 +08:00

176 lines
6.1 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { useMemo, useRef } from "react";
import { toast } from "react-hot-toast";
import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys } from "@/hooks/useMemoQueries";
import { userKeys } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components";
import { FOCUS_MODE_STYLES } from "./constants";
import type { EditorRefActions } from "./Editor";
import { useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks";
import { cacheService, errorService, memoService, validationService } from "./services";
import { EditorProvider, useEditorContext } from "./state";
import { MemoEditorContext } from "./types";
export interface MemoEditorProps {
className?: string;
cacheKey?: string;
placeholder?: string;
memoName?: string;
parentMemoName?: string;
autoFocus?: boolean;
onConfirm?: (memoName: string) => void;
onCancel?: () => void;
}
const MemoEditor = (props: MemoEditorProps) => {
const { className, cacheKey, memoName, parentMemoName, autoFocus, placeholder, onConfirm, onCancel } = props;
return (
<EditorProvider>
<MemoEditorImpl
className={className}
cacheKey={cacheKey}
memoName={memoName}
parentMemoName={parentMemoName}
autoFocus={autoFocus}
placeholder={placeholder}
onConfirm={onConfirm}
onCancel={onCancel}
/>
</EditorProvider>
);
};
const MemoEditorImpl: React.FC<MemoEditorProps> = ({
className,
cacheKey,
memoName,
parentMemoName,
autoFocus,
placeholder,
onConfirm,
onCancel,
}) => {
const t = useTranslate();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const editorRef = useRef<EditorRefActions>(null);
const { state, actions, dispatch } = useEditorContext();
// Bridge for old MemoEditorContext (used by InsertMenu and other components)
const legacyContextValue = useMemo(
() => ({
attachmentList: state.metadata.attachments,
relationList: state.metadata.relations,
setAttachmentList: (attachments: typeof state.metadata.attachments) => dispatch(actions.setMetadata({ attachments })),
setRelationList: (relations: typeof state.metadata.relations) => dispatch(actions.setMetadata({ relations })),
memoName,
addLocalFiles: (files: typeof state.localFiles) => {
files.forEach((file) => {
dispatch(actions.addLocalFile(file));
});
},
removeLocalFile: (previewUrl: string) => dispatch(actions.removeLocalFile(previewUrl)),
localFiles: state.localFiles,
}),
[state.metadata.attachments, state.metadata.relations, state.localFiles, memoName, actions, dispatch],
);
// Initialize editor (load memo or cache)
useMemoInit(editorRef, memoName, cacheKey, currentUser?.name ?? "", autoFocus);
// Auto-save content to localStorage
useAutoSave(state.content, currentUser?.name ?? "", cacheKey);
// Focus mode management with body scroll lock
useFocusMode(state.ui.isFocusMode);
const handleToggleFocusMode = () => {
dispatch(actions.toggleFocusMode());
};
// Keyboard shortcuts
useKeyboard(editorRef, { onSave: handleSave, onToggleFocusMode: handleToggleFocusMode });
async function handleSave() {
// 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
cacheService.clear(cacheService.key(currentUser?.name ?? "", cacheKey));
// Invalidate React Query cache to refresh memo lists across the app
await Promise.all([
queryClient.invalidateQueries({ queryKey: memoKeys.lists() }),
queryClient.invalidateQueries({ queryKey: userKeys.stats() }),
]);
// Reset editor state to initial values
dispatch(actions.reset());
// Notify parent component of successful save
onConfirm?.(result.memoName);
toast.success("Saved successfully");
} catch (error) {
const errorMessage = errorService.getErrorMessage(error);
toast.error(errorMessage);
console.error("Failed to save memo:", error);
} finally {
dispatch(actions.setLoading("saving", false));
}
}
return (
<MemoEditorContext.Provider value={legacyContextValue}>
<FocusModeOverlay isActive={state.ui.isFocusMode} onToggle={handleToggleFocusMode} />
{/*
Layout structure:
- Uses justify-between to push content to top and bottom
- In focus mode: becomes fixed with specific spacing, editor grows to fill space
- In normal mode: stays relative with max-height constraint
*/}
<div
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",
FOCUS_MODE_STYLES.transition,
state.ui.isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
className,
)}
>
{/* Exit button is absolutely positioned in top-right corner when active */}
<FocusModeExitButton isActive={state.ui.isFocusMode} onToggle={handleToggleFocusMode} title={t("editor.exit-focus-mode")} />
{/* Editor content grows to fill available space in focus mode */}
<EditorContent ref={editorRef} placeholder={placeholder} autoFocus={autoFocus} />
{/* Metadata and toolbar grouped together at bottom */}
<div className="w-full flex flex-col gap-2">
<EditorMetadata />
<EditorToolbar onSave={handleSave} onCancel={onCancel} />
</div>
</div>
</MemoEditorContext.Provider>
);
};
export default MemoEditor;