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(null); // Content caching const contentCacheKey = `${currentUser.name}-${cacheKey || ""}`; const [contentCache, setContentCache] = useLocalStorage(contentCacheKey, ""); const [hasContent, setHasContent] = useState(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>(); 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 ( addFiles(Array.from(files.map((f) => f.file))), removeLocalFile: removeFile, localFiles, setRelationList, memoName, }} > {/* Focus Mode Backdrop */}
{/* Focus Mode Exit Button */} setLocation(undefined)} /> {/* Show attachments and pending files together */}
e.stopPropagation()}>
{props.onCancel && ( )}
{/* Show memo metadata if memoName is provided */} {memoName && (
{!isEqual(createTime, updateTime) && updateTime && ( <> Updated )} {createTime && ( <> Created )} ID
)}
); }); export default MemoEditor;