chore: enhance MemoView component structure

This commit is contained in:
Johnny 2025-11-30 12:41:24 +08:00
parent 7aa8262ef2
commit 6dcf7cc74c
12 changed files with 115 additions and 475 deletions

View File

@ -1,6 +1,7 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { memo, useMemo, useRef, useState } from "react"; import { memo, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Memo } from "@/types/proto/api/v1/memo_service";
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";
@ -16,40 +17,31 @@ import {
useNsfwContent, useNsfwContent,
} from "./hooks"; } from "./hooks";
import { MemoViewContext } from "./MemoViewContext"; import { MemoViewContext } from "./MemoViewContext";
import type { MemoViewProps } from "./types";
/** interface Props {
* MemoView component displays a single memo card with full functionality including: memo: Memo;
* - Creator information and display time compact?: boolean;
* - Memo content with markdown rendering showCreator?: boolean;
* - Attachments and location showVisibility?: boolean;
* - Reactions and comments showPinned?: boolean;
* - Edit mode with inline editor showNsfwContent?: boolean;
* - Keyboard shortcuts for quick actions className?: string;
* - NSFW content blur protection parentPage?: string;
*/ }
const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
const MemoView: React.FC<Props> = observer((props: Props) => {
const { memo: memoData, className } = props; const { memo: memoData, className } = props;
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
// State
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false); const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
// Custom hooks for data fetching
const creator = useMemoCreator(memoData.creator); const creator = useMemoCreator(memoData.creator);
const { commentAmount, relativeTimeFormat, isArchived, readonly, isInMemoDetailPage, parentPage } = useMemoViewDerivedState(
// Custom hooks for derived state memoData,
const { commentAmount, relativeTimeFormat, isArchived, readonly, isInMemoDetailPage, parentPage } = useMemoViewDerivedState({ props.parentPage,
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 { showEditor, openEditor, handleEditorConfirm, handleEditorCancel } = useMemoEditor(); const { showEditor, openEditor, handleEditorConfirm, handleEditorCancel } = useMemoEditor();
// Custom hooks for actions
const { archiveMemo, unpinMemo } = useMemoActions(memoData); const { archiveMemo, unpinMemo } = useMemoActions(memoData);
const { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({ const { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({
memoName: memoData.name, memoName: memoData.name,
@ -58,9 +50,7 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
openEditor, openEditor,
openPreview, openPreview,
}); });
useKeyboardShortcuts(cardRef, {
// Keyboard shortcuts
const { handleShortcutActivation } = useKeyboardShortcuts(cardRef, {
enabled: true, enabled: true,
readonly, readonly,
showEditor, showEditor,
@ -69,8 +59,6 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
onArchive: archiveMemo, onArchive: archiveMemo,
}); });
// Memoize context value to prevent unnecessary re-renders
// IMPORTANT: This must be before the early return to satisfy Rules of Hooks
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
memo: memoData, memo: memoData,
@ -87,7 +75,6 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
[memoData, creator, isArchived, readonly, isInMemoDetailPage, parentPage, commentAmount, relativeTimeFormat, nsfw, showNSFWContent], [memoData, creator, isArchived, readonly, isInMemoDetailPage, parentPage, commentAmount, relativeTimeFormat, nsfw, showNSFWContent],
); );
// Render inline editor when editing
if (showEditor) { if (showEditor) {
return ( return (
<MemoEditor <MemoEditor
@ -101,16 +88,9 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
); );
} }
// Render memo card
return ( return (
<MemoViewContext.Provider value={contextValue}> <MemoViewContext.Provider value={contextValue}>
<article <article className={cn(MEMO_CARD_BASE_CLASSES, className)} ref={cardRef} tabIndex={readonly ? -1 : 0}>
className={cn(MEMO_CARD_BASE_CLASSES, className)}
ref={cardRef}
tabIndex={readonly ? -1 : 0}
onFocus={() => handleShortcutActivation(true)}
onBlur={() => handleShortcutActivation(false)}
>
<MemoHeader <MemoHeader
showCreator={props.showCreator} showCreator={props.showCreator}
showVisibility={props.showVisibility} showVisibility={props.showVisibility}

View File

@ -2,43 +2,21 @@ import { createContext, useContext } from "react";
import type { Memo } from "@/types/proto/api/v1/memo_service"; import type { Memo } from "@/types/proto/api/v1/memo_service";
import type { User } from "@/types/proto/api/v1/user_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 { export interface MemoViewContextValue {
/** The memo data */
memo: Memo; memo: Memo;
/** The memo creator user data */
creator: User | undefined; creator: User | undefined;
/** Whether the memo is in archived state */
isArchived: boolean; isArchived: boolean;
/** Whether the current user can only view (not edit) the memo */
readonly: boolean; readonly: boolean;
/** Whether we're currently on the memo detail page */
isInMemoDetailPage: boolean; isInMemoDetailPage: boolean;
/** Parent page path for navigation state */
parentPage: string; parentPage: string;
/** Number of comments on this memo */
commentAmount: number; commentAmount: number;
/** Time format to use (datetime for old memos, auto for recent) */
relativeTimeFormat: "datetime" | "auto"; relativeTimeFormat: "datetime" | "auto";
/** Whether this memo contains NSFW content */
nsfw: boolean; nsfw: boolean;
/** Whether to show NSFW content without blur */
showNSFWContent: boolean; 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); export const MemoViewContext = createContext<MemoViewContextValue | null>(null);
/**
* Hook to access MemoView context
* @throws Error if used outside of MemoViewContext.Provider
*/
export const useMemoViewContext = (): MemoViewContextValue => { export const useMemoViewContext = (): MemoViewContextValue => {
const context = useContext(MemoViewContext); const context = useContext(MemoViewContext);
if (!context) { if (!context) {

View File

@ -5,18 +5,15 @@ 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 { useMemoViewContext } from "../MemoViewContext";
import type { MemoBodyProps } from "../types";
/** interface Props {
* MemoBody component displays the main content of a memo including: compact?: boolean;
* - Memo content (markdown) onContentClick: (e: React.MouseEvent) => void;
* - Location display onContentDoubleClick: (e: React.MouseEvent) => void;
* - Attachments onToggleNsfwVisibility: () => void;
* - Related memos }
* - Reactions
* - NSFW content overlay const MemoBody: React.FC<Props> = ({ compact, onContentClick, onContentDoubleClick, onToggleNsfwVisibility }) => {
*/
const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentDoubleClick, onToggleNsfwVisibility }) => {
const t = useTranslate(); const t = useTranslate();
// Get shared state from context // Get shared state from context

View File

@ -12,20 +12,20 @@ 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 { useMemoViewContext } from "../MemoViewContext";
import type { MemoHeaderProps } from "../types";
/** interface Props {
* MemoHeader component displays the top section of a memo card including: showCreator?: boolean;
* - Creator info (avatar, name) when showCreator is true showVisibility?: boolean;
* - Display time (relative or absolute) showPinned?: boolean;
* - Reaction selector onEdit: () => void;
* - Comment count link onGotoDetail: () => void;
* - Visibility icon onUnpin: () => void;
* - Pin indicator onToggleNsfwVisibility?: () => void;
* - NSFW hide button reactionSelectorOpen: boolean;
* - Action menu onReactionSelectorOpenChange: (open: boolean) => void;
*/ }
const MemoHeader: React.FC<MemoHeaderProps> = ({
const MemoHeader: React.FC<Props> = ({
showCreator, showCreator,
showVisibility, showVisibility,
showPinned, showPinned,
@ -130,9 +130,6 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({
); );
}; };
/**
* Creator display with avatar and name
*/
interface CreatorDisplayProps { interface CreatorDisplayProps {
creator: User; creator: User;
displayTime: React.ReactNode; displayTime: React.ReactNode;
@ -163,9 +160,6 @@ const CreatorDisplay: React.FC<CreatorDisplayProps> = ({ creator, displayTime, o
</div> </div>
); );
/**
* Simple time display without creator info
*/
interface TimeDisplayProps { interface TimeDisplayProps {
displayTime: React.ReactNode; displayTime: React.ReactNode;
onGotoDetail: () => void; onGotoDetail: () => void;

View File

@ -1,19 +1,8 @@
/**
* Constants for MemoView component
*/
/** CSS class for memo card styling */
export const MEMO_CARD_BASE_CLASSES = export const MEMO_CARD_BASE_CLASSES =
"relative group flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-2 gap-2 text-card-foreground rounded-lg border border-border transition-colors"; "relative group flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-2 gap-2 text-card-foreground rounded-lg border border-border transition-colors";
/** Keyboard shortcut keys */ export const KEYBOARD_SHORTCUTS = { EDIT: "e", ARCHIVE: "a" } as const;
export const KEYBOARD_SHORTCUTS = {
EDIT: "e",
ARCHIVE: "a",
} as const;
/** Text input element types for keyboard shortcut filtering */
export const TEXT_INPUT_TYPES = ["text", "search", "email", "password", "url", "tel", "number"] as const; export const TEXT_INPUT_TYPES = ["text", "search", "email", "password", "url", "tel", "number"] as const;
/** Time threshold for relative time format (24 hours in milliseconds) */
export const RELATIVE_TIME_THRESHOLD_MS = 1000 * 60 * 60 * 24; export const RELATIVE_TIME_THRESHOLD_MS = 1000 * 60 * 60 * 24;

View File

@ -1,7 +1,4 @@
export type { UseMemoEditorReturn } from "./useMemoEditor";
export { useMemoEditor } from "./useMemoEditor"; export { useMemoEditor } from "./useMemoEditor";
export type { UseMemoHandlersOptions, UseMemoHandlersReturn } from "./useMemoHandlers";
export { useMemoHandlers } from "./useMemoHandlers"; export { useMemoHandlers } from "./useMemoHandlers";
export type { UseMemoViewDerivedStateOptions, UseMemoViewDerivedStateReturn } from "./useMemoViewDerivedState";
export { useMemoViewDerivedState } from "./useMemoViewDerivedState"; export { useMemoViewDerivedState } from "./useMemoViewDerivedState";
export { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./useMemoViewState"; export { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./useMemoViewState";

View File

@ -1,37 +1,16 @@
import { useCallback, useState } from "react"; import { useState } from "react";
import { userStore } from "@/store"; import { userStore } from "@/store";
export interface UseMemoEditorReturn { export const useMemoEditor = () => {
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 [showEditor, setShowEditor] = useState(false);
const openEditor = useCallback(() => {
setShowEditor(true);
}, []);
const handleEditorConfirm = useCallback(() => {
setShowEditor(false);
userStore.setStatsStateId();
}, []);
const handleEditorCancel = useCallback(() => {
setShowEditor(false);
}, []);
return { return {
showEditor, showEditor,
openEditor, openEditor: () => setShowEditor(true),
handleEditorConfirm, handleEditorConfirm: () => {
handleEditorCancel, setShowEditor(false);
userStore.setStatsStateId();
},
handleEditorCancel: () => setShowEditor(false),
}; };
}; };

View File

@ -1,52 +1,32 @@
import { useCallback } from "react"; import { useCallback } from "react";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { instanceStore } from "@/store"; import { instanceStore } from "@/store";
import type { UseImagePreviewReturn } from "../types";
export interface UseMemoHandlersOptions { interface UseMemoHandlersOptions {
memoName: string; memoName: string;
parentPage: string; parentPage: string;
readonly: boolean; readonly: boolean;
openEditor: () => void; openEditor: () => void;
openPreview: UseImagePreviewReturn["openPreview"]; openPreview: (url: string) => void;
} }
export interface UseMemoHandlersReturn { export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
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 { memoName, parentPage, readonly, openEditor, openPreview } = options;
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
// These useCallbacks are necessary since they have real dependencies
const handleGotoMemoDetailPage = useCallback(() => { const handleGotoMemoDetailPage = useCallback(() => {
navigateTo(`/${memoName}`, { navigateTo(`/${memoName}`, { state: { from: parentPage } });
state: { from: parentPage },
});
}, [memoName, parentPage, navigateTo]); }, [memoName, parentPage, navigateTo]);
const handleMemoContentClick = useCallback( const handleMemoContentClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement; const targetEl = e.target as HTMLElement;
if (targetEl.tagName === "IMG") { if (targetEl.tagName === "IMG") {
// Check if the image is inside a link
const linkElement = targetEl.closest("a"); const linkElement = targetEl.closest("a");
if (linkElement) { if (linkElement) return; // If image is inside a link, don't show preview
// If image is inside a link, only navigate to the link (don't show preview)
return;
}
const imgUrl = targetEl.getAttribute("src"); const imgUrl = targetEl.getAttribute("src");
if (imgUrl) { if (imgUrl) openPreview(imgUrl);
openPreview(imgUrl);
}
} }
}, },
[openPreview], [openPreview],
@ -55,9 +35,7 @@ export const useMemoHandlers = (options: UseMemoHandlersOptions): UseMemoHandler
const handleMemoContentDoubleClick = useCallback( const handleMemoContentDoubleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (readonly) return; if (readonly) return;
if (instanceStore.state.memoRelatedSetting.enableDoubleClickEdit) {
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
if (instanceMemoRelatedSetting.enableDoubleClickEdit) {
e.preventDefault(); e.preventDefault();
openEditor(); openEditor();
} }
@ -65,9 +43,5 @@ export const useMemoHandlers = (options: UseMemoHandlersOptions): UseMemoHandler
[readonly, openEditor], [readonly, openEditor],
); );
return { return { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick };
handleGotoMemoDetailPage,
handleMemoContentClick,
handleMemoContentDoubleClick,
};
}; };

View File

@ -1,4 +1,3 @@
import { useMemo } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common"; import { State } from "@/types/proto/api/v1/common";
@ -7,55 +6,21 @@ import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
import { isSuperUser } from "@/utils/user"; import { isSuperUser } from "@/utils/user";
import { RELATIVE_TIME_THRESHOLD_MS } from "../constants"; import { RELATIVE_TIME_THRESHOLD_MS } from "../constants";
export interface UseMemoViewDerivedStateOptions { export const useMemoViewDerivedState = (memo: Memo, parentPageProp?: string) => {
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 location = useLocation();
const user = useCurrentUser(); const user = useCurrentUser();
// Compute all derived state const commentAmount = memo.relations.filter(
const commentAmount = useMemo( (relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memo.name,
() => ).length;
memo.relations.filter((relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memo.name).length,
[memo.relations, memo.name],
);
const relativeTimeFormat: "datetime" | "auto" = useMemo( const relativeTimeFormat: "datetime" | "auto" =
() => (memo.displayTime && Date.now() - memo.displayTime.getTime() > RELATIVE_TIME_THRESHOLD_MS ? "datetime" : "auto"), 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 isArchived = memo.state === State.ARCHIVED;
const readonly = memo.creator !== user?.name && !isSuperUser(user);
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
const parentPage = parentPageProp || location.pathname;
const readonly = useMemo(() => memo.creator !== user?.name && !isSuperUser(user), [memo.creator, user]); return { commentAmount, relativeTimeFormat, isArchived, readonly, isInMemoDetailPage, parentPage };
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

@ -1,38 +1,34 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { instanceStore, memoStore, userStore } from "@/store"; import { instanceStore, memoStore, userStore } from "@/store";
import { State } from "@/types/proto/api/v1/common"; import { State } from "@/types/proto/api/v1/common";
import type { Memo } from "@/types/proto/api/v1/memo_service"; import type { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { KEYBOARD_SHORTCUTS, TEXT_INPUT_TYPES } from "../constants"; import { KEYBOARD_SHORTCUTS, TEXT_INPUT_TYPES } from "../constants";
import type {
ImagePreviewState,
UseImagePreviewReturn,
UseKeyboardShortcutsOptions,
UseMemoActionsReturn,
UseNsfwContentReturn,
} from "../types";
/** interface ImagePreviewState {
* Hook for handling memo actions (archive, unpin) open: boolean;
*/ urls: string[];
export const useMemoActions = (memo: Memo): UseMemoActionsReturn => { index: number;
}
interface UseKeyboardShortcutsOptions {
enabled: boolean;
readonly: boolean;
showEditor: boolean;
isArchived: boolean;
onEdit: () => void;
onArchive: () => Promise<void>;
}
export const useMemoActions = (memo: Memo) => {
const t = useTranslate(); const t = useTranslate();
const isArchived = memo.state === State.ARCHIVED; const isArchived = memo.state === State.ARCHIVED;
const archiveMemo = useCallback(async () => { const archiveMemo = async () => {
if (isArchived) { if (isArchived) return;
return;
}
try { try {
await memoStore.updateMemo( await memoStore.updateMemo({ name: memo.name, state: State.ARCHIVED }, ["state"]);
{
name: memo.name,
state: State.ARCHIVED,
},
["state"],
);
toast.success(t("message.archived-successfully")); toast.success(t("message.archived-successfully"));
userStore.setStatsStateId(); userStore.setStatsStateId();
} catch (error: unknown) { } catch (error: unknown) {
@ -40,66 +36,37 @@ export const useMemoActions = (memo: Memo): UseMemoActionsReturn => {
const err = error as { details?: string }; const err = error as { details?: string };
toast.error(err?.details || "Failed to archive memo"); toast.error(err?.details || "Failed to archive memo");
} }
}, [isArchived, memo.name, t]); };
const unpinMemo = useCallback(async () => { const unpinMemo = async () => {
if (!memo.pinned) { if (!memo.pinned) return;
return; await memoStore.updateMemo({ name: memo.name, pinned: false }, ["pinned"]);
} };
await memoStore.updateMemo(
{
name: memo.name,
pinned: false,
},
["pinned"],
);
}, [memo.name, memo.pinned]);
return { archiveMemo, unpinMemo }; return { archiveMemo, unpinMemo };
}; };
/** const isTextInputElement = (element: HTMLElement | null): boolean => {
* Hook for handling keyboard shortcuts on the memo card
*/
export const useKeyboardShortcuts = (
cardRef: React.RefObject<HTMLDivElement | null>,
options: UseKeyboardShortcutsOptions,
): {
shortcutActive: boolean;
handleShortcutActivation: (active: boolean) => void;
} => {
const { enabled, readonly, showEditor, isArchived, onEdit, onArchive } = options;
const [shortcutActive, setShortcutActive] = useState(false);
const isTextInputElement = useCallback((element: HTMLElement | null): boolean => {
if (!element) return false; if (!element) return false;
if (element.isContentEditable) return true; if (element.isContentEditable) return true;
if (element instanceof HTMLTextAreaElement) return true; if (element instanceof HTMLTextAreaElement) return true;
if (element instanceof HTMLInputElement) { if (element instanceof HTMLInputElement) {
return TEXT_INPUT_TYPES.includes(element.type as (typeof TEXT_INPUT_TYPES)[number]); return TEXT_INPUT_TYPES.includes(element.type as (typeof TEXT_INPUT_TYPES)[number]);
} }
return false; return false;
}, []); };
export const useKeyboardShortcuts = (cardRef: React.RefObject<HTMLDivElement | null>, options: UseKeyboardShortcutsOptions) => {
const { enabled, readonly, showEditor, isArchived, onEdit, onArchive } = options;
useEffect(() => { useEffect(() => {
if (!enabled || readonly || showEditor || !cardRef.current) { if (!enabled || readonly || showEditor || !cardRef.current) return;
return;
}
const cardEl = cardRef.current; const cardEl = cardRef.current;
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
if (!cardEl.contains(target) || isTextInputElement(target)) { if (!cardEl.contains(target) || isTextInputElement(target)) return;
return; if (event.metaKey || event.ctrlKey || event.altKey) return;
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return;
}
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
if (key === KEYBOARD_SHORTCUTS.EDIT) { if (key === KEYBOARD_SHORTCUTS.EDIT) {
@ -113,29 +80,10 @@ export const useKeyboardShortcuts = (
cardEl.addEventListener("keydown", handleKeyDown); cardEl.addEventListener("keydown", handleKeyDown);
return () => cardEl.removeEventListener("keydown", handleKeyDown); return () => cardEl.removeEventListener("keydown", handleKeyDown);
}, [enabled, readonly, showEditor, isArchived, onEdit, onArchive, cardRef, isTextInputElement]); }, [enabled, readonly, showEditor, isArchived, onEdit, onArchive, cardRef]);
useEffect(() => {
if (showEditor || readonly) {
setShortcutActive(false);
}
}, [showEditor, readonly]);
const handleShortcutActivation = useCallback(
(active: boolean) => {
if (readonly) return;
setShortcutActive(active);
},
[readonly],
);
return { shortcutActive, handleShortcutActivation };
}; };
/** export const useNsfwContent = (memo: Memo, initialShowNsfw?: boolean) => {
* Hook for managing NSFW content visibility
*/
export const useNsfwContent = (memo: Memo, initialShowNsfw?: boolean): UseNsfwContentReturn => {
const [showNSFWContent, setShowNSFWContent] = useState(initialShowNsfw ?? false); const [showNSFWContent, setShowNSFWContent] = useState(initialShowNsfw ?? false);
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting; const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
@ -143,50 +91,23 @@ export const useNsfwContent = (memo: Memo, initialShowNsfw?: boolean): UseNsfwCo
instanceMemoRelatedSetting.enableBlurNsfwContent && instanceMemoRelatedSetting.enableBlurNsfwContent &&
memo.tags?.some((tag) => instanceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`))); memo.tags?.some((tag) => instanceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));
const toggleNsfwVisibility = useCallback(() => {
setShowNSFWContent((prev) => !prev);
}, []);
return { return {
nsfw: nsfw ?? false, nsfw: nsfw ?? false,
showNSFWContent, showNSFWContent,
toggleNsfwVisibility, toggleNsfwVisibility: () => setShowNSFWContent((prev) => !prev),
}; };
}; };
/** export const useImagePreview = () => {
* Hook for managing image preview dialog state const [previewState, setPreviewState] = useState<ImagePreviewState>({ open: false, urls: [], index: 0 });
*/
export const useImagePreview = (): UseImagePreviewReturn => {
const [previewState, setPreviewState] = useState<ImagePreviewState>({
open: false,
urls: [],
index: 0,
});
const openPreview = useCallback((url: string) => {
setPreviewState({ open: true, urls: [url], index: 0 });
}, []);
const closePreview = useCallback(() => {
setPreviewState((prev) => ({ ...prev, open: false }));
}, []);
const setPreviewOpen = useCallback((open: boolean) => {
setPreviewState((prev) => ({ ...prev, open }));
}, []);
return { return {
previewState, previewState,
openPreview, openPreview: (url: string) => setPreviewState({ open: true, urls: [url], index: 0 }),
closePreview, setPreviewOpen: (open: boolean) => setPreviewState((prev) => ({ ...prev, open })),
setPreviewOpen,
}; };
}; };
/**
* Hook for fetching and managing memo creator data
*/
export const useMemoCreator = (creatorName: string) => { export const useMemoCreator = (creatorName: string) => {
const [creator, setCreator] = useState(userStore.getUserByName(creatorName)); const [creator, setCreator] = useState(userStore.getUserByName(creatorName));
const fetchedRef = useRef(false); const fetchedRef = useRef(false);
@ -194,7 +115,6 @@ export const useMemoCreator = (creatorName: string) => {
useEffect(() => { useEffect(() => {
if (fetchedRef.current) return; if (fetchedRef.current) return;
fetchedRef.current = true; fetchedRef.current = true;
(async () => { (async () => {
const user = await userStore.getOrFetchUserByName(creatorName); const user = await userStore.getOrFetchUserByName(creatorName);
setCreator(user); setCreator(user);

View File

@ -10,33 +10,4 @@
export { MemoBody, MemoHeader } from "./components"; export { MemoBody, MemoHeader } from "./components";
export * from "./constants"; export * from "./constants";
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 {
ImagePreviewState,
MemoBodyProps,
MemoHeaderProps,
MemoViewProps,
UseImagePreviewReturn,
UseKeyboardShortcutsOptions,
UseMemoActionsReturn,
UseNsfwContentReturn,
} from "./types";

View File

@ -1,104 +0,0 @@
import type { Memo } from "@/types/proto/api/v1/memo_service";
import type { User } from "@/types/proto/api/v1/user_service";
/**
* Props for the MemoView component
*/
export interface MemoViewProps {
/** The memo data to display */
memo: Memo;
/** Enable compact mode with truncated content */
compact?: boolean;
/** Show creator avatar and name */
showCreator?: boolean;
/** Show visibility icon */
showVisibility?: boolean;
/** Show pinned indicator */
showPinned?: boolean;
/** Show NSFW content without blur */
showNsfwContent?: boolean;
/** Additional CSS classes */
className?: string;
/** Parent page path for navigation state */
parentPage?: string;
}
/**
* Props for the MemoHeader component
* Note: Most data props now come from MemoViewContext
*/
export interface MemoHeaderProps {
// Display options
showCreator?: boolean;
showVisibility?: boolean;
showPinned?: boolean;
// Callbacks
onEdit: () => void;
onGotoDetail: () => void;
onUnpin: () => void;
onToggleNsfwVisibility?: () => void;
// Reaction state
reactionSelectorOpen: boolean;
onReactionSelectorOpenChange: (open: boolean) => void;
}
/**
* Props for the MemoBody component
* Note: Most data props now come from MemoViewContext
*/
export interface MemoBodyProps {
// Display options
compact?: boolean;
// Callbacks
onContentClick: (e: React.MouseEvent) => void;
onContentDoubleClick: (e: React.MouseEvent) => void;
onToggleNsfwVisibility: () => void;
}
/**
* State for image preview dialog
*/
export interface ImagePreviewState {
open: boolean;
urls: string[];
index: number;
}
/**
* Return type for useMemoActions hook
*/
export interface UseMemoActionsReturn {
archiveMemo: () => Promise<void>;
unpinMemo: () => Promise<void>;
}
/**
* Return type for useKeyboardShortcuts hook
*/
export interface UseKeyboardShortcutsOptions {
enabled: boolean;
readonly: boolean;
showEditor: boolean;
isArchived: boolean;
onEdit: () => void;
onArchive: () => Promise<void>;
}
/**
* Return type for useNsfwContent hook
*/
export interface UseNsfwContentReturn {
nsfw: boolean;
showNSFWContent: boolean;
toggleNsfwVisibility: () => void;
}
/**
* Return type for useImagePreview hook
*/
export interface UseImagePreviewReturn {
previewState: ImagePreviewState;
openPreview: (url: string) => void;
closePreview: () => void;
setPreviewOpen: (open: boolean) => void;
}