import copy from "copy-to-clipboard"; import { isEqual } from "lodash-es"; import { LoaderIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; import type React from "react"; 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 { TAB_SPACE_WIDTH } from "@/helpers/consts"; import { isValidUrl } from "@/helpers/utils"; import useAsyncEffect from "@/hooks/useAsyncEffect"; import useCurrentUser from "@/hooks/useCurrentUser"; import { cn } from "@/lib/utils"; import { instanceStore, memoStore, userStore } from "@/store"; import { extractMemoIdFromName } from "@/store/common"; import type { Attachment } from "@/types/proto/api/v1/attachment_service"; import { type Location, type MemoRelation, MemoRelation_Type, Visibility } from "@/types/proto/api/v1/memo_service"; import { useTranslate } from "@/utils/i18n"; import { convertVisibilityFromString } from "@/utils/memo"; import DateTimeInput from "../DateTimeInput"; import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata"; import { FocusModeExitButton, FocusModeOverlay } from "./components"; import { FOCUS_MODE_EXIT_KEY, FOCUS_MODE_STYLES, FOCUS_MODE_TOGGLE_KEY, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants"; import Editor, { type EditorRefActions } from "./Editor"; import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./Editor/markdownShortcuts"; import ErrorBoundary from "./ErrorBoundary"; import { useDebounce, useDragAndDrop, useFocusMode, useLocalFileManager, useMemoSave } from "./hooks"; import InsertMenu from "./Toolbar/InsertMenu"; import VisibilitySelector from "./Toolbar/VisibilitySelector"; import { MemoEditorContext } from "./types"; import type { MemoEditorProps, MemoEditorState } from "./types/memo-editor"; // Re-export for backward compatibility export type { MemoEditorProps as Props }; const MemoEditor = observer((props: MemoEditorProps) => { const { className, cacheKey, memoName, parentMemoName, autoFocus, onConfirm, onCancel } = props; const t = useTranslate(); const { i18n } = useTranslation(); const currentUser = useCurrentUser(); // Custom hooks for file management const { localFiles, addFiles, removeFile, clearFiles } = useLocalFileManager(); // Internal component state const [state, setState] = useState({ memoVisibility: Visibility.PRIVATE, isFocusMode: false, attachmentList: [], relationList: [], location: undefined, isUploadingAttachment: false, isRequesting: false, isComposing: false, isDraggingFile: false, }); const [createTime, setCreateTime] = useState(); const [updateTime, setUpdateTime] = useState(); const [hasContent, setHasContent] = useState(false); const editorRef = useRef(null); const userGeneralSetting = userStore.state.userGeneralSetting; const contentCacheKey = `${currentUser.name}-${cacheKey || ""}`; const [contentCache, setContentCache] = useLocalStorage(contentCacheKey, ""); const referenceRelations = memoName ? state.relationList.filter( (relation) => relation.memo?.name === memoName && relation.relatedMemo?.name !== memoName && relation.type === MemoRelation_Type.REFERENCE, ) : state.relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE); const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting; // Memo save hook - handles create/update logic const { saveMemo } = useMemoSave({ onUploadingChange: useCallback((uploading: boolean) => { setState((s) => ({ ...s, isUploadingAttachment: uploading })); }, []), onRequestingChange: useCallback((requesting: boolean) => { setState((s) => ({ ...s, isRequesting: requesting })); }, []), 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: useCallback(() => { setState((s) => ({ ...s, isRequesting: false, attachmentList: [], relationList: [], location: undefined, isDraggingFile: false, })); }, []), t, }); useEffect(() => { editorRef.current?.setContent(contentCache || ""); }, []); useEffect(() => { if (autoFocus) { handleEditorFocus(); } }, [autoFocus]); useAsyncEffect(async () => { let visibility = convertVisibilityFromString(userGeneralSetting?.memoVisibility || "PRIVATE"); if (instanceMemoRelatedSetting.disallowPublicVisibility && visibility === Visibility.PUBLIC) { visibility = Visibility.PROTECTED; } if (parentMemoName) { const parentMemo = await memoStore.getOrFetchMemoByName(parentMemoName); visibility = parentMemo.visibility; } setState((prevState) => ({ ...prevState, memoVisibility: convertVisibilityFromString(visibility), })); }, [parentMemoName, userGeneralSetting?.memoVisibility, instanceMemoRelatedSetting.disallowPublicVisibility]); useAsyncEffect(async () => { if (!memoName) { return; } const memo = await memoStore.getOrFetchMemoByName(memoName); if (memo) { handleEditorFocus(); setCreateTime(memo.createTime); setUpdateTime(memo.updateTime); setState((prevState) => ({ ...prevState, memoVisibility: memo.visibility, attachmentList: memo.attachments, relationList: memo.relations, location: memo.location, })); if (!contentCache) { editorRef.current?.setContent(memo.content ?? ""); } } }, [memoName]); // Focus mode management with body scroll lock const { toggleFocusMode } = useFocusMode({ isFocusMode: state.isFocusMode, onToggle: () => { setState((prevState) => ({ ...prevState, isFocusMode: !prevState.isFocusMode, })); }, }); const handleCompositionStart = () => { setState((prevState) => ({ ...prevState, isComposing: true, })); }; const handleCompositionEnd = () => { setState((prevState) => ({ ...prevState, isComposing: false, })); }; const handleKeyDown = (event: React.KeyboardEvent) => { if (!editorRef.current) { return; } const isMetaKey = event.ctrlKey || event.metaKey; // Focus Mode toggle: Cmd/Ctrl + Shift + F if (isMetaKey && event.shiftKey && event.key.toLowerCase() === FOCUS_MODE_TOGGLE_KEY) { event.preventDefault(); toggleFocusMode(); return; } // Exit Focus Mode: Escape if (event.key === FOCUS_MODE_EXIT_KEY && state.isFocusMode) { event.preventDefault(); toggleFocusMode(); return; } if (isMetaKey) { if (event.key === "Enter") { event.preventDefault(); handleSaveBtnClick(); return; } if (event.key.toLowerCase() === "s") { event.preventDefault(); handleSaveBtnClick(); return; } if (!instanceMemoRelatedSetting.disableMarkdownShortcuts) { handleEditorKeydownWithMarkdownShortcuts(event, editorRef.current); } } if (event.key === "Tab" && !state.isComposing) { event.preventDefault(); const tabSpace = " ".repeat(TAB_SPACE_WIDTH); const cursorPosition = editorRef.current.getCursorPosition(); const selectedContent = editorRef.current.getSelectedContent(); editorRef.current.insertText(tabSpace); if (selectedContent) { editorRef.current.setCursorPosition(cursorPosition + TAB_SPACE_WIDTH); } return; } }; const handleMemoVisibilityChange = (visibility: Visibility) => { setState((prevState) => ({ ...prevState, memoVisibility: visibility, })); }; const handleSetAttachmentList = (attachmentList: Attachment[]) => { setState((prevState) => ({ ...prevState, attachmentList, })); }; // Add local files from InsertMenu // Drag-and-drop for file uploads const { isDragging, dragHandlers } = useDragAndDrop({ onDrop: (files) => addFiles(files), }); // Sync drag state with component state useEffect(() => { setState((prevState) => ({ ...prevState, isDraggingFile: isDragging, })); }, [isDragging]); const handleSetRelationList = (relationList: MemoRelation[]) => { setState((prevState) => ({ ...prevState, relationList, })); }; const handlePasteEvent = async (event: React.ClipboardEvent) => { if (event.clipboardData && event.clipboardData.files.length > 0) { event.preventDefault(); addFiles(event.clipboardData.files); } else if ( editorRef.current != null && editorRef.current.getSelectedContent().length !== 0 && isValidUrl(event.clipboardData.getData("Text")) ) { event.preventDefault(); hyperlinkHighlightedText(editorRef.current, event.clipboardData.getData("Text")); } }; // Debounced cache setter to avoid writing to localStorage on every keystroke const saveContentToCache = useDebounce((content: string) => { if (content !== "") { setContentCache(content); } else { localStorage.removeItem(contentCacheKey); } }, LOCALSTORAGE_DEBOUNCE_DELAY); const handleContentChange = (content: string) => { setHasContent(content !== ""); saveContentToCache(content); }; const handleSaveBtnClick = async () => { if (state.isRequesting) { return; } const content = editorRef.current?.getContent() ?? ""; await saveMemo(content, { memoName, parentMemoName, visibility: state.memoVisibility, attachmentList: state.attachmentList, relationList: state.relationList, location: state.location, localFiles, createTime, updateTime, }); }; const handleEditorFocus = () => { editorRef.current?.focus(); }; const editorConfig = useMemo( () => ({ className: "", initialContent: "", placeholder: props.placeholder ?? t("editor.any-thoughts"), onContentChange: handleContentChange, onPaste: handlePasteEvent, isFocusMode: state.isFocusMode, isInIME: state.isComposing, onCompositionStart: handleCompositionStart, onCompositionEnd: handleCompositionEnd, }), [i18n.language, state.isFocusMode, state.isComposing], ); const allowSave = (hasContent || state.attachmentList.length > 0 || localFiles.length > 0) && !state.isUploadingAttachment && !state.isRequesting; return ( addFiles(Array.from(files.map((f) => f.file))), removeLocalFile: removeFile, localFiles, setRelationList: (relationList: MemoRelation[]) => { setState((prevState) => ({ ...prevState, relationList, })); }, memoName, }} > {/* Focus Mode Backdrop */}
{/* Focus Mode Exit Button */} setState((prevState) => ({ ...prevState, location: undefined, })) } /> {/* Show attachments and pending files together */}
e.stopPropagation()}>
setState((prevState) => ({ ...prevState, location, })) } onToggleFocusMode={toggleFocusMode} />
handleMemoVisibilityChange(visibility)} />
{props.onCancel && ( )}
{/* Show memo metadata if memoName is provided */} {memoName && (
{!isEqual(createTime, updateTime) && updateTime && ( <> Updated )} {createTime && ( <> Created )} ID
)}
); }); export default MemoEditor;