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 { ...@@ -16,14 +16,11 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service"; import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { LinkMemoDialog, LocationDialog } from "../components";
import { GEOCODING } from "../constants"; import { GEOCODING } from "../constants";
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
import { useAbortController } from "../hooks/useAbortController"; import { useAbortController } from "../hooks/useAbortController";
import { MemoEditorContext } from "../types"; 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 { interface Props {
isUploading?: boolean; 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"; ...@@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { VisuallyHidden } from "@/components/ui/visually-hidden"; import { VisuallyHidden } from "@/components/ui/visually-hidden";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { LocationState } from "./types"; import { LocationState } from "../types/insert-menu";
interface LocationDialogProps { interface LocationDialogProps {
open: boolean; open: boolean;
......
// UI components for MemoEditor // UI components for MemoEditor
export { default as ErrorBoundary } from "./ErrorBoundary";
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay"; export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
export { LinkMemoDialog } from "./LinkMemoDialog";
export { LocationDialog } from "./LocationDialog";
...@@ -3,6 +3,17 @@ export { useAbortController } from "./useAbortController"; ...@@ -3,6 +3,17 @@ export { useAbortController } from "./useAbortController";
export { useBlobUrls } from "./useBlobUrls"; export { useBlobUrls } from "./useBlobUrls";
export { useDebounce } from "./useDebounce"; export { useDebounce } from "./useDebounce";
export { useDragAndDrop } from "./useDragAndDrop"; export { useDragAndDrop } from "./useDragAndDrop";
export { useFileUpload } from "./useFileUpload";
export { useFocusMode } from "./useFocusMode"; export { useFocusMode } from "./useFocusMode";
export { useLinkMemo } from "./useLinkMemo";
export { useLocalFileManager } from "./useLocalFileManager"; 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"; export { useMemoSave } from "./useMemoSave";
import { LatLng } from "leaflet"; import { LatLng } from "leaflet";
import { useState } from "react"; import { useState } from "react";
import { Location } from "@/types/proto/api/v1/memo_service"; import { Location } from "@/types/proto/api/v1/memo_service";
import { LocationState } from "./types"; import { LocationState } from "../types/insert-menu";
export const useLocation = (initialLocation?: Location) => { export const useLocation = (initialLocation?: Location) => {
const [locationInitialized, setLocationInitialized] = useState(false); 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 // MemoEditor type exports
export type { Command } from "./command"; export type { Command } from "./command";
export { MemoEditorContext, type MemoEditorContextValue } from "./context"; export { MemoEditorContext, type MemoEditorContextValue } from "./context";
export type { LinkMemoState, LocationState } from "./insert-menu";
export type { EditorConfig, MemoEditorProps, MemoEditorState } from "./memo-editor"; export type { EditorConfig, MemoEditorProps, MemoEditorState } from "./memo-editor";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { memo, useCallback, useRef, useState } from "react"; import { memo, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import useCurrentUser from "@/hooks/useCurrentUser";
import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils"; 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 MemoEditor from "../MemoEditor";
import PreviewImageDialog from "../PreviewImageDialog"; import PreviewImageDialog from "../PreviewImageDialog";
import { MemoBody, MemoHeader } from "./components"; import { MemoBody, MemoHeader } from "./components";
import { MEMO_CARD_BASE_CLASSES, RELATIVE_TIME_THRESHOLD_MS } from "./constants"; import { MEMO_CARD_BASE_CLASSES } from "./constants";
import { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./hooks"; import {
useImagePreview,
useKeyboardShortcuts,
useMemoActions,
useMemoCreator,
useMemoEditor,
useMemoHandlers,
useMemoViewDerivedState,
useNsfwContent,
} from "./hooks";
import { MemoViewContext } from "./MemoViewContext";
import type { MemoViewProps } from "./types"; import type { MemoViewProps } from "./types";
/** /**
...@@ -27,34 +30,34 @@ import type { MemoViewProps } from "./types"; ...@@ -27,34 +30,34 @@ import type { MemoViewProps } from "./types";
*/ */
const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => { const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
const { memo: memoData, className } = props; const { memo: memoData, className } = props;
const location = useLocation();
const navigateTo = useNavigateTo();
const user = useCurrentUser();
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
// State // State
const [showEditor, setShowEditor] = useState(false);
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false); const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
// Fetch creator data // Custom hooks for data fetching
const creator = useMemoCreator(memoData.creator); 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 { nsfw, showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent);
const { previewState, openPreview, setPreviewOpen } = useImagePreview(); const { previewState, openPreview, setPreviewOpen } = useImagePreview();
const { archiveMemo, unpinMemo } = useMemoActions(memoData); const { showEditor, openEditor, handleEditorConfirm, handleEditorCancel } = useMemoEditor();
// Derived state // Custom hooks for actions
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting; const { archiveMemo, unpinMemo } = useMemoActions(memoData);
const commentAmount = memoData.relations.filter( const { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({
(relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memoData.name, memoName: memoData.name,
).length; parentPage,
const relativeTimeFormat = readonly,
memoData.displayTime && Date.now() - memoData.displayTime.getTime() > RELATIVE_TIME_THRESHOLD_MS ? "datetime" : "auto"; openEditor,
const isArchived = memoData.state === State.ARCHIVED; openPreview,
const readonly = memoData.creator !== user?.name && !isSuperUser(user); });
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`);
const parentPage = props.parentPage || location.pathname;
// Keyboard shortcuts // Keyboard shortcuts
const { handleShortcutActivation } = useKeyboardShortcuts(cardRef, { const { handleShortcutActivation } = useKeyboardShortcuts(cardRef, {
...@@ -62,59 +65,28 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => { ...@@ -62,59 +65,28 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
readonly, readonly,
showEditor, showEditor,
isArchived, isArchived,
onEdit: () => setShowEditor(true), onEdit: openEditor,
onArchive: archiveMemo, onArchive: archiveMemo,
}); });
// Handlers // Memoize context value to prevent unnecessary re-renders
const handleGotoMemoDetailPage = useCallback(() => { // IMPORTANT: This must be before the early return to satisfy Rules of Hooks
navigateTo(`/${memoData.name}`, { const contextValue = useMemo(
state: { from: parentPage }, () => ({
}); memo: memoData,
}, [memoData.name, parentPage, navigateTo]); creator,
isArchived,
const handleMemoContentClick = useCallback( readonly,
(e: React.MouseEvent) => { isInMemoDetailPage,
const targetEl = e.target as HTMLElement; parentPage,
commentAmount,
if (targetEl.tagName === "IMG") { relativeTimeFormat,
// Check if the image is inside a link nsfw,
const linkElement = targetEl.closest("a"); showNSFWContent,
if (linkElement) { }),
// If image is inside a link, only navigate to the link (don't show preview) [memoData, creator, isArchived, readonly, isInMemoDetailPage, parentPage, commentAmount, relativeTimeFormat, nsfw, showNSFWContent],
return;
}
const imgUrl = targetEl.getAttribute("src");
if (imgUrl) {
openPreview(imgUrl);
}
}
},
[openPreview],
); );
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 // Render inline editor when editing
if (showEditor) { if (showEditor) {
return ( return (
...@@ -131,54 +103,41 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => { ...@@ -131,54 +103,41 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
// Render memo card // Render memo card
return ( return (
<article <MemoViewContext.Provider value={contextValue}>
className={cn(MEMO_CARD_BASE_CLASSES, className)} <article
ref={cardRef} className={cn(MEMO_CARD_BASE_CLASSES, className)}
tabIndex={readonly ? -1 : 0} ref={cardRef}
onFocus={() => handleShortcutActivation(true)} tabIndex={readonly ? -1 : 0}
onBlur={() => handleShortcutActivation(false)} onFocus={() => handleShortcutActivation(true)}
> onBlur={() => handleShortcutActivation(false)}
<MemoHeader >
memo={memoData} <MemoHeader
creator={creator} showCreator={props.showCreator}
showCreator={props.showCreator} showVisibility={props.showVisibility}
showVisibility={props.showVisibility} showPinned={props.showPinned}
showPinned={props.showPinned} onEdit={openEditor}
isArchived={isArchived} onGotoDetail={handleGotoMemoDetailPage}
commentAmount={commentAmount} onUnpin={unpinMemo}
isInMemoDetailPage={isInMemoDetailPage} onToggleNsfwVisibility={toggleNsfwVisibility}
parentPage={parentPage} reactionSelectorOpen={reactionSelectorOpen}
readonly={readonly} onReactionSelectorOpenChange={setReactionSelectorOpen}
relativeTimeFormat={relativeTimeFormat} />
onEdit={() => setShowEditor(true)}
onGotoDetail={handleGotoMemoDetailPage} <MemoBody
onUnpin={unpinMemo} compact={props.compact}
onToggleNsfwVisibility={toggleNsfwVisibility} onContentClick={handleMemoContentClick}
nsfw={nsfw} onContentDoubleClick={handleMemoContentDoubleClick}
showNSFWContent={showNSFWContent} onToggleNsfwVisibility={toggleNsfwVisibility}
reactionSelectorOpen={reactionSelectorOpen} />
onReactionSelectorOpenChange={setReactionSelectorOpen}
/> <PreviewImageDialog
open={previewState.open}
<MemoBody onOpenChange={setPreviewOpen}
memo={memoData} imgUrls={previewState.urls}
readonly={readonly} initialIndex={previewState.index}
compact={props.compact} />
parentPage={parentPage} </article>
nsfw={nsfw} </MemoViewContext.Provider>
showNSFWContent={showNSFWContent}
onContentClick={handleMemoContentClick}
onContentDoubleClick={handleMemoContentDoubleClick}
onToggleNsfwVisibility={toggleNsfwVisibility}
/>
<PreviewImageDialog
open={previewState.open}
onOpenChange={setPreviewOpen}
imgUrls={previewState.urls}
initialIndex={previewState.index}
/>
</article>
); );
}); });
......
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"; ...@@ -4,6 +4,7 @@ import { useTranslate } from "@/utils/i18n";
import MemoContent from "../../MemoContent"; import MemoContent from "../../MemoContent";
import { MemoReactionListView } from "../../MemoReactionListView"; import { MemoReactionListView } from "../../MemoReactionListView";
import { AttachmentList, LocationDisplay, RelationList } from "../../memo-metadata"; import { AttachmentList, LocationDisplay, RelationList } from "../../memo-metadata";
import { useMemoViewContext } from "../MemoViewContext";
import type { MemoBodyProps } from "../types"; import type { MemoBodyProps } from "../types";
/** /**
...@@ -15,18 +16,12 @@ import type { MemoBodyProps } from "../types"; ...@@ -15,18 +16,12 @@ import type { MemoBodyProps } from "../types";
* - Reactions * - Reactions
* - NSFW content overlay * - NSFW content overlay
*/ */
const MemoBody: React.FC<MemoBodyProps> = ({ const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentDoubleClick, onToggleNsfwVisibility }) => {
memo,
readonly,
compact,
parentPage,
nsfw,
showNSFWContent,
onContentClick,
onContentDoubleClick,
onToggleNsfwVisibility,
}) => {
const t = useTranslate(); 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); const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
return ( return (
......
...@@ -4,12 +4,14 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp ...@@ -4,12 +4,14 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import i18n from "@/i18n"; import i18n from "@/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Visibility } from "@/types/proto/api/v1/memo_service"; 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 { useTranslate } from "@/utils/i18n";
import { convertVisibilityToString } from "@/utils/memo"; import { convertVisibilityToString } from "@/utils/memo";
import MemoActionMenu from "../../MemoActionMenu"; import MemoActionMenu from "../../MemoActionMenu";
import { ReactionSelector } from "../../reactions"; import { ReactionSelector } from "../../reactions";
import UserAvatar from "../../UserAvatar"; import UserAvatar from "../../UserAvatar";
import VisibilityIcon from "../../VisibilityIcon"; import VisibilityIcon from "../../VisibilityIcon";
import { useMemoViewContext } from "../MemoViewContext";
import type { MemoHeaderProps } from "../types"; import type { MemoHeaderProps } from "../types";
/** /**
...@@ -24,28 +26,22 @@ import type { MemoHeaderProps } from "../types"; ...@@ -24,28 +26,22 @@ import type { MemoHeaderProps } from "../types";
* - Action menu * - Action menu
*/ */
const MemoHeader: React.FC<MemoHeaderProps> = ({ const MemoHeader: React.FC<MemoHeaderProps> = ({
memo,
creator,
showCreator, showCreator,
showVisibility, showVisibility,
showPinned, showPinned,
isArchived,
commentAmount,
isInMemoDetailPage,
parentPage,
readonly,
relativeTimeFormat,
onEdit, onEdit,
onGotoDetail, onGotoDetail,
onUnpin, onUnpin,
onToggleNsfwVisibility, onToggleNsfwVisibility,
nsfw,
showNSFWContent,
reactionSelectorOpen, reactionSelectorOpen,
onReactionSelectorOpenChange, onReactionSelectorOpenChange,
}) => { }) => {
const t = useTranslate(); const t = useTranslate();
// Get shared state from context
const { memo, creator, isArchived, commentAmount, isInMemoDetailPage, parentPage, readonly, relativeTimeFormat, nsfw, showNSFWContent } =
useMemoViewContext();
const displayTime = isArchived ? ( const displayTime = isArchived ? (
memo.displayTime?.toLocaleString(i18n.language) memo.displayTime?.toLocaleString(i18n.language)
) : ( ) : (
...@@ -138,7 +134,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ ...@@ -138,7 +134,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({
* Creator display with avatar and name * Creator display with avatar and name
*/ */
interface CreatorDisplayProps { interface CreatorDisplayProps {
creator: NonNullable<MemoHeaderProps["creator"]>; creator: User;
displayTime: React.ReactNode; displayTime: React.ReactNode;
onGotoDetail: () => void; 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"; 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 @@ ...@@ -10,8 +10,26 @@
export { MemoBody, MemoHeader } from "./components"; export { MemoBody, MemoHeader } from "./components";
export * from "./constants"; 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 { default, default as MemoView } from "./MemoView";
export type { MemoViewContextValue } from "./MemoViewContext";
export { MemoViewContext, useMemoViewContext } from "./MemoViewContext";
export type { export type {
ImagePreviewState, ImagePreviewState,
MemoBodyProps, MemoBodyProps,
......
...@@ -25,39 +25,31 @@ export interface MemoViewProps { ...@@ -25,39 +25,31 @@ export interface MemoViewProps {
/** /**
* Props for the MemoHeader component * Props for the MemoHeader component
* Note: Most data props now come from MemoViewContext
*/ */
export interface MemoHeaderProps { export interface MemoHeaderProps {
memo: Memo; // Display options
creator: User | undefined;
showCreator?: boolean; showCreator?: boolean;
showVisibility?: boolean; showVisibility?: boolean;
showPinned?: boolean; showPinned?: boolean;
isArchived: boolean; // Callbacks
commentAmount: number;
isInMemoDetailPage: boolean;
parentPage: string;
readonly: boolean;
relativeTimeFormat: "datetime" | "auto";
onEdit: () => void; onEdit: () => void;
onGotoDetail: () => void; onGotoDetail: () => void;
onUnpin: () => void; onUnpin: () => void;
onToggleNsfwVisibility?: () => void; onToggleNsfwVisibility?: () => void;
nsfw?: boolean; // Reaction state
showNSFWContent?: boolean;
reactionSelectorOpen: boolean; reactionSelectorOpen: boolean;
onReactionSelectorOpenChange: (open: boolean) => void; onReactionSelectorOpenChange: (open: boolean) => void;
} }
/** /**
* Props for the MemoBody component * Props for the MemoBody component
* Note: Most data props now come from MemoViewContext
*/ */
export interface MemoBodyProps { export interface MemoBodyProps {
memo: Memo; // Display options
readonly: boolean;
compact?: boolean; compact?: boolean;
parentPage: string; // Callbacks
nsfw: boolean;
showNSFWContent: boolean;
onContentClick: (e: React.MouseEvent) => void; onContentClick: (e: React.MouseEvent) => void;
onContentDoubleClick: (e: React.MouseEvent) => void; onContentDoubleClick: (e: React.MouseEvent) => void;
onToggleNsfwVisibility: () => void; onToggleNsfwVisibility: () => void;
......
...@@ -4,9 +4,9 @@ import { useState } from "react"; ...@@ -4,9 +4,9 @@ import { useState } from "react";
import type { Attachment } from "@/types/proto/api/v1/attachment_service"; import type { Attachment } from "@/types/proto/api/v1/attachment_service";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import MemoAttachment from "../MemoAttachment"; import MemoAttachment from "../MemoAttachment";
import SortableItem from "../MemoEditor/SortableItem";
import PreviewImageDialog from "../PreviewImageDialog"; import PreviewImageDialog from "../PreviewImageDialog";
import AttachmentCard from "./AttachmentCard"; import AttachmentCard from "./AttachmentCard";
import SortableItem from "./SortableItem";
import type { AttachmentItem, BaseMetadataProps, LocalFile } from "./types"; import type { AttachmentItem, BaseMetadataProps, LocalFile } from "./types";
import { separateMediaAndDocs, toAttachmentItems } 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