refactor: clean up MemoView and MemoEditor component architecture

This commit refactors MemoView and MemoEditor components for better
maintainability, introducing React Context, custom hooks, and improved
folder structure.

MemoView improvements:
- Introduce MemoViewContext to eliminate prop drilling
- Reduce MemoHeader props from 18 to 8
- Reduce MemoBody props from 9 to 4
- Extract custom hooks: useMemoViewDerivedState, useMemoEditor,
  useMemoHandlers for better separation of concerns
- Fix React hooks ordering bug in edit mode

MemoEditor improvements:
- Extract state management into useMemoEditorState hook
- Extract keyboard handling into useMemoEditorKeyboard hook
- Extract event handlers into useMemoEditorHandlers hook
- Extract initialization logic into useMemoEditorInit hook
- Reduce main component from 461 to 317 lines (31% reduction)

Folder structure cleanup:
- Move SortableItem to memo-metadata (correct location)
- Move ErrorBoundary to components folder
- Flatten Toolbar/InsertMenu structure (remove unnecessary nesting)
- Consolidate hooks in main hooks folder
- Consolidate types in main types folder

Benefits:
- Better separation of concerns
- Improved testability
- Easier maintenance
- Cleaner code organization
- No functionality changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Johnny 2025-11-30 11:15:20 +08:00
parent bb7e0cdb79
commit 2516cdf2b4
29 changed files with 876 additions and 454 deletions

View File

@ -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;

View File

@ -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";

View File

@ -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;

View File

@ -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";

View File

@ -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";

View File

@ -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);

View File

@ -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<EditorRefActions>;
onContentChange: (content: string) => void;
onFilesAdded: (files: FileList) => void;
setComposing: (isComposing: boolean) => void;
}
export interface UseMemoEditorHandlersReturn {
handleCompositionStart: () => void;
handleCompositionEnd: () => void;
handlePasteEvent: (event: React.ClipboardEvent) => Promise<void>;
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,
};
};

View File

@ -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<EditorRefActions>;
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<Date | undefined>();
const [updateTime, setUpdateTime] = useState<Date | undefined>();
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,
};
};

View File

@ -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<EditorRefActions>;
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 };
};

View File

@ -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<MemoEditorState>({
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,
};
};

View File

@ -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<EditorRefActions>(null);
// Content caching
const contentCacheKey = `${currentUser.name}-${cacheKey || ""}`;
const [contentCache, setContentCache] = useLocalStorage<string>(contentCacheKey, "");
const [hasContent, setHasContent] = useState<boolean>(false);
// Custom hooks for file management
const { localFiles, addFiles, removeFile, clearFiles } = useLocalFileManager();
// Internal component state
const [state, setState] = useState<MemoEditorState>({
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<Date | undefined>();
const [updateTime, setUpdateTime] = useState<Date | undefined>();
const [hasContent, setHasContent] = useState<boolean>(false);
const editorRef = useRef<EditorRefActions>(null);
const userGeneralSetting = userStore.state.userGeneralSetting;
const contentCacheKey = `${currentUser.name}-${cacheKey || ""}`;
const [contentCache, setContentCache] = useLocalStorage<string>(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 (
<ErrorBoundary>
<MemoEditorContext.Provider
value={{
attachmentList: state.attachmentList,
relationList: state.relationList,
setAttachmentList: handleSetAttachmentList,
attachmentList,
relationList,
setAttachmentList,
addLocalFiles: (files) => addFiles(Array.from(files.map((f) => f.file))),
removeLocalFile: removeFile,
localFiles,
setRelationList: (relationList: MemoRelation[]) => {
setState((prevState) => ({
...prevState,
relationList,
}));
},
setRelationList,
memoName,
}}
>
{/* Focus Mode Backdrop */}
<FocusModeOverlay isActive={state.isFocusMode} onToggle={toggleFocusMode} />
<FocusModeOverlay isActive={isFocusMode} onToggle={toggleFocusMode} />
<div
className={cn(
"group relative w-full flex flex-col justify-start items-start bg-card px-4 pt-3 pb-2 rounded-lg border",
FOCUS_MODE_STYLES.transition,
state.isDraggingFile ? "border-dashed border-muted-foreground cursor-copy" : "border-border cursor-auto",
state.isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
isDraggingFile ? "border-dashed border-muted-foreground cursor-copy" : "border-border cursor-auto",
isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
className,
)}
tabIndex={0}
@ -363,49 +246,35 @@ const MemoEditor = observer((props: MemoEditorProps) => {
onFocus={handleEditorFocus}
>
{/* Focus Mode Exit Button */}
<FocusModeExitButton isActive={state.isFocusMode} onToggle={toggleFocusMode} title={t("editor.exit-focus-mode")} />
<FocusModeExitButton isActive={isFocusMode} onToggle={toggleFocusMode} title={t("editor.exit-focus-mode")} />
<Editor ref={editorRef} {...editorConfig} />
<LocationDisplay
mode="edit"
location={state.location}
onRemove={() =>
setState((prevState) => ({
...prevState,
location: undefined,
}))
}
/>
<LocationDisplay mode="edit" location={location} onRemove={() => setLocation(undefined)} />
{/* Show attachments and pending files together */}
<AttachmentList
mode="edit"
attachments={state.attachmentList}
onAttachmentsChange={handleSetAttachmentList}
attachments={attachmentList}
onAttachmentsChange={setAttachmentList}
localFiles={localFiles}
onRemoveLocalFile={removeFile}
/>
<RelationList mode="edit" relations={referenceRelations} onRelationsChange={handleSetRelationList} />
<RelationList mode="edit" relations={referenceRelations} onRelationsChange={setRelationList} />
<div className="relative w-full flex flex-row justify-between items-center pt-2 gap-2" onFocus={(e) => e.stopPropagation()}>
<div className="flex flex-row justify-start items-center gap-1">
<InsertMenu
isUploading={state.isUploadingAttachment}
location={state.location}
onLocationChange={(location) =>
setState((prevState) => ({
...prevState,
location,
}))
}
isUploading={isUploadingAttachment}
location={location}
onLocationChange={setLocation}
onToggleFocusMode={toggleFocusMode}
/>
</div>
<div className="shrink-0 flex flex-row justify-end items-center">
<VisibilitySelector value={state.memoVisibility} onChange={(visibility) => handleMemoVisibilityChange(visibility)} />
<VisibilitySelector value={memoVisibility} onChange={setMemoVisibility} />
<div className="flex flex-row justify-end gap-1">
{props.onCancel && (
<Button
variant="ghost"
disabled={state.isRequesting}
disabled={isRequesting}
onClick={() => {
clearFiles();
if (props.onCancel) props.onCancel();
@ -414,8 +283,8 @@ const MemoEditor = observer((props: MemoEditorProps) => {
{t("common.cancel")}
</Button>
)}
<Button disabled={!allowSave || state.isRequesting} onClick={handleSaveBtnClick}>
{state.isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
<Button disabled={!allowSave || isRequesting} onClick={handleSaveBtnClick}>
{isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
</Button>
</div>
</div>

View File

@ -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";

View File

@ -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<MemoViewProps> = observer((props: MemoViewProps) => {
const { memo: memoData, className } = props;
const location = useLocation();
const navigateTo = useNavigateTo();
const user = useCurrentUser();
const cardRef = useRef<HTMLDivElement>(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<MemoViewProps> = 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,6 +103,7 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
// Render memo card
return (
<MemoViewContext.Provider value={contextValue}>
<article
className={cn(MEMO_CARD_BASE_CLASSES, className)}
ref={cardRef}
@ -139,34 +112,19 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
onBlur={() => handleShortcutActivation(false)}
>
<MemoHeader
memo={memoData}
creator={creator}
showCreator={props.showCreator}
showVisibility={props.showVisibility}
showPinned={props.showPinned}
isArchived={isArchived}
commentAmount={commentAmount}
isInMemoDetailPage={isInMemoDetailPage}
parentPage={parentPage}
readonly={readonly}
relativeTimeFormat={relativeTimeFormat}
onEdit={() => setShowEditor(true)}
onEdit={openEditor}
onGotoDetail={handleGotoMemoDetailPage}
onUnpin={unpinMemo}
onToggleNsfwVisibility={toggleNsfwVisibility}
nsfw={nsfw}
showNSFWContent={showNSFWContent}
reactionSelectorOpen={reactionSelectorOpen}
onReactionSelectorOpenChange={setReactionSelectorOpen}
/>
<MemoBody
memo={memoData}
readonly={readonly}
compact={props.compact}
parentPage={parentPage}
nsfw={nsfw}
showNSFWContent={showNSFWContent}
onContentClick={handleMemoContentClick}
onContentDoubleClick={handleMemoContentDoubleClick}
onToggleNsfwVisibility={toggleNsfwVisibility}
@ -179,6 +137,7 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
initialIndex={previewState.index}
/>
</article>
</MemoViewContext.Provider>
);
});

View File

@ -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<MemoViewContextValue | null>(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;
};

View File

@ -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<MemoBodyProps> = ({
memo,
readonly,
compact,
parentPage,
nsfw,
showNSFWContent,
onContentClick,
onContentDoubleClick,
onToggleNsfwVisibility,
}) => {
const MemoBody: React.FC<MemoBodyProps> = ({ 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 (

View File

@ -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<MemoHeaderProps> = ({
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<MemoHeaderProps> = ({
* Creator display with avatar and name
*/
interface CreatorDisplayProps {
creator: NonNullable<MemoHeaderProps["creator"]>;
creator: User;
displayTime: React.ReactNode;
onGotoDetail: () => void;
}

View File

@ -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";

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,

View File

@ -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;

View File

@ -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";