Commit 2516cdf2 authored by Johnny's avatar Johnny

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: 's avatarClaude <noreply@anthropic.com>
parent bb7e0cdb
......@@ -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;
......
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";
......@@ -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;
......
// UI components for MemoEditor
export { default as ErrorBoundary } from "./ErrorBoundary";
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
export { LinkMemoDialog } from "./LinkMemoDialog";
export { LocationDialog } from "./LocationDialog";
......@@ -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";
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);
......
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,
};
};
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,
};
};
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 };
};
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,
};
};
This diff is collapsed.
// 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";
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,54 +103,41 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
// Render memo card
return (
<article
className={cn(MEMO_CARD_BASE_CLASSES, className)}
ref={cardRef}
tabIndex={readonly ? -1 : 0}
onFocus={() => handleShortcutActivation(true)}
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)}
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}
/>
<PreviewImageDialog
open={previewState.open}
onOpenChange={setPreviewOpen}
imgUrls={previewState.urls}
initialIndex={previewState.index}
/>
</article>
<MemoViewContext.Provider value={contextValue}>
<article
className={cn(MEMO_CARD_BASE_CLASSES, className)}
ref={cardRef}
tabIndex={readonly ? -1 : 0}
onFocus={() => handleShortcutActivation(true)}
onBlur={() => handleShortcutActivation(false)}
>
<MemoHeader
showCreator={props.showCreator}
showVisibility={props.showVisibility}
showPinned={props.showPinned}
onEdit={openEditor}
onGotoDetail={handleGotoMemoDetailPage}
onUnpin={unpinMemo}
onToggleNsfwVisibility={toggleNsfwVisibility}
reactionSelectorOpen={reactionSelectorOpen}
onReactionSelectorOpenChange={setReactionSelectorOpen}
/>
<MemoBody
compact={props.compact}
onContentClick={handleMemoContentClick}
onContentDoubleClick={handleMemoContentDoubleClick}
onToggleNsfwVisibility={toggleNsfwVisibility}
/>
<PreviewImageDialog
open={previewState.open}
onOpenChange={setPreviewOpen}
imgUrls={previewState.urls}
initialIndex={previewState.index}
/>
</article>
</MemoViewContext.Provider>
);
});
......
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;
};
......@@ -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 (
......
......@@ -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;
}
......
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";
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,
};
};
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,
};
};
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,
};
};
......@@ -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,
......
......@@ -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;
......
......@@ -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";
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment