refactor(web): improve MemoView and MemoEditor maintainability (#5754)

Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
memoclaw 2026-03-21 07:20:18 +08:00 committed by GitHub
parent 7b4f3a9fa5
commit ac077ac3d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 159 additions and 214 deletions

View File

@ -52,24 +52,33 @@ const InsertMenu = (props: InsertMenuProps) => {
});
const location = useLocation(props.location);
const {
state: locationState,
locationInitialized,
handlePositionChange: handleLocationPositionChange,
getLocation,
reset: locationReset,
updateCoordinate,
setPlaceholder,
} = location;
const [debouncedPosition, setDebouncedPosition] = useState<LatLng | undefined>(undefined);
useDebounce(
() => {
setDebouncedPosition(location.state.position);
setDebouncedPosition(locationState.position);
},
1000,
[location.state.position],
[locationState.position],
);
const { data: displayName } = useReverseGeocoding(debouncedPosition?.lat, debouncedPosition?.lng);
useEffect(() => {
if (displayName) {
location.setPlaceholder(displayName);
setPlaceholder(displayName);
}
}, [displayName]);
}, [displayName, setPlaceholder]);
const isUploading = selectingFlag || isUploadingProp;
@ -79,11 +88,11 @@ const InsertMenu = (props: InsertMenuProps) => {
const handleLocationClick = useCallback(() => {
setLocationDialogOpen(true);
if (!initialLocation && !location.locationInitialized) {
if (!initialLocation && !locationInitialized) {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
location.handlePositionChange(new LatLng(position.coords.latitude, position.coords.longitude));
handleLocationPositionChange(new LatLng(position.coords.latitude, position.coords.longitude));
},
(error) => {
console.error("Geolocation error:", error);
@ -91,27 +100,20 @@ const InsertMenu = (props: InsertMenuProps) => {
);
}
}
}, [initialLocation, location]);
}, [initialLocation, locationInitialized, handleLocationPositionChange]);
const handleLocationConfirm = useCallback(() => {
const newLocation = location.getLocation();
const newLocation = getLocation();
if (newLocation) {
onLocationChange(newLocation);
setLocationDialogOpen(false);
}
}, [location, onLocationChange]);
}, [getLocation, onLocationChange]);
const handleLocationCancel = useCallback(() => {
location.reset();
locationReset();
setLocationDialogOpen(false);
}, [location]);
const handlePositionChange = useCallback(
(position: LatLng) => {
location.handlePositionChange(position);
},
[location],
);
}, [locationReset]);
const handleToggleFocusMode = useCallback(() => {
onToggleFocusMode?.();
@ -200,11 +202,10 @@ const InsertMenu = (props: InsertMenuProps) => {
<LocationDialog
open={locationDialogOpen}
onOpenChange={setLocationDialogOpen}
state={location.state}
locationInitialized={location.locationInitialized}
onPositionChange={handlePositionChange}
onUpdateCoordinate={location.updateCoordinate}
onPlaceholderChange={location.setPlaceholder}
state={locationState}
onPositionChange={handleLocationPositionChange}
onUpdateCoordinate={updateCoordinate}
onPlaceholderChange={setPlaceholder}
onCancel={handleLocationCancel}
onConfirm={handleLocationConfirm}
/>

View File

@ -12,7 +12,6 @@ export const LocationDialog = ({
open,
onOpenChange,
state,
locationInitialized: _locationInitialized,
onPositionChange,
onUpdateCoordinate,
onPlaceholderChange,

View File

@ -1,5 +1,3 @@
export const LOCALSTORAGE_DEBOUNCE_DELAY = 500;
export const FOCUS_MODE_STYLES = {
backdrop: "fixed inset-0 bg-black/20 backdrop-blur-sm z-40",
container: {

View File

@ -1,11 +1,10 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import type { EditorRefActions } from "../Editor";
interface UseKeyboardOptions {
onSave: () => void;
}
export const useKeyboard = (editorRef: React.RefObject<EditorRefActions | null>, onSave: () => void) => {
const onSaveRef = useRef(onSave);
onSaveRef.current = onSave;
export const useKeyboard = (editorRef: React.RefObject<EditorRefActions | null>, options: UseKeyboardOptions) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key !== "Enter") {
@ -24,10 +23,10 @@ export const useKeyboard = (editorRef: React.RefObject<EditorRefActions | null>,
}
event.preventDefault();
options.onSave();
onSaveRef.current();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [editorRef, options]);
}, [editorRef]);
};

View File

@ -1,11 +1,14 @@
import { create } from "@bufbuild/protobuf";
import { LatLng } from "leaflet";
import { useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Location, LocationSchema } from "@/types/proto/api/v1/memo_service_pb";
import { LocationState } from "../types/insert-menu";
export const useLocation = (initialLocation?: Location) => {
const [locationInitialized, setLocationInitialized] = useState(false);
const locationInitializedRef = useRef(locationInitialized);
locationInitializedRef.current = locationInitialized;
const [state, setState] = useState<LocationState>({
placeholder: initialLocation?.placeholder || "",
position: initialLocation ? new LatLng(initialLocation.latitude, initialLocation.longitude) : undefined,
@ -13,34 +16,48 @@ export const useLocation = (initialLocation?: Location) => {
lngInput: initialLocation ? String(initialLocation.longitude) : "",
});
const updatePosition = (position?: LatLng) => {
// Ref to latest state so getLocation can be stable without closing over state.
const stateRef = useRef(state);
stateRef.current = state;
const updatePosition = useCallback((position?: LatLng) => {
setState((prev) => ({
...prev,
position,
latInput: position ? String(position.lat) : "",
lngInput: position ? String(position.lng) : "",
}));
};
}, []);
const handlePositionChange = (position: LatLng) => {
if (!locationInitialized) setLocationInitialized(true);
updatePosition(position);
};
// Stable — reads locationInitialized via ref to avoid recreating on every change.
const handlePositionChange = useCallback(
(position: LatLng) => {
if (!locationInitializedRef.current) setLocationInitialized(true);
updatePosition(position);
},
[updatePosition],
);
const updateCoordinate = (type: "lat" | "lng", value: string) => {
setState((prev) => ({ ...prev, [type === "lat" ? "latInput" : "lngInput"]: value }));
// Stable — merges coordinate update into a single functional setState, avoiding closure over state.position.
const updateCoordinate = useCallback((type: "lat" | "lng", value: string) => {
const num = parseFloat(value);
const isValid = type === "lat" ? !isNaN(num) && num >= -90 && num <= 90 : !isNaN(num) && num >= -180 && num <= 180;
if (isValid && state.position) {
updatePosition(type === "lat" ? new LatLng(num, state.position.lng) : new LatLng(state.position.lat, num));
}
};
setState((prev) => {
const next = { ...prev, [type === "lat" ? "latInput" : "lngInput"]: value };
if (isValid && prev.position) {
const newPos = type === "lat" ? new LatLng(num, prev.position.lng) : new LatLng(prev.position.lat, num);
return { ...next, position: newPos, latInput: String(newPos.lat), lngInput: String(newPos.lng) };
}
return next;
});
}, []);
const setPlaceholder = (placeholder: string) => {
// Stable reference — uses functional setState, no closure deps.
const setPlaceholder = useCallback((placeholder: string) => {
setState((prev) => ({ ...prev, placeholder }));
};
}, []);
const reset = () => {
const reset = useCallback(() => {
setState({
placeholder: "",
position: undefined,
@ -48,26 +65,23 @@ export const useLocation = (initialLocation?: Location) => {
lngInput: "",
});
setLocationInitialized(false);
};
}, []);
const getLocation = (): Location | undefined => {
if (!state.position || !state.placeholder.trim()) {
// Stable — reads latest state via ref, no closure over state.
const getLocation = useCallback((): Location | undefined => {
const { position, placeholder } = stateRef.current;
if (!position || !placeholder.trim()) {
return undefined;
}
return create(LocationSchema, {
latitude: state.position.lat,
longitude: state.position.lng,
placeholder: state.placeholder,
latitude: position.lat,
longitude: position.lng,
placeholder,
});
};
}, []);
return {
state,
locationInitialized,
handlePositionChange,
updateCoordinate,
setPlaceholder,
reset,
getLocation,
};
return useMemo(
() => ({ state, locationInitialized, handlePositionChange, updateCoordinate, setPlaceholder, reset, getLocation }),
[state, locationInitialized, handlePositionChange, updateCoordinate, setPlaceholder, reset, getLocation],
);
};

View File

@ -57,7 +57,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
dispatch(actions.toggleFocusMode());
};
useKeyboard(editorRef, { onSave: handleSave });
useKeyboard(editorRef, handleSave);
async function handleSave() {
// Validate before saving
@ -145,7 +145,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
)}
{/* Editor content grows to fill available space in focus mode */}
<EditorContent ref={editorRef} placeholder={placeholder} autoFocus={autoFocus} />
<EditorContent ref={editorRef} placeholder={placeholder} />
{/* Metadata and toolbar grouped together at bottom */}
<div className="w-full flex flex-col gap-2">

View File

@ -135,7 +135,6 @@ export const memoService = {
ui: {
isFocusMode: false,
isLoading: { saving: false, uploading: false, loading: false },
isDragging: false,
isComposing: false,
},
timestamps: {

View File

@ -62,11 +62,6 @@ export const editorActions = {
payload: { key, value },
}),
setDragging: (value: boolean): EditorAction => ({
type: "SET_DRAGGING",
payload: value,
}),
setComposing: (value: boolean): EditorAction => ({
type: "SET_COMPOSING",
payload: value,

View File

@ -101,15 +101,6 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
},
};
case "SET_DRAGGING":
return {
...state,
ui: {
...state.ui,
isDragging: action.payload,
},
};
case "SET_COMPOSING":
return {
...state,

View File

@ -20,7 +20,6 @@ export interface EditorState {
uploading: boolean;
loading: boolean;
};
isDragging: boolean;
isComposing: boolean;
};
timestamps: {
@ -43,7 +42,6 @@ export type EditorAction =
| { type: "CLEAR_LOCAL_FILES" }
| { type: "TOGGLE_FOCUS_MODE" }
| { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } }
| { type: "SET_DRAGGING"; payload: boolean }
| { type: "SET_COMPOSING"; payload: boolean }
| { type: "SET_TIMESTAMPS"; payload: Partial<EditorState["timestamps"]> }
| { type: "RESET" };
@ -63,7 +61,6 @@ export const initialState: EditorState = {
uploading: false,
loading: false,
},
isDragging: false,
isComposing: false,
},
timestamps: {

View File

@ -18,7 +18,6 @@ export interface MemoEditorProps {
export interface EditorContentProps {
placeholder?: string;
autoFocus?: boolean;
}
export interface EditorToolbarProps {
@ -57,7 +56,6 @@ export interface LocationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
state: LocationState;
locationInitialized: boolean;
onPositionChange: (position: LatLng) => void;
onUpdateCoordinate: (type: "lat" | "lng", value: string) => void;
onPlaceholderChange: (placeholder: string) => void;

View File

@ -1,27 +0,0 @@
import { createContext } from "react";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import type { LocalFile } from "./attachment";
export interface MemoEditorContextValue {
attachmentList: Attachment[];
relationList: MemoRelation[];
setAttachmentList: (attachmentList: Attachment[]) => void;
setRelationList: (relationList: MemoRelation[]) => void;
memoName?: string;
addLocalFiles?: (files: LocalFile[]) => void;
removeLocalFile?: (previewUrl: string) => void;
localFiles?: LocalFile[];
}
const defaultContextValue: MemoEditorContextValue = {
attachmentList: [],
relationList: [],
setAttachmentList: () => {},
setRelationList: () => {},
addLocalFiles: () => {},
removeLocalFile: () => {},
localFiles: [],
};
export const MemoEditorContext = createContext<MemoEditorContextValue>(defaultContextValue);

View File

@ -15,5 +15,4 @@ export type {
TagSuggestionsProps,
VisibilitySelectorProps,
} from "./components";
export { MemoEditorContext, type MemoEditorContextValue } from "./context";
export type { LocationState } from "./insert-menu";

View File

@ -23,6 +23,9 @@ const STUB_CONTEXT: MemoViewContextValue = {
readonly: true,
showNSFWContent: false,
nsfw: false,
openEditor: () => {},
toggleNsfwVisibility: () => {},
openPreview: () => {},
};
const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) => {

View File

@ -1,4 +1,4 @@
import { memo, useMemo, useRef, useState } from "react";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useUser } from "@/hooks/useUserQueries";
@ -9,7 +9,7 @@ import MemoEditor from "../MemoEditor";
import PreviewImageDialog from "../PreviewImageDialog";
import { MemoBody, MemoCommentListView, MemoHeader } from "./components";
import { MEMO_CARD_BASE_CLASSES } from "./constants";
import { useImagePreview, useMemoActions, useMemoHandlers } from "./hooks";
import { useImagePreview } from "./hooks";
import { computeCommentAmount, MemoViewContext } from "./MemoViewContext";
import type { MemoViewProps } from "./types";
@ -27,21 +27,12 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
// NSFW content management: always blur content tagged with NSFW (case-insensitive)
const [showNSFWContent, setShowNSFWContent] = useState(false);
const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false;
const toggleNsfwVisibility = () => setShowNSFWContent((prev) => !prev);
const toggleNsfwVisibility = useCallback(() => setShowNSFWContent((prev) => !prev), []);
const { previewState, openPreview, setPreviewOpen } = useImagePreview();
const { unpinMemo } = useMemoActions(memoData);
const closeEditor = () => setShowEditor(false);
const openEditor = () => setShowEditor(true);
const { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({
memoName: memoData.name,
parentPage,
readonly,
openEditor,
openPreview,
});
const openEditor = useCallback(() => setShowEditor(true), []);
const closeEditor = useCallback(() => setShowEditor(false), []);
const location = useLocation();
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`);
@ -57,8 +48,23 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
readonly,
showNSFWContent,
nsfw,
openEditor,
toggleNsfwVisibility,
openPreview,
}),
[memoData, creator, currentUser, parentPage, isArchived, readonly, showNSFWContent, nsfw],
[
memoData,
creator,
currentUser,
parentPage,
isArchived,
readonly,
showNSFWContent,
nsfw,
openEditor,
toggleNsfwVisibility,
openPreview,
],
);
if (showEditor) {
@ -80,21 +86,9 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
ref={cardRef}
tabIndex={readonly ? -1 : 0}
>
<MemoHeader
showCreator={showCreator}
showVisibility={showVisibility}
showPinned={showPinned}
onEdit={openEditor}
onGotoDetail={handleGotoMemoDetailPage}
onUnpin={unpinMemo}
/>
<MemoHeader showCreator={showCreator} showVisibility={showVisibility} showPinned={showPinned} />
<MemoBody
compact={compact}
onContentClick={handleMemoContentClick}
onContentDoubleClick={handleMemoContentDoubleClick}
onToggleNsfwVisibility={toggleNsfwVisibility}
/>
<MemoBody compact={compact} />
<PreviewImageDialog
open={previewState.open}

View File

@ -15,6 +15,9 @@ export interface MemoViewContextValue {
readonly: boolean;
showNSFWContent: boolean;
nsfw: boolean;
openEditor: () => void;
toggleNsfwVisibility: () => void;
openPreview: (urls: string | string[], index?: number) => void;
}
export const MemoViewContext = createContext<MemoViewContextValue | null>(null);

View File

@ -3,6 +3,7 @@ import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import MemoContent from "../../MemoContent";
import { MemoReactionListView } from "../../MemoReactionListView";
import { useMemoHandlers } from "../hooks";
import { useMemoViewContext } from "../MemoViewContext";
import type { MemoBodyProps } from "../types";
import { AttachmentList, LocationDisplay, RelationList } from "./metadata";
@ -21,8 +22,10 @@ const NsfwOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => {
);
};
const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentDoubleClick, onToggleNsfwVisibility }) => {
const { memo, parentPage, showNSFWContent, nsfw } = useMemoViewContext();
const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
const { memo, parentPage, showNSFWContent, nsfw, readonly, openEditor, openPreview, toggleNsfwVisibility } = useMemoViewContext();
const { handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({ readonly, openEditor, openPreview });
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
@ -37,8 +40,8 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentD
<MemoContent
key={`${memo.name}-${memo.updateTime}`}
content={memo.content}
onClick={onContentClick}
onDoubleClick={onContentDoubleClick}
onClick={handleMemoContentClick}
onDoubleClick={handleMemoContentDoubleClick}
compact={memo.pinned ? false : compact} // Always show full content when pinned
/>
<AttachmentList attachments={memo.attachments} />
@ -47,7 +50,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentD
<MemoReactionListView memo={memo} reactions={memo.reactions} />
</div>
{nsfw && !showNSFWContent && <NsfwOverlay onClick={onToggleNsfwVisibility} />}
{nsfw && !showNSFWContent && <NsfwOverlay onClick={toggleNsfwVisibility} />}
</>
);
};

View File

@ -1,8 +1,9 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { BookmarkIcon } from "lucide-react";
import { useState } from "react";
import { useCallback, useState } from "react";
import { Link } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useNavigateTo from "@/hooks/useNavigateTo";
import i18n from "@/i18n";
import { cn } from "@/lib/utils";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
@ -13,16 +14,24 @@ import MemoActionMenu from "../../MemoActionMenu";
import { ReactionSelector } from "../../MemoReactionListView";
import UserAvatar from "../../UserAvatar";
import VisibilityIcon from "../../VisibilityIcon";
import { useMemoActions } from "../hooks";
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
import type { MemoHeaderProps } from "../types";
const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, showPinned, onEdit, onGotoDetail, onUnpin }) => {
const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, showPinned }) => {
const t = useTranslate();
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
const { memo, creator, currentUser, isArchived, readonly } = useMemoViewContext();
const { memo, creator, currentUser, parentPage, isArchived, readonly, openEditor } = useMemoViewContext();
const { relativeTimeFormat } = useMemoViewDerived();
const navigateTo = useNavigateTo();
const handleGotoMemoDetailPage = useCallback(() => {
navigateTo(`/${memo.name}`, { state: { from: parentPage } });
}, [memo.name, parentPage, navigateTo]);
const { unpinMemo } = useMemoActions(memo);
const displayTime = isArchived ? (
(memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language)
) : (
@ -37,9 +46,9 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
<div className="w-full flex flex-row justify-between items-center gap-2">
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
{showCreator && creator ? (
<CreatorDisplay creator={creator} displayTime={displayTime} onGotoDetail={onGotoDetail} />
<CreatorDisplay creator={creator} displayTime={displayTime} onGotoDetail={handleGotoMemoDetailPage} />
) : (
<TimeDisplay displayTime={displayTime} onGotoDetail={onGotoDetail} />
<TimeDisplay displayTime={displayTime} onGotoDetail={handleGotoMemoDetailPage} />
)}
</div>
@ -70,7 +79,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-pointer">
<BookmarkIcon className="w-4 h-auto text-primary" onClick={onUnpin} />
<BookmarkIcon className="w-4 h-auto text-primary" onClick={unpinMemo} />
</span>
</TooltipTrigger>
<TooltipContent>
@ -80,7 +89,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
</TooltipProvider>
)}
<MemoActionMenu memo={memo} readonly={readonly} onEdit={onEdit} />
<MemoActionMenu memo={memo} readonly={readonly} onEdit={openEditor} />
</div>
</div>
);

View File

@ -1,9 +1,9 @@
import { FileAudioIcon, FileIcon, PaperclipIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
import PreviewImageDialog from "../../../PreviewImageDialog";
import { useMemoViewContext } from "../../MemoViewContext";
import AttachmentCard from "./AttachmentCard";
import SectionHeader from "./SectionHeader";
@ -128,12 +128,7 @@ const DocsList = ({ attachments }: { attachments: Attachment[] }) => (
const Divider = () => <div className="border-t mt-1 border-border opacity-60" />;
const AttachmentList = ({ attachments }: AttachmentListProps) => {
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number; mimeType?: string }>({
open: false,
urls: [],
index: 0,
mimeType: undefined,
});
const { openPreview } = useMemoViewContext();
const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]);
@ -146,38 +141,28 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
const handleImageClick = (imgUrl: string) => {
const index = imageUrls.findIndex((url) => url === imgUrl);
const mimeType = imageAttachments[index]?.type;
setPreviewImage({ open: true, urls: imageUrls, index, mimeType });
openPreview(imageUrls, index >= 0 ? index : 0);
};
const sections = [visual.length > 0, audio.length > 0, docs.length > 0];
const sectionCount = sections.filter(Boolean).length;
return (
<>
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
<SectionHeader icon={PaperclipIcon} title="Attachments" count={attachments.length} />
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
<SectionHeader icon={PaperclipIcon} title="Attachments" count={attachments.length} />
<div className="p-1.5 flex flex-col gap-1">
{visual.length > 0 && <VisualGrid attachments={visual} onImageClick={handleImageClick} />}
<div className="p-1.5 flex flex-col gap-1">
{visual.length > 0 && <VisualGrid attachments={visual} onImageClick={handleImageClick} />}
{visual.length > 0 && sectionCount > 1 && <Divider />}
{visual.length > 0 && sectionCount > 1 && <Divider />}
{audio.length > 0 && <AudioList attachments={audio} />}
{audio.length > 0 && <AudioList attachments={audio} />}
{audio.length > 0 && docs.length > 0 && <Divider />}
{audio.length > 0 && docs.length > 0 && <Divider />}
{docs.length > 0 && <DocsList attachments={docs} />}
</div>
{docs.length > 0 && <DocsList attachments={docs} />}
</div>
<PreviewImageDialog
open={previewImage.open}
onOpenChange={(open: boolean) => setPreviewImage((prev) => ({ ...prev, open }))}
imgUrls={previewImage.urls}
initialIndex={previewImage.index}
/>
</>
</div>
);
};

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useState } from "react";
export interface ImagePreviewState {
open: boolean;
@ -8,16 +8,20 @@ export interface ImagePreviewState {
export interface UseImagePreviewReturn {
previewState: ImagePreviewState;
openPreview: (url: string) => void;
openPreview: (urls: string | string[], index?: number) => 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 }),
setPreviewOpen: (open: boolean) => setPreviewState((prev) => ({ ...prev, open })),
};
const openPreview = useCallback((urls: string | string[], index = 0) => {
setPreviewState({ open: true, urls: Array.isArray(urls) ? urls : [urls], index });
}, []);
const setPreviewOpen = useCallback((open: boolean) => {
setPreviewState((prev) => ({ ...prev, open }));
}, []);
return { previewState, openPreview, setPreviewOpen };
};

View File

@ -1,24 +1,16 @@
import { useCallback } from "react";
import { useInstance } from "@/contexts/InstanceContext";
import useNavigateTo from "@/hooks/useNavigateTo";
interface UseMemoHandlersOptions {
memoName: string;
parentPage: string;
readonly: boolean;
openEditor: () => void;
openPreview: (url: string) => void;
openPreview: (urls: string | string[], index?: number) => void;
}
export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
const { memoName, parentPage, readonly, openEditor, openPreview } = options;
const navigateTo = useNavigateTo();
const { readonly, openEditor, openPreview } = options;
const { memoRelatedSetting } = useInstance();
const handleGotoMemoDetailPage = useCallback(() => {
navigateTo(`/${memoName}`, { state: { from: parentPage } });
}, [memoName, parentPage, navigateTo]);
const handleMemoContentClick = useCallback(
(e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement;
@ -43,5 +35,5 @@ export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
[readonly, openEditor, memoRelatedSetting.enableDoubleClickEdit],
);
return { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick };
return { handleMemoContentClick, handleMemoContentDoubleClick };
};

View File

@ -14,14 +14,8 @@ export interface MemoHeaderProps {
showCreator?: boolean;
showVisibility?: boolean;
showPinned?: boolean;
onEdit: () => void;
onGotoDetail: () => void;
onUnpin: () => void;
}
export interface MemoBodyProps {
compact?: boolean;
onContentClick: (e: React.MouseEvent) => void;
onContentDoubleClick: (e: React.MouseEvent) => void;
onToggleNsfwVisibility: () => void;
}

View File

@ -47,12 +47,7 @@ const MemoDetail = () => {
return;
}
if (error.code === Code.PermissionDenied) {
navigateTo("/403", { replace: true });
return;
}
if (error.code === Code.NotFound) {
if (error.code === Code.PermissionDenied || error.code === Code.NotFound) {
navigateTo("/404", { replace: true });
return;
}