mirror of https://github.com/usememos/memos.git
338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
import copy from "copy-to-clipboard";
|
|
import { isEqual } from "lodash-es";
|
|
import { LoaderIcon } from "lucide-react";
|
|
import { observer } from "mobx-react-lite";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "react-hot-toast";
|
|
import { useTranslation } from "react-i18next";
|
|
import useLocalStorage from "react-use/lib/useLocalStorage";
|
|
import { Button } from "@/components/ui/button";
|
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
|
import { cn } from "@/lib/utils";
|
|
import { extractMemoIdFromName } from "@/store/common";
|
|
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
|
|
import { useTranslate } from "@/utils/i18n";
|
|
import DateTimeInput from "../DateTimeInput";
|
|
import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata";
|
|
import { ErrorBoundary, FocusModeExitButton, FocusModeOverlay } from "./components";
|
|
import { FOCUS_MODE_STYLES, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants";
|
|
import Editor, { type EditorRefActions } from "./Editor";
|
|
import {
|
|
useDragAndDrop,
|
|
useFocusMode,
|
|
useLocalFileManager,
|
|
useMemoEditorHandlers,
|
|
useMemoEditorInit,
|
|
useMemoEditorKeyboard,
|
|
useMemoEditorState,
|
|
useMemoSave,
|
|
} from "./hooks";
|
|
import InsertMenu from "./Toolbar/InsertMenu";
|
|
import VisibilitySelector from "./Toolbar/VisibilitySelector";
|
|
import { MemoEditorContext } from "./types";
|
|
|
|
export interface Props {
|
|
className?: string;
|
|
cacheKey?: string;
|
|
placeholder?: string;
|
|
memoName?: string;
|
|
parentMemoName?: string;
|
|
autoFocus?: boolean;
|
|
onConfirm?: (memoName: string) => void;
|
|
onCancel?: () => void;
|
|
}
|
|
|
|
const MemoEditor = observer((props: Props) => {
|
|
const { className, cacheKey, memoName, parentMemoName, autoFocus, onConfirm, onCancel } = props;
|
|
const t = useTranslate();
|
|
const { i18n } = useTranslation();
|
|
const currentUser = useCurrentUser();
|
|
const editorRef = useRef<EditorRefActions>(null);
|
|
|
|
// Content caching
|
|
const contentCacheKey = `${currentUser.name}-${cacheKey || ""}`;
|
|
const [contentCache, setContentCache] = useLocalStorage<string>(contentCacheKey, "");
|
|
const [hasContent, setHasContent] = useState<boolean>(false);
|
|
|
|
// Custom hooks for file management
|
|
const { localFiles, addFiles, removeFile, clearFiles } = useLocalFileManager();
|
|
|
|
// Custom hooks for state management
|
|
const {
|
|
memoVisibility,
|
|
attachmentList,
|
|
relationList,
|
|
location,
|
|
isFocusMode,
|
|
isUploadingAttachment,
|
|
isRequesting,
|
|
isComposing,
|
|
isDraggingFile,
|
|
setMemoVisibility,
|
|
setAttachmentList,
|
|
setRelationList,
|
|
setLocation,
|
|
toggleFocusMode,
|
|
setUploadingAttachment,
|
|
setRequesting,
|
|
setComposing,
|
|
setDraggingFile,
|
|
resetState,
|
|
} = useMemoEditorState();
|
|
|
|
// Event handlers hook
|
|
const { handleCompositionStart, handleCompositionEnd, handlePasteEvent, handleEditorFocus } = useMemoEditorHandlers({
|
|
editorRef,
|
|
onContentChange: (content: string) => {
|
|
setHasContent(content !== "");
|
|
saveContentToCache(content);
|
|
},
|
|
onFilesAdded: addFiles,
|
|
setComposing,
|
|
});
|
|
|
|
// Initialization hook
|
|
const { createTime, updateTime, setCreateTime, setUpdateTime } = useMemoEditorInit({
|
|
editorRef,
|
|
memoName,
|
|
parentMemoName,
|
|
contentCache,
|
|
autoFocus,
|
|
onEditorFocus: handleEditorFocus,
|
|
onVisibilityChange: setMemoVisibility,
|
|
onAttachmentsChange: setAttachmentList,
|
|
onRelationsChange: setRelationList,
|
|
onLocationChange: setLocation,
|
|
});
|
|
|
|
// Memo save hook - handles create/update logic
|
|
const { saveMemo } = useMemoSave({
|
|
onUploadingChange: setUploadingAttachment,
|
|
onRequestingChange: setRequesting,
|
|
onSuccess: useCallback(
|
|
(savedMemoName: string) => {
|
|
editorRef.current?.setContent("");
|
|
clearFiles();
|
|
localStorage.removeItem(contentCacheKey);
|
|
if (onConfirm) onConfirm(savedMemoName);
|
|
},
|
|
[clearFiles, contentCacheKey, onConfirm],
|
|
),
|
|
onCancel: useCallback(() => {
|
|
if (onCancel) onCancel();
|
|
}, [onCancel]),
|
|
onReset: resetState,
|
|
t,
|
|
});
|
|
|
|
// Save memo handler
|
|
const handleSaveBtnClick = useCallback(async () => {
|
|
if (isRequesting) {
|
|
return;
|
|
}
|
|
const content = editorRef.current?.getContent() ?? "";
|
|
await saveMemo(content, {
|
|
memoName,
|
|
parentMemoName,
|
|
visibility: memoVisibility,
|
|
attachmentList,
|
|
relationList,
|
|
location,
|
|
localFiles,
|
|
createTime,
|
|
updateTime,
|
|
});
|
|
}, [
|
|
isRequesting,
|
|
saveMemo,
|
|
memoName,
|
|
parentMemoName,
|
|
memoVisibility,
|
|
attachmentList,
|
|
relationList,
|
|
location,
|
|
localFiles,
|
|
createTime,
|
|
updateTime,
|
|
]);
|
|
|
|
// Keyboard shortcuts hook
|
|
const { handleKeyDown } = useMemoEditorKeyboard({
|
|
editorRef,
|
|
isFocusMode,
|
|
isComposing,
|
|
onSave: handleSaveBtnClick,
|
|
onToggleFocusMode: toggleFocusMode,
|
|
});
|
|
|
|
// Focus mode management with body scroll lock
|
|
useFocusMode(isFocusMode);
|
|
|
|
// Drag-and-drop for file uploads
|
|
const { isDragging, dragHandlers } = useDragAndDrop(addFiles);
|
|
|
|
// Sync drag state with component state
|
|
useEffect(() => {
|
|
setDraggingFile(isDragging);
|
|
}, [isDragging, setDraggingFile]);
|
|
|
|
// Debounced cache setter
|
|
const cacheTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
|
const saveContentToCache = useCallback(
|
|
(content: string) => {
|
|
clearTimeout(cacheTimeoutRef.current);
|
|
cacheTimeoutRef.current = setTimeout(() => {
|
|
if (content !== "") {
|
|
setContentCache(content);
|
|
} else {
|
|
localStorage.removeItem(contentCacheKey);
|
|
}
|
|
}, LOCALSTORAGE_DEBOUNCE_DELAY);
|
|
},
|
|
[contentCacheKey, setContentCache],
|
|
);
|
|
|
|
// Compute reference relations
|
|
const referenceRelations = useMemo(() => {
|
|
if (memoName) {
|
|
return relationList.filter(
|
|
(relation) =>
|
|
relation.memo?.name === memoName && relation.relatedMemo?.name !== memoName && relation.type === MemoRelation_Type.REFERENCE,
|
|
);
|
|
}
|
|
return relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
|
}, [memoName, relationList]);
|
|
|
|
const editorConfig = useMemo(
|
|
() => ({
|
|
className: "",
|
|
initialContent: "",
|
|
placeholder: props.placeholder ?? t("editor.any-thoughts"),
|
|
onContentChange: (content: string) => {
|
|
setHasContent(content !== "");
|
|
saveContentToCache(content);
|
|
},
|
|
onPaste: handlePasteEvent,
|
|
isFocusMode,
|
|
isInIME: isComposing,
|
|
onCompositionStart: handleCompositionStart,
|
|
onCompositionEnd: handleCompositionEnd,
|
|
}),
|
|
[i18n.language, isFocusMode, isComposing, handlePasteEvent, handleCompositionStart, handleCompositionEnd, saveContentToCache],
|
|
);
|
|
|
|
const allowSave = (hasContent || attachmentList.length > 0 || localFiles.length > 0) && !isUploadingAttachment && !isRequesting;
|
|
|
|
return (
|
|
<ErrorBoundary>
|
|
<MemoEditorContext.Provider
|
|
value={{
|
|
attachmentList,
|
|
relationList,
|
|
setAttachmentList,
|
|
addLocalFiles: (files) => addFiles(Array.from(files.map((f) => f.file))),
|
|
removeLocalFile: removeFile,
|
|
localFiles,
|
|
setRelationList,
|
|
memoName,
|
|
}}
|
|
>
|
|
{/* Focus Mode Backdrop */}
|
|
<FocusModeOverlay isActive={isFocusMode} onToggle={toggleFocusMode} />
|
|
|
|
<div
|
|
className={cn(
|
|
"group relative w-full flex flex-col justify-start items-start bg-card px-4 pt-3 pb-2 rounded-lg border",
|
|
FOCUS_MODE_STYLES.transition,
|
|
isDraggingFile ? "border-dashed border-muted-foreground cursor-copy" : "border-border cursor-auto",
|
|
isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
|
|
className,
|
|
)}
|
|
tabIndex={0}
|
|
onKeyDown={handleKeyDown}
|
|
{...dragHandlers}
|
|
onFocus={handleEditorFocus}
|
|
>
|
|
{/* Focus Mode Exit Button */}
|
|
<FocusModeExitButton isActive={isFocusMode} onToggle={toggleFocusMode} title={t("editor.exit-focus-mode")} />
|
|
|
|
<Editor ref={editorRef} {...editorConfig} />
|
|
<LocationDisplay mode="edit" location={location} onRemove={() => setLocation(undefined)} />
|
|
{/* Show attachments and pending files together */}
|
|
<AttachmentList
|
|
mode="edit"
|
|
attachments={attachmentList}
|
|
onAttachmentsChange={setAttachmentList}
|
|
localFiles={localFiles}
|
|
onRemoveLocalFile={removeFile}
|
|
/>
|
|
<RelationList mode="edit" relations={referenceRelations} onRelationsChange={setRelationList} />
|
|
<div className="relative w-full flex flex-row justify-between items-center pt-2 gap-2" onFocus={(e) => e.stopPropagation()}>
|
|
<div className="flex flex-row justify-start items-center gap-1">
|
|
<InsertMenu
|
|
isUploading={isUploadingAttachment}
|
|
location={location}
|
|
onLocationChange={setLocation}
|
|
onToggleFocusMode={toggleFocusMode}
|
|
/>
|
|
</div>
|
|
<div className="shrink-0 flex flex-row justify-end items-center">
|
|
<VisibilitySelector value={memoVisibility} onChange={setMemoVisibility} />
|
|
<div className="flex flex-row justify-end gap-1">
|
|
{props.onCancel && (
|
|
<Button
|
|
variant="ghost"
|
|
disabled={isRequesting}
|
|
onClick={() => {
|
|
clearFiles();
|
|
if (props.onCancel) props.onCancel();
|
|
}}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
)}
|
|
<Button disabled={!allowSave || isRequesting} onClick={handleSaveBtnClick}>
|
|
{isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Show memo metadata if memoName is provided */}
|
|
{memoName && (
|
|
<div className="w-full -mt-1 mb-4 text-xs leading-5 px-4 opacity-60 font-mono text-muted-foreground">
|
|
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 items-center">
|
|
{!isEqual(createTime, updateTime) && updateTime && (
|
|
<>
|
|
<span className="text-left">Updated</span>
|
|
<DateTimeInput value={updateTime} onChange={setUpdateTime} />
|
|
</>
|
|
)}
|
|
{createTime && (
|
|
<>
|
|
<span className="text-left">Created</span>
|
|
<DateTimeInput value={createTime} onChange={setCreateTime} />
|
|
</>
|
|
)}
|
|
<span className="text-left">ID</span>
|
|
<button
|
|
type="button"
|
|
className="px-1 border border-transparent cursor-default text-left"
|
|
onClick={() => {
|
|
copy(extractMemoIdFromName(memoName));
|
|
toast.success(t("message.copied"));
|
|
}}
|
|
>
|
|
{extractMemoIdFromName(memoName)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</MemoEditorContext.Provider>
|
|
</ErrorBoundary>
|
|
);
|
|
});
|
|
|
|
export default MemoEditor;
|