mirror of https://github.com/usememos/memos.git
193 lines
6.3 KiB
TypeScript
193 lines
6.3 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import { useCallback, useRef, useState } from "react";
|
|
import { toast } from "react-hot-toast";
|
|
import TableEditorDialog from "@/components/TableEditorDialog";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
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 { useTranslate } from "@/utils/i18n";
|
|
import { convertVisibilityFromString } from "@/utils/memo";
|
|
import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay, TimestampPopover } 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 type { MemoEditorProps } from "./types";
|
|
|
|
const MemoEditor = (props: MemoEditorProps) => (
|
|
<EditorProvider>
|
|
<MemoEditorImpl {...props} />
|
|
</EditorProvider>
|
|
);
|
|
|
|
const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|
className,
|
|
cacheKey,
|
|
memo,
|
|
parentMemoName,
|
|
autoFocus,
|
|
placeholder,
|
|
onConfirm,
|
|
onCancel,
|
|
}) => {
|
|
const t = useTranslate();
|
|
const queryClient = useQueryClient();
|
|
const currentUser = useCurrentUser();
|
|
const editorRef = useRef<EditorRefActions>(null);
|
|
const { state, actions, dispatch } = useEditorContext();
|
|
const { userGeneralSetting } = useAuth();
|
|
|
|
const memoName = memo?.name;
|
|
|
|
// Get default visibility from user settings
|
|
const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined;
|
|
|
|
useMemoInit({
|
|
editorRef,
|
|
memo,
|
|
cacheKey,
|
|
username: currentUser?.name ?? "",
|
|
autoFocus,
|
|
defaultVisibility,
|
|
});
|
|
|
|
// 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());
|
|
};
|
|
|
|
// Table editor dialog (shared by slash command and toolbar).
|
|
const [tableDialogOpen, setTableDialogOpen] = useState(false);
|
|
|
|
const handleOpenTableEditor = useCallback(() => {
|
|
setTableDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleTableConfirm = useCallback((markdown: string) => {
|
|
editorRef.current?.insertText(markdown);
|
|
}, []);
|
|
|
|
useKeyboard(editorRef, handleSave);
|
|
|
|
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
|
|
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 }));
|
|
}
|
|
|
|
// 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 (
|
|
<>
|
|
<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 gap-2",
|
|
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")} />
|
|
|
|
{memoName && (
|
|
<div className="w-full -mb-1">
|
|
<TimestampPopover />
|
|
</div>
|
|
)}
|
|
|
|
{/* Editor content grows to fill available space in focus mode */}
|
|
<EditorContent ref={editorRef} placeholder={placeholder} onOpenTableEditor={handleOpenTableEditor} />
|
|
|
|
{/* Metadata and toolbar grouped together at bottom */}
|
|
<div className="w-full flex flex-col gap-2">
|
|
<EditorMetadata memoName={memoName} />
|
|
<EditorToolbar onSave={handleSave} onCancel={onCancel} memoName={memoName} onOpenTableEditor={handleOpenTableEditor} />
|
|
</div>
|
|
</div>
|
|
|
|
<TableEditorDialog open={tableDialogOpen} onOpenChange={setTableDialogOpen} onConfirm={handleTableConfirm} />
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default MemoEditor;
|