mirror of https://github.com/usememos/memos.git
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:
parent
7b4f3a9fa5
commit
ac077ac3d3
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ export const LocationDialog = ({
|
|||
open,
|
||||
onOpenChange,
|
||||
state,
|
||||
locationInitialized: _locationInitialized,
|
||||
onPositionChange,
|
||||
onUpdateCoordinate,
|
||||
onPlaceholderChange,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -135,7 +135,6 @@ export const memoService = {
|
|||
ui: {
|
||||
isFocusMode: false,
|
||||
isLoading: { saving: false, uploading: false, loading: false },
|
||||
isDragging: false,
|
||||
isComposing: false,
|
||||
},
|
||||
timestamps: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -15,5 +15,4 @@ export type {
|
|||
TagSuggestionsProps,
|
||||
VisibilitySelectorProps,
|
||||
} from "./components";
|
||||
export { MemoEditorContext, type MemoEditorContextValue } from "./context";
|
||||
export type { LocationState } from "./insert-menu";
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ const STUB_CONTEXT: MemoViewContextValue = {
|
|||
readonly: true,
|
||||
showNSFWContent: false,
|
||||
nsfw: false,
|
||||
openEditor: () => {},
|
||||
toggleNsfwVisibility: () => {},
|
||||
openPreview: () => {},
|
||||
};
|
||||
|
||||
const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue