From 50199fe998f30ee15bcf97c6d6f3f0ff867dee6a Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 28 Nov 2025 09:21:53 +0800 Subject: [PATCH] feat: add LocationDialog and related hooks for location management in MemoEditor - Implemented LocationDialog component for selecting and entering location coordinates. - Created useLocation hook to manage location state and updates. - Added LocationState type for managing location data. - Introduced useLinkMemo hook for linking memos with search functionality. - Added VisibilitySelector component for selecting memo visibility. - Refactored MemoEditor to integrate new hooks and components for improved functionality. - Removed obsolete handlers and streamlined memo save logic with useMemoSave hook. - Enhanced focus mode functionality with dedicated components for overlay and exit button. --- .../MemoEditor/Editor/markdownShortcuts.ts | 91 +++++++ .../{ActionButton => Toolbar}/InsertMenu.tsx | 0 .../InsertMenu/LinkMemoDialog.tsx | 0 .../InsertMenu/LocationDialog.tsx | 0 .../InsertMenu/index.ts | 0 .../InsertMenu/types.ts | 0 .../InsertMenu/useFileUpload.ts | 0 .../InsertMenu/useLinkMemo.tsx | 0 .../InsertMenu/useLocation.ts | 0 .../VisibilitySelector.tsx | 0 .../components/MemoEditor/Toolbar/index.ts | 3 + .../components/FocusModeOverlay.tsx | 46 ++++ .../components/MemoEditor/components/index.ts | 2 + web/src/components/MemoEditor/handlers.ts | 57 ---- web/src/components/MemoEditor/hooks/index.ts | 8 + .../MemoEditor/hooks/useFocusMode.ts | 40 +++ .../MemoEditor/hooks/useMemoSave.ts | 199 ++++++++++++++ web/src/components/MemoEditor/index.tsx | 246 ++++++------------ .../components/MemoEditor/types/context.ts | 21 +- web/src/components/MemoEditor/types/index.ts | 5 +- 20 files changed, 483 insertions(+), 235 deletions(-) create mode 100644 web/src/components/MemoEditor/Editor/markdownShortcuts.ts rename web/src/components/MemoEditor/{ActionButton => Toolbar}/InsertMenu.tsx (100%) rename web/src/components/MemoEditor/{ActionButton => Toolbar}/InsertMenu/LinkMemoDialog.tsx (100%) rename web/src/components/MemoEditor/{ActionButton => Toolbar}/InsertMenu/LocationDialog.tsx (100%) rename web/src/components/MemoEditor/{ActionButton => Toolbar}/InsertMenu/index.ts (100%) rename web/src/components/MemoEditor/{ActionButton => Toolbar}/InsertMenu/types.ts (100%) rename web/src/components/MemoEditor/{ActionButton => Toolbar}/InsertMenu/useFileUpload.ts (100%) rename web/src/components/MemoEditor/{ActionButton => Toolbar}/InsertMenu/useLinkMemo.tsx (100%) rename web/src/components/MemoEditor/{ActionButton => Toolbar}/InsertMenu/useLocation.ts (100%) rename web/src/components/MemoEditor/{ActionButton => Toolbar}/VisibilitySelector.tsx (100%) create mode 100644 web/src/components/MemoEditor/Toolbar/index.ts create mode 100644 web/src/components/MemoEditor/components/FocusModeOverlay.tsx create mode 100644 web/src/components/MemoEditor/components/index.ts delete mode 100644 web/src/components/MemoEditor/handlers.ts create mode 100644 web/src/components/MemoEditor/hooks/index.ts create mode 100644 web/src/components/MemoEditor/hooks/useFocusMode.ts create mode 100644 web/src/components/MemoEditor/hooks/useMemoSave.ts diff --git a/web/src/components/MemoEditor/Editor/markdownShortcuts.ts b/web/src/components/MemoEditor/Editor/markdownShortcuts.ts new file mode 100644 index 000000000..eb0c04e0e --- /dev/null +++ b/web/src/components/MemoEditor/Editor/markdownShortcuts.ts @@ -0,0 +1,91 @@ +import type { EditorRefActions } from "./index"; + +/** + * Handles keyboard shortcuts for markdown formatting + * Requires Cmd/Ctrl key to be pressed + * + * @alias handleEditorKeydownWithMarkdownShortcuts - for backward compatibility + */ +export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: EditorRefActions): void { + switch (event.key.toLowerCase()) { + case "b": + event.preventDefault(); + toggleTextStyle(editor, "**"); // Bold + break; + case "i": + event.preventDefault(); + toggleTextStyle(editor, "*"); // Italic + break; + case "k": + event.preventDefault(); + insertHyperlink(editor); + break; + } +} + +// Backward compatibility alias +export const handleEditorKeydownWithMarkdownShortcuts = handleMarkdownShortcuts; + +/** + * Inserts a hyperlink for the selected text + * If selected text is a URL, creates a link with empty text + * Otherwise, creates a link with placeholder URL + */ +export function insertHyperlink(editor: EditorRefActions, url?: string): void { + const cursorPosition = editor.getCursorPosition(); + const selectedContent = editor.getSelectedContent(); + const placeholderUrl = "url"; + const urlRegex = /^https?:\/\/[^\s]+$/; + + // If selected content looks like a URL and no URL provided, use it as the href + if (!url && urlRegex.test(selectedContent.trim())) { + editor.insertText(`[](${selectedContent})`); + // Move cursor between brackets for text input + editor.setCursorPosition(cursorPosition + 1, cursorPosition + 1); + return; + } + + const href = url ?? placeholderUrl; + editor.insertText(`[${selectedContent}](${href})`); + + // If using placeholder URL, select it for easy replacement + if (href === placeholderUrl) { + const urlStart = cursorPosition + selectedContent.length + 3; // After "](" + editor.setCursorPosition(urlStart, urlStart + href.length); + } +} + +/** + * Toggles text styling (bold, italic, etc.) + * If already styled, removes the style; otherwise adds it + */ +function toggleTextStyle(editor: EditorRefActions, delimiter: string): void { + const cursorPosition = editor.getCursorPosition(); + const selectedContent = editor.getSelectedContent(); + + // Check if already styled - remove style + if (selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter)) { + const unstyled = selectedContent.slice(delimiter.length, -delimiter.length); + editor.insertText(unstyled); + editor.setCursorPosition(cursorPosition, cursorPosition + unstyled.length); + } else { + // Add style + editor.insertText(`${delimiter}${selectedContent}${delimiter}`); + editor.setCursorPosition(cursorPosition + delimiter.length, cursorPosition + delimiter.length + selectedContent.length); + } +} + +/** + * Hyperlinks the currently highlighted/selected text with the given URL + * Used when pasting a URL while text is selected + */ +export function hyperlinkHighlightedText(editor: EditorRefActions, url: string): void { + const selectedContent = editor.getSelectedContent(); + const cursorPosition = editor.getCursorPosition(); + + editor.insertText(`[${selectedContent}](${url})`); + + // Position cursor after the link + const newPosition = cursorPosition + selectedContent.length + url.length + 4; // []() + editor.setCursorPosition(newPosition, newPosition); +} diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx similarity index 100% rename from web/src/components/MemoEditor/ActionButton/InsertMenu.tsx rename to web/src/components/MemoEditor/Toolbar/InsertMenu.tsx diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu/LinkMemoDialog.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu/LinkMemoDialog.tsx similarity index 100% rename from web/src/components/MemoEditor/ActionButton/InsertMenu/LinkMemoDialog.tsx rename to web/src/components/MemoEditor/Toolbar/InsertMenu/LinkMemoDialog.tsx diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu/LocationDialog.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu/LocationDialog.tsx similarity index 100% rename from web/src/components/MemoEditor/ActionButton/InsertMenu/LocationDialog.tsx rename to web/src/components/MemoEditor/Toolbar/InsertMenu/LocationDialog.tsx diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu/index.ts b/web/src/components/MemoEditor/Toolbar/InsertMenu/index.ts similarity index 100% rename from web/src/components/MemoEditor/ActionButton/InsertMenu/index.ts rename to web/src/components/MemoEditor/Toolbar/InsertMenu/index.ts diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu/types.ts b/web/src/components/MemoEditor/Toolbar/InsertMenu/types.ts similarity index 100% rename from web/src/components/MemoEditor/ActionButton/InsertMenu/types.ts rename to web/src/components/MemoEditor/Toolbar/InsertMenu/types.ts diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu/useFileUpload.ts b/web/src/components/MemoEditor/Toolbar/InsertMenu/useFileUpload.ts similarity index 100% rename from web/src/components/MemoEditor/ActionButton/InsertMenu/useFileUpload.ts rename to web/src/components/MemoEditor/Toolbar/InsertMenu/useFileUpload.ts diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu/useLinkMemo.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu/useLinkMemo.tsx similarity index 100% rename from web/src/components/MemoEditor/ActionButton/InsertMenu/useLinkMemo.tsx rename to web/src/components/MemoEditor/Toolbar/InsertMenu/useLinkMemo.tsx diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu/useLocation.ts b/web/src/components/MemoEditor/Toolbar/InsertMenu/useLocation.ts similarity index 100% rename from web/src/components/MemoEditor/ActionButton/InsertMenu/useLocation.ts rename to web/src/components/MemoEditor/Toolbar/InsertMenu/useLocation.ts diff --git a/web/src/components/MemoEditor/ActionButton/VisibilitySelector.tsx b/web/src/components/MemoEditor/Toolbar/VisibilitySelector.tsx similarity index 100% rename from web/src/components/MemoEditor/ActionButton/VisibilitySelector.tsx rename to web/src/components/MemoEditor/Toolbar/VisibilitySelector.tsx diff --git a/web/src/components/MemoEditor/Toolbar/index.ts b/web/src/components/MemoEditor/Toolbar/index.ts new file mode 100644 index 000000000..35034fc83 --- /dev/null +++ b/web/src/components/MemoEditor/Toolbar/index.ts @@ -0,0 +1,3 @@ +// Toolbar components for MemoEditor +export { default as InsertMenu } from "./InsertMenu"; +export { default as VisibilitySelector } from "./VisibilitySelector"; diff --git a/web/src/components/MemoEditor/components/FocusModeOverlay.tsx b/web/src/components/MemoEditor/components/FocusModeOverlay.tsx new file mode 100644 index 000000000..346479d4e --- /dev/null +++ b/web/src/components/MemoEditor/components/FocusModeOverlay.tsx @@ -0,0 +1,46 @@ +import { Minimize2Icon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { FOCUS_MODE_STYLES } from "../constants"; + +interface FocusModeOverlayProps { + isActive: boolean; + onToggle: () => void; +} + +/** + * Focus mode overlay with backdrop and exit button + * Renders the semi-transparent backdrop when focus mode is active + */ +export function FocusModeOverlay({ isActive, onToggle }: FocusModeOverlayProps) { + if (!isActive) return null; + + return ( + + ); +} diff --git a/web/src/components/MemoEditor/components/index.ts b/web/src/components/MemoEditor/components/index.ts new file mode 100644 index 000000000..ec285ee58 --- /dev/null +++ b/web/src/components/MemoEditor/components/index.ts @@ -0,0 +1,2 @@ +// UI components for MemoEditor +export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay"; diff --git a/web/src/components/MemoEditor/handlers.ts b/web/src/components/MemoEditor/handlers.ts deleted file mode 100644 index c3ae9e51f..000000000 --- a/web/src/components/MemoEditor/handlers.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { EditorRefActions } from "./Editor"; - -export const handleEditorKeydownWithMarkdownShortcuts = (event: React.KeyboardEvent, editorRef: EditorRefActions) => { - if (event.key === "b") { - const boldDelimiter = "**"; - event.preventDefault(); - styleHighlightedText(editorRef, boldDelimiter); - } else if (event.key === "i") { - const italicsDelimiter = "*"; - event.preventDefault(); - styleHighlightedText(editorRef, italicsDelimiter); - } else if (event.key === "k") { - event.preventDefault(); - hyperlinkHighlightedText(editorRef); - } -}; - -export const hyperlinkHighlightedText = (editor: EditorRefActions, url?: string) => { - const cursorPosition = editor.getCursorPosition(); - const selectedContent = editor.getSelectedContent(); - const blankURL = "url"; - - // If the selected content looks like a URL and no URL is provided, - // create a link with empty text and the URL - const urlRegex = /^(https?:\/\/[^\s]+)$/; - if (!url && urlRegex.test(selectedContent.trim())) { - editor.insertText(`[](${selectedContent})`); - // insertText places cursor at end, move it between the brackets - const linkTextPosition = cursorPosition + 1; // After the opening bracket - editor.setCursorPosition(linkTextPosition, linkTextPosition); - } else { - url = url ?? blankURL; - - editor.insertText(`[${selectedContent}](${url})`); - - if (url === blankURL) { - // insertText places cursor at end, select the placeholder URL - const urlStart = cursorPosition + selectedContent.length + 3; // After "](" - const urlEnd = urlStart + url.length; - editor.setCursorPosition(urlStart, urlEnd); - } - // If url is provided, cursor stays at end (default insertText behavior) - } -}; - -const styleHighlightedText = (editor: EditorRefActions, delimiter: string) => { - const cursorPosition = editor.getCursorPosition(); - const selectedContent = editor.getSelectedContent(); - if (selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter)) { - editor.insertText(selectedContent.slice(delimiter.length, -delimiter.length)); - const newContentLength = selectedContent.length - delimiter.length * 2; - editor.setCursorPosition(cursorPosition, cursorPosition + newContentLength); - } else { - editor.insertText(`${delimiter}${selectedContent}${delimiter}`); - editor.setCursorPosition(cursorPosition + delimiter.length, cursorPosition + delimiter.length + selectedContent.length); - } -}; diff --git a/web/src/components/MemoEditor/hooks/index.ts b/web/src/components/MemoEditor/hooks/index.ts new file mode 100644 index 000000000..2c34b8b91 --- /dev/null +++ b/web/src/components/MemoEditor/hooks/index.ts @@ -0,0 +1,8 @@ +// Custom hooks for MemoEditor +export { useAbortController } from "./useAbortController"; +export { useBlobUrls } from "./useBlobUrls"; +export { useDebounce } from "./useDebounce"; +export { useDragAndDrop } from "./useDragAndDrop"; +export { useFocusMode } from "./useFocusMode"; +export { useLocalFileManager } from "./useLocalFileManager"; +export { useMemoSave } from "./useMemoSave"; diff --git a/web/src/components/MemoEditor/hooks/useFocusMode.ts b/web/src/components/MemoEditor/hooks/useFocusMode.ts new file mode 100644 index 000000000..7b63768d3 --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useFocusMode.ts @@ -0,0 +1,40 @@ +import { useCallback, useEffect } from "react"; + +interface UseFocusModeOptions { + isFocusMode: boolean; + onToggle: () => void; +} + +interface UseFocusModeReturn { + toggleFocusMode: () => void; +} + +/** + * Custom hook for managing focus mode functionality + * Handles: + * - Body scroll lock when focus mode is active + * - Toggle functionality + * - Cleanup on unmount + */ +export function useFocusMode({ isFocusMode, onToggle }: UseFocusModeOptions): UseFocusModeReturn { + // Lock body scroll when focus mode is active to prevent background scrolling + useEffect(() => { + if (isFocusMode) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + // Cleanup on unmount + return () => { + document.body.style.overflow = ""; + }; + }, [isFocusMode]); + + const toggleFocusMode = useCallback(() => { + onToggle(); + }, [onToggle]); + + return { + toggleFocusMode, + }; +} diff --git a/web/src/components/MemoEditor/hooks/useMemoSave.ts b/web/src/components/MemoEditor/hooks/useMemoSave.ts new file mode 100644 index 000000000..5981ea7cf --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useMemoSave.ts @@ -0,0 +1,199 @@ +import { isEqual } from "lodash-es"; +import { useCallback } from "react"; +import { toast } from "react-hot-toast"; +import type { LocalFile } from "@/components/memo-metadata"; +import { memoServiceClient } from "@/grpcweb"; +import { attachmentStore, memoStore } from "@/store"; +import { Attachment } from "@/types/proto/api/v1/attachment_service"; +import type { Location, Memo, MemoRelation, Visibility } from "@/types/proto/api/v1/memo_service"; + +interface MemoSaveContext { + /** Current memo name (for update mode) */ + memoName?: string; + /** Parent memo name (for comment mode) */ + parentMemoName?: string; + /** Current visibility setting */ + visibility: Visibility; + /** Current attachments */ + attachmentList: Attachment[]; + /** Current relations */ + relationList: MemoRelation[]; + /** Current location */ + location?: Location; + /** Local files pending upload */ + localFiles: LocalFile[]; + /** Create time override */ + createTime?: Date; + /** Update time override */ + updateTime?: Date; +} + +interface MemoSaveCallbacks { + /** Called when upload state changes */ + onUploadingChange: (uploading: boolean) => void; + /** Called when request state changes */ + onRequestingChange: (requesting: boolean) => void; + /** Called on successful save */ + onSuccess: (memoName: string) => void; + /** Called on cancellation (no changes) */ + onCancel: () => void; + /** Called to reset after save */ + onReset: () => void; + /** Translation function */ + t: (key: string) => string; +} + +/** + * Uploads local files and creates attachments + */ +async function uploadLocalFiles(localFiles: LocalFile[], onUploadingChange: (uploading: boolean) => void): Promise { + if (localFiles.length === 0) return []; + + onUploadingChange(true); + try { + const attachments: Attachment[] = []; + for (const { file } of localFiles) { + const buffer = new Uint8Array(await file.arrayBuffer()); + const attachment = await attachmentStore.createAttachment({ + attachment: Attachment.fromPartial({ + filename: file.name, + size: file.size, + type: file.type, + content: buffer, + }), + attachmentId: "", + }); + attachments.push(attachment); + } + return attachments; + } finally { + onUploadingChange(false); + } +} + +/** + * Builds an update mask by comparing memo properties + */ +function buildUpdateMask( + prevMemo: Memo, + content: string, + allAttachments: Attachment[], + context: MemoSaveContext, +): { mask: Set; patch: Partial } { + const mask = new Set(); + const patch: Partial = { + name: prevMemo.name, + content, + }; + + if (!isEqual(content, prevMemo.content)) { + mask.add("content"); + patch.content = content; + } + if (!isEqual(context.visibility, prevMemo.visibility)) { + mask.add("visibility"); + patch.visibility = context.visibility; + } + if (!isEqual(allAttachments, prevMemo.attachments)) { + mask.add("attachments"); + patch.attachments = allAttachments; + } + if (!isEqual(context.relationList, prevMemo.relations)) { + mask.add("relations"); + patch.relations = context.relationList; + } + if (!isEqual(context.location, prevMemo.location)) { + mask.add("location"); + patch.location = context.location; + } + + // Auto-update timestamp if content changed + if (["content", "attachments", "relations", "location"].some((key) => mask.has(key))) { + mask.add("update_time"); + } + + // Handle custom timestamps + if (context.createTime && !isEqual(context.createTime, prevMemo.createTime)) { + mask.add("create_time"); + patch.createTime = context.createTime; + } + if (context.updateTime && !isEqual(context.updateTime, prevMemo.updateTime)) { + mask.add("update_time"); + patch.updateTime = context.updateTime; + } + + return { mask, patch }; +} + +/** + * Hook for saving/updating memos + * Extracts complex save logic from MemoEditor + */ +export function useMemoSave(callbacks: MemoSaveCallbacks) { + const { onUploadingChange, onRequestingChange, onSuccess, onCancel, onReset, t } = callbacks; + + const saveMemo = useCallback( + async (content: string, context: MemoSaveContext) => { + onRequestingChange(true); + + try { + // 1. Upload local files + const newAttachments = await uploadLocalFiles(context.localFiles, onUploadingChange); + const allAttachments = [...context.attachmentList, ...newAttachments]; + + // 2. Update existing memo + if (context.memoName) { + const prevMemo = await memoStore.getOrFetchMemoByName(context.memoName); + if (prevMemo) { + const { mask, patch } = buildUpdateMask(prevMemo, content, allAttachments, context); + + if (mask.size === 0) { + toast.error(t("editor.no-changes-detected")); + onCancel(); + return; + } + + const memo = await memoStore.updateMemo(patch, Array.from(mask)); + onSuccess(memo.name); + } + } else { + // 3. Create new memo or comment + const memo = context.parentMemoName + ? await memoServiceClient.createMemoComment({ + name: context.parentMemoName, + comment: { + content, + visibility: context.visibility, + attachments: context.attachmentList, + relations: context.relationList, + location: context.location, + }, + }) + : await memoStore.createMemo({ + memo: { + content, + visibility: context.visibility, + attachments: allAttachments, + relations: context.relationList, + location: context.location, + } as Memo, + memoId: "", + }); + + onSuccess(memo.name); + } + + onReset(); + } catch (error: unknown) { + console.error(error); + const errorMessage = error instanceof Error ? (error as { details?: string }).details || error.message : "Unknown error"; + toast.error(errorMessage); + } finally { + onRequestingChange(false); + } + }, + [onUploadingChange, onRequestingChange, onSuccess, onCancel, onReset, t], + ); + + return { saveMemo }; +} diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 468c657e9..e232c30f0 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -1,36 +1,34 @@ import copy from "copy-to-clipboard"; import { isEqual } from "lodash-es"; -import { LoaderIcon, Minimize2Icon } from "lucide-react"; +import { LoaderIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +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 { 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 { 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 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 type { LocalFile } from "../memo-metadata"; import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata"; -import InsertMenu from "./ActionButton/InsertMenu"; -import VisibilitySelector from "./ActionButton/VisibilitySelector"; +import { FocusModeExitButton, FocusModeOverlay } from "./components"; import { FOCUS_MODE_EXIT_KEY, FOCUS_MODE_STYLES, FOCUS_MODE_TOGGLE_KEY, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants"; -import Editor, { EditorRefActions } from "./Editor"; +import Editor, { type EditorRefActions } from "./Editor"; +import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./Editor/markdownShortcuts"; import ErrorBoundary from "./ErrorBoundary"; -import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers"; -import { useDebounce } from "./hooks/useDebounce"; -import { useDragAndDrop } from "./hooks/useDragAndDrop"; -import { useLocalFileManager } from "./hooks/useLocalFileManager"; +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"; @@ -73,6 +71,39 @@ const MemoEditor = observer((props: MemoEditorProps) => { : 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 || ""); }, []); @@ -121,6 +152,17 @@ const MemoEditor = observer((props: MemoEditorProps) => { } }, [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, @@ -184,21 +226,6 @@ const MemoEditor = observer((props: MemoEditorProps) => { } }; - /** - * 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, @@ -240,7 +267,7 @@ const MemoEditor = observer((props: MemoEditorProps) => { addFiles(event.clipboardData.files); } else if ( editorRef.current != null && - editorRef.current.getSelectedContent().length != 0 && + editorRef.current.getSelectedContent().length !== 0 && isValidUrl(event.clipboardData.getData("Text")) ) { event.preventDefault(); @@ -266,135 +293,17 @@ const MemoEditor = observer((props: MemoEditorProps) => { if (state.isRequesting) { return; } - - setState((state) => ({ ...state, isRequesting: true })); const content = editorRef.current?.getContent() ?? ""; - try { - // 1. Upload all local files and create attachments - const newAttachments: Attachment[] = []; - if (localFiles.length > 0) { - setState((state) => ({ ...state, isUploadingAttachment: true })); - try { - for (const { file } of localFiles) { - const buffer = new Uint8Array(await file.arrayBuffer()); - const attachment = await attachmentStore.createAttachment({ - attachment: Attachment.fromPartial({ - filename: file.name, - size: file.size, - type: file.type, - content: buffer, - }), - attachmentId: "", - }); - newAttachments.push(attachment); - } - } finally { - // Always reset upload state, even on error - setState((state) => ({ ...state, isUploadingAttachment: false })); - } - } - // 2. Update attachmentList with new attachments - const allAttachments = [...state.attachmentList, ...newAttachments]; - // 3. Save memo (create or update) - 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(allAttachments, prevMemo.attachments)) { - updateMask.add("attachments"); - memoPatch.attachments = allAttachments; - } - 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: allAttachments, - 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(""); - // Clean up local files after successful save - clearFiles(); - } 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, - }; + await saveMemo(content, { + memoName, + parentMemoName, + visibility: state.memoVisibility, + attachmentList: state.attachmentList, + relationList: state.relationList, + location: state.location, + localFiles, + createTime, + updateTime, }); }; @@ -440,7 +349,7 @@ const MemoEditor = observer((props: MemoEditorProps) => { }} > {/* Focus Mode Backdrop */} - {state.isFocusMode &&
} +
{ onFocus={handleEditorFocus} > {/* Focus Mode Exit Button */} - {state.isFocusMode && ( - - )} + { )} ID - { copy(extractMemoIdFromName(memoName)); toast.success(t("message.copied")); }} > {extractMemoIdFromName(memoName)} - +
)} diff --git a/web/src/components/MemoEditor/types/context.ts b/web/src/components/MemoEditor/types/context.ts index 7e66a7967..e4cec4528 100644 --- a/web/src/components/MemoEditor/types/context.ts +++ b/web/src/components/MemoEditor/types/context.ts @@ -3,19 +3,30 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service"; import type { MemoRelation } from "@/types/proto/api/v1/memo_service"; import type { LocalFile } from "../../memo-metadata"; -interface Context { +/** + * Context interface for MemoEditor + * Provides access to editor state and actions for child components + */ +export interface MemoEditorContextValue { + /** List of uploaded attachments */ attachmentList: Attachment[]; + /** List of memo relations/links */ relationList: MemoRelation[]; + /** Update the attachment list */ setAttachmentList: (attachmentList: Attachment[]) => void; + /** Update the relation list */ setRelationList: (relationList: MemoRelation[]) => void; + /** Name of memo being edited (undefined for new memos) */ memoName?: string; - // For local file upload/preview + /** Add local files for upload preview */ addLocalFiles?: (files: LocalFile[]) => void; + /** Remove a local file by preview URL */ removeLocalFile?: (previewUrl: string) => void; + /** List of local files pending upload */ localFiles?: LocalFile[]; } -export const MemoEditorContext = createContext({ +const defaultContextValue: MemoEditorContextValue = { attachmentList: [], relationList: [], setAttachmentList: () => {}, @@ -23,4 +34,6 @@ export const MemoEditorContext = createContext({ addLocalFiles: () => {}, removeLocalFile: () => {}, localFiles: [], -}); +}; + +export const MemoEditorContext = createContext(defaultContextValue); diff --git a/web/src/components/MemoEditor/types/index.ts b/web/src/components/MemoEditor/types/index.ts index 2edd280c7..a96d5b7de 100644 --- a/web/src/components/MemoEditor/types/index.ts +++ b/web/src/components/MemoEditor/types/index.ts @@ -1 +1,4 @@ -export * from "./context"; +// MemoEditor type exports +export type { Command } from "./command"; +export { MemoEditorContext, type MemoEditorContextValue } from "./context"; +export type { EditorConfig, MemoEditorProps, MemoEditorState } from "./memo-editor";