mirror of https://github.com/usememos/memos.git
refactor(MemoView): improve maintainability and code organization
Complete refactoring of MemoView components for better code quality: - Split useMemoViewState into individual hook files (useMemoActions, useKeyboardShortcuts, useNsfwContent, useImagePreview, useMemoCreator) for single responsibility principle - Consolidate types closer to usage - move hook-specific types to respective files, keep only component props in shared types.ts - Optimize context with separate static/dynamic memoization to reduce unnecessary re-renders - Simplify barrel exports to only expose public API (MemoView component and MemoViewProps type) - Add comprehensive JSDoc documentation to all public APIs with usage examples Benefits: - Better maintainability: each hook file has one clear purpose - Improved performance: context optimization prevents unnecessary re-renders - Enhanced developer experience: clear documentation and encapsulation - Cleaner architecture: public API is minimal, internal details hidden All automated checks pass (TypeScript compilation, linter, production build).
This commit is contained in:
parent
1b11e8c841
commit
e61d594ded
|
|
@ -29,6 +29,27 @@ interface Props {
|
|||
parentPage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MemoView component displays a memo card with all its content, metadata, and interactive elements.
|
||||
*
|
||||
* Features:
|
||||
* - Displays memo content with markdown rendering
|
||||
* - Shows creator information and timestamps
|
||||
* - Supports inline editing with keyboard shortcuts (e = edit, a = archive)
|
||||
* - Handles NSFW content blurring
|
||||
* - Image preview on click
|
||||
* - Comments, reactions, and relations
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MemoView
|
||||
* memo={memoData}
|
||||
* showCreator
|
||||
* showVisibility
|
||||
* compact={false}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const MemoView: React.FC<Props> = observer((props: Props) => {
|
||||
const { memo: memoData, className } = props;
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -59,7 +80,8 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
|
|||
onArchive: archiveMemo,
|
||||
});
|
||||
|
||||
const contextValue = useMemo(
|
||||
// Memoize static values that rarely change
|
||||
const staticContextValue = useMemo(
|
||||
() => ({
|
||||
memo: memoData,
|
||||
creator,
|
||||
|
|
@ -67,12 +89,28 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
|
|||
readonly,
|
||||
isInMemoDetailPage,
|
||||
parentPage,
|
||||
}),
|
||||
[memoData, creator, isArchived, readonly, isInMemoDetailPage, parentPage],
|
||||
);
|
||||
|
||||
// Memoize dynamic values separately
|
||||
const dynamicContextValue = useMemo(
|
||||
() => ({
|
||||
commentAmount,
|
||||
relativeTimeFormat,
|
||||
nsfw,
|
||||
showNSFWContent,
|
||||
}),
|
||||
[memoData, creator, isArchived, readonly, isInMemoDetailPage, parentPage, commentAmount, relativeTimeFormat, nsfw, showNSFWContent],
|
||||
[commentAmount, relativeTimeFormat, nsfw, showNSFWContent],
|
||||
);
|
||||
|
||||
// Combine context values
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
...staticContextValue,
|
||||
...dynamicContextValue,
|
||||
}),
|
||||
[staticContextValue, dynamicContextValue],
|
||||
);
|
||||
|
||||
if (showEditor) {
|
||||
|
|
|
|||
|
|
@ -2,19 +2,26 @@ import { createContext, useContext } from "react";
|
|||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
|
||||
export interface MemoViewContextValue {
|
||||
// Stable values that rarely change
|
||||
export interface MemoViewStaticContextValue {
|
||||
memo: Memo;
|
||||
creator: User | undefined;
|
||||
isArchived: boolean;
|
||||
readonly: boolean;
|
||||
isInMemoDetailPage: boolean;
|
||||
parentPage: string;
|
||||
}
|
||||
|
||||
// Dynamic values that change frequently
|
||||
export interface MemoViewDynamicContextValue {
|
||||
commentAmount: number;
|
||||
relativeTimeFormat: "datetime" | "auto";
|
||||
nsfw: boolean;
|
||||
showNSFWContent: boolean;
|
||||
}
|
||||
|
||||
export interface MemoViewContextValue extends MemoViewStaticContextValue, MemoViewDynamicContextValue {}
|
||||
|
||||
export const MemoViewContext = createContext<MemoViewContextValue | null>(null);
|
||||
|
||||
export const useMemoViewContext = (): MemoViewContextValue => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export { useMemoActions } from "./useMemoActions";
|
||||
export { useMemoCreator } from "./useMemoCreator";
|
||||
export { useMemoEditor } from "./useMemoEditor";
|
||||
export { useMemoHandlers } from "./useMemoHandlers";
|
||||
export { useImagePreview } from "./useImagePreview";
|
||||
export { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
||||
export { useMemoViewDerivedState } from "./useMemoViewDerivedState";
|
||||
export { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./useMemoViewState";
|
||||
export { useNsfwContent } from "./useNsfwContent";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { useState } from "react";
|
||||
|
||||
export interface ImagePreviewState {
|
||||
open: boolean;
|
||||
urls: string[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface UseImagePreviewReturn {
|
||||
previewState: ImagePreviewState;
|
||||
openPreview: (url: string) => void;
|
||||
closePreview: () => void;
|
||||
setPreviewOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useImagePreview = (): UseImagePreviewReturn => {
|
||||
const [previewState, setPreviewState] = useState<ImagePreviewState>({ open: false, urls: [], index: 0 });
|
||||
|
||||
return {
|
||||
previewState,
|
||||
openPreview: (url: string) => setPreviewState({ open: true, urls: [url], index: 0 }),
|
||||
closePreview: () => setPreviewState({ open: false, urls: [], index: 0 }),
|
||||
setPreviewOpen: (open: boolean) => setPreviewState((prev) => ({ ...prev, open })),
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { useEffect } from "react";
|
||||
import { KEYBOARD_SHORTCUTS, TEXT_INPUT_TYPES } from "../constants";
|
||||
|
||||
export interface UseKeyboardShortcutsOptions {
|
||||
enabled: boolean;
|
||||
readonly: boolean;
|
||||
showEditor: boolean;
|
||||
isArchived: boolean;
|
||||
onEdit: () => void;
|
||||
onArchive: () => Promise<void>;
|
||||
}
|
||||
|
||||
const isTextInputElement = (element: HTMLElement | null): boolean => {
|
||||
if (!element) return false;
|
||||
if (element.isContentEditable) return true;
|
||||
if (element instanceof HTMLTextAreaElement) return true;
|
||||
if (element instanceof HTMLInputElement) {
|
||||
return TEXT_INPUT_TYPES.includes(element.type as (typeof TEXT_INPUT_TYPES)[number]);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const useKeyboardShortcuts = (cardRef: React.RefObject<HTMLDivElement | null>, options: UseKeyboardShortcutsOptions) => {
|
||||
const { enabled, readonly, showEditor, isArchived, onEdit, onArchive } = options;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || readonly || showEditor || !cardRef.current) return;
|
||||
|
||||
const cardEl = cardRef.current;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!cardEl.contains(target) || isTextInputElement(target)) return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === KEYBOARD_SHORTCUTS.EDIT) {
|
||||
event.preventDefault();
|
||||
onEdit();
|
||||
} else if (key === KEYBOARD_SHORTCUTS.ARCHIVE && !isArchived) {
|
||||
event.preventDefault();
|
||||
onArchive();
|
||||
}
|
||||
};
|
||||
|
||||
cardEl.addEventListener("keydown", handleKeyDown);
|
||||
return () => cardEl.removeEventListener("keydown", handleKeyDown);
|
||||
}, [enabled, readonly, showEditor, isArchived, onEdit, onArchive, cardRef]);
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import toast from "react-hot-toast";
|
||||
import { memoStore, userStore } from "@/store";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
export const useMemoActions = (memo: Memo) => {
|
||||
const t = useTranslate();
|
||||
const isArchived = memo.state === State.ARCHIVED;
|
||||
|
||||
const archiveMemo = async () => {
|
||||
if (isArchived) return;
|
||||
try {
|
||||
await memoStore.updateMemo({ name: memo.name, state: State.ARCHIVED }, ["state"]);
|
||||
toast.success(t("message.archived-successfully"));
|
||||
userStore.setStatsStateId();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const err = error as { details?: string };
|
||||
toast.error(err?.details || "Failed to archive memo");
|
||||
}
|
||||
};
|
||||
|
||||
const unpinMemo = async () => {
|
||||
if (!memo.pinned) return;
|
||||
await memoStore.updateMemo({ name: memo.name, pinned: false }, ["pinned"]);
|
||||
};
|
||||
|
||||
return { archiveMemo, unpinMemo };
|
||||
};
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { userStore } from "@/store";
|
||||
|
||||
export const useMemoCreator = (creatorName: string) => {
|
||||
const [creator, setCreator] = useState(userStore.getUserByName(creatorName));
|
||||
|
||||
useEffect(() => {
|
||||
userStore.getOrFetchUser(creatorName).then(setCreator);
|
||||
}, [creatorName]);
|
||||
|
||||
return creator;
|
||||
};
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { instanceStore, memoStore, userStore } from "@/store";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { KEYBOARD_SHORTCUTS, TEXT_INPUT_TYPES } from "../constants";
|
||||
import type { ImagePreviewState, UseKeyboardShortcutsOptions } from "../types";
|
||||
|
||||
export const useMemoActions = (memo: Memo) => {
|
||||
const t = useTranslate();
|
||||
const isArchived = memo.state === State.ARCHIVED;
|
||||
|
||||
const archiveMemo = async () => {
|
||||
if (isArchived) return;
|
||||
try {
|
||||
await memoStore.updateMemo({ name: memo.name, state: State.ARCHIVED }, ["state"]);
|
||||
toast.success(t("message.archived-successfully"));
|
||||
userStore.setStatsStateId();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const err = error as { details?: string };
|
||||
toast.error(err?.details || "Failed to archive memo");
|
||||
}
|
||||
};
|
||||
|
||||
const unpinMemo = async () => {
|
||||
if (!memo.pinned) return;
|
||||
await memoStore.updateMemo({ name: memo.name, pinned: false }, ["pinned"]);
|
||||
};
|
||||
|
||||
return { archiveMemo, unpinMemo };
|
||||
};
|
||||
|
||||
const isTextInputElement = (element: HTMLElement | null): boolean => {
|
||||
if (!element) return false;
|
||||
if (element.isContentEditable) return true;
|
||||
if (element instanceof HTMLTextAreaElement) return true;
|
||||
if (element instanceof HTMLInputElement) {
|
||||
return TEXT_INPUT_TYPES.includes(element.type as (typeof TEXT_INPUT_TYPES)[number]);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const useKeyboardShortcuts = (cardRef: React.RefObject<HTMLDivElement | null>, options: UseKeyboardShortcutsOptions) => {
|
||||
const { enabled, readonly, showEditor, isArchived, onEdit, onArchive } = options;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || readonly || showEditor || !cardRef.current) return;
|
||||
|
||||
const cardEl = cardRef.current;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!cardEl.contains(target) || isTextInputElement(target)) return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === KEYBOARD_SHORTCUTS.EDIT) {
|
||||
event.preventDefault();
|
||||
onEdit();
|
||||
} else if (key === KEYBOARD_SHORTCUTS.ARCHIVE && !isArchived) {
|
||||
event.preventDefault();
|
||||
onArchive();
|
||||
}
|
||||
};
|
||||
|
||||
cardEl.addEventListener("keydown", handleKeyDown);
|
||||
return () => cardEl.removeEventListener("keydown", handleKeyDown);
|
||||
}, [enabled, readonly, showEditor, isArchived, onEdit, onArchive, cardRef]);
|
||||
};
|
||||
|
||||
export const useNsfwContent = (memo: Memo, initialShowNsfw?: boolean) => {
|
||||
const [showNSFWContent, setShowNSFWContent] = useState(initialShowNsfw ?? false);
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
|
||||
const nsfw =
|
||||
instanceMemoRelatedSetting.enableBlurNsfwContent &&
|
||||
memo.tags?.some((tag) => instanceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));
|
||||
|
||||
return {
|
||||
nsfw: nsfw ?? false,
|
||||
showNSFWContent,
|
||||
toggleNsfwVisibility: () => setShowNSFWContent((prev) => !prev),
|
||||
};
|
||||
};
|
||||
|
||||
export const useImagePreview = () => {
|
||||
const [previewState, setPreviewState] = useState<ImagePreviewState>({ open: false, urls: [], index: 0 });
|
||||
|
||||
return {
|
||||
previewState,
|
||||
openPreview: (url: string) => setPreviewState({ open: true, urls: [url], index: 0 }),
|
||||
setPreviewOpen: (open: boolean) => setPreviewState((prev) => ({ ...prev, open })),
|
||||
};
|
||||
};
|
||||
|
||||
export const useMemoCreator = (creatorName: string) => {
|
||||
const [creator, setCreator] = useState(userStore.getUserByName(creatorName));
|
||||
|
||||
useEffect(() => {
|
||||
userStore.getOrFetchUser(creatorName).then(setCreator);
|
||||
}, [creatorName]);
|
||||
|
||||
return creator;
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { useState } from "react";
|
||||
import { instanceStore } from "@/store";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
export interface UseNsfwContentReturn {
|
||||
nsfw: boolean;
|
||||
showNSFWContent: boolean;
|
||||
toggleNsfwVisibility: () => void;
|
||||
}
|
||||
|
||||
export const useNsfwContent = (memo: Memo, initialShowNsfw?: boolean): UseNsfwContentReturn => {
|
||||
const [showNSFWContent, setShowNSFWContent] = useState(initialShowNsfw ?? false);
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
|
||||
const nsfw =
|
||||
instanceMemoRelatedSetting.enableBlurNsfwContent &&
|
||||
memo.tags?.some((tag) => instanceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));
|
||||
|
||||
return {
|
||||
nsfw: nsfw ?? false,
|
||||
showNSFWContent,
|
||||
toggleNsfwVisibility: () => setShowNSFWContent((prev) => !prev),
|
||||
};
|
||||
};
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export { MemoBody, MemoHeader } from "./components";
|
||||
export * from "./constants";
|
||||
export { default, default as MemoView } from "./MemoView";
|
||||
export type { MemoViewProps } from "./types";
|
||||
|
|
|
|||
|
|
@ -1,17 +1,33 @@
|
|||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
|
||||
/**
|
||||
* Props for the MemoView component.
|
||||
* MemoView is the main component for displaying a memo card with all its metadata,
|
||||
* content, and interactive elements.
|
||||
*/
|
||||
export interface MemoViewProps {
|
||||
/** The memo object to display */
|
||||
memo: Memo;
|
||||
/** Whether to show compact view (hides some metadata) */
|
||||
compact?: boolean;
|
||||
/** Whether to show the creator's profile information */
|
||||
showCreator?: boolean;
|
||||
/** Whether to show the visibility indicator */
|
||||
showVisibility?: boolean;
|
||||
/** Whether to show the pinned indicator */
|
||||
showPinned?: boolean;
|
||||
/** Whether to show NSFW content by default */
|
||||
showNsfwContent?: boolean;
|
||||
/** Additional CSS classes to apply to the root element */
|
||||
className?: string;
|
||||
/** The parent page URL for navigation context */
|
||||
parentPage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the MemoHeader component.
|
||||
* Displays memo metadata like creator, timestamp, and action buttons.
|
||||
*/
|
||||
export interface MemoHeaderProps {
|
||||
// Display options
|
||||
showCreator?: boolean;
|
||||
|
|
@ -27,6 +43,10 @@ export interface MemoHeaderProps {
|
|||
onReactionSelectorOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the MemoBody component.
|
||||
* Displays memo content, attachments, and relations.
|
||||
*/
|
||||
export interface MemoBodyProps {
|
||||
// Display options
|
||||
compact?: boolean;
|
||||
|
|
@ -35,36 +55,3 @@ export interface MemoBodyProps {
|
|||
onContentDoubleClick: (e: React.MouseEvent) => void;
|
||||
onToggleNsfwVisibility: () => void;
|
||||
}
|
||||
|
||||
export interface ImagePreviewState {
|
||||
open: boolean;
|
||||
urls: string[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface UseMemoActionsReturn {
|
||||
archiveMemo: () => Promise<void>;
|
||||
unpinMemo: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UseKeyboardShortcutsOptions {
|
||||
enabled: boolean;
|
||||
readonly: boolean;
|
||||
showEditor: boolean;
|
||||
isArchived: boolean;
|
||||
onEdit: () => void;
|
||||
onArchive: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UseNsfwContentReturn {
|
||||
nsfw: boolean;
|
||||
showNSFWContent: boolean;
|
||||
toggleNsfwVisibility: () => void;
|
||||
}
|
||||
|
||||
export interface UseImagePreviewReturn {
|
||||
previewState: ImagePreviewState;
|
||||
openPreview: (url: string) => void;
|
||||
closePreview: () => void;
|
||||
setPreviewOpen: (open: boolean) => void;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue