import copy from "copy-to-clipboard"; import { isEqual } from "lodash-es"; import { LoaderIcon, Minimize2Icon } from "lucide-react"; import { observer } from "mobx-react-lite"; import React, { 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 { memoServiceClient } from "@/grpcweb"; 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 { attachmentStore, instanceStore, memoStore, userStore } from "@/store"; import { extractMemoIdFromName } from "@/store/common"; import { Attachment } from "@/types/proto/api/v1/attachment_service"; import { Location, Memo, 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 InsertMenu from "./ActionButton/InsertMenu"; import VisibilitySelector from "./ActionButton/VisibilitySelector"; import Editor, { EditorRefActions } from "./Editor"; import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers"; import { MemoEditorContext } from "./types"; /** * Focus Mode keyboard shortcuts * - Toggle: Cmd/Ctrl + Shift + F (matches GitHub, Google Docs convention) * - Exit: Escape key */ const FOCUS_MODE_TOGGLE_KEY = "f"; const FOCUS_MODE_EXIT_KEY = "Escape"; /** * Focus Mode styling constants * Centralized to make it easy to adjust the appearance and maintain consistency */ const FOCUS_MODE_STYLES = { backdrop: "fixed inset-0 bg-black/20 backdrop-blur-sm z-40", container: { base: "fixed z-50 w-auto max-w-5xl mx-auto shadow-2xl border-border h-auto overflow-y-auto", /** * Responsive spacing using explicit positioning to avoid width conflicts: * - Mobile (< 640px): 8px margin (0.5rem) * - Tablet (640-768px): 16px margin (1rem) * - Desktop (> 768px): 32px margin (2rem) */ spacing: "top-2 left-2 right-2 bottom-2 sm:top-4 sm:left-4 sm:right-4 sm:bottom-4 md:top-8 md:left-8 md:right-8 md:bottom-8", }, transition: "transition-all duration-300 ease-in-out", exitButton: "absolute top-2 right-2 z-10 opacity-60 hover:opacity-100", } as const; export interface Props { className?: string; cacheKey?: string; placeholder?: string; // The name of the memo to be edited. memoName?: string; // The name of the parent memo if the memo is a comment. parentMemoName?: string; autoFocus?: boolean; onConfirm?: (memoName: string) => void; onCancel?: () => void; } interface State { memoVisibility: Visibility; attachmentList: Attachment[]; relationList: MemoRelation[]; location: Location | undefined; isUploadingAttachment: boolean; isRequesting: boolean; isComposing: boolean; isDraggingFile: boolean; /** Whether Focus Mode (distraction-free writing) is enabled */ isFocusMode: boolean; } const MemoEditor = observer((props: Props) => { const { className, cacheKey, memoName, parentMemoName, autoFocus, onConfirm, onCancel } = props; const t = useTranslate(); const { i18n } = useTranslation(); const currentUser = useCurrentUser(); 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; 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]); 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") { 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; } }; /** * Toggle Focus Mode on/off * Focus Mode provides a distraction-free writing experience with: * - Expanded editor taking ~80-90% of viewport * - Semi-transparent backdrop * - Centered layout with optimal width * - All editor functionality preserved */ const toggleFocusMode = () => { setState((prevState) => ({ ...prevState, isFocusMode: !prevState.isFocusMode, })); }; const handleMemoVisibilityChange = (visibility: Visibility) => { setState((prevState) => ({ ...prevState, memoVisibility: visibility, })); }; const handleSetAttachmentList = (attachmentList: Attachment[]) => { setState((prevState) => ({ ...prevState, attachmentList, })); }; const handleSetRelationList = (relationList: MemoRelation[]) => { setState((prevState) => ({ ...prevState, relationList, })); }; const handleUploadResource = async (file: File) => { setState((state) => { return { ...state, isUploadingAttachment: true, }; }); const { name: filename, size, type } = file; const buffer = new Uint8Array(await file.arrayBuffer()); try { const attachment = await attachmentStore.createAttachment({ attachment: Attachment.fromPartial({ filename, size, type, content: buffer, }), attachmentId: "", }); setState((state) => { return { ...state, isUploadingAttachment: false, }; }); return attachment; } catch (error: any) { console.error(error); toast.error(error.details); setState((state) => { return { ...state, isUploadingAttachment: false, }; }); } }; const uploadMultiFiles = async (files: FileList) => { const uploadedAttachmentList: Attachment[] = []; for (const file of files) { const attachment = await handleUploadResource(file); if (attachment) { uploadedAttachmentList.push(attachment); if (memoName) { await attachmentStore.updateAttachment({ attachment: Attachment.fromPartial({ name: attachment.name, memo: memoName, }), updateMask: ["memo"], }); } } } if (uploadedAttachmentList.length > 0) { setState((prevState) => ({ ...prevState, attachmentList: [...prevState.attachmentList, ...uploadedAttachmentList], })); } }; const handleDropEvent = async (event: React.DragEvent) => { if (event.dataTransfer && event.dataTransfer.files.length > 0) { event.preventDefault(); setState((prevState) => ({ ...prevState, isDraggingFile: false, })); await uploadMultiFiles(event.dataTransfer.files); } }; const handleDragOver = (event: React.DragEvent) => { if (event.dataTransfer && event.dataTransfer.types.includes("Files")) { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; if (!state.isDraggingFile) { setState((prevState) => ({ ...prevState, isDraggingFile: true, })); } } }; const handleDragLeave = (event: React.DragEvent) => { event.preventDefault(); setState((prevState) => ({ ...prevState, isDraggingFile: false, })); }; const handlePasteEvent = async (event: React.ClipboardEvent) => { if (event.clipboardData && event.clipboardData.files.length > 0) { event.preventDefault(); await uploadMultiFiles(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")); } }; const handleContentChange = (content: string) => { setHasContent(content !== ""); if (content !== "") { setContentCache(content); } else { localStorage.removeItem(contentCacheKey); } }; const handleSaveBtnClick = async () => { if (state.isRequesting) { return; } setState((state) => { return { ...state, isRequesting: true, }; }); const content = editorRef.current?.getContent() ?? ""; try { // Update memo. if (memoName) { const prevMemo = await memoStore.getOrFetchMemoByName(memoName); if (prevMemo) { const updateMask = new Set(); const memoPatch: Partial = { name: prevMemo.name, content, }; if (!isEqual(content, prevMemo.content)) { updateMask.add("content"); memoPatch.content = content; } if (!isEqual(state.memoVisibility, prevMemo.visibility)) { updateMask.add("visibility"); memoPatch.visibility = state.memoVisibility; } if (!isEqual(state.attachmentList, prevMemo.attachments)) { updateMask.add("attachments"); memoPatch.attachments = state.attachmentList; } if (!isEqual(state.relationList, prevMemo.relations)) { updateMask.add("relations"); memoPatch.relations = state.relationList; } if (!isEqual(state.location, prevMemo.location)) { updateMask.add("location"); memoPatch.location = state.location; } if (["content", "attachments", "relations", "location"].some((key) => updateMask.has(key))) { updateMask.add("update_time"); } if (createTime && !isEqual(createTime, prevMemo.createTime)) { updateMask.add("create_time"); memoPatch.createTime = createTime; } if (updateTime && !isEqual(updateTime, prevMemo.updateTime)) { updateMask.add("update_time"); memoPatch.updateTime = updateTime; } if (updateMask.size === 0) { toast.error(t("editor.no-changes-detected")); if (onCancel) { onCancel(); } return; } const memo = await memoStore.updateMemo(memoPatch, Array.from(updateMask)); if (onConfirm) { onConfirm(memo.name); } } } else { // Create memo or memo comment. const request = !parentMemoName ? memoStore.createMemo({ memo: Memo.fromPartial({ content, visibility: state.memoVisibility, attachments: state.attachmentList, relations: state.relationList, location: state.location, }), memoId: "", }) : memoServiceClient .createMemoComment({ name: parentMemoName, comment: { content, visibility: state.memoVisibility, attachments: state.attachmentList, relations: state.relationList, location: state.location, }, }) .then((memo) => memo); const memo = await request; if (onConfirm) { onConfirm(memo.name); } } editorRef.current?.setContent(""); } catch (error: any) { console.error(error); toast.error(error.details); } localStorage.removeItem(contentCacheKey); setState((state) => { return { ...state, isRequesting: false, attachmentList: [], relationList: [], location: undefined, isDraggingFile: false, }; }); }; const handleCancelBtnClick = () => { localStorage.removeItem(contentCacheKey); if (onCancel) { onCancel(); } }; const handleEditorFocus = () => { editorRef.current?.focus(); }; const editorConfig = useMemo( () => ({ className: "", initialContent: "", placeholder: props.placeholder ?? t("editor.any-thoughts"), onContentChange: handleContentChange, onPaste: handlePasteEvent, isFocusMode: state.isFocusMode, }), [i18n.language, state.isFocusMode], ); const allowSave = (hasContent || state.attachmentList.length > 0) && !state.isUploadingAttachment && !state.isRequesting; return ( { setState((prevState) => ({ ...prevState, attachmentList, })); }, setRelationList: (relationList: MemoRelation[]) => { setState((prevState) => ({ ...prevState, relationList, })); }, memoName, }} > {/* Focus Mode Backdrop */} {state.isFocusMode &&
}
{/* Focus Mode Exit Button */} {state.isFocusMode && ( )} setState((prevState) => ({ ...prevState, location: undefined, })) } />
e.stopPropagation()}>
setState((prevState) => ({ ...prevState, location, })) } />
handleMemoVisibilityChange(visibility)} />
{props.onCancel && ( )}
{/* Show memo metadata if memoName is provided */} {memoName && (
{!isEqual(createTime, updateTime) && updateTime && ( <> Updated )} {createTime && ( <> Created )} ID { copy(extractMemoIdFromName(memoName)); toast.success(t("message.copied")); }} > {extractMemoIdFromName(memoName)}
)} ); }); export default MemoEditor;