diff --git a/web/src/components/MemoEditor/README.md b/web/src/components/MemoEditor/README.md new file mode 100644 index 000000000..46bf71608 --- /dev/null +++ b/web/src/components/MemoEditor/README.md @@ -0,0 +1,73 @@ +# MemoEditor Architecture + +## Overview + +MemoEditor uses a three-layer architecture for better separation of concerns and testability. + +## Architecture + +``` +┌─────────────────────────────────────────┐ +│ Presentation Layer (Components) │ +│ - EditorToolbar, EditorContent, etc. │ +└─────────────────┬───────────────────────┘ + │ +┌─────────────────▼───────────────────────┐ +│ State Layer (Reducer + Context) │ +│ - state/, useEditorContext() │ +└─────────────────┬───────────────────────┘ + │ +┌─────────────────▼───────────────────────┐ +│ Service Layer (Business Logic) │ +│ - services/ (pure functions) │ +└─────────────────────────────────────────┘ +``` + +## Directory Structure + +``` +MemoEditor/ +├── state/ # State management (reducer, actions, context) +├── services/ # Business logic (pure functions) +├── components/ # UI components +├── hooks/ # React hooks (utilities) +├── Editor/ # Core editor component +├── Toolbar/ # Toolbar components +├── constants.ts +└── types/ +``` + +## Key Concepts + +### State Management + +Uses `useReducer` + Context for predictable state transitions. All state changes go through action creators. + +### Services + +Pure TypeScript functions containing business logic. No React hooks, easy to test. + +### Components + +Thin presentation components that dispatch actions and render UI. + +## Usage + +```typescript +import MemoEditor from "@/components/MemoEditor"; + + console.log('Saved:', name)} + onCancel={() => console.log('Cancelled')} +/> +``` + +## Testing + +Services are pure functions - easy to unit test without React. + +```typescript +const state = mockEditorState(); +const result = await memoService.save(state, { memoName: 'memos/123' }); +``` diff --git a/web/src/components/MemoEditor/components/EditorContent.tsx b/web/src/components/MemoEditor/components/EditorContent.tsx new file mode 100644 index 000000000..54cbd4bf7 --- /dev/null +++ b/web/src/components/MemoEditor/components/EditorContent.tsx @@ -0,0 +1,48 @@ +import { forwardRef } from "react"; +import type { LocalFile } from "@/components/memo-metadata"; +import Editor, { type EditorRefActions } from "../Editor"; +import { useBlobUrls, useDragAndDrop } from "../hooks"; +import { useEditorContext } from "../state"; + +interface EditorContentProps { + placeholder?: string; + autoFocus?: boolean; +} + +export const EditorContent = forwardRef(({ placeholder }, ref) => { + const { state, actions, dispatch } = useEditorContext(); + const { createBlobUrl } = useBlobUrls(); + + const { dragHandlers } = useDragAndDrop((files: FileList) => { + const localFiles: LocalFile[] = Array.from(files).map((file) => ({ + file, + previewUrl: createBlobUrl(file), + })); + localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile))); + }); + + const handleCompositionStart = () => { + dispatch(actions.setComposing(true)); + }; + + const handleCompositionEnd = () => { + dispatch(actions.setComposing(false)); + }; + + return ( +
+ {}} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + /> +
+ ); +}); + +EditorContent.displayName = "EditorContent"; diff --git a/web/src/components/MemoEditor/components/EditorMetadata.tsx b/web/src/components/MemoEditor/components/EditorMetadata.tsx new file mode 100644 index 000000000..4f0b2d4dc --- /dev/null +++ b/web/src/components/MemoEditor/components/EditorMetadata.tsx @@ -0,0 +1,34 @@ +import type { FC } from "react"; +import { AttachmentList, LocationDisplay, RelationList } from "@/components/memo-metadata"; +import { useEditorContext } from "../state"; + +export const EditorMetadata: FC = () => { + const { state, actions, dispatch } = useEditorContext(); + + return ( +
+ {state.metadata.location && ( + dispatch(actions.setMetadata({ location: undefined }))} + /> + )} + + dispatch(actions.setMetadata({ attachments }))} + onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))} + /> + + dispatch(actions.setMetadata({ relations }))} + /> +
+ ); +}; diff --git a/web/src/components/MemoEditor/components/EditorToolbar.tsx b/web/src/components/MemoEditor/components/EditorToolbar.tsx new file mode 100644 index 000000000..937eff91c --- /dev/null +++ b/web/src/components/MemoEditor/components/EditorToolbar.tsx @@ -0,0 +1,45 @@ +import type { FC } from "react"; +import { Button } from "@/components/ui/button"; +import { validationService } from "../services"; +import { useEditorContext } from "../state"; +import InsertMenu from "../Toolbar/InsertMenu"; +import VisibilitySelector from "../Toolbar/VisibilitySelector"; + +interface EditorToolbarProps { + onSave: () => void; + onCancel?: () => void; +} + +export const EditorToolbar: FC = ({ onSave, onCancel }) => { + const { state, actions } = useEditorContext(); + const { valid } = validationService.canSave(state); + + const isSaving = state.ui.isLoading.saving; + + return ( +
+
+ actions.setMetadata({ location })} + onToggleFocusMode={actions.toggleFocusMode} + /> +
+ +
+ actions.setMetadata({ visibility: v })} /> + + {onCancel && ( + + )} + + +
+
+ ); +}; diff --git a/web/src/components/MemoEditor/components/index.ts b/web/src/components/MemoEditor/components/index.ts index 566179215..75cf6cbb9 100644 --- a/web/src/components/MemoEditor/components/index.ts +++ b/web/src/components/MemoEditor/components/index.ts @@ -1,4 +1,8 @@ // UI components for MemoEditor + +export * from "./EditorContent"; +export * from "./EditorMetadata"; +export * from "./EditorToolbar"; export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay"; export { LinkMemoDialog } from "./LinkMemoDialog"; export { LocationDialog } from "./LocationDialog"; diff --git a/web/src/components/MemoEditor/hooks/index.ts b/web/src/components/MemoEditor/hooks/index.ts index 82ad7c42f..a9f799aa8 100644 --- a/web/src/components/MemoEditor/hooks/index.ts +++ b/web/src/components/MemoEditor/hooks/index.ts @@ -1,14 +1,12 @@ // Custom hooks for MemoEditor (internal use only) export { useAbortController } from "./useAbortController"; +export { useAutoSave } from "./useAutoSave"; export { useBlobUrls } from "./useBlobUrls"; export { useDragAndDrop } from "./useDragAndDrop"; export { useFileUpload } from "./useFileUpload"; export { useFocusMode } from "./useFocusMode"; +export { useKeyboard } from "./useKeyboard"; export { useLinkMemo } from "./useLinkMemo"; export { useLocalFileManager } from "./useLocalFileManager"; export { useLocation } from "./useLocation"; -export { useMemoEditorHandlers } from "./useMemoEditorHandlers"; -export { useMemoEditorInit } from "./useMemoEditorInit"; -export { useMemoEditorKeyboard } from "./useMemoEditorKeyboard"; -export { useMemoEditorState } from "./useMemoEditorState"; -export { useMemoSave } from "./useMemoSave"; +export { useMemoInit } from "./useMemoInit"; diff --git a/web/src/components/MemoEditor/hooks/useAutoSave.ts b/web/src/components/MemoEditor/hooks/useAutoSave.ts new file mode 100644 index 000000000..556938f1a --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useAutoSave.ts @@ -0,0 +1,9 @@ +import { useEffect } from "react"; +import { cacheService } from "../services"; + +export const useAutoSave = (content: string, username: string, cacheKey: string | undefined) => { + useEffect(() => { + const key = cacheService.key(username, cacheKey); + cacheService.save(key, content); + }, [content, username, cacheKey]); +}; diff --git a/web/src/components/MemoEditor/hooks/useKeyboard.ts b/web/src/components/MemoEditor/hooks/useKeyboard.ts new file mode 100644 index 000000000..5b60e16f4 --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useKeyboard.ts @@ -0,0 +1,21 @@ +import { useEffect } from "react"; +import type { EditorRefActions } from "../Editor"; + +interface UseKeyboardOptions { + onSave: () => void; +} + +export const useKeyboard = (editorRef: React.RefObject, options: UseKeyboardOptions) => { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Cmd/Ctrl + Enter to save + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { + event.preventDefault(); + options.onSave(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [options]); +}; diff --git a/web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts b/web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts deleted file mode 100644 index f3ee019b3..000000000 --- a/web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useCallback } from "react"; -import { isValidUrl } from "@/helpers/utils"; -import type { EditorRefActions } from "../Editor"; -import { hyperlinkHighlightedText } from "../Editor/shortcuts"; - -export interface UseMemoEditorHandlersOptions { - editorRef: React.RefObject; - onContentChange: (content: string) => void; - onFilesAdded: (files: FileList) => void; - setComposing: (isComposing: boolean) => void; -} - -export interface UseMemoEditorHandlersReturn { - handleCompositionStart: () => void; - handleCompositionEnd: () => void; - handlePasteEvent: (event: React.ClipboardEvent) => Promise; - handleEditorFocus: () => void; -} - -export const useMemoEditorHandlers = (options: UseMemoEditorHandlersOptions): UseMemoEditorHandlersReturn => { - const { editorRef, onFilesAdded, setComposing } = options; - - const handleCompositionStart = useCallback(() => { - setComposing(true); - }, [setComposing]); - - const handleCompositionEnd = useCallback(() => { - setComposing(false); - }, [setComposing]); - - const handlePasteEvent = useCallback( - async (event: React.ClipboardEvent) => { - if (event.clipboardData && event.clipboardData.files.length > 0) { - event.preventDefault(); - onFilesAdded(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")); - } - }, - [editorRef, onFilesAdded], - ); - - const handleEditorFocus = useCallback(() => { - editorRef.current?.focus(); - }, [editorRef]); - - return { - handleCompositionStart, - handleCompositionEnd, - handlePasteEvent, - handleEditorFocus, - }; -}; diff --git a/web/src/components/MemoEditor/hooks/useMemoEditorInit.ts b/web/src/components/MemoEditor/hooks/useMemoEditorInit.ts deleted file mode 100644 index 209592138..000000000 --- a/web/src/components/MemoEditor/hooks/useMemoEditorInit.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { timestampDate } from "@bufbuild/protobuf/wkt"; -import { useEffect, useState } from "react"; -import useAsyncEffect from "@/hooks/useAsyncEffect"; -import { instanceStore, memoStore, userStore } from "@/store"; -import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; -import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; -import { Visibility } from "@/types/proto/api/v1/memo_service_pb"; -import { convertVisibilityFromString } from "@/utils/memo"; -import type { EditorRefActions } from "../Editor"; - -export interface UseMemoEditorInitOptions { - editorRef: React.RefObject; - memoName?: string; - parentMemoName?: string; - contentCache?: string; - autoFocus?: boolean; - onEditorFocus: () => void; - onVisibilityChange: (visibility: Visibility) => void; - onAttachmentsChange: (attachments: Attachment[]) => void; - onRelationsChange: (relations: MemoRelation[]) => void; - onLocationChange: (location: Location | undefined) => void; -} - -export interface UseMemoEditorInitReturn { - createTime: Date | undefined; - updateTime: Date | undefined; - setCreateTime: (time: Date | undefined) => void; - setUpdateTime: (time: Date | undefined) => void; -} - -export const useMemoEditorInit = (options: UseMemoEditorInitOptions): UseMemoEditorInitReturn => { - const { - editorRef, - memoName, - parentMemoName, - contentCache, - autoFocus, - onEditorFocus, - onVisibilityChange, - onAttachmentsChange, - onRelationsChange, - onLocationChange, - } = options; - - const [createTime, setCreateTime] = useState(); - const [updateTime, setUpdateTime] = useState(); - const userGeneralSetting = userStore.state.userGeneralSetting; - const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting; - - // Initialize content cache - useEffect(() => { - editorRef.current?.setContent(contentCache || ""); - }, []); - - // Auto-focus if requested - useEffect(() => { - if (autoFocus) { - onEditorFocus(); - } - }, [autoFocus, onEditorFocus]); - - // Set initial visibility based on user settings or parent memo - 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; - } - onVisibilityChange(visibility); - }, [parentMemoName, userGeneralSetting?.memoVisibility, instanceMemoRelatedSetting.disallowPublicVisibility]); - - // Load existing memo if editing - useAsyncEffect(async () => { - if (!memoName) { - return; - } - - const memo = await memoStore.getOrFetchMemoByName(memoName); - if (memo) { - onEditorFocus(); - setCreateTime(memo.createTime ? timestampDate(memo.createTime) : undefined); - setUpdateTime(memo.updateTime ? timestampDate(memo.updateTime) : undefined); - onVisibilityChange(memo.visibility); - onAttachmentsChange(memo.attachments); - onRelationsChange(memo.relations); - onLocationChange(memo.location); - if (!contentCache) { - editorRef.current?.setContent(memo.content ?? ""); - } - } - }, [memoName]); - - return { - createTime, - updateTime, - setCreateTime, - setUpdateTime, - }; -}; diff --git a/web/src/components/MemoEditor/hooks/useMemoEditorKeyboard.ts b/web/src/components/MemoEditor/hooks/useMemoEditorKeyboard.ts deleted file mode 100644 index b6620dbd5..000000000 --- a/web/src/components/MemoEditor/hooks/useMemoEditorKeyboard.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { useCallback } from "react"; -import { TAB_SPACE_WIDTH } from "@/helpers/consts"; -import { FOCUS_MODE_EXIT_KEY, FOCUS_MODE_TOGGLE_KEY } from "../constants"; -import type { EditorRefActions } from "../Editor"; -import { handleMarkdownShortcuts } from "../Editor/shortcuts"; - -export interface UseMemoEditorKeyboardOptions { - editorRef: React.RefObject; - isFocusMode: boolean; - isComposing: boolean; - onSave: () => void; - onToggleFocusMode: () => void; -} - -export const useMemoEditorKeyboard = (options: UseMemoEditorKeyboardOptions) => { - const { editorRef, isFocusMode, isComposing, onSave, onToggleFocusMode } = options; - - const handleKeyDown = useCallback( - (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(); - onToggleFocusMode(); - return; - } - - // Exit Focus Mode: Escape - if (event.key === FOCUS_MODE_EXIT_KEY && isFocusMode) { - event.preventDefault(); - onToggleFocusMode(); - return; - } - - // Save: Cmd/Ctrl + Enter or Cmd/Ctrl + S - if (isMetaKey) { - if (event.key === "Enter" || event.key.toLowerCase() === "s") { - event.preventDefault(); - onSave(); - return; - } - handleMarkdownShortcuts(event, editorRef.current); - } - - // Tab handling - if (event.key === "Tab" && !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; - } - }, - [editorRef, isFocusMode, isComposing, onSave, onToggleFocusMode], - ); - - return { handleKeyDown }; -}; diff --git a/web/src/components/MemoEditor/hooks/useMemoEditorState.ts b/web/src/components/MemoEditor/hooks/useMemoEditorState.ts deleted file mode 100644 index 84d8a31e7..000000000 --- a/web/src/components/MemoEditor/hooks/useMemoEditorState.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { useCallback, useState } from "react"; -import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; -import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; -import { Visibility } from "@/types/proto/api/v1/memo_service_pb"; - -interface MemoEditorState { - memoVisibility: Visibility; - attachmentList: Attachment[]; - relationList: MemoRelation[]; - location: Location | undefined; - isFocusMode: boolean; - isUploadingAttachment: boolean; - isRequesting: boolean; - isComposing: boolean; - isDraggingFile: boolean; -} - -/** - * Custom hook for managing MemoEditor state with stable setter references. - * - * Note: All setter functions are wrapped with useCallback to ensure stable references. - * This prevents infinite loops when these setters are used in useEffect dependencies. - * While this makes the code verbose, it's necessary for proper React dependency tracking. - */ -export const useMemoEditorState = (initialVisibility: Visibility = Visibility.PRIVATE) => { - const [state, setState] = useState({ - memoVisibility: initialVisibility, - isFocusMode: false, - attachmentList: [], - relationList: [], - location: undefined, - isUploadingAttachment: false, - isRequesting: false, - isComposing: false, - isDraggingFile: false, - }); - - // All setters are memoized with useCallback to provide stable function references. - // This prevents unnecessary re-renders and infinite loops in useEffect hooks. - const setMemoVisibility = useCallback((v: Visibility) => { - setState((prev) => ({ ...prev, memoVisibility: v })); - }, []); - - const setAttachmentList = useCallback((v: Attachment[]) => { - setState((prev) => ({ ...prev, attachmentList: v })); - }, []); - - const setRelationList = useCallback((v: MemoRelation[]) => { - setState((prev) => ({ ...prev, relationList: v })); - }, []); - - const setLocation = useCallback((v: Location | undefined) => { - setState((prev) => ({ ...prev, location: v })); - }, []); - - const toggleFocusMode = useCallback(() => { - setState((prev) => ({ ...prev, isFocusMode: !prev.isFocusMode })); - }, []); - - const setUploadingAttachment = useCallback((v: boolean) => { - setState((prev) => ({ ...prev, isUploadingAttachment: v })); - }, []); - - const setRequesting = useCallback((v: boolean) => { - setState((prev) => ({ ...prev, isRequesting: v })); - }, []); - - const setComposing = useCallback((v: boolean) => { - setState((prev) => ({ ...prev, isComposing: v })); - }, []); - - const setDraggingFile = useCallback((v: boolean) => { - setState((prev) => ({ ...prev, isDraggingFile: v })); - }, []); - - const resetState = useCallback(() => { - setState((prev) => ({ - ...prev, - isRequesting: false, - attachmentList: [], - relationList: [], - location: undefined, - isDraggingFile: false, - })); - }, []); - - return { - ...state, - setMemoVisibility, - setAttachmentList, - setRelationList, - setLocation, - toggleFocusMode, - setUploadingAttachment, - setRequesting, - setComposing, - setDraggingFile, - resetState, - }; -}; diff --git a/web/src/components/MemoEditor/hooks/useMemoInit.ts b/web/src/components/MemoEditor/hooks/useMemoInit.ts new file mode 100644 index 000000000..4edf9a673 --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useMemoInit.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef } from "react"; +import type { EditorRefActions } from "../Editor"; +import { cacheService, memoService } from "../services"; +import { useEditorContext } from "../state"; + +export const useMemoInit = ( + editorRef: React.RefObject, + memoName: string | undefined, + cacheKey: string | undefined, + username: string, + autoFocus?: boolean, +) => { + const { actions } = useEditorContext(); + const initializedRef = useRef(false); + + useEffect(() => { + if (initializedRef.current) return; + initializedRef.current = true; + + const init = async () => { + actions.setLoading("loading", true); + + try { + if (memoName) { + // Load existing memo + const loadedState = await memoService.load(memoName); + actions.initMemo({ + content: loadedState.content, + metadata: loadedState.metadata, + timestamps: loadedState.timestamps, + }); + } else { + // Load from cache for new memo + const cachedContent = cacheService.load(cacheService.key(username, cacheKey)); + if (cachedContent) { + actions.updateContent(cachedContent); + } + } + } catch (error) { + console.error("Failed to initialize editor:", error); + } finally { + actions.setLoading("loading", false); + + if (autoFocus) { + setTimeout(() => { + editorRef.current?.focus(); + }, 100); + } + } + }; + + init(); + }, [memoName, cacheKey, username, autoFocus, actions, editorRef]); +}; diff --git a/web/src/components/MemoEditor/hooks/useMemoSave.ts b/web/src/components/MemoEditor/hooks/useMemoSave.ts deleted file mode 100644 index 73b764fc2..000000000 --- a/web/src/components/MemoEditor/hooks/useMemoSave.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { create } from "@bufbuild/protobuf"; -import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt"; -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 "@/connect"; -import { attachmentStore, memoStore } from "@/store"; -import { Attachment, AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb"; -import type { Location, Memo, MemoRelation, Visibility } from "@/types/proto/api/v1/memo_service_pb"; -import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; -import type { Translations } from "@/utils/i18n"; - -interface MemoSaveContext { - memoName?: string; - parentMemoName?: string; - visibility: Visibility; - attachmentList: Attachment[]; - relationList: MemoRelation[]; - location?: Location; - localFiles: LocalFile[]; - createTime?: Date; - updateTime?: Date; -} - -interface MemoSaveCallbacks { - onUploadingChange: (uploading: boolean) => void; - onRequestingChange: (requesting: boolean) => void; - onSuccess: (memoName: string) => void; - onCancel: () => void; - onReset: () => void; - t: (key: Translations, params?: Record) => string; -} - -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( - create(AttachmentSchema, { - filename: file.name, - size: BigInt(file.size), - type: file.type, - content: buffer, - }), - ); - attachments.push(attachment); - } - return attachments; - } finally { - onUploadingChange(false); - } -} - -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 ? timestampDate(prevMemo.createTime) : undefined)) { - mask.add("create_time"); - patch.createTime = timestampFromDate(context.createTime); - } - if (context.updateTime && !isEqual(context.updateTime, prevMemo.updateTime ? timestampDate(prevMemo.updateTime) : undefined)) { - mask.add("update_time"); - patch.updateTime = timestampFromDate(context.updateTime); - } - - return { mask, patch }; -} - -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: create(MemoSchema, { - content, - visibility: context.visibility, - attachments: context.attachmentList, - relations: context.relationList, - location: context.location, - }), - }) - : await memoStore.createMemo( - create(MemoSchema, { - content, - visibility: context.visibility, - attachments: allAttachments, - relations: context.relationList, - location: context.location, - }), - ); - - 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 ff75df9f3..f5e398916 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -1,34 +1,14 @@ -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 { useMemo, useRef } 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_pb"; import { useTranslate } from "@/utils/i18n"; -import DateTimeInput from "../DateTimeInput"; -import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata"; -import { 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 { EditorContent, EditorMetadata, EditorToolbar, FocusModeOverlay } from "./components"; +import type { EditorRefActions } from "./Editor"; +import { useAutoSave, useKeyboard, useMemoInit } from "./hooks"; +import { cacheService, errorService, memoService, validationService } from "./services"; +import { EditorProvider, useEditorContext } from "./state"; import { MemoEditorContext } from "./types"; export interface Props { @@ -43,293 +23,112 @@ export interface Props { } 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; + const { className, cacheKey, memoName, parentMemoName, autoFocus, placeholder, onConfirm, onCancel } = props; 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 - -
-
- )} -
+ + + ); }); +const MemoEditorImpl: React.FC = ({ + className, + cacheKey, + memoName, + parentMemoName, + autoFocus, + placeholder, + onConfirm, + onCancel, +}) => { + const t = useTranslate(); + const currentUser = useCurrentUser(); + const editorRef = useRef(null); + const { state, actions, dispatch } = useEditorContext(); + + // Bridge for old MemoEditorContext (used by InsertMenu and other components) + const legacyContextValue = useMemo( + () => ({ + attachmentList: state.metadata.attachments, + relationList: state.metadata.relations, + setAttachmentList: (attachments: typeof state.metadata.attachments) => dispatch(actions.setMetadata({ attachments })), + setRelationList: (relations: typeof state.metadata.relations) => dispatch(actions.setMetadata({ relations })), + memoName, + addLocalFiles: (files: typeof state.localFiles) => { + files.forEach((file) => dispatch(actions.addLocalFile(file))); + }, + removeLocalFile: (previewUrl: string) => dispatch(actions.removeLocalFile(previewUrl)), + localFiles: state.localFiles, + }), + [state.metadata.attachments, state.metadata.relations, state.localFiles, memoName, actions, dispatch], + ); + + // Initialize editor (load memo or cache) + useMemoInit(editorRef, memoName, cacheKey, currentUser.name, autoFocus); + + // Auto-save content to localStorage + useAutoSave(state.content, currentUser.name, cacheKey); + + // Keyboard shortcuts + useKeyboard(editorRef, { onSave: handleSave }); + + async function handleSave() { + const { valid, reason } = validationService.canSave(state); + if (!valid) { + toast.error(reason || "Cannot save"); + return; + } + + actions.setLoading("saving", true); + + try { + const result = await memoService.save(state, { memoName, parentMemoName }); + + if (!result.hasChanges) { + toast.error(t("editor.no-changes-detected")); + onCancel?.(); + return; + } + + // Clear cache on successful save + cacheService.clear(cacheService.key(currentUser.name, cacheKey)); + + // Reset editor state + actions.reset(); + + // Notify parent + onConfirm?.(result.memoName); + + toast.success("Saved successfully"); + } catch (error) { + const message = errorService.handle(error, t); + toast.error(message); + } finally { + actions.setLoading("saving", false); + } + } + + return ( + + + +
+ + + +
+
+ ); +}; + export default MemoEditor; diff --git a/web/src/components/MemoEditor/services/cacheService.ts b/web/src/components/MemoEditor/services/cacheService.ts new file mode 100644 index 000000000..bc311952e --- /dev/null +++ b/web/src/components/MemoEditor/services/cacheService.ts @@ -0,0 +1,25 @@ +import { debounce } from "lodash-es"; + +export const CACHE_DEBOUNCE_DELAY = 500; + +export const cacheService = { + key: (username: string, cacheKey?: string): string => { + return `${username}-${cacheKey || ""}`; + }, + + save: debounce((key: string, content: string) => { + if (content.trim()) { + localStorage.setItem(key, content); + } else { + localStorage.removeItem(key); + } + }, CACHE_DEBOUNCE_DELAY), + + load(key: string): string { + return localStorage.getItem(key) || ""; + }, + + clear(key: string): void { + localStorage.removeItem(key); + }, +}; diff --git a/web/src/components/MemoEditor/services/errorService.ts b/web/src/components/MemoEditor/services/errorService.ts new file mode 100644 index 000000000..a2e0f4145 --- /dev/null +++ b/web/src/components/MemoEditor/services/errorService.ts @@ -0,0 +1,33 @@ +import type { Translations } from "@/utils/i18n"; + +export type EditorErrorCode = "UPLOAD_FAILED" | "SAVE_FAILED" | "VALIDATION_FAILED" | "LOAD_FAILED"; + +export class EditorError extends Error { + constructor( + public code: EditorErrorCode, + public details?: unknown, + ) { + super(`Editor error: ${code}`); + this.name = "EditorError"; + } +} + +export const errorService = { + handle(error: unknown, t: (key: Translations, params?: Record) => string): string { + if (error instanceof EditorError) { + // Try to get localized error message + const key = `editor.error.${error.code.toLowerCase()}` as Translations; + return t(key, { details: error.details }); + } + + if (error && typeof error === "object" && "details" in error) { + return (error as { details?: string }).details || "An unknown error occurred"; + } + + if (error instanceof Error) { + return error.message; + } + + return "An unknown error occurred"; + }, +}; diff --git a/web/src/components/MemoEditor/services/index.ts b/web/src/components/MemoEditor/services/index.ts new file mode 100644 index 000000000..7b9fb3f4c --- /dev/null +++ b/web/src/components/MemoEditor/services/index.ts @@ -0,0 +1,5 @@ +export * from "./cacheService"; +export * from "./errorService"; +export * from "./memoService"; +export * from "./uploadService"; +export * from "./validationService"; diff --git a/web/src/components/MemoEditor/services/memoService.ts b/web/src/components/MemoEditor/services/memoService.ts new file mode 100644 index 000000000..ae0367cd0 --- /dev/null +++ b/web/src/components/MemoEditor/services/memoService.ts @@ -0,0 +1,163 @@ +import { create } from "@bufbuild/protobuf"; +import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt"; +import { isEqual } from "lodash-es"; +import { memoServiceClient } from "@/connect"; +import { memoStore } from "@/store"; +import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; +import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; +import type { EditorState } from "../state"; +import { EditorError } from "./errorService"; +import { uploadService } from "./uploadService"; + +function buildUpdateMask( + prevMemo: Memo, + state: EditorState, + allAttachments: typeof state.metadata.attachments, +): { mask: Set; patch: Partial } { + const mask = new Set(); + const patch: Partial = { + name: prevMemo.name, + content: state.content, + }; + + if (!isEqual(state.content, prevMemo.content)) { + mask.add("content"); + patch.content = state.content; + } + if (!isEqual(state.metadata.visibility, prevMemo.visibility)) { + mask.add("visibility"); + patch.visibility = state.metadata.visibility; + } + if (!isEqual(allAttachments, prevMemo.attachments)) { + mask.add("attachments"); + patch.attachments = allAttachments; + } + if (!isEqual(state.metadata.relations, prevMemo.relations)) { + mask.add("relations"); + patch.relations = state.metadata.relations; + } + if (!isEqual(state.metadata.location, prevMemo.location)) { + mask.add("location"); + patch.location = state.metadata.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 (state.timestamps.createTime) { + const prevCreateTime = prevMemo.createTime ? timestampDate(prevMemo.createTime) : undefined; + if (!isEqual(state.timestamps.createTime, prevCreateTime)) { + mask.add("create_time"); + patch.createTime = timestampFromDate(state.timestamps.createTime); + } + } + if (state.timestamps.updateTime) { + const prevUpdateTime = prevMemo.updateTime ? timestampDate(prevMemo.updateTime) : undefined; + if (!isEqual(state.timestamps.updateTime, prevUpdateTime)) { + mask.add("update_time"); + patch.updateTime = timestampFromDate(state.timestamps.updateTime); + } + } + + return { mask, patch }; +} + +export const memoService = { + async save( + state: EditorState, + options: { + memoName?: string; + parentMemoName?: string; + }, + ): Promise<{ memoName: string; hasChanges: boolean }> { + try { + // 1. Upload local files first + const newAttachments = await uploadService.uploadFiles(state.localFiles); + const allAttachments = [...state.metadata.attachments, ...newAttachments]; + + // 2. Update existing memo + if (options.memoName) { + const prevMemo = await memoStore.getOrFetchMemoByName(options.memoName); + if (!prevMemo) { + throw new EditorError("SAVE_FAILED", "Memo not found"); + } + + const { mask, patch } = buildUpdateMask(prevMemo, state, allAttachments); + + if (mask.size === 0) { + return { memoName: prevMemo.name, hasChanges: false }; + } + + const memo = await memoStore.updateMemo(patch, Array.from(mask)); + return { memoName: memo.name, hasChanges: true }; + } + + // 3. Create new memo or comment + const memoData = create(MemoSchema, { + content: state.content, + visibility: state.metadata.visibility, + attachments: allAttachments, + relations: state.metadata.relations, + location: state.metadata.location, + createTime: state.timestamps.createTime ? timestampFromDate(state.timestamps.createTime) : undefined, + updateTime: state.timestamps.updateTime ? timestampFromDate(state.timestamps.updateTime) : undefined, + }); + + const memo = options.parentMemoName + ? await memoServiceClient.createMemoComment({ + name: options.parentMemoName, + comment: memoData, + }) + : await memoStore.createMemo(memoData); + + return { memoName: memo.name, hasChanges: true }; + } catch (error) { + if (error instanceof EditorError) { + throw error; + } + throw new EditorError("SAVE_FAILED", error); + } + }, + + async load(memoName: string): Promise { + try { + const memo = await memoStore.getOrFetchMemoByName(memoName); + if (!memo) { + throw new EditorError("LOAD_FAILED", "Memo not found"); + } + + return { + content: memo.content, + metadata: { + visibility: memo.visibility, + attachments: memo.attachments, + relations: memo.relations, + location: memo.location, + }, + ui: { + isFocusMode: false, + isLoading: { + saving: false, + uploading: false, + loading: false, + }, + isDragging: false, + isComposing: false, + }, + timestamps: { + createTime: memo.createTime ? timestampDate(memo.createTime) : undefined, + updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined, + }, + localFiles: [], + }; + } catch (error) { + if (error instanceof EditorError) { + throw error; + } + throw new EditorError("LOAD_FAILED", error); + } + }, +}; diff --git a/web/src/components/MemoEditor/services/uploadService.ts b/web/src/components/MemoEditor/services/uploadService.ts new file mode 100644 index 000000000..b5e57e5cf --- /dev/null +++ b/web/src/components/MemoEditor/services/uploadService.ts @@ -0,0 +1,33 @@ +import { create } from "@bufbuild/protobuf"; +import type { LocalFile } from "@/components/memo-metadata"; +import { attachmentStore } from "@/store"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb"; +import { EditorError } from "./errorService"; + +export const uploadService = { + async uploadFiles(localFiles: LocalFile[]): Promise { + if (localFiles.length === 0) return []; + + try { + const attachments: Attachment[] = []; + + for (const { file } of localFiles) { + const buffer = new Uint8Array(await file.arrayBuffer()); + const attachment = await attachmentStore.createAttachment( + create(AttachmentSchema, { + filename: file.name, + size: BigInt(file.size), + type: file.type, + content: buffer, + }), + ); + attachments.push(attachment); + } + + return attachments; + } catch (error) { + throw new EditorError("UPLOAD_FAILED", error); + } + }, +}; diff --git a/web/src/components/MemoEditor/services/validationService.ts b/web/src/components/MemoEditor/services/validationService.ts new file mode 100644 index 000000000..1f2ac06ba --- /dev/null +++ b/web/src/components/MemoEditor/services/validationService.ts @@ -0,0 +1,27 @@ +import type { EditorState } from "../state"; + +export interface ValidationResult { + valid: boolean; + reason?: string; +} + +export const validationService = { + canSave(state: EditorState): ValidationResult { + // Must have content, attachment, or local file + if (!state.content.trim() && state.metadata.attachments.length === 0 && state.localFiles.length === 0) { + return { valid: false, reason: "Content, attachment, or file required" }; + } + + // Cannot save while uploading + if (state.ui.isLoading.uploading) { + return { valid: false, reason: "Wait for upload to complete" }; + } + + // Cannot save while already saving + if (state.ui.isLoading.saving) { + return { valid: false, reason: "Save in progress" }; + } + + return { valid: true }; + }, +}; diff --git a/web/src/components/MemoEditor/state/actions.ts b/web/src/components/MemoEditor/state/actions.ts new file mode 100644 index 000000000..fc405d54c --- /dev/null +++ b/web/src/components/MemoEditor/state/actions.ts @@ -0,0 +1,78 @@ +import type { LocalFile } from "@/components/memo-metadata"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; +import type { EditorAction, EditorState, LoadingKey } from "./types"; + +export const editorActions = { + initMemo: (payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] }): EditorAction => ({ + type: "INIT_MEMO", + payload, + }), + + updateContent: (content: string): EditorAction => ({ + type: "UPDATE_CONTENT", + payload: content, + }), + + setMetadata: (metadata: Partial): EditorAction => ({ + type: "SET_METADATA", + payload: metadata, + }), + + addAttachment: (attachment: Attachment): EditorAction => ({ + type: "ADD_ATTACHMENT", + payload: attachment, + }), + + removeAttachment: (name: string): EditorAction => ({ + type: "REMOVE_ATTACHMENT", + payload: name, + }), + + addRelation: (relation: MemoRelation): EditorAction => ({ + type: "ADD_RELATION", + payload: relation, + }), + + removeRelation: (name: string): EditorAction => ({ + type: "REMOVE_RELATION", + payload: name, + }), + + addLocalFile: (file: LocalFile): EditorAction => ({ + type: "ADD_LOCAL_FILE", + payload: file, + }), + + removeLocalFile: (previewUrl: string): EditorAction => ({ + type: "REMOVE_LOCAL_FILE", + payload: previewUrl, + }), + + clearLocalFiles: (): EditorAction => ({ + type: "CLEAR_LOCAL_FILES", + }), + + toggleFocusMode: (): EditorAction => ({ + type: "TOGGLE_FOCUS_MODE", + }), + + setLoading: (key: LoadingKey, value: boolean): EditorAction => ({ + type: "SET_LOADING", + payload: { key, value }, + }), + + setDragging: (value: boolean): EditorAction => ({ + type: "SET_DRAGGING", + payload: value, + }), + + setComposing: (value: boolean): EditorAction => ({ + type: "SET_COMPOSING", + payload: value, + }), + + reset: (): EditorAction => ({ + type: "RESET", + }), +}; diff --git a/web/src/components/MemoEditor/state/context.tsx b/web/src/components/MemoEditor/state/context.tsx new file mode 100644 index 000000000..4b0eb4e81 --- /dev/null +++ b/web/src/components/MemoEditor/state/context.tsx @@ -0,0 +1,40 @@ +import { createContext, type Dispatch, type FC, type PropsWithChildren, useContext, useMemo, useReducer } from "react"; +import { editorActions } from "./actions"; +import { editorReducer } from "./reducer"; +import type { EditorAction, EditorState } from "./types"; +import { initialState } from "./types"; + +interface EditorContextValue { + state: EditorState; + dispatch: Dispatch; + actions: typeof editorActions; +} + +const EditorContext = createContext(null); + +export const useEditorContext = () => { + const context = useContext(EditorContext); + if (!context) { + throw new Error("useEditorContext must be used within EditorProvider"); + } + return context; +}; + +interface EditorProviderProps extends PropsWithChildren { + initialEditorState?: EditorState; +} + +export const EditorProvider: FC = ({ children, initialEditorState }) => { + const [state, dispatch] = useReducer(editorReducer, initialEditorState || initialState); + + const value = useMemo( + () => ({ + state, + dispatch, + actions: editorActions, + }), + [state], + ); + + return {children}; +}; diff --git a/web/src/components/MemoEditor/state/index.ts b/web/src/components/MemoEditor/state/index.ts new file mode 100644 index 000000000..b4bece287 --- /dev/null +++ b/web/src/components/MemoEditor/state/index.ts @@ -0,0 +1,4 @@ +export * from "./actions"; +export * from "./context"; +export * from "./reducer"; +export * from "./types"; diff --git a/web/src/components/MemoEditor/state/reducer.ts b/web/src/components/MemoEditor/state/reducer.ts new file mode 100644 index 000000000..cc935f2bf --- /dev/null +++ b/web/src/components/MemoEditor/state/reducer.ts @@ -0,0 +1,130 @@ +import type { EditorAction, EditorState } from "./types"; +import { initialState } from "./types"; + +export function editorReducer(state: EditorState, action: EditorAction): EditorState { + switch (action.type) { + case "INIT_MEMO": + return { + ...state, + content: action.payload.content, + metadata: action.payload.metadata, + timestamps: action.payload.timestamps, + }; + + case "UPDATE_CONTENT": + return { + ...state, + content: action.payload, + }; + + case "SET_METADATA": + return { + ...state, + metadata: { + ...state.metadata, + ...action.payload, + }, + }; + + case "ADD_ATTACHMENT": + return { + ...state, + metadata: { + ...state.metadata, + attachments: [...state.metadata.attachments, action.payload], + }, + }; + + case "REMOVE_ATTACHMENT": + return { + ...state, + metadata: { + ...state.metadata, + attachments: state.metadata.attachments.filter((a) => a.name !== action.payload), + }, + }; + + case "ADD_RELATION": + return { + ...state, + metadata: { + ...state.metadata, + relations: [...state.metadata.relations, action.payload], + }, + }; + + case "REMOVE_RELATION": + return { + ...state, + metadata: { + ...state.metadata, + relations: state.metadata.relations.filter((r) => r.relatedMemo?.name !== action.payload), + }, + }; + + case "ADD_LOCAL_FILE": + return { + ...state, + localFiles: [...state.localFiles, action.payload], + }; + + case "REMOVE_LOCAL_FILE": + return { + ...state, + localFiles: state.localFiles.filter((f) => f.previewUrl !== action.payload), + }; + + case "CLEAR_LOCAL_FILES": + return { + ...state, + localFiles: [], + }; + + case "TOGGLE_FOCUS_MODE": + return { + ...state, + ui: { + ...state.ui, + isFocusMode: !state.ui.isFocusMode, + }, + }; + + case "SET_LOADING": + return { + ...state, + ui: { + ...state.ui, + isLoading: { + ...state.ui.isLoading, + [action.payload.key]: action.payload.value, + }, + }, + }; + + case "SET_DRAGGING": + return { + ...state, + ui: { + ...state.ui, + isDragging: action.payload, + }, + }; + + case "SET_COMPOSING": + return { + ...state, + ui: { + ...state.ui, + isComposing: action.payload, + }, + }; + + case "RESET": + return { + ...initialState, + }; + + default: + return state; + } +} diff --git a/web/src/components/MemoEditor/state/types.ts b/web/src/components/MemoEditor/state/types.ts new file mode 100644 index 000000000..a0bd33380 --- /dev/null +++ b/web/src/components/MemoEditor/state/types.ts @@ -0,0 +1,73 @@ +import type { LocalFile } from "@/components/memo-metadata"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; +import { Visibility } from "@/types/proto/api/v1/memo_service_pb"; + +export type LoadingKey = "saving" | "uploading" | "loading"; + +export interface EditorState { + content: string; + metadata: { + visibility: Visibility; + attachments: Attachment[]; + relations: MemoRelation[]; + location?: Location; + }; + ui: { + isFocusMode: boolean; + isLoading: { + saving: boolean; + uploading: boolean; + loading: boolean; + }; + isDragging: boolean; + isComposing: boolean; + }; + timestamps: { + createTime?: Date; + updateTime?: Date; + }; + localFiles: LocalFile[]; +} + +export type EditorAction = + | { type: "INIT_MEMO"; payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] } } + | { type: "UPDATE_CONTENT"; payload: string } + | { type: "SET_METADATA"; payload: Partial } + | { type: "ADD_ATTACHMENT"; payload: Attachment } + | { type: "REMOVE_ATTACHMENT"; payload: string } + | { type: "ADD_RELATION"; payload: MemoRelation } + | { type: "REMOVE_RELATION"; payload: string } + | { type: "ADD_LOCAL_FILE"; payload: LocalFile } + | { type: "REMOVE_LOCAL_FILE"; payload: string } + | { type: "CLEAR_LOCAL_FILES" } + | { type: "TOGGLE_FOCUS_MODE" } + | { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } } + | { type: "SET_DRAGGING"; payload: boolean } + | { type: "SET_COMPOSING"; payload: boolean } + | { type: "RESET" }; + +export const initialState: EditorState = { + content: "", + metadata: { + visibility: Visibility.PRIVATE, + attachments: [], + relations: [], + location: undefined, + }, + ui: { + isFocusMode: false, + isLoading: { + saving: false, + uploading: false, + loading: false, + }, + isDragging: false, + isComposing: false, + }, + timestamps: { + createTime: undefined, + updateTime: undefined, + }, + localFiles: [], +}; diff --git a/web/src/components/MemoView/components/MemoBody.tsx b/web/src/components/MemoView/components/MemoBody.tsx index d17d16080..615feec43 100644 --- a/web/src/components/MemoView/components/MemoBody.tsx +++ b/web/src/components/MemoView/components/MemoBody.tsx @@ -15,8 +15,6 @@ interface Props { const MemoBody: React.FC = ({ compact, onContentClick, onContentDoubleClick, onToggleNsfwVisibility }) => { const t = useTranslate(); - - // Get shared state from context const { memo, readonly, parentPage, nsfw, showNSFWContent } = useMemoViewContext(); const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE); @@ -36,7 +34,7 @@ const MemoBody: React.FC = ({ compact, onContentClick, onContentDoubleCli readonly={readonly} onClick={onContentClick} onDoubleClick={onContentDoubleClick} - compact={memo.pinned ? false : compact} // Always show full content when pinned + compact={memo.pinned ? false : compact} parentPage={parentPage} /> {memo.location && } @@ -45,7 +43,6 @@ const MemoBody: React.FC = ({ compact, onContentClick, onContentDoubleCli - {/* NSFW content overlay */} {nsfw && !showNSFWContent && ( <>
diff --git a/web/src/components/MemoView/components/MemoHeader.tsx b/web/src/components/MemoView/components/MemoHeader.tsx index bb3a782bb..411270398 100644 --- a/web/src/components/MemoView/components/MemoHeader.tsx +++ b/web/src/components/MemoView/components/MemoHeader.tsx @@ -38,24 +38,18 @@ const MemoHeader: React.FC = ({ onReactionSelectorOpenChange, }) => { const t = useTranslate(); - - // Get shared state from context const { memo, creator, isArchived, commentAmount, isInMemoDetailPage, parentPage, readonly, relativeTimeFormat, nsfw, showNSFWContent } = useMemoViewContext(); + const timestamp = memo.displayTime ? timestampDate(memo.displayTime) : undefined; const displayTime = isArchived ? ( - (memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language) + timestamp?.toLocaleString(i18n.language) ) : ( - + ); return (
- {/* Left section: Creator info or time */}
{showCreator && creator ? ( @@ -64,9 +58,7 @@ const MemoHeader: React.FC = ({ )}
- {/* Right section: Actions */}
- {/* Reaction selector */} {!isArchived && ( = ({ /> )} - {/* Comment count link */} {!isInMemoDetailPage && ( = ({ )} - {/* Visibility icon */} {showVisibility && memo.visibility !== Visibility.PRIVATE && ( @@ -105,7 +95,6 @@ const MemoHeader: React.FC = ({ )} - {/* Pinned indicator */} {showPinned && memo.pinned && ( @@ -121,14 +110,12 @@ const MemoHeader: React.FC = ({ )} - {/* NSFW hide button */} {nsfw && showNSFWContent && onToggleNsfwVisibility && ( )} - {/* Action menu */}
diff --git a/web/src/components/MemoView/hooks/useMemoHandlers.ts b/web/src/components/MemoView/hooks/useMemoHandlers.ts index e5dcee1e6..c9ad588e7 100644 --- a/web/src/components/MemoView/hooks/useMemoHandlers.ts +++ b/web/src/components/MemoView/hooks/useMemoHandlers.ts @@ -14,7 +14,6 @@ export const useMemoHandlers = (options: UseMemoHandlersOptions) => { const { memoName, parentPage, readonly, openEditor, openPreview } = options; const navigateTo = useNavigateTo(); - // These useCallbacks are necessary since they have real dependencies const handleGotoMemoDetailPage = useCallback(() => { navigateTo(`/${memoName}`, { state: { from: parentPage } }); }, [memoName, parentPage, navigateTo]); diff --git a/web/src/components/MemoView/hooks/useMemoViewState.ts b/web/src/components/MemoView/hooks/useMemoViewState.ts index fee314d0c..f75309dfe 100644 --- a/web/src/components/MemoView/hooks/useMemoViewState.ts +++ b/web/src/components/MemoView/hooks/useMemoViewState.ts @@ -5,21 +5,7 @@ import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; import { KEYBOARD_SHORTCUTS, TEXT_INPUT_TYPES } from "../constants"; - -interface ImagePreviewState { - open: boolean; - urls: string[]; - index: number; -} - -interface UseKeyboardShortcutsOptions { - enabled: boolean; - readonly: boolean; - showEditor: boolean; - isArchived: boolean; - onEdit: () => void; - onArchive: () => Promise; -} +import type { ImagePreviewState, UseKeyboardShortcutsOptions } from "../types"; export const useMemoActions = (memo: Memo) => { const t = useTranslate(); @@ -110,15 +96,9 @@ export const useImagePreview = () => { export const useMemoCreator = (creatorName: string) => { const [creator, setCreator] = useState(userStore.getUserByName(creatorName)); - const fetchedRef = useRef(false); useEffect(() => { - if (fetchedRef.current) return; - fetchedRef.current = true; - (async () => { - const user = await userStore.getOrFetchUser(creatorName); - setCreator(user); - })(); + userStore.getOrFetchUser(creatorName).then(setCreator); }, [creatorName]); return creator;