diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index 691c34443..a8f43e464 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -16,14 +16,11 @@ import { } from "@/components/ui/dropdown-menu"; import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service"; import { useTranslate } from "@/utils/i18n"; +import { LinkMemoDialog, LocationDialog } from "../components"; import { GEOCODING } from "../constants"; +import { useFileUpload, useLinkMemo, useLocation } from "../hooks"; import { useAbortController } from "../hooks/useAbortController"; import { MemoEditorContext } from "../types"; -import { LinkMemoDialog } from "./InsertMenu/LinkMemoDialog"; -import { LocationDialog } from "./InsertMenu/LocationDialog"; -import { useFileUpload } from "./InsertMenu/useFileUpload"; -import { useLinkMemo } from "./InsertMenu/useLinkMemo"; -import { useLocation } from "./InsertMenu/useLocation"; interface Props { isUploading?: boolean; diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu/index.ts b/web/src/components/MemoEditor/Toolbar/InsertMenu/index.ts deleted file mode 100644 index 6312276ca..000000000 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { LinkMemoDialog } from "./LinkMemoDialog"; -export { LocationDialog } from "./LocationDialog"; -export type { LinkMemoState, LocationState } from "./types"; -export { useFileUpload } from "./useFileUpload"; -export { useLinkMemo } from "./useLinkMemo"; -export { useLocation } from "./useLocation"; diff --git a/web/src/components/MemoEditor/ErrorBoundary.tsx b/web/src/components/MemoEditor/components/ErrorBoundary.tsx similarity index 100% rename from web/src/components/MemoEditor/ErrorBoundary.tsx rename to web/src/components/MemoEditor/components/ErrorBoundary.tsx diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu/LinkMemoDialog.tsx b/web/src/components/MemoEditor/components/LinkMemoDialog.tsx similarity index 100% rename from web/src/components/MemoEditor/Toolbar/InsertMenu/LinkMemoDialog.tsx rename to web/src/components/MemoEditor/components/LinkMemoDialog.tsx diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu/LocationDialog.tsx b/web/src/components/MemoEditor/components/LocationDialog.tsx similarity index 98% rename from web/src/components/MemoEditor/Toolbar/InsertMenu/LocationDialog.tsx rename to web/src/components/MemoEditor/components/LocationDialog.tsx index 6a33c3913..a812be061 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu/LocationDialog.tsx +++ b/web/src/components/MemoEditor/components/LocationDialog.tsx @@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { VisuallyHidden } from "@/components/ui/visually-hidden"; import { useTranslate } from "@/utils/i18n"; -import { LocationState } from "./types"; +import { LocationState } from "../types/insert-menu"; interface LocationDialogProps { open: boolean; diff --git a/web/src/components/MemoEditor/components/index.ts b/web/src/components/MemoEditor/components/index.ts index ec285ee58..86dc5b2ff 100644 --- a/web/src/components/MemoEditor/components/index.ts +++ b/web/src/components/MemoEditor/components/index.ts @@ -1,2 +1,5 @@ // UI components for MemoEditor +export { default as ErrorBoundary } from "./ErrorBoundary"; 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 2c34b8b91..38da0cd6c 100644 --- a/web/src/components/MemoEditor/hooks/index.ts +++ b/web/src/components/MemoEditor/hooks/index.ts @@ -3,6 +3,17 @@ export { useAbortController } from "./useAbortController"; export { useBlobUrls } from "./useBlobUrls"; export { useDebounce } from "./useDebounce"; export { useDragAndDrop } from "./useDragAndDrop"; +export { useFileUpload } from "./useFileUpload"; export { useFocusMode } from "./useFocusMode"; +export { useLinkMemo } from "./useLinkMemo"; export { useLocalFileManager } from "./useLocalFileManager"; +export { useLocation } from "./useLocation"; +export type { UseMemoEditorHandlersOptions, UseMemoEditorHandlersReturn } from "./useMemoEditorHandlers"; +export { useMemoEditorHandlers } from "./useMemoEditorHandlers"; +export type { UseMemoEditorInitOptions, UseMemoEditorInitReturn } from "./useMemoEditorInit"; +export { useMemoEditorInit } from "./useMemoEditorInit"; +export type { UseMemoEditorKeyboardOptions } from "./useMemoEditorKeyboard"; +export { useMemoEditorKeyboard } from "./useMemoEditorKeyboard"; +export type { UseMemoEditorStateReturn } from "./useMemoEditorState"; +export { useMemoEditorState } from "./useMemoEditorState"; export { useMemoSave } from "./useMemoSave"; diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu/useFileUpload.ts b/web/src/components/MemoEditor/hooks/useFileUpload.ts similarity index 100% rename from web/src/components/MemoEditor/Toolbar/InsertMenu/useFileUpload.ts rename to web/src/components/MemoEditor/hooks/useFileUpload.ts diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu/useLinkMemo.tsx b/web/src/components/MemoEditor/hooks/useLinkMemo.tsx similarity index 100% rename from web/src/components/MemoEditor/Toolbar/InsertMenu/useLinkMemo.tsx rename to web/src/components/MemoEditor/hooks/useLinkMemo.tsx diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu/useLocation.ts b/web/src/components/MemoEditor/hooks/useLocation.ts similarity index 97% rename from web/src/components/MemoEditor/Toolbar/InsertMenu/useLocation.ts rename to web/src/components/MemoEditor/hooks/useLocation.ts index 634c9580e..4b3a5858c 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu/useLocation.ts +++ b/web/src/components/MemoEditor/hooks/useLocation.ts @@ -1,7 +1,7 @@ import { LatLng } from "leaflet"; import { useState } from "react"; import { Location } from "@/types/proto/api/v1/memo_service"; -import { LocationState } from "./types"; +import { LocationState } from "../types/insert-menu"; export const useLocation = (initialLocation?: Location) => { const [locationInitialized, setLocationInitialized] = useState(false); diff --git a/web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts b/web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts new file mode 100644 index 000000000..d07a2a7c0 --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts @@ -0,0 +1,62 @@ +import { useCallback } from "react"; +import { isValidUrl } from "@/helpers/utils"; +import type { EditorRefActions } from "../Editor"; +import { hyperlinkHighlightedText } from "../Editor/markdownShortcuts"; + +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; +} + +/** + * Hook for managing MemoEditor event handlers + * Centralizes composition, paste, and focus handling + */ +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 new file mode 100644 index 000000000..3f1eae60c --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useMemoEditorInit.ts @@ -0,0 +1,105 @@ +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"; +import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service"; +import { Visibility } from "@/types/proto/api/v1/memo_service"; +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; +} + +/** + * Hook for initializing MemoEditor state + * Handles loading existing memo data and setting initial visibility + */ +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(convertVisibilityFromString(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); + setUpdateTime(memo.updateTime); + 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 new file mode 100644 index 000000000..8814977d8 --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useMemoEditorKeyboard.ts @@ -0,0 +1,71 @@ +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 { handleEditorKeydownWithMarkdownShortcuts } from "../Editor/markdownShortcuts"; + +export interface UseMemoEditorKeyboardOptions { + editorRef: React.RefObject; + isFocusMode: boolean; + isComposing: boolean; + onSave: () => void; + onToggleFocusMode: () => void; +} + +/** + * Hook for handling keyboard shortcuts in MemoEditor + * Centralizes all keyboard event handling logic + */ +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; + } + handleEditorKeydownWithMarkdownShortcuts(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 new file mode 100644 index 000000000..9eaa9b2cc --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useMemoEditorState.ts @@ -0,0 +1,124 @@ +import { useCallback, useState } from "react"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service"; +import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service"; +import { Visibility } from "@/types/proto/api/v1/memo_service"; +import type { MemoEditorState } from "../types/memo-editor"; + +export interface UseMemoEditorStateReturn { + state: MemoEditorState; + memoVisibility: Visibility; + attachmentList: Attachment[]; + relationList: MemoRelation[]; + location: Location | undefined; + isFocusMode: boolean; + isUploadingAttachment: boolean; + isRequesting: boolean; + isComposing: boolean; + isDraggingFile: boolean; + + setMemoVisibility: (visibility: Visibility) => void; + setAttachmentList: (attachments: Attachment[]) => void; + setRelationList: (relations: MemoRelation[]) => void; + setLocation: (location: Location | undefined) => void; + setIsFocusMode: (isFocusMode: boolean) => void; + toggleFocusMode: () => void; + setUploadingAttachment: (isUploading: boolean) => void; + setRequesting: (isRequesting: boolean) => void; + setComposing: (isComposing: boolean) => void; + setDraggingFile: (isDragging: boolean) => void; + resetState: () => void; +} + +/** + * Hook for managing MemoEditor state + * Centralizes all state management and provides clean setters + */ +export const useMemoEditorState = (initialVisibility: Visibility = Visibility.PRIVATE): UseMemoEditorStateReturn => { + const [state, setState] = useState({ + memoVisibility: initialVisibility, + isFocusMode: false, + attachmentList: [], + relationList: [], + location: undefined, + isUploadingAttachment: false, + isRequesting: false, + isComposing: false, + isDraggingFile: false, + }); + + const setMemoVisibility = useCallback((visibility: Visibility) => { + setState((prev) => ({ ...prev, memoVisibility: visibility })); + }, []); + + const setAttachmentList = useCallback((attachments: Attachment[]) => { + setState((prev) => ({ ...prev, attachmentList: attachments })); + }, []); + + const setRelationList = useCallback((relations: MemoRelation[]) => { + setState((prev) => ({ ...prev, relationList: relations })); + }, []); + + const setLocation = useCallback((location: Location | undefined) => { + setState((prev) => ({ ...prev, location })); + }, []); + + const setIsFocusMode = useCallback((isFocusMode: boolean) => { + setState((prev) => ({ ...prev, isFocusMode })); + }, []); + + const toggleFocusMode = useCallback(() => { + setState((prev) => ({ ...prev, isFocusMode: !prev.isFocusMode })); + }, []); + + const setUploadingAttachment = useCallback((isUploading: boolean) => { + setState((prev) => ({ ...prev, isUploadingAttachment: isUploading })); + }, []); + + const setRequesting = useCallback((isRequesting: boolean) => { + setState((prev) => ({ ...prev, isRequesting })); + }, []); + + const setComposing = useCallback((isComposing: boolean) => { + setState((prev) => ({ ...prev, isComposing })); + }, []); + + const setDraggingFile = useCallback((isDragging: boolean) => { + setState((prev) => ({ ...prev, isDraggingFile: isDragging })); + }, []); + + const resetState = useCallback(() => { + setState((prev) => ({ + ...prev, + isRequesting: false, + attachmentList: [], + relationList: [], + location: undefined, + isDraggingFile: false, + })); + }, []); + + return { + state, + memoVisibility: state.memoVisibility, + attachmentList: state.attachmentList, + relationList: state.relationList, + location: state.location, + isFocusMode: state.isFocusMode, + isUploadingAttachment: state.isUploadingAttachment, + isRequesting: state.isRequesting, + isComposing: state.isComposing, + isDraggingFile: state.isDraggingFile, + + setMemoVisibility, + setAttachmentList, + setRelationList, + setLocation, + setIsFocusMode, + toggleFocusMode, + setUploadingAttachment, + setRequesting, + setComposing, + setDraggingFile, + resetState, + }; +}; diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index a3bd2288b..4d017118b 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -2,35 +2,36 @@ import copy from "copy-to-clipboard"; import { isEqual } from "lodash-es"; import { LoaderIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; -import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "react-hot-toast"; import { useTranslation } from "react-i18next"; import useLocalStorage from "react-use/lib/useLocalStorage"; import { Button } from "@/components/ui/button"; -import { TAB_SPACE_WIDTH } from "@/helpers/consts"; -import { isValidUrl } from "@/helpers/utils"; -import useAsyncEffect from "@/hooks/useAsyncEffect"; import useCurrentUser from "@/hooks/useCurrentUser"; import { cn } from "@/lib/utils"; -import { instanceStore, memoStore, userStore } from "@/store"; import { extractMemoIdFromName } from "@/store/common"; -import type { Attachment } from "@/types/proto/api/v1/attachment_service"; -import { type Location, type MemoRelation, MemoRelation_Type, Visibility } from "@/types/proto/api/v1/memo_service"; +import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service"; import { useTranslate } from "@/utils/i18n"; -import { convertVisibilityFromString } from "@/utils/memo"; import DateTimeInput from "../DateTimeInput"; import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata"; -import { FocusModeExitButton, FocusModeOverlay } from "./components"; -import { FOCUS_MODE_EXIT_KEY, FOCUS_MODE_STYLES, FOCUS_MODE_TOGGLE_KEY, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants"; +import { ErrorBoundary, FocusModeExitButton, FocusModeOverlay } from "./components"; +import { FOCUS_MODE_STYLES, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants"; import Editor, { type EditorRefActions } from "./Editor"; -import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./Editor/markdownShortcuts"; -import ErrorBoundary from "./ErrorBoundary"; -import { useDebounce, useDragAndDrop, useFocusMode, useLocalFileManager, useMemoSave } from "./hooks"; +import { + useDebounce, + useDragAndDrop, + useFocusMode, + useLocalFileManager, + useMemoEditorHandlers, + useMemoEditorInit, + useMemoEditorKeyboard, + useMemoEditorState, + 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"; +import type { MemoEditorProps } from "./types/memo-editor"; // Re-export for backward compatibility export type { MemoEditorProps as Props }; @@ -40,45 +41,68 @@ const MemoEditor = observer((props: MemoEditorProps) => { 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(); - // Internal component state - const [state, setState] = useState({ - memoVisibility: Visibility.PRIVATE, - isFocusMode: false, - attachmentList: [], - relationList: [], - location: undefined, - isUploadingAttachment: false, - isRequesting: false, - isComposing: false, - isDraggingFile: false, + // 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, }); - const [createTime, setCreateTime] = useState(); - const [updateTime, setUpdateTime] = useState(); - const [hasContent, setHasContent] = useState(false); - const editorRef = useRef(null); - const userGeneralSetting = userStore.state.userGeneralSetting; - const contentCacheKey = `${currentUser.name}-${cacheKey || ""}`; - const [contentCache, setContentCache] = useLocalStorage(contentCacheKey, ""); - const referenceRelations = memoName - ? state.relationList.filter( - (relation) => - relation.memo?.name === memoName && relation.relatedMemo?.name !== memoName && relation.type === MemoRelation_Type.REFERENCE, - ) - : state.relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE); - const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting; // Memo save hook - handles create/update logic const { saveMemo } = useMemoSave({ - onUploadingChange: useCallback((uploading: boolean) => { - setState((s) => ({ ...s, isUploadingAttachment: uploading })); - }, []), - onRequestingChange: useCallback((requesting: boolean) => { - setState((s) => ({ ...s, isRequesting: requesting })); - }, []), + onUploadingChange: setUploadingAttachment, + onRequestingChange: setRequesting, onSuccess: useCallback( (savedMemoName: string) => { editorRef.current?.setContent(""); @@ -91,187 +115,65 @@ const MemoEditor = observer((props: MemoEditorProps) => { onCancel: useCallback(() => { if (onCancel) onCancel(); }, [onCancel]), - onReset: useCallback(() => { - setState((s) => ({ - ...s, - isRequesting: false, - attachmentList: [], - relationList: [], - location: undefined, - isDraggingFile: false, - })); - }, []), + onReset: resetState, t, }); - useEffect(() => { - editorRef.current?.setContent(contentCache || ""); - }, []); - - useEffect(() => { - if (autoFocus) { - handleEditorFocus(); - } - }, [autoFocus]); - - useAsyncEffect(async () => { - let visibility = convertVisibilityFromString(userGeneralSetting?.memoVisibility || "PRIVATE"); - if (instanceMemoRelatedSetting.disallowPublicVisibility && visibility === Visibility.PUBLIC) { - visibility = Visibility.PROTECTED; - } - if (parentMemoName) { - const parentMemo = await memoStore.getOrFetchMemoByName(parentMemoName); - visibility = parentMemo.visibility; - } - setState((prevState) => ({ - ...prevState, - memoVisibility: convertVisibilityFromString(visibility), - })); - }, [parentMemoName, userGeneralSetting?.memoVisibility, instanceMemoRelatedSetting.disallowPublicVisibility]); - - useAsyncEffect(async () => { - if (!memoName) { + // 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, + ]); - const memo = await memoStore.getOrFetchMemoByName(memoName); - if (memo) { - handleEditorFocus(); - setCreateTime(memo.createTime); - setUpdateTime(memo.updateTime); - setState((prevState) => ({ - ...prevState, - memoVisibility: memo.visibility, - attachmentList: memo.attachments, - relationList: memo.relations, - location: memo.location, - })); - if (!contentCache) { - editorRef.current?.setContent(memo.content ?? ""); - } - } - }, [memoName]); - - // Focus mode management with body scroll lock - const { toggleFocusMode } = useFocusMode({ - isFocusMode: state.isFocusMode, - onToggle: () => { - setState((prevState) => ({ - ...prevState, - isFocusMode: !prevState.isFocusMode, - })); - }, + // Keyboard shortcuts hook + const { handleKeyDown } = useMemoEditorKeyboard({ + editorRef, + isFocusMode, + isComposing, + onSave: handleSaveBtnClick, + onToggleFocusMode: toggleFocusMode, }); - const handleCompositionStart = () => { - setState((prevState) => ({ - ...prevState, - isComposing: true, - })); - }; + // Focus mode management with body scroll lock + useFocusMode({ + isFocusMode, + onToggle: toggleFocusMode, + }); - const handleCompositionEnd = () => { - setState((prevState) => ({ - ...prevState, - isComposing: false, - })); - }; - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (!editorRef.current) { - return; - } - - const isMetaKey = event.ctrlKey || event.metaKey; - - // Focus Mode toggle: Cmd/Ctrl + Shift + F - if (isMetaKey && event.shiftKey && event.key.toLowerCase() === FOCUS_MODE_TOGGLE_KEY) { - event.preventDefault(); - toggleFocusMode(); - return; - } - - // Exit Focus Mode: Escape - if (event.key === FOCUS_MODE_EXIT_KEY && state.isFocusMode) { - event.preventDefault(); - toggleFocusMode(); - return; - } - - if (isMetaKey) { - if (event.key === "Enter") { - event.preventDefault(); - handleSaveBtnClick(); - return; - } - if (event.key.toLowerCase() === "s") { - event.preventDefault(); - handleSaveBtnClick(); - return; - } - handleEditorKeydownWithMarkdownShortcuts(event, editorRef.current); - } - if (event.key === "Tab" && !state.isComposing) { - event.preventDefault(); - const tabSpace = " ".repeat(TAB_SPACE_WIDTH); - const cursorPosition = editorRef.current.getCursorPosition(); - const selectedContent = editorRef.current.getSelectedContent(); - editorRef.current.insertText(tabSpace); - if (selectedContent) { - editorRef.current.setCursorPosition(cursorPosition + TAB_SPACE_WIDTH); - } - return; - } - }; - - const handleMemoVisibilityChange = (visibility: Visibility) => { - setState((prevState) => ({ - ...prevState, - memoVisibility: visibility, - })); - }; - - const handleSetAttachmentList = (attachmentList: Attachment[]) => { - setState((prevState) => ({ - ...prevState, - attachmentList, - })); - }; - - // Add local files from InsertMenu // Drag-and-drop for file uploads const { isDragging, dragHandlers } = useDragAndDrop({ - onDrop: (files) => addFiles(files), + onDrop: addFiles, }); // Sync drag state with component state useEffect(() => { - setState((prevState) => ({ - ...prevState, - isDraggingFile: isDragging, - })); - }, [isDragging]); - - const handleSetRelationList = (relationList: MemoRelation[]) => { - setState((prevState) => ({ - ...prevState, - relationList, - })); - }; - - const handlePasteEvent = async (event: React.ClipboardEvent) => { - if (event.clipboardData && event.clipboardData.files.length > 0) { - event.preventDefault(); - addFiles(event.clipboardData.files); - } else if ( - editorRef.current != null && - editorRef.current.getSelectedContent().length !== 0 && - isValidUrl(event.clipboardData.getData("Text")) - ) { - event.preventDefault(); - hyperlinkHighlightedText(editorRef.current, event.clipboardData.getData("Text")); - } - }; + setDraggingFile(isDragging); + }, [isDragging, setDraggingFile]); // Debounced cache setter to avoid writing to localStorage on every keystroke const saveContentToCache = useDebounce((content: string) => { @@ -282,79 +184,60 @@ const MemoEditor = observer((props: MemoEditorProps) => { } }, LOCALSTORAGE_DEBOUNCE_DELAY); - const handleContentChange = (content: string) => { - setHasContent(content !== ""); - saveContentToCache(content); - }; - - const handleSaveBtnClick = async () => { - if (state.isRequesting) { - return; + // 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, + ); } - const content = editorRef.current?.getContent() ?? ""; - await saveMemo(content, { - memoName, - parentMemoName, - visibility: state.memoVisibility, - attachmentList: state.attachmentList, - relationList: state.relationList, - location: state.location, - localFiles, - createTime, - updateTime, - }); - }; - - const handleEditorFocus = () => { - editorRef.current?.focus(); - }; + return relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE); + }, [memoName, relationList]); const editorConfig = useMemo( () => ({ className: "", initialContent: "", placeholder: props.placeholder ?? t("editor.any-thoughts"), - onContentChange: handleContentChange, + onContentChange: (content: string) => { + setHasContent(content !== ""); + saveContentToCache(content); + }, onPaste: handlePasteEvent, - isFocusMode: state.isFocusMode, - isInIME: state.isComposing, + isFocusMode, + isInIME: isComposing, onCompositionStart: handleCompositionStart, onCompositionEnd: handleCompositionEnd, }), - [i18n.language, state.isFocusMode, state.isComposing], + [i18n.language, isFocusMode, isComposing, handlePasteEvent, handleCompositionStart, handleCompositionEnd, saveContentToCache], ); - const allowSave = - (hasContent || state.attachmentList.length > 0 || localFiles.length > 0) && !state.isUploadingAttachment && !state.isRequesting; + const allowSave = (hasContent || attachmentList.length > 0 || localFiles.length > 0) && !isUploadingAttachment && !isRequesting; return ( addFiles(Array.from(files.map((f) => f.file))), removeLocalFile: removeFile, localFiles, - setRelationList: (relationList: MemoRelation[]) => { - setState((prevState) => ({ - ...prevState, - relationList, - })); - }, + setRelationList, memoName, }} > {/* Focus Mode Backdrop */} - +
{ onFocus={handleEditorFocus} > {/* Focus Mode Exit Button */} - + - - setState((prevState) => ({ - ...prevState, - location: undefined, - })) - } - /> + setLocation(undefined)} /> {/* Show attachments and pending files together */} - +
e.stopPropagation()}>
- setState((prevState) => ({ - ...prevState, - location, - })) - } + isUploading={isUploadingAttachment} + location={location} + onLocationChange={setLocation} onToggleFocusMode={toggleFocusMode} />
- handleMemoVisibilityChange(visibility)} /> +
{props.onCancel && ( )} -
diff --git a/web/src/components/MemoEditor/types/index.ts b/web/src/components/MemoEditor/types/index.ts index a96d5b7de..9e2f38b24 100644 --- a/web/src/components/MemoEditor/types/index.ts +++ b/web/src/components/MemoEditor/types/index.ts @@ -1,4 +1,5 @@ // MemoEditor type exports export type { Command } from "./command"; export { MemoEditorContext, type MemoEditorContextValue } from "./context"; +export type { LinkMemoState, LocationState } from "./insert-menu"; export type { EditorConfig, MemoEditorProps, MemoEditorState } from "./memo-editor"; diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu/types.ts b/web/src/components/MemoEditor/types/insert-menu.ts similarity index 100% rename from web/src/components/MemoEditor/Toolbar/InsertMenu/types.ts rename to web/src/components/MemoEditor/types/insert-menu.ts diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx index 079282b75..bbfbc2604 100644 --- a/web/src/components/MemoView/MemoView.tsx +++ b/web/src/components/MemoView/MemoView.tsx @@ -1,18 +1,21 @@ import { observer } from "mobx-react-lite"; -import { memo, useCallback, useRef, useState } from "react"; -import { useLocation } from "react-router-dom"; -import useCurrentUser from "@/hooks/useCurrentUser"; -import useNavigateTo from "@/hooks/useNavigateTo"; +import { memo, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; -import { instanceStore, userStore } from "@/store"; -import { State } from "@/types/proto/api/v1/common"; -import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service"; -import { isSuperUser } from "@/utils/user"; import MemoEditor from "../MemoEditor"; import PreviewImageDialog from "../PreviewImageDialog"; import { MemoBody, MemoHeader } from "./components"; -import { MEMO_CARD_BASE_CLASSES, RELATIVE_TIME_THRESHOLD_MS } from "./constants"; -import { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./hooks"; +import { MEMO_CARD_BASE_CLASSES } from "./constants"; +import { + useImagePreview, + useKeyboardShortcuts, + useMemoActions, + useMemoCreator, + useMemoEditor, + useMemoHandlers, + useMemoViewDerivedState, + useNsfwContent, +} from "./hooks"; +import { MemoViewContext } from "./MemoViewContext"; import type { MemoViewProps } from "./types"; /** @@ -27,34 +30,34 @@ import type { MemoViewProps } from "./types"; */ const MemoView: React.FC = observer((props: MemoViewProps) => { const { memo: memoData, className } = props; - const location = useLocation(); - const navigateTo = useNavigateTo(); - const user = useCurrentUser(); const cardRef = useRef(null); // State - const [showEditor, setShowEditor] = useState(false); const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false); - // Fetch creator data + // Custom hooks for data fetching const creator = useMemoCreator(memoData.creator); - // Custom hooks for state management + // Custom hooks for derived state + const { commentAmount, relativeTimeFormat, isArchived, readonly, isInMemoDetailPage, parentPage } = useMemoViewDerivedState({ + memo: memoData, + parentPage: props.parentPage, + }); + + // Custom hooks for UI state management const { nsfw, showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent); const { previewState, openPreview, setPreviewOpen } = useImagePreview(); - const { archiveMemo, unpinMemo } = useMemoActions(memoData); + const { showEditor, openEditor, handleEditorConfirm, handleEditorCancel } = useMemoEditor(); - // Derived state - const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting; - const commentAmount = memoData.relations.filter( - (relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memoData.name, - ).length; - const relativeTimeFormat = - memoData.displayTime && Date.now() - memoData.displayTime.getTime() > RELATIVE_TIME_THRESHOLD_MS ? "datetime" : "auto"; - const isArchived = memoData.state === State.ARCHIVED; - const readonly = memoData.creator !== user?.name && !isSuperUser(user); - const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`); - const parentPage = props.parentPage || location.pathname; + // Custom hooks for actions + const { archiveMemo, unpinMemo } = useMemoActions(memoData); + const { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({ + memoName: memoData.name, + parentPage, + readonly, + openEditor, + openPreview, + }); // Keyboard shortcuts const { handleShortcutActivation } = useKeyboardShortcuts(cardRef, { @@ -62,59 +65,28 @@ const MemoView: React.FC = observer((props: MemoViewProps) => { readonly, showEditor, isArchived, - onEdit: () => setShowEditor(true), + onEdit: openEditor, onArchive: archiveMemo, }); - // Handlers - const handleGotoMemoDetailPage = useCallback(() => { - navigateTo(`/${memoData.name}`, { - state: { from: parentPage }, - }); - }, [memoData.name, parentPage, navigateTo]); - - const handleMemoContentClick = useCallback( - (e: React.MouseEvent) => { - const targetEl = e.target as HTMLElement; - - if (targetEl.tagName === "IMG") { - // Check if the image is inside a link - const linkElement = targetEl.closest("a"); - if (linkElement) { - // If image is inside a link, only navigate to the link (don't show preview) - return; - } - - const imgUrl = targetEl.getAttribute("src"); - if (imgUrl) { - openPreview(imgUrl); - } - } - }, - [openPreview], + // Memoize context value to prevent unnecessary re-renders + // IMPORTANT: This must be before the early return to satisfy Rules of Hooks + const contextValue = useMemo( + () => ({ + memo: memoData, + creator, + isArchived, + readonly, + isInMemoDetailPage, + parentPage, + commentAmount, + relativeTimeFormat, + nsfw, + showNSFWContent, + }), + [memoData, creator, isArchived, readonly, isInMemoDetailPage, parentPage, commentAmount, relativeTimeFormat, nsfw, showNSFWContent], ); - const handleMemoContentDoubleClick = useCallback( - (e: React.MouseEvent) => { - if (readonly) return; - - if (instanceMemoRelatedSetting.enableDoubleClickEdit) { - e.preventDefault(); - setShowEditor(true); - } - }, - [readonly, instanceMemoRelatedSetting.enableDoubleClickEdit], - ); - - const handleEditorConfirm = useCallback(() => { - setShowEditor(false); - userStore.setStatsStateId(); - }, []); - - const handleEditorCancel = useCallback(() => { - setShowEditor(false); - }, []); - // Render inline editor when editing if (showEditor) { return ( @@ -131,54 +103,41 @@ const MemoView: React.FC = observer((props: MemoViewProps) => { // Render memo card return ( -
handleShortcutActivation(true)} - onBlur={() => handleShortcutActivation(false)} - > - setShowEditor(true)} - onGotoDetail={handleGotoMemoDetailPage} - onUnpin={unpinMemo} - onToggleNsfwVisibility={toggleNsfwVisibility} - nsfw={nsfw} - showNSFWContent={showNSFWContent} - reactionSelectorOpen={reactionSelectorOpen} - onReactionSelectorOpenChange={setReactionSelectorOpen} - /> + +
handleShortcutActivation(true)} + onBlur={() => handleShortcutActivation(false)} + > + - + - -
+ +
+ ); }); diff --git a/web/src/components/MemoView/MemoViewContext.tsx b/web/src/components/MemoView/MemoViewContext.tsx new file mode 100644 index 000000000..ece759e3a --- /dev/null +++ b/web/src/components/MemoView/MemoViewContext.tsx @@ -0,0 +1,48 @@ +import { createContext, useContext } from "react"; +import type { Memo } from "@/types/proto/api/v1/memo_service"; +import type { User } from "@/types/proto/api/v1/user_service"; + +/** + * Context value for MemoView component tree + * Provides shared state and props to child components + */ +export interface MemoViewContextValue { + /** The memo data */ + memo: Memo; + /** The memo creator user data */ + creator: User | undefined; + /** Whether the memo is in archived state */ + isArchived: boolean; + /** Whether the current user can only view (not edit) the memo */ + readonly: boolean; + /** Whether we're currently on the memo detail page */ + isInMemoDetailPage: boolean; + /** Parent page path for navigation state */ + parentPage: string; + /** Number of comments on this memo */ + commentAmount: number; + /** Time format to use (datetime for old memos, auto for recent) */ + relativeTimeFormat: "datetime" | "auto"; + /** Whether this memo contains NSFW content */ + nsfw: boolean; + /** Whether to show NSFW content without blur */ + showNSFWContent: boolean; +} + +/** + * Context for sharing MemoView state across child components + * This eliminates prop drilling for commonly used values + */ +export const MemoViewContext = createContext(null); + +/** + * Hook to access MemoView context + * @throws Error if used outside of MemoViewContext.Provider + */ +export const useMemoViewContext = (): MemoViewContextValue => { + const context = useContext(MemoViewContext); + if (!context) { + throw new Error("useMemoViewContext must be used within MemoViewContext.Provider"); + } + return context; +}; diff --git a/web/src/components/MemoView/components/MemoBody.tsx b/web/src/components/MemoView/components/MemoBody.tsx index f87a635f6..0b077a5fb 100644 --- a/web/src/components/MemoView/components/MemoBody.tsx +++ b/web/src/components/MemoView/components/MemoBody.tsx @@ -4,6 +4,7 @@ import { useTranslate } from "@/utils/i18n"; import MemoContent from "../../MemoContent"; import { MemoReactionListView } from "../../MemoReactionListView"; import { AttachmentList, LocationDisplay, RelationList } from "../../memo-metadata"; +import { useMemoViewContext } from "../MemoViewContext"; import type { MemoBodyProps } from "../types"; /** @@ -15,18 +16,12 @@ import type { MemoBodyProps } from "../types"; * - Reactions * - NSFW content overlay */ -const MemoBody: React.FC = ({ - memo, - readonly, - compact, - parentPage, - nsfw, - showNSFWContent, - onContentClick, - onContentDoubleClick, - onToggleNsfwVisibility, -}) => { +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); return ( diff --git a/web/src/components/MemoView/components/MemoHeader.tsx b/web/src/components/MemoView/components/MemoHeader.tsx index 321572a53..ceaa9fa99 100644 --- a/web/src/components/MemoView/components/MemoHeader.tsx +++ b/web/src/components/MemoView/components/MemoHeader.tsx @@ -4,12 +4,14 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import i18n from "@/i18n"; import { cn } from "@/lib/utils"; import { Visibility } from "@/types/proto/api/v1/memo_service"; +import type { User } from "@/types/proto/api/v1/user_service"; import { useTranslate } from "@/utils/i18n"; import { convertVisibilityToString } from "@/utils/memo"; import MemoActionMenu from "../../MemoActionMenu"; import { ReactionSelector } from "../../reactions"; import UserAvatar from "../../UserAvatar"; import VisibilityIcon from "../../VisibilityIcon"; +import { useMemoViewContext } from "../MemoViewContext"; import type { MemoHeaderProps } from "../types"; /** @@ -24,28 +26,22 @@ import type { MemoHeaderProps } from "../types"; * - Action menu */ const MemoHeader: React.FC = ({ - memo, - creator, showCreator, showVisibility, showPinned, - isArchived, - commentAmount, - isInMemoDetailPage, - parentPage, - readonly, - relativeTimeFormat, onEdit, onGotoDetail, onUnpin, onToggleNsfwVisibility, - nsfw, - showNSFWContent, reactionSelectorOpen, onReactionSelectorOpenChange, }) => { const t = useTranslate(); + // Get shared state from context + const { memo, creator, isArchived, commentAmount, isInMemoDetailPage, parentPage, readonly, relativeTimeFormat, nsfw, showNSFWContent } = + useMemoViewContext(); + const displayTime = isArchived ? ( memo.displayTime?.toLocaleString(i18n.language) ) : ( @@ -138,7 +134,7 @@ const MemoHeader: React.FC = ({ * Creator display with avatar and name */ interface CreatorDisplayProps { - creator: NonNullable; + creator: User; displayTime: React.ReactNode; onGotoDetail: () => void; } diff --git a/web/src/components/MemoView/hooks/index.ts b/web/src/components/MemoView/hooks/index.ts index 02f3b6195..e324132f4 100644 --- a/web/src/components/MemoView/hooks/index.ts +++ b/web/src/components/MemoView/hooks/index.ts @@ -1 +1,7 @@ +export type { UseMemoEditorReturn } from "./useMemoEditor"; +export { useMemoEditor } from "./useMemoEditor"; +export type { UseMemoHandlersOptions, UseMemoHandlersReturn } from "./useMemoHandlers"; +export { useMemoHandlers } from "./useMemoHandlers"; +export type { UseMemoViewDerivedStateOptions, UseMemoViewDerivedStateReturn } from "./useMemoViewDerivedState"; +export { useMemoViewDerivedState } from "./useMemoViewDerivedState"; export { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./useMemoViewState"; diff --git a/web/src/components/MemoView/hooks/useMemoEditor.ts b/web/src/components/MemoView/hooks/useMemoEditor.ts new file mode 100644 index 000000000..5737db0a6 --- /dev/null +++ b/web/src/components/MemoView/hooks/useMemoEditor.ts @@ -0,0 +1,37 @@ +import { useCallback, useState } from "react"; +import { userStore } from "@/store"; + +export interface UseMemoEditorReturn { + showEditor: boolean; + openEditor: () => void; + handleEditorConfirm: () => void; + handleEditorCancel: () => void; +} + +/** + * Hook for managing memo editor state and actions + * Encapsulates all editor-related state and handlers + */ +export const useMemoEditor = (): UseMemoEditorReturn => { + const [showEditor, setShowEditor] = useState(false); + + const openEditor = useCallback(() => { + setShowEditor(true); + }, []); + + const handleEditorConfirm = useCallback(() => { + setShowEditor(false); + userStore.setStatsStateId(); + }, []); + + const handleEditorCancel = useCallback(() => { + setShowEditor(false); + }, []); + + return { + showEditor, + openEditor, + handleEditorConfirm, + handleEditorCancel, + }; +}; diff --git a/web/src/components/MemoView/hooks/useMemoHandlers.ts b/web/src/components/MemoView/hooks/useMemoHandlers.ts new file mode 100644 index 000000000..8ce8dd79c --- /dev/null +++ b/web/src/components/MemoView/hooks/useMemoHandlers.ts @@ -0,0 +1,73 @@ +import { useCallback } from "react"; +import useNavigateTo from "@/hooks/useNavigateTo"; +import { instanceStore } from "@/store"; +import type { UseImagePreviewReturn } from "../types"; + +export interface UseMemoHandlersOptions { + memoName: string; + parentPage: string; + readonly: boolean; + openEditor: () => void; + openPreview: UseImagePreviewReturn["openPreview"]; +} + +export interface UseMemoHandlersReturn { + handleGotoMemoDetailPage: () => void; + handleMemoContentClick: (e: React.MouseEvent) => void; + handleMemoContentDoubleClick: (e: React.MouseEvent) => void; +} + +/** + * Hook for managing memo event handlers + * Centralizes all click and interaction handlers + */ +export const useMemoHandlers = (options: UseMemoHandlersOptions): UseMemoHandlersReturn => { + const { memoName, parentPage, readonly, openEditor, openPreview } = options; + const navigateTo = useNavigateTo(); + + const handleGotoMemoDetailPage = useCallback(() => { + navigateTo(`/${memoName}`, { + state: { from: parentPage }, + }); + }, [memoName, parentPage, navigateTo]); + + const handleMemoContentClick = useCallback( + (e: React.MouseEvent) => { + const targetEl = e.target as HTMLElement; + + if (targetEl.tagName === "IMG") { + // Check if the image is inside a link + const linkElement = targetEl.closest("a"); + if (linkElement) { + // If image is inside a link, only navigate to the link (don't show preview) + return; + } + + const imgUrl = targetEl.getAttribute("src"); + if (imgUrl) { + openPreview(imgUrl); + } + } + }, + [openPreview], + ); + + const handleMemoContentDoubleClick = useCallback( + (e: React.MouseEvent) => { + if (readonly) return; + + const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting; + if (instanceMemoRelatedSetting.enableDoubleClickEdit) { + e.preventDefault(); + openEditor(); + } + }, + [readonly, openEditor], + ); + + return { + handleGotoMemoDetailPage, + handleMemoContentClick, + handleMemoContentDoubleClick, + }; +}; diff --git a/web/src/components/MemoView/hooks/useMemoViewDerivedState.ts b/web/src/components/MemoView/hooks/useMemoViewDerivedState.ts new file mode 100644 index 000000000..97e947e1e --- /dev/null +++ b/web/src/components/MemoView/hooks/useMemoViewDerivedState.ts @@ -0,0 +1,61 @@ +import { useMemo } from "react"; +import { useLocation } from "react-router-dom"; +import useCurrentUser from "@/hooks/useCurrentUser"; +import { State } from "@/types/proto/api/v1/common"; +import type { Memo } from "@/types/proto/api/v1/memo_service"; +import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service"; +import { isSuperUser } from "@/utils/user"; +import { RELATIVE_TIME_THRESHOLD_MS } from "../constants"; + +export interface UseMemoViewDerivedStateOptions { + memo: Memo; + parentPage?: string; +} + +export interface UseMemoViewDerivedStateReturn { + commentAmount: number; + relativeTimeFormat: "datetime" | "auto"; + isArchived: boolean; + readonly: boolean; + isInMemoDetailPage: boolean; + parentPage: string; +} + +/** + * Hook for computing derived state from memo data + * Centralizes all computed values to avoid repetition and improve readability + */ +export const useMemoViewDerivedState = (options: UseMemoViewDerivedStateOptions): UseMemoViewDerivedStateReturn => { + const { memo, parentPage: parentPageProp } = options; + const location = useLocation(); + const user = useCurrentUser(); + + // Compute all derived state + const commentAmount = useMemo( + () => + memo.relations.filter((relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memo.name).length, + [memo.relations, memo.name], + ); + + const relativeTimeFormat: "datetime" | "auto" = useMemo( + () => (memo.displayTime && Date.now() - memo.displayTime.getTime() > RELATIVE_TIME_THRESHOLD_MS ? "datetime" : "auto"), + [memo.displayTime], + ); + + const isArchived = useMemo(() => memo.state === State.ARCHIVED, [memo.state]); + + const readonly = useMemo(() => memo.creator !== user?.name && !isSuperUser(user), [memo.creator, user]); + + const isInMemoDetailPage = useMemo(() => location.pathname.startsWith(`/${memo.name}`), [location.pathname, memo.name]); + + const parentPage = useMemo(() => parentPageProp || location.pathname, [parentPageProp, location.pathname]); + + return { + commentAmount, + relativeTimeFormat, + isArchived, + readonly, + isInMemoDetailPage, + parentPage, + }; +}; diff --git a/web/src/components/MemoView/index.ts b/web/src/components/MemoView/index.ts index ee160abfe..6d7dc8109 100644 --- a/web/src/components/MemoView/index.ts +++ b/web/src/components/MemoView/index.ts @@ -10,8 +10,26 @@ export { MemoBody, MemoHeader } from "./components"; export * from "./constants"; -export { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./hooks"; +export type { + UseMemoEditorReturn, + UseMemoHandlersOptions, + UseMemoHandlersReturn, + UseMemoViewDerivedStateOptions, + UseMemoViewDerivedStateReturn, +} from "./hooks"; +export { + useImagePreview, + useKeyboardShortcuts, + useMemoActions, + useMemoCreator, + useMemoEditor, + useMemoHandlers, + useMemoViewDerivedState, + useNsfwContent, +} from "./hooks"; export { default, default as MemoView } from "./MemoView"; +export type { MemoViewContextValue } from "./MemoViewContext"; +export { MemoViewContext, useMemoViewContext } from "./MemoViewContext"; export type { ImagePreviewState, MemoBodyProps, diff --git a/web/src/components/MemoView/types.ts b/web/src/components/MemoView/types.ts index 5b50b87cf..9ab19a329 100644 --- a/web/src/components/MemoView/types.ts +++ b/web/src/components/MemoView/types.ts @@ -25,39 +25,31 @@ export interface MemoViewProps { /** * Props for the MemoHeader component + * Note: Most data props now come from MemoViewContext */ export interface MemoHeaderProps { - memo: Memo; - creator: User | undefined; + // Display options showCreator?: boolean; showVisibility?: boolean; showPinned?: boolean; - isArchived: boolean; - commentAmount: number; - isInMemoDetailPage: boolean; - parentPage: string; - readonly: boolean; - relativeTimeFormat: "datetime" | "auto"; + // Callbacks onEdit: () => void; onGotoDetail: () => void; onUnpin: () => void; onToggleNsfwVisibility?: () => void; - nsfw?: boolean; - showNSFWContent?: boolean; + // Reaction state reactionSelectorOpen: boolean; onReactionSelectorOpenChange: (open: boolean) => void; } /** * Props for the MemoBody component + * Note: Most data props now come from MemoViewContext */ export interface MemoBodyProps { - memo: Memo; - readonly: boolean; + // Display options compact?: boolean; - parentPage: string; - nsfw: boolean; - showNSFWContent: boolean; + // Callbacks onContentClick: (e: React.MouseEvent) => void; onContentDoubleClick: (e: React.MouseEvent) => void; onToggleNsfwVisibility: () => void; diff --git a/web/src/components/memo-metadata/AttachmentList.tsx b/web/src/components/memo-metadata/AttachmentList.tsx index 9be4d8b9a..dfdd7b069 100644 --- a/web/src/components/memo-metadata/AttachmentList.tsx +++ b/web/src/components/memo-metadata/AttachmentList.tsx @@ -4,9 +4,9 @@ import { useState } from "react"; import type { Attachment } from "@/types/proto/api/v1/attachment_service"; import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; import MemoAttachment from "../MemoAttachment"; -import SortableItem from "../MemoEditor/SortableItem"; import PreviewImageDialog from "../PreviewImageDialog"; import AttachmentCard from "./AttachmentCard"; +import SortableItem from "./SortableItem"; import type { AttachmentItem, BaseMetadataProps, LocalFile } from "./types"; import { separateMediaAndDocs, toAttachmentItems } from "./types"; diff --git a/web/src/components/MemoEditor/SortableItem.tsx b/web/src/components/memo-metadata/SortableItem.tsx similarity index 100% rename from web/src/components/MemoEditor/SortableItem.tsx rename to web/src/components/memo-metadata/SortableItem.tsx