mirror of https://github.com/usememos/memos.git
refactor: clean up MemoView and MemoEditor component architecture
This commit refactors MemoView and MemoEditor components for better maintainability, introducing React Context, custom hooks, and improved folder structure. MemoView improvements: - Introduce MemoViewContext to eliminate prop drilling - Reduce MemoHeader props from 18 to 8 - Reduce MemoBody props from 9 to 4 - Extract custom hooks: useMemoViewDerivedState, useMemoEditor, useMemoHandlers for better separation of concerns - Fix React hooks ordering bug in edit mode MemoEditor improvements: - Extract state management into useMemoEditorState hook - Extract keyboard handling into useMemoEditorKeyboard hook - Extract event handlers into useMemoEditorHandlers hook - Extract initialization logic into useMemoEditorInit hook - Reduce main component from 461 to 317 lines (31% reduction) Folder structure cleanup: - Move SortableItem to memo-metadata (correct location) - Move ErrorBoundary to components folder - Flatten Toolbar/InsertMenu structure (remove unnecessary nesting) - Consolidate hooks in main hooks folder - Consolidate types in main types folder Benefits: - Better separation of concerns - Improved testability - Easier maintenance - Cleaner code organization - No functionality changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
bb7e0cdb79
commit
2516cdf2b4
|
|
@ -16,14 +16,11 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { LinkMemoDialog, LocationDialog } from "../components";
|
||||
import { GEOCODING } from "../constants";
|
||||
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
|
||||
import { useAbortController } from "../hooks/useAbortController";
|
||||
import { MemoEditorContext } from "../types";
|
||||
import { LinkMemoDialog } from "./InsertMenu/LinkMemoDialog";
|
||||
import { LocationDialog } from "./InsertMenu/LocationDialog";
|
||||
import { useFileUpload } from "./InsertMenu/useFileUpload";
|
||||
import { useLinkMemo } from "./InsertMenu/useLinkMemo";
|
||||
import { useLocation } from "./InsertMenu/useLocation";
|
||||
|
||||
interface Props {
|
||||
isUploading?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
export { LinkMemoDialog } from "./LinkMemoDialog";
|
||||
export { LocationDialog } from "./LocationDialog";
|
||||
export type { LinkMemoState, LocationState } from "./types";
|
||||
export { useFileUpload } from "./useFileUpload";
|
||||
export { useLinkMemo } from "./useLinkMemo";
|
||||
export { useLocation } from "./useLocation";
|
||||
|
|
@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { VisuallyHidden } from "@/components/ui/visually-hidden";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { LocationState } from "./types";
|
||||
import { LocationState } from "../types/insert-menu";
|
||||
|
||||
interface LocationDialogProps {
|
||||
open: boolean;
|
||||
|
|
@ -1,2 +1,5 @@
|
|||
// UI components for MemoEditor
|
||||
export { default as ErrorBoundary } from "./ErrorBoundary";
|
||||
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
|
||||
export { LinkMemoDialog } from "./LinkMemoDialog";
|
||||
export { LocationDialog } from "./LocationDialog";
|
||||
|
|
|
|||
|
|
@ -3,6 +3,17 @@ export { useAbortController } from "./useAbortController";
|
|||
export { useBlobUrls } from "./useBlobUrls";
|
||||
export { useDebounce } from "./useDebounce";
|
||||
export { useDragAndDrop } from "./useDragAndDrop";
|
||||
export { useFileUpload } from "./useFileUpload";
|
||||
export { useFocusMode } from "./useFocusMode";
|
||||
export { useLinkMemo } from "./useLinkMemo";
|
||||
export { useLocalFileManager } from "./useLocalFileManager";
|
||||
export { useLocation } from "./useLocation";
|
||||
export type { UseMemoEditorHandlersOptions, UseMemoEditorHandlersReturn } from "./useMemoEditorHandlers";
|
||||
export { useMemoEditorHandlers } from "./useMemoEditorHandlers";
|
||||
export type { UseMemoEditorInitOptions, UseMemoEditorInitReturn } from "./useMemoEditorInit";
|
||||
export { useMemoEditorInit } from "./useMemoEditorInit";
|
||||
export type { UseMemoEditorKeyboardOptions } from "./useMemoEditorKeyboard";
|
||||
export { useMemoEditorKeyboard } from "./useMemoEditorKeyboard";
|
||||
export type { UseMemoEditorStateReturn } from "./useMemoEditorState";
|
||||
export { useMemoEditorState } from "./useMemoEditorState";
|
||||
export { useMemoSave } from "./useMemoSave";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { LatLng } from "leaflet";
|
||||
import { useState } from "react";
|
||||
import { Location } from "@/types/proto/api/v1/memo_service";
|
||||
import { LocationState } from "./types";
|
||||
import { LocationState } from "../types/insert-menu";
|
||||
|
||||
export const useLocation = (initialLocation?: Location) => {
|
||||
const [locationInitialized, setLocationInitialized] = useState(false);
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { useCallback } from "react";
|
||||
import { isValidUrl } from "@/helpers/utils";
|
||||
import type { EditorRefActions } from "../Editor";
|
||||
import { hyperlinkHighlightedText } from "../Editor/markdownShortcuts";
|
||||
|
||||
export interface UseMemoEditorHandlersOptions {
|
||||
editorRef: React.RefObject<EditorRefActions>;
|
||||
onContentChange: (content: string) => void;
|
||||
onFilesAdded: (files: FileList) => void;
|
||||
setComposing: (isComposing: boolean) => void;
|
||||
}
|
||||
|
||||
export interface UseMemoEditorHandlersReturn {
|
||||
handleCompositionStart: () => void;
|
||||
handleCompositionEnd: () => void;
|
||||
handlePasteEvent: (event: React.ClipboardEvent) => Promise<void>;
|
||||
handleEditorFocus: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing MemoEditor event handlers
|
||||
* Centralizes composition, paste, and focus handling
|
||||
*/
|
||||
export const useMemoEditorHandlers = (options: UseMemoEditorHandlersOptions): UseMemoEditorHandlersReturn => {
|
||||
const { editorRef, onFilesAdded, setComposing } = options;
|
||||
|
||||
const handleCompositionStart = useCallback(() => {
|
||||
setComposing(true);
|
||||
}, [setComposing]);
|
||||
|
||||
const handleCompositionEnd = useCallback(() => {
|
||||
setComposing(false);
|
||||
}, [setComposing]);
|
||||
|
||||
const handlePasteEvent = useCallback(
|
||||
async (event: React.ClipboardEvent) => {
|
||||
if (event.clipboardData && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault();
|
||||
onFilesAdded(event.clipboardData.files);
|
||||
} else if (
|
||||
editorRef.current != null &&
|
||||
editorRef.current.getSelectedContent().length !== 0 &&
|
||||
isValidUrl(event.clipboardData.getData("Text"))
|
||||
) {
|
||||
event.preventDefault();
|
||||
hyperlinkHighlightedText(editorRef.current, event.clipboardData.getData("Text"));
|
||||
}
|
||||
},
|
||||
[editorRef, onFilesAdded],
|
||||
);
|
||||
|
||||
const handleEditorFocus = useCallback(() => {
|
||||
editorRef.current?.focus();
|
||||
}, [editorRef]);
|
||||
|
||||
return {
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
handlePasteEvent,
|
||||
handleEditorFocus,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import useAsyncEffect from "@/hooks/useAsyncEffect";
|
||||
import { instanceStore, memoStore, userStore } from "@/store";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service";
|
||||
import { Visibility } from "@/types/proto/api/v1/memo_service";
|
||||
import { convertVisibilityFromString } from "@/utils/memo";
|
||||
import type { EditorRefActions } from "../Editor";
|
||||
|
||||
export interface UseMemoEditorInitOptions {
|
||||
editorRef: React.RefObject<EditorRefActions>;
|
||||
memoName?: string;
|
||||
parentMemoName?: string;
|
||||
contentCache?: string;
|
||||
autoFocus?: boolean;
|
||||
onEditorFocus: () => void;
|
||||
onVisibilityChange: (visibility: Visibility) => void;
|
||||
onAttachmentsChange: (attachments: Attachment[]) => void;
|
||||
onRelationsChange: (relations: MemoRelation[]) => void;
|
||||
onLocationChange: (location: Location | undefined) => void;
|
||||
}
|
||||
|
||||
export interface UseMemoEditorInitReturn {
|
||||
createTime: Date | undefined;
|
||||
updateTime: Date | undefined;
|
||||
setCreateTime: (time: Date | undefined) => void;
|
||||
setUpdateTime: (time: Date | undefined) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for initializing MemoEditor state
|
||||
* Handles loading existing memo data and setting initial visibility
|
||||
*/
|
||||
export const useMemoEditorInit = (options: UseMemoEditorInitOptions): UseMemoEditorInitReturn => {
|
||||
const {
|
||||
editorRef,
|
||||
memoName,
|
||||
parentMemoName,
|
||||
contentCache,
|
||||
autoFocus,
|
||||
onEditorFocus,
|
||||
onVisibilityChange,
|
||||
onAttachmentsChange,
|
||||
onRelationsChange,
|
||||
onLocationChange,
|
||||
} = options;
|
||||
|
||||
const [createTime, setCreateTime] = useState<Date | undefined>();
|
||||
const [updateTime, setUpdateTime] = useState<Date | undefined>();
|
||||
const userGeneralSetting = userStore.state.userGeneralSetting;
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
|
||||
// Initialize content cache
|
||||
useEffect(() => {
|
||||
editorRef.current?.setContent(contentCache || "");
|
||||
}, []);
|
||||
|
||||
// Auto-focus if requested
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
onEditorFocus();
|
||||
}
|
||||
}, [autoFocus, onEditorFocus]);
|
||||
|
||||
// Set initial visibility based on user settings or parent memo
|
||||
useAsyncEffect(async () => {
|
||||
let visibility = convertVisibilityFromString(userGeneralSetting?.memoVisibility || "PRIVATE");
|
||||
if (instanceMemoRelatedSetting.disallowPublicVisibility && visibility === Visibility.PUBLIC) {
|
||||
visibility = Visibility.PROTECTED;
|
||||
}
|
||||
if (parentMemoName) {
|
||||
const parentMemo = await memoStore.getOrFetchMemoByName(parentMemoName);
|
||||
visibility = parentMemo.visibility;
|
||||
}
|
||||
onVisibilityChange(convertVisibilityFromString(visibility));
|
||||
}, [parentMemoName, userGeneralSetting?.memoVisibility, instanceMemoRelatedSetting.disallowPublicVisibility]);
|
||||
|
||||
// Load existing memo if editing
|
||||
useAsyncEffect(async () => {
|
||||
if (!memoName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memo = await memoStore.getOrFetchMemoByName(memoName);
|
||||
if (memo) {
|
||||
onEditorFocus();
|
||||
setCreateTime(memo.createTime);
|
||||
setUpdateTime(memo.updateTime);
|
||||
onVisibilityChange(memo.visibility);
|
||||
onAttachmentsChange(memo.attachments);
|
||||
onRelationsChange(memo.relations);
|
||||
onLocationChange(memo.location);
|
||||
if (!contentCache) {
|
||||
editorRef.current?.setContent(memo.content ?? "");
|
||||
}
|
||||
}
|
||||
}, [memoName]);
|
||||
|
||||
return {
|
||||
createTime,
|
||||
updateTime,
|
||||
setCreateTime,
|
||||
setUpdateTime,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { useCallback } from "react";
|
||||
import { TAB_SPACE_WIDTH } from "@/helpers/consts";
|
||||
import { FOCUS_MODE_EXIT_KEY, FOCUS_MODE_TOGGLE_KEY } from "../constants";
|
||||
import type { EditorRefActions } from "../Editor";
|
||||
import { handleEditorKeydownWithMarkdownShortcuts } from "../Editor/markdownShortcuts";
|
||||
|
||||
export interface UseMemoEditorKeyboardOptions {
|
||||
editorRef: React.RefObject<EditorRefActions>;
|
||||
isFocusMode: boolean;
|
||||
isComposing: boolean;
|
||||
onSave: () => void;
|
||||
onToggleFocusMode: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling keyboard shortcuts in MemoEditor
|
||||
* Centralizes all keyboard event handling logic
|
||||
*/
|
||||
export const useMemoEditorKeyboard = (options: UseMemoEditorKeyboardOptions) => {
|
||||
const { editorRef, isFocusMode, isComposing, onSave, onToggleFocusMode } = options;
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMetaKey = event.ctrlKey || event.metaKey;
|
||||
|
||||
// Focus Mode toggle: Cmd/Ctrl + Shift + F
|
||||
if (isMetaKey && event.shiftKey && event.key.toLowerCase() === FOCUS_MODE_TOGGLE_KEY) {
|
||||
event.preventDefault();
|
||||
onToggleFocusMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// Exit Focus Mode: Escape
|
||||
if (event.key === FOCUS_MODE_EXIT_KEY && isFocusMode) {
|
||||
event.preventDefault();
|
||||
onToggleFocusMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// Save: Cmd/Ctrl + Enter or Cmd/Ctrl + S
|
||||
if (isMetaKey) {
|
||||
if (event.key === "Enter" || event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
onSave();
|
||||
return;
|
||||
}
|
||||
handleEditorKeydownWithMarkdownShortcuts(event, editorRef.current);
|
||||
}
|
||||
|
||||
// Tab handling
|
||||
if (event.key === "Tab" && !isComposing) {
|
||||
event.preventDefault();
|
||||
const tabSpace = " ".repeat(TAB_SPACE_WIDTH);
|
||||
const cursorPosition = editorRef.current.getCursorPosition();
|
||||
const selectedContent = editorRef.current.getSelectedContent();
|
||||
editorRef.current.insertText(tabSpace);
|
||||
if (selectedContent) {
|
||||
editorRef.current.setCursorPosition(cursorPosition + TAB_SPACE_WIDTH);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
[editorRef, isFocusMode, isComposing, onSave, onToggleFocusMode],
|
||||
);
|
||||
|
||||
return { handleKeyDown };
|
||||
};
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service";
|
||||
import { Visibility } from "@/types/proto/api/v1/memo_service";
|
||||
import type { MemoEditorState } from "../types/memo-editor";
|
||||
|
||||
export interface UseMemoEditorStateReturn {
|
||||
state: MemoEditorState;
|
||||
memoVisibility: Visibility;
|
||||
attachmentList: Attachment[];
|
||||
relationList: MemoRelation[];
|
||||
location: Location | undefined;
|
||||
isFocusMode: boolean;
|
||||
isUploadingAttachment: boolean;
|
||||
isRequesting: boolean;
|
||||
isComposing: boolean;
|
||||
isDraggingFile: boolean;
|
||||
|
||||
setMemoVisibility: (visibility: Visibility) => void;
|
||||
setAttachmentList: (attachments: Attachment[]) => void;
|
||||
setRelationList: (relations: MemoRelation[]) => void;
|
||||
setLocation: (location: Location | undefined) => void;
|
||||
setIsFocusMode: (isFocusMode: boolean) => void;
|
||||
toggleFocusMode: () => void;
|
||||
setUploadingAttachment: (isUploading: boolean) => void;
|
||||
setRequesting: (isRequesting: boolean) => void;
|
||||
setComposing: (isComposing: boolean) => void;
|
||||
setDraggingFile: (isDragging: boolean) => void;
|
||||
resetState: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing MemoEditor state
|
||||
* Centralizes all state management and provides clean setters
|
||||
*/
|
||||
export const useMemoEditorState = (initialVisibility: Visibility = Visibility.PRIVATE): UseMemoEditorStateReturn => {
|
||||
const [state, setState] = useState<MemoEditorState>({
|
||||
memoVisibility: initialVisibility,
|
||||
isFocusMode: false,
|
||||
attachmentList: [],
|
||||
relationList: [],
|
||||
location: undefined,
|
||||
isUploadingAttachment: false,
|
||||
isRequesting: false,
|
||||
isComposing: false,
|
||||
isDraggingFile: false,
|
||||
});
|
||||
|
||||
const setMemoVisibility = useCallback((visibility: Visibility) => {
|
||||
setState((prev) => ({ ...prev, memoVisibility: visibility }));
|
||||
}, []);
|
||||
|
||||
const setAttachmentList = useCallback((attachments: Attachment[]) => {
|
||||
setState((prev) => ({ ...prev, attachmentList: attachments }));
|
||||
}, []);
|
||||
|
||||
const setRelationList = useCallback((relations: MemoRelation[]) => {
|
||||
setState((prev) => ({ ...prev, relationList: relations }));
|
||||
}, []);
|
||||
|
||||
const setLocation = useCallback((location: Location | undefined) => {
|
||||
setState((prev) => ({ ...prev, location }));
|
||||
}, []);
|
||||
|
||||
const setIsFocusMode = useCallback((isFocusMode: boolean) => {
|
||||
setState((prev) => ({ ...prev, isFocusMode }));
|
||||
}, []);
|
||||
|
||||
const toggleFocusMode = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, isFocusMode: !prev.isFocusMode }));
|
||||
}, []);
|
||||
|
||||
const setUploadingAttachment = useCallback((isUploading: boolean) => {
|
||||
setState((prev) => ({ ...prev, isUploadingAttachment: isUploading }));
|
||||
}, []);
|
||||
|
||||
const setRequesting = useCallback((isRequesting: boolean) => {
|
||||
setState((prev) => ({ ...prev, isRequesting }));
|
||||
}, []);
|
||||
|
||||
const setComposing = useCallback((isComposing: boolean) => {
|
||||
setState((prev) => ({ ...prev, isComposing }));
|
||||
}, []);
|
||||
|
||||
const setDraggingFile = useCallback((isDragging: boolean) => {
|
||||
setState((prev) => ({ ...prev, isDraggingFile: isDragging }));
|
||||
}, []);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isRequesting: false,
|
||||
attachmentList: [],
|
||||
relationList: [],
|
||||
location: undefined,
|
||||
isDraggingFile: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
memoVisibility: state.memoVisibility,
|
||||
attachmentList: state.attachmentList,
|
||||
relationList: state.relationList,
|
||||
location: state.location,
|
||||
isFocusMode: state.isFocusMode,
|
||||
isUploadingAttachment: state.isUploadingAttachment,
|
||||
isRequesting: state.isRequesting,
|
||||
isComposing: state.isComposing,
|
||||
isDraggingFile: state.isDraggingFile,
|
||||
|
||||
setMemoVisibility,
|
||||
setAttachmentList,
|
||||
setRelationList,
|
||||
setLocation,
|
||||
setIsFocusMode,
|
||||
toggleFocusMode,
|
||||
setUploadingAttachment,
|
||||
setRequesting,
|
||||
setComposing,
|
||||
setDraggingFile,
|
||||
resetState,
|
||||
};
|
||||
};
|
||||
|
|
@ -2,35 +2,36 @@ import copy from "copy-to-clipboard";
|
|||
import { isEqual } from "lodash-es";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TAB_SPACE_WIDTH } from "@/helpers/consts";
|
||||
import { isValidUrl } from "@/helpers/utils";
|
||||
import useAsyncEffect from "@/hooks/useAsyncEffect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { instanceStore, memoStore, userStore } from "@/store";
|
||||
import { extractMemoIdFromName } from "@/store/common";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import { type Location, type MemoRelation, MemoRelation_Type, Visibility } from "@/types/proto/api/v1/memo_service";
|
||||
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { convertVisibilityFromString } from "@/utils/memo";
|
||||
import DateTimeInput from "../DateTimeInput";
|
||||
import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata";
|
||||
import { FocusModeExitButton, FocusModeOverlay } from "./components";
|
||||
import { FOCUS_MODE_EXIT_KEY, FOCUS_MODE_STYLES, FOCUS_MODE_TOGGLE_KEY, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants";
|
||||
import { ErrorBoundary, FocusModeExitButton, FocusModeOverlay } from "./components";
|
||||
import { FOCUS_MODE_STYLES, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants";
|
||||
import Editor, { type EditorRefActions } from "./Editor";
|
||||
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./Editor/markdownShortcuts";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import { useDebounce, useDragAndDrop, useFocusMode, useLocalFileManager, useMemoSave } from "./hooks";
|
||||
import {
|
||||
useDebounce,
|
||||
useDragAndDrop,
|
||||
useFocusMode,
|
||||
useLocalFileManager,
|
||||
useMemoEditorHandlers,
|
||||
useMemoEditorInit,
|
||||
useMemoEditorKeyboard,
|
||||
useMemoEditorState,
|
||||
useMemoSave,
|
||||
} from "./hooks";
|
||||
import InsertMenu from "./Toolbar/InsertMenu";
|
||||
import VisibilitySelector from "./Toolbar/VisibilitySelector";
|
||||
import { MemoEditorContext } from "./types";
|
||||
import type { MemoEditorProps, MemoEditorState } from "./types/memo-editor";
|
||||
import type { MemoEditorProps } from "./types/memo-editor";
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { MemoEditorProps as Props };
|
||||
|
|
@ -40,45 +41,68 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
const t = useTranslate();
|
||||
const { i18n } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const editorRef = useRef<EditorRefActions>(null);
|
||||
|
||||
// Content caching
|
||||
const contentCacheKey = `${currentUser.name}-${cacheKey || ""}`;
|
||||
const [contentCache, setContentCache] = useLocalStorage<string>(contentCacheKey, "");
|
||||
const [hasContent, setHasContent] = useState<boolean>(false);
|
||||
|
||||
// Custom hooks for file management
|
||||
const { localFiles, addFiles, removeFile, clearFiles } = useLocalFileManager();
|
||||
|
||||
// Internal component state
|
||||
const [state, setState] = useState<MemoEditorState>({
|
||||
memoVisibility: Visibility.PRIVATE,
|
||||
isFocusMode: false,
|
||||
attachmentList: [],
|
||||
relationList: [],
|
||||
location: undefined,
|
||||
isUploadingAttachment: false,
|
||||
isRequesting: false,
|
||||
isComposing: false,
|
||||
isDraggingFile: false,
|
||||
// Custom hooks for state management
|
||||
const {
|
||||
memoVisibility,
|
||||
attachmentList,
|
||||
relationList,
|
||||
location,
|
||||
isFocusMode,
|
||||
isUploadingAttachment,
|
||||
isRequesting,
|
||||
isComposing,
|
||||
isDraggingFile,
|
||||
setMemoVisibility,
|
||||
setAttachmentList,
|
||||
setRelationList,
|
||||
setLocation,
|
||||
toggleFocusMode,
|
||||
setUploadingAttachment,
|
||||
setRequesting,
|
||||
setComposing,
|
||||
setDraggingFile,
|
||||
resetState,
|
||||
} = useMemoEditorState();
|
||||
|
||||
// Event handlers hook
|
||||
const { handleCompositionStart, handleCompositionEnd, handlePasteEvent, handleEditorFocus } = useMemoEditorHandlers({
|
||||
editorRef,
|
||||
onContentChange: (content: string) => {
|
||||
setHasContent(content !== "");
|
||||
saveContentToCache(content);
|
||||
},
|
||||
onFilesAdded: addFiles,
|
||||
setComposing,
|
||||
});
|
||||
|
||||
// Initialization hook
|
||||
const { createTime, updateTime, setCreateTime, setUpdateTime } = useMemoEditorInit({
|
||||
editorRef,
|
||||
memoName,
|
||||
parentMemoName,
|
||||
contentCache,
|
||||
autoFocus,
|
||||
onEditorFocus: handleEditorFocus,
|
||||
onVisibilityChange: setMemoVisibility,
|
||||
onAttachmentsChange: setAttachmentList,
|
||||
onRelationsChange: setRelationList,
|
||||
onLocationChange: setLocation,
|
||||
});
|
||||
const [createTime, setCreateTime] = useState<Date | undefined>();
|
||||
const [updateTime, setUpdateTime] = useState<Date | undefined>();
|
||||
const [hasContent, setHasContent] = useState<boolean>(false);
|
||||
const editorRef = useRef<EditorRefActions>(null);
|
||||
const userGeneralSetting = userStore.state.userGeneralSetting;
|
||||
const contentCacheKey = `${currentUser.name}-${cacheKey || ""}`;
|
||||
const [contentCache, setContentCache] = useLocalStorage<string>(contentCacheKey, "");
|
||||
const referenceRelations = memoName
|
||||
? state.relationList.filter(
|
||||
(relation) =>
|
||||
relation.memo?.name === memoName && relation.relatedMemo?.name !== memoName && relation.type === MemoRelation_Type.REFERENCE,
|
||||
)
|
||||
: state.relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
|
||||
// Memo save hook - handles create/update logic
|
||||
const { saveMemo } = useMemoSave({
|
||||
onUploadingChange: useCallback((uploading: boolean) => {
|
||||
setState((s) => ({ ...s, isUploadingAttachment: uploading }));
|
||||
}, []),
|
||||
onRequestingChange: useCallback((requesting: boolean) => {
|
||||
setState((s) => ({ ...s, isRequesting: requesting }));
|
||||
}, []),
|
||||
onUploadingChange: setUploadingAttachment,
|
||||
onRequestingChange: setRequesting,
|
||||
onSuccess: useCallback(
|
||||
(savedMemoName: string) => {
|
||||
editorRef.current?.setContent("");
|
||||
|
|
@ -91,187 +115,65 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
onCancel: useCallback(() => {
|
||||
if (onCancel) onCancel();
|
||||
}, [onCancel]),
|
||||
onReset: useCallback(() => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
isRequesting: false,
|
||||
attachmentList: [],
|
||||
relationList: [],
|
||||
location: undefined,
|
||||
isDraggingFile: false,
|
||||
}));
|
||||
}, []),
|
||||
onReset: resetState,
|
||||
t,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
editorRef.current?.setContent(contentCache || "");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
handleEditorFocus();
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
let visibility = convertVisibilityFromString(userGeneralSetting?.memoVisibility || "PRIVATE");
|
||||
if (instanceMemoRelatedSetting.disallowPublicVisibility && visibility === Visibility.PUBLIC) {
|
||||
visibility = Visibility.PROTECTED;
|
||||
}
|
||||
if (parentMemoName) {
|
||||
const parentMemo = await memoStore.getOrFetchMemoByName(parentMemoName);
|
||||
visibility = parentMemo.visibility;
|
||||
}
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
memoVisibility: convertVisibilityFromString(visibility),
|
||||
}));
|
||||
}, [parentMemoName, userGeneralSetting?.memoVisibility, instanceMemoRelatedSetting.disallowPublicVisibility]);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (!memoName) {
|
||||
// Save memo handler
|
||||
const handleSaveBtnClick = useCallback(async () => {
|
||||
if (isRequesting) {
|
||||
return;
|
||||
}
|
||||
const content = editorRef.current?.getContent() ?? "";
|
||||
await saveMemo(content, {
|
||||
memoName,
|
||||
parentMemoName,
|
||||
visibility: memoVisibility,
|
||||
attachmentList,
|
||||
relationList,
|
||||
location,
|
||||
localFiles,
|
||||
createTime,
|
||||
updateTime,
|
||||
});
|
||||
}, [
|
||||
isRequesting,
|
||||
saveMemo,
|
||||
memoName,
|
||||
parentMemoName,
|
||||
memoVisibility,
|
||||
attachmentList,
|
||||
relationList,
|
||||
location,
|
||||
localFiles,
|
||||
createTime,
|
||||
updateTime,
|
||||
]);
|
||||
|
||||
const memo = await memoStore.getOrFetchMemoByName(memoName);
|
||||
if (memo) {
|
||||
handleEditorFocus();
|
||||
setCreateTime(memo.createTime);
|
||||
setUpdateTime(memo.updateTime);
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
memoVisibility: memo.visibility,
|
||||
attachmentList: memo.attachments,
|
||||
relationList: memo.relations,
|
||||
location: memo.location,
|
||||
}));
|
||||
if (!contentCache) {
|
||||
editorRef.current?.setContent(memo.content ?? "");
|
||||
}
|
||||
}
|
||||
}, [memoName]);
|
||||
|
||||
// Focus mode management with body scroll lock
|
||||
const { toggleFocusMode } = useFocusMode({
|
||||
isFocusMode: state.isFocusMode,
|
||||
onToggle: () => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
isFocusMode: !prevState.isFocusMode,
|
||||
}));
|
||||
},
|
||||
// Keyboard shortcuts hook
|
||||
const { handleKeyDown } = useMemoEditorKeyboard({
|
||||
editorRef,
|
||||
isFocusMode,
|
||||
isComposing,
|
||||
onSave: handleSaveBtnClick,
|
||||
onToggleFocusMode: toggleFocusMode,
|
||||
});
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
isComposing: true,
|
||||
}));
|
||||
};
|
||||
// Focus mode management with body scroll lock
|
||||
useFocusMode({
|
||||
isFocusMode,
|
||||
onToggle: toggleFocusMode,
|
||||
});
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
isComposing: false,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMetaKey = event.ctrlKey || event.metaKey;
|
||||
|
||||
// Focus Mode toggle: Cmd/Ctrl + Shift + F
|
||||
if (isMetaKey && event.shiftKey && event.key.toLowerCase() === FOCUS_MODE_TOGGLE_KEY) {
|
||||
event.preventDefault();
|
||||
toggleFocusMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// Exit Focus Mode: Escape
|
||||
if (event.key === FOCUS_MODE_EXIT_KEY && state.isFocusMode) {
|
||||
event.preventDefault();
|
||||
toggleFocusMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMetaKey) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleSaveBtnClick();
|
||||
return;
|
||||
}
|
||||
if (event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
handleSaveBtnClick();
|
||||
return;
|
||||
}
|
||||
handleEditorKeydownWithMarkdownShortcuts(event, editorRef.current);
|
||||
}
|
||||
if (event.key === "Tab" && !state.isComposing) {
|
||||
event.preventDefault();
|
||||
const tabSpace = " ".repeat(TAB_SPACE_WIDTH);
|
||||
const cursorPosition = editorRef.current.getCursorPosition();
|
||||
const selectedContent = editorRef.current.getSelectedContent();
|
||||
editorRef.current.insertText(tabSpace);
|
||||
if (selectedContent) {
|
||||
editorRef.current.setCursorPosition(cursorPosition + TAB_SPACE_WIDTH);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemoVisibilityChange = (visibility: Visibility) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
memoVisibility: visibility,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSetAttachmentList = (attachmentList: Attachment[]) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
attachmentList,
|
||||
}));
|
||||
};
|
||||
|
||||
// Add local files from InsertMenu
|
||||
// Drag-and-drop for file uploads
|
||||
const { isDragging, dragHandlers } = useDragAndDrop({
|
||||
onDrop: (files) => addFiles(files),
|
||||
onDrop: addFiles,
|
||||
});
|
||||
|
||||
// Sync drag state with component state
|
||||
useEffect(() => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
isDraggingFile: isDragging,
|
||||
}));
|
||||
}, [isDragging]);
|
||||
|
||||
const handleSetRelationList = (relationList: MemoRelation[]) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
relationList,
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePasteEvent = async (event: React.ClipboardEvent) => {
|
||||
if (event.clipboardData && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault();
|
||||
addFiles(event.clipboardData.files);
|
||||
} else if (
|
||||
editorRef.current != null &&
|
||||
editorRef.current.getSelectedContent().length !== 0 &&
|
||||
isValidUrl(event.clipboardData.getData("Text"))
|
||||
) {
|
||||
event.preventDefault();
|
||||
hyperlinkHighlightedText(editorRef.current, event.clipboardData.getData("Text"));
|
||||
}
|
||||
};
|
||||
setDraggingFile(isDragging);
|
||||
}, [isDragging, setDraggingFile]);
|
||||
|
||||
// Debounced cache setter to avoid writing to localStorage on every keystroke
|
||||
const saveContentToCache = useDebounce((content: string) => {
|
||||
|
|
@ -282,79 +184,60 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
}
|
||||
}, LOCALSTORAGE_DEBOUNCE_DELAY);
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
setHasContent(content !== "");
|
||||
saveContentToCache(content);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (state.isRequesting) {
|
||||
return;
|
||||
// Compute reference relations
|
||||
const referenceRelations = useMemo(() => {
|
||||
if (memoName) {
|
||||
return relationList.filter(
|
||||
(relation) =>
|
||||
relation.memo?.name === memoName && relation.relatedMemo?.name !== memoName && relation.type === MemoRelation_Type.REFERENCE,
|
||||
);
|
||||
}
|
||||
const content = editorRef.current?.getContent() ?? "";
|
||||
await saveMemo(content, {
|
||||
memoName,
|
||||
parentMemoName,
|
||||
visibility: state.memoVisibility,
|
||||
attachmentList: state.attachmentList,
|
||||
relationList: state.relationList,
|
||||
location: state.location,
|
||||
localFiles,
|
||||
createTime,
|
||||
updateTime,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditorFocus = () => {
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
return relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
||||
}, [memoName, relationList]);
|
||||
|
||||
const editorConfig = useMemo(
|
||||
() => ({
|
||||
className: "",
|
||||
initialContent: "",
|
||||
placeholder: props.placeholder ?? t("editor.any-thoughts"),
|
||||
onContentChange: handleContentChange,
|
||||
onContentChange: (content: string) => {
|
||||
setHasContent(content !== "");
|
||||
saveContentToCache(content);
|
||||
},
|
||||
onPaste: handlePasteEvent,
|
||||
isFocusMode: state.isFocusMode,
|
||||
isInIME: state.isComposing,
|
||||
isFocusMode,
|
||||
isInIME: isComposing,
|
||||
onCompositionStart: handleCompositionStart,
|
||||
onCompositionEnd: handleCompositionEnd,
|
||||
}),
|
||||
[i18n.language, state.isFocusMode, state.isComposing],
|
||||
[i18n.language, isFocusMode, isComposing, handlePasteEvent, handleCompositionStart, handleCompositionEnd, saveContentToCache],
|
||||
);
|
||||
|
||||
const allowSave =
|
||||
(hasContent || state.attachmentList.length > 0 || localFiles.length > 0) && !state.isUploadingAttachment && !state.isRequesting;
|
||||
const allowSave = (hasContent || attachmentList.length > 0 || localFiles.length > 0) && !isUploadingAttachment && !isRequesting;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<MemoEditorContext.Provider
|
||||
value={{
|
||||
attachmentList: state.attachmentList,
|
||||
relationList: state.relationList,
|
||||
setAttachmentList: handleSetAttachmentList,
|
||||
attachmentList,
|
||||
relationList,
|
||||
setAttachmentList,
|
||||
addLocalFiles: (files) => addFiles(Array.from(files.map((f) => f.file))),
|
||||
removeLocalFile: removeFile,
|
||||
localFiles,
|
||||
setRelationList: (relationList: MemoRelation[]) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
relationList,
|
||||
}));
|
||||
},
|
||||
setRelationList,
|
||||
memoName,
|
||||
}}
|
||||
>
|
||||
{/* Focus Mode Backdrop */}
|
||||
<FocusModeOverlay isActive={state.isFocusMode} onToggle={toggleFocusMode} />
|
||||
<FocusModeOverlay isActive={isFocusMode} onToggle={toggleFocusMode} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full flex flex-col justify-start items-start bg-card px-4 pt-3 pb-2 rounded-lg border",
|
||||
FOCUS_MODE_STYLES.transition,
|
||||
state.isDraggingFile ? "border-dashed border-muted-foreground cursor-copy" : "border-border cursor-auto",
|
||||
state.isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
|
||||
isDraggingFile ? "border-dashed border-muted-foreground cursor-copy" : "border-border cursor-auto",
|
||||
isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
|
||||
className,
|
||||
)}
|
||||
tabIndex={0}
|
||||
|
|
@ -363,49 +246,35 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
onFocus={handleEditorFocus}
|
||||
>
|
||||
{/* Focus Mode Exit Button */}
|
||||
<FocusModeExitButton isActive={state.isFocusMode} onToggle={toggleFocusMode} title={t("editor.exit-focus-mode")} />
|
||||
<FocusModeExitButton isActive={isFocusMode} onToggle={toggleFocusMode} title={t("editor.exit-focus-mode")} />
|
||||
|
||||
<Editor ref={editorRef} {...editorConfig} />
|
||||
<LocationDisplay
|
||||
mode="edit"
|
||||
location={state.location}
|
||||
onRemove={() =>
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
location: undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<LocationDisplay mode="edit" location={location} onRemove={() => setLocation(undefined)} />
|
||||
{/* Show attachments and pending files together */}
|
||||
<AttachmentList
|
||||
mode="edit"
|
||||
attachments={state.attachmentList}
|
||||
onAttachmentsChange={handleSetAttachmentList}
|
||||
attachments={attachmentList}
|
||||
onAttachmentsChange={setAttachmentList}
|
||||
localFiles={localFiles}
|
||||
onRemoveLocalFile={removeFile}
|
||||
/>
|
||||
<RelationList mode="edit" relations={referenceRelations} onRelationsChange={handleSetRelationList} />
|
||||
<RelationList mode="edit" relations={referenceRelations} onRelationsChange={setRelationList} />
|
||||
<div className="relative w-full flex flex-row justify-between items-center pt-2 gap-2" onFocus={(e) => e.stopPropagation()}>
|
||||
<div className="flex flex-row justify-start items-center gap-1">
|
||||
<InsertMenu
|
||||
isUploading={state.isUploadingAttachment}
|
||||
location={state.location}
|
||||
onLocationChange={(location) =>
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
location,
|
||||
}))
|
||||
}
|
||||
isUploading={isUploadingAttachment}
|
||||
location={location}
|
||||
onLocationChange={setLocation}
|
||||
onToggleFocusMode={toggleFocusMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-row justify-end items-center">
|
||||
<VisibilitySelector value={state.memoVisibility} onChange={(visibility) => handleMemoVisibilityChange(visibility)} />
|
||||
<VisibilitySelector value={memoVisibility} onChange={setMemoVisibility} />
|
||||
<div className="flex flex-row justify-end gap-1">
|
||||
{props.onCancel && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={state.isRequesting}
|
||||
disabled={isRequesting}
|
||||
onClick={() => {
|
||||
clearFiles();
|
||||
if (props.onCancel) props.onCancel();
|
||||
|
|
@ -414,8 +283,8 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={!allowSave || state.isRequesting} onClick={handleSaveBtnClick}>
|
||||
{state.isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
|
||||
<Button disabled={!allowSave || isRequesting} onClick={handleSaveBtnClick}>
|
||||
{isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// MemoEditor type exports
|
||||
export type { Command } from "./command";
|
||||
export { MemoEditorContext, type MemoEditorContextValue } from "./context";
|
||||
export type { LinkMemoState, LocationState } from "./insert-menu";
|
||||
export type { EditorConfig, MemoEditorProps, MemoEditorState } from "./memo-editor";
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { memo, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { instanceStore, userStore } from "@/store";
|
||||
import { State } from "@/types/proto/api/v1/common";
|
||||
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
|
||||
import { isSuperUser } from "@/utils/user";
|
||||
import MemoEditor from "../MemoEditor";
|
||||
import PreviewImageDialog from "../PreviewImageDialog";
|
||||
import { MemoBody, MemoHeader } from "./components";
|
||||
import { MEMO_CARD_BASE_CLASSES, RELATIVE_TIME_THRESHOLD_MS } from "./constants";
|
||||
import { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./hooks";
|
||||
import { MEMO_CARD_BASE_CLASSES } from "./constants";
|
||||
import {
|
||||
useImagePreview,
|
||||
useKeyboardShortcuts,
|
||||
useMemoActions,
|
||||
useMemoCreator,
|
||||
useMemoEditor,
|
||||
useMemoHandlers,
|
||||
useMemoViewDerivedState,
|
||||
useNsfwContent,
|
||||
} from "./hooks";
|
||||
import { MemoViewContext } from "./MemoViewContext";
|
||||
import type { MemoViewProps } from "./types";
|
||||
|
||||
/**
|
||||
|
|
@ -27,34 +30,34 @@ import type { MemoViewProps } from "./types";
|
|||
*/
|
||||
const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
|
||||
const { memo: memoData, className } = props;
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
const user = useCurrentUser();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// State
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
|
||||
|
||||
// Fetch creator data
|
||||
// Custom hooks for data fetching
|
||||
const creator = useMemoCreator(memoData.creator);
|
||||
|
||||
// Custom hooks for state management
|
||||
// Custom hooks for derived state
|
||||
const { commentAmount, relativeTimeFormat, isArchived, readonly, isInMemoDetailPage, parentPage } = useMemoViewDerivedState({
|
||||
memo: memoData,
|
||||
parentPage: props.parentPage,
|
||||
});
|
||||
|
||||
// Custom hooks for UI state management
|
||||
const { nsfw, showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent);
|
||||
const { previewState, openPreview, setPreviewOpen } = useImagePreview();
|
||||
const { archiveMemo, unpinMemo } = useMemoActions(memoData);
|
||||
const { showEditor, openEditor, handleEditorConfirm, handleEditorCancel } = useMemoEditor();
|
||||
|
||||
// Derived state
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
const commentAmount = memoData.relations.filter(
|
||||
(relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memoData.name,
|
||||
).length;
|
||||
const relativeTimeFormat =
|
||||
memoData.displayTime && Date.now() - memoData.displayTime.getTime() > RELATIVE_TIME_THRESHOLD_MS ? "datetime" : "auto";
|
||||
const isArchived = memoData.state === State.ARCHIVED;
|
||||
const readonly = memoData.creator !== user?.name && !isSuperUser(user);
|
||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`);
|
||||
const parentPage = props.parentPage || location.pathname;
|
||||
// Custom hooks for actions
|
||||
const { archiveMemo, unpinMemo } = useMemoActions(memoData);
|
||||
const { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({
|
||||
memoName: memoData.name,
|
||||
parentPage,
|
||||
readonly,
|
||||
openEditor,
|
||||
openPreview,
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
const { handleShortcutActivation } = useKeyboardShortcuts(cardRef, {
|
||||
|
|
@ -62,59 +65,28 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
|
|||
readonly,
|
||||
showEditor,
|
||||
isArchived,
|
||||
onEdit: () => setShowEditor(true),
|
||||
onEdit: openEditor,
|
||||
onArchive: archiveMemo,
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleGotoMemoDetailPage = useCallback(() => {
|
||||
navigateTo(`/${memoData.name}`, {
|
||||
state: { from: parentPage },
|
||||
});
|
||||
}, [memoData.name, parentPage, navigateTo]);
|
||||
|
||||
const handleMemoContentClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
|
||||
if (targetEl.tagName === "IMG") {
|
||||
// Check if the image is inside a link
|
||||
const linkElement = targetEl.closest("a");
|
||||
if (linkElement) {
|
||||
// If image is inside a link, only navigate to the link (don't show preview)
|
||||
return;
|
||||
}
|
||||
|
||||
const imgUrl = targetEl.getAttribute("src");
|
||||
if (imgUrl) {
|
||||
openPreview(imgUrl);
|
||||
}
|
||||
}
|
||||
},
|
||||
[openPreview],
|
||||
// Memoize context value to prevent unnecessary re-renders
|
||||
// IMPORTANT: This must be before the early return to satisfy Rules of Hooks
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
memo: memoData,
|
||||
creator,
|
||||
isArchived,
|
||||
readonly,
|
||||
isInMemoDetailPage,
|
||||
parentPage,
|
||||
commentAmount,
|
||||
relativeTimeFormat,
|
||||
nsfw,
|
||||
showNSFWContent,
|
||||
}),
|
||||
[memoData, creator, isArchived, readonly, isInMemoDetailPage, parentPage, commentAmount, relativeTimeFormat, nsfw, showNSFWContent],
|
||||
);
|
||||
|
||||
const handleMemoContentDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (readonly) return;
|
||||
|
||||
if (instanceMemoRelatedSetting.enableDoubleClickEdit) {
|
||||
e.preventDefault();
|
||||
setShowEditor(true);
|
||||
}
|
||||
},
|
||||
[readonly, instanceMemoRelatedSetting.enableDoubleClickEdit],
|
||||
);
|
||||
|
||||
const handleEditorConfirm = useCallback(() => {
|
||||
setShowEditor(false);
|
||||
userStore.setStatsStateId();
|
||||
}, []);
|
||||
|
||||
const handleEditorCancel = useCallback(() => {
|
||||
setShowEditor(false);
|
||||
}, []);
|
||||
|
||||
// Render inline editor when editing
|
||||
if (showEditor) {
|
||||
return (
|
||||
|
|
@ -131,54 +103,41 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
|
|||
|
||||
// Render memo card
|
||||
return (
|
||||
<article
|
||||
className={cn(MEMO_CARD_BASE_CLASSES, className)}
|
||||
ref={cardRef}
|
||||
tabIndex={readonly ? -1 : 0}
|
||||
onFocus={() => handleShortcutActivation(true)}
|
||||
onBlur={() => handleShortcutActivation(false)}
|
||||
>
|
||||
<MemoHeader
|
||||
memo={memoData}
|
||||
creator={creator}
|
||||
showCreator={props.showCreator}
|
||||
showVisibility={props.showVisibility}
|
||||
showPinned={props.showPinned}
|
||||
isArchived={isArchived}
|
||||
commentAmount={commentAmount}
|
||||
isInMemoDetailPage={isInMemoDetailPage}
|
||||
parentPage={parentPage}
|
||||
readonly={readonly}
|
||||
relativeTimeFormat={relativeTimeFormat}
|
||||
onEdit={() => setShowEditor(true)}
|
||||
onGotoDetail={handleGotoMemoDetailPage}
|
||||
onUnpin={unpinMemo}
|
||||
onToggleNsfwVisibility={toggleNsfwVisibility}
|
||||
nsfw={nsfw}
|
||||
showNSFWContent={showNSFWContent}
|
||||
reactionSelectorOpen={reactionSelectorOpen}
|
||||
onReactionSelectorOpenChange={setReactionSelectorOpen}
|
||||
/>
|
||||
<MemoViewContext.Provider value={contextValue}>
|
||||
<article
|
||||
className={cn(MEMO_CARD_BASE_CLASSES, className)}
|
||||
ref={cardRef}
|
||||
tabIndex={readonly ? -1 : 0}
|
||||
onFocus={() => handleShortcutActivation(true)}
|
||||
onBlur={() => handleShortcutActivation(false)}
|
||||
>
|
||||
<MemoHeader
|
||||
showCreator={props.showCreator}
|
||||
showVisibility={props.showVisibility}
|
||||
showPinned={props.showPinned}
|
||||
onEdit={openEditor}
|
||||
onGotoDetail={handleGotoMemoDetailPage}
|
||||
onUnpin={unpinMemo}
|
||||
onToggleNsfwVisibility={toggleNsfwVisibility}
|
||||
reactionSelectorOpen={reactionSelectorOpen}
|
||||
onReactionSelectorOpenChange={setReactionSelectorOpen}
|
||||
/>
|
||||
|
||||
<MemoBody
|
||||
memo={memoData}
|
||||
readonly={readonly}
|
||||
compact={props.compact}
|
||||
parentPage={parentPage}
|
||||
nsfw={nsfw}
|
||||
showNSFWContent={showNSFWContent}
|
||||
onContentClick={handleMemoContentClick}
|
||||
onContentDoubleClick={handleMemoContentDoubleClick}
|
||||
onToggleNsfwVisibility={toggleNsfwVisibility}
|
||||
/>
|
||||
<MemoBody
|
||||
compact={props.compact}
|
||||
onContentClick={handleMemoContentClick}
|
||||
onContentDoubleClick={handleMemoContentDoubleClick}
|
||||
onToggleNsfwVisibility={toggleNsfwVisibility}
|
||||
/>
|
||||
|
||||
<PreviewImageDialog
|
||||
open={previewState.open}
|
||||
onOpenChange={setPreviewOpen}
|
||||
imgUrls={previewState.urls}
|
||||
initialIndex={previewState.index}
|
||||
/>
|
||||
</article>
|
||||
<PreviewImageDialog
|
||||
open={previewState.open}
|
||||
onOpenChange={setPreviewOpen}
|
||||
imgUrls={previewState.urls}
|
||||
initialIndex={previewState.index}
|
||||
/>
|
||||
</article>
|
||||
</MemoViewContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { createContext, useContext } from "react";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import type { User } from "@/types/proto/api/v1/user_service";
|
||||
|
||||
/**
|
||||
* Context value for MemoView component tree
|
||||
* Provides shared state and props to child components
|
||||
*/
|
||||
export interface MemoViewContextValue {
|
||||
/** The memo data */
|
||||
memo: Memo;
|
||||
/** The memo creator user data */
|
||||
creator: User | undefined;
|
||||
/** Whether the memo is in archived state */
|
||||
isArchived: boolean;
|
||||
/** Whether the current user can only view (not edit) the memo */
|
||||
readonly: boolean;
|
||||
/** Whether we're currently on the memo detail page */
|
||||
isInMemoDetailPage: boolean;
|
||||
/** Parent page path for navigation state */
|
||||
parentPage: string;
|
||||
/** Number of comments on this memo */
|
||||
commentAmount: number;
|
||||
/** Time format to use (datetime for old memos, auto for recent) */
|
||||
relativeTimeFormat: "datetime" | "auto";
|
||||
/** Whether this memo contains NSFW content */
|
||||
nsfw: boolean;
|
||||
/** Whether to show NSFW content without blur */
|
||||
showNSFWContent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for sharing MemoView state across child components
|
||||
* This eliminates prop drilling for commonly used values
|
||||
*/
|
||||
export const MemoViewContext = createContext<MemoViewContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Hook to access MemoView context
|
||||
* @throws Error if used outside of MemoViewContext.Provider
|
||||
*/
|
||||
export const useMemoViewContext = (): MemoViewContextValue => {
|
||||
const context = useContext(MemoViewContext);
|
||||
if (!context) {
|
||||
throw new Error("useMemoViewContext must be used within MemoViewContext.Provider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
@ -4,6 +4,7 @@ import { useTranslate } from "@/utils/i18n";
|
|||
import MemoContent from "../../MemoContent";
|
||||
import { MemoReactionListView } from "../../MemoReactionListView";
|
||||
import { AttachmentList, LocationDisplay, RelationList } from "../../memo-metadata";
|
||||
import { useMemoViewContext } from "../MemoViewContext";
|
||||
import type { MemoBodyProps } from "../types";
|
||||
|
||||
/**
|
||||
|
|
@ -15,18 +16,12 @@ import type { MemoBodyProps } from "../types";
|
|||
* - Reactions
|
||||
* - NSFW content overlay
|
||||
*/
|
||||
const MemoBody: React.FC<MemoBodyProps> = ({
|
||||
memo,
|
||||
readonly,
|
||||
compact,
|
||||
parentPage,
|
||||
nsfw,
|
||||
showNSFWContent,
|
||||
onContentClick,
|
||||
onContentDoubleClick,
|
||||
onToggleNsfwVisibility,
|
||||
}) => {
|
||||
const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentDoubleClick, onToggleNsfwVisibility }) => {
|
||||
const t = useTranslate();
|
||||
|
||||
// Get shared state from context
|
||||
const { memo, readonly, parentPage, nsfw, showNSFWContent } = useMemoViewContext();
|
||||
|
||||
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||
import i18n from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Visibility } from "@/types/proto/api/v1/memo_service";
|
||||
import type { User } from "@/types/proto/api/v1/user_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { convertVisibilityToString } from "@/utils/memo";
|
||||
import MemoActionMenu from "../../MemoActionMenu";
|
||||
import { ReactionSelector } from "../../reactions";
|
||||
import UserAvatar from "../../UserAvatar";
|
||||
import VisibilityIcon from "../../VisibilityIcon";
|
||||
import { useMemoViewContext } from "../MemoViewContext";
|
||||
import type { MemoHeaderProps } from "../types";
|
||||
|
||||
/**
|
||||
|
|
@ -24,28 +26,22 @@ import type { MemoHeaderProps } from "../types";
|
|||
* - Action menu
|
||||
*/
|
||||
const MemoHeader: React.FC<MemoHeaderProps> = ({
|
||||
memo,
|
||||
creator,
|
||||
showCreator,
|
||||
showVisibility,
|
||||
showPinned,
|
||||
isArchived,
|
||||
commentAmount,
|
||||
isInMemoDetailPage,
|
||||
parentPage,
|
||||
readonly,
|
||||
relativeTimeFormat,
|
||||
onEdit,
|
||||
onGotoDetail,
|
||||
onUnpin,
|
||||
onToggleNsfwVisibility,
|
||||
nsfw,
|
||||
showNSFWContent,
|
||||
reactionSelectorOpen,
|
||||
onReactionSelectorOpenChange,
|
||||
}) => {
|
||||
const t = useTranslate();
|
||||
|
||||
// Get shared state from context
|
||||
const { memo, creator, isArchived, commentAmount, isInMemoDetailPage, parentPage, readonly, relativeTimeFormat, nsfw, showNSFWContent } =
|
||||
useMemoViewContext();
|
||||
|
||||
const displayTime = isArchived ? (
|
||||
memo.displayTime?.toLocaleString(i18n.language)
|
||||
) : (
|
||||
|
|
@ -138,7 +134,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({
|
|||
* Creator display with avatar and name
|
||||
*/
|
||||
interface CreatorDisplayProps {
|
||||
creator: NonNullable<MemoHeaderProps["creator"]>;
|
||||
creator: User;
|
||||
displayTime: React.ReactNode;
|
||||
onGotoDetail: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,7 @@
|
|||
export type { UseMemoEditorReturn } from "./useMemoEditor";
|
||||
export { useMemoEditor } from "./useMemoEditor";
|
||||
export type { UseMemoHandlersOptions, UseMemoHandlersReturn } from "./useMemoHandlers";
|
||||
export { useMemoHandlers } from "./useMemoHandlers";
|
||||
export type { UseMemoViewDerivedStateOptions, UseMemoViewDerivedStateReturn } from "./useMemoViewDerivedState";
|
||||
export { useMemoViewDerivedState } from "./useMemoViewDerivedState";
|
||||
export { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./useMemoViewState";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { userStore } from "@/store";
|
||||
|
||||
export interface UseMemoEditorReturn {
|
||||
showEditor: boolean;
|
||||
openEditor: () => void;
|
||||
handleEditorConfirm: () => void;
|
||||
handleEditorCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing memo editor state and actions
|
||||
* Encapsulates all editor-related state and handlers
|
||||
*/
|
||||
export const useMemoEditor = (): UseMemoEditorReturn => {
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
|
||||
const openEditor = useCallback(() => {
|
||||
setShowEditor(true);
|
||||
}, []);
|
||||
|
||||
const handleEditorConfirm = useCallback(() => {
|
||||
setShowEditor(false);
|
||||
userStore.setStatsStateId();
|
||||
}, []);
|
||||
|
||||
const handleEditorCancel = useCallback(() => {
|
||||
setShowEditor(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showEditor,
|
||||
openEditor,
|
||||
handleEditorConfirm,
|
||||
handleEditorCancel,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { useCallback } from "react";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { instanceStore } from "@/store";
|
||||
import type { UseImagePreviewReturn } from "../types";
|
||||
|
||||
export interface UseMemoHandlersOptions {
|
||||
memoName: string;
|
||||
parentPage: string;
|
||||
readonly: boolean;
|
||||
openEditor: () => void;
|
||||
openPreview: UseImagePreviewReturn["openPreview"];
|
||||
}
|
||||
|
||||
export interface UseMemoHandlersReturn {
|
||||
handleGotoMemoDetailPage: () => void;
|
||||
handleMemoContentClick: (e: React.MouseEvent) => void;
|
||||
handleMemoContentDoubleClick: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing memo event handlers
|
||||
* Centralizes all click and interaction handlers
|
||||
*/
|
||||
export const useMemoHandlers = (options: UseMemoHandlersOptions): UseMemoHandlersReturn => {
|
||||
const { memoName, parentPage, readonly, openEditor, openPreview } = options;
|
||||
const navigateTo = useNavigateTo();
|
||||
|
||||
const handleGotoMemoDetailPage = useCallback(() => {
|
||||
navigateTo(`/${memoName}`, {
|
||||
state: { from: parentPage },
|
||||
});
|
||||
}, [memoName, parentPage, navigateTo]);
|
||||
|
||||
const handleMemoContentClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
|
||||
if (targetEl.tagName === "IMG") {
|
||||
// Check if the image is inside a link
|
||||
const linkElement = targetEl.closest("a");
|
||||
if (linkElement) {
|
||||
// If image is inside a link, only navigate to the link (don't show preview)
|
||||
return;
|
||||
}
|
||||
|
||||
const imgUrl = targetEl.getAttribute("src");
|
||||
if (imgUrl) {
|
||||
openPreview(imgUrl);
|
||||
}
|
||||
}
|
||||
},
|
||||
[openPreview],
|
||||
);
|
||||
|
||||
const handleMemoContentDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (readonly) return;
|
||||
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
if (instanceMemoRelatedSetting.enableDoubleClickEdit) {
|
||||
e.preventDefault();
|
||||
openEditor();
|
||||
}
|
||||
},
|
||||
[readonly, openEditor],
|
||||
);
|
||||
|
||||
return {
|
||||
handleGotoMemoDetailPage,
|
||||
handleMemoContentClick,
|
||||
handleMemoContentDoubleClick,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { State } from "@/types/proto/api/v1/common";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
|
||||
import { isSuperUser } from "@/utils/user";
|
||||
import { RELATIVE_TIME_THRESHOLD_MS } from "../constants";
|
||||
|
||||
export interface UseMemoViewDerivedStateOptions {
|
||||
memo: Memo;
|
||||
parentPage?: string;
|
||||
}
|
||||
|
||||
export interface UseMemoViewDerivedStateReturn {
|
||||
commentAmount: number;
|
||||
relativeTimeFormat: "datetime" | "auto";
|
||||
isArchived: boolean;
|
||||
readonly: boolean;
|
||||
isInMemoDetailPage: boolean;
|
||||
parentPage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for computing derived state from memo data
|
||||
* Centralizes all computed values to avoid repetition and improve readability
|
||||
*/
|
||||
export const useMemoViewDerivedState = (options: UseMemoViewDerivedStateOptions): UseMemoViewDerivedStateReturn => {
|
||||
const { memo, parentPage: parentPageProp } = options;
|
||||
const location = useLocation();
|
||||
const user = useCurrentUser();
|
||||
|
||||
// Compute all derived state
|
||||
const commentAmount = useMemo(
|
||||
() =>
|
||||
memo.relations.filter((relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memo.name).length,
|
||||
[memo.relations, memo.name],
|
||||
);
|
||||
|
||||
const relativeTimeFormat: "datetime" | "auto" = useMemo(
|
||||
() => (memo.displayTime && Date.now() - memo.displayTime.getTime() > RELATIVE_TIME_THRESHOLD_MS ? "datetime" : "auto"),
|
||||
[memo.displayTime],
|
||||
);
|
||||
|
||||
const isArchived = useMemo(() => memo.state === State.ARCHIVED, [memo.state]);
|
||||
|
||||
const readonly = useMemo(() => memo.creator !== user?.name && !isSuperUser(user), [memo.creator, user]);
|
||||
|
||||
const isInMemoDetailPage = useMemo(() => location.pathname.startsWith(`/${memo.name}`), [location.pathname, memo.name]);
|
||||
|
||||
const parentPage = useMemo(() => parentPageProp || location.pathname, [parentPageProp, location.pathname]);
|
||||
|
||||
return {
|
||||
commentAmount,
|
||||
relativeTimeFormat,
|
||||
isArchived,
|
||||
readonly,
|
||||
isInMemoDetailPage,
|
||||
parentPage,
|
||||
};
|
||||
};
|
||||
|
|
@ -10,8 +10,26 @@
|
|||
|
||||
export { MemoBody, MemoHeader } from "./components";
|
||||
export * from "./constants";
|
||||
export { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./hooks";
|
||||
export type {
|
||||
UseMemoEditorReturn,
|
||||
UseMemoHandlersOptions,
|
||||
UseMemoHandlersReturn,
|
||||
UseMemoViewDerivedStateOptions,
|
||||
UseMemoViewDerivedStateReturn,
|
||||
} from "./hooks";
|
||||
export {
|
||||
useImagePreview,
|
||||
useKeyboardShortcuts,
|
||||
useMemoActions,
|
||||
useMemoCreator,
|
||||
useMemoEditor,
|
||||
useMemoHandlers,
|
||||
useMemoViewDerivedState,
|
||||
useNsfwContent,
|
||||
} from "./hooks";
|
||||
export { default, default as MemoView } from "./MemoView";
|
||||
export type { MemoViewContextValue } from "./MemoViewContext";
|
||||
export { MemoViewContext, useMemoViewContext } from "./MemoViewContext";
|
||||
export type {
|
||||
ImagePreviewState,
|
||||
MemoBodyProps,
|
||||
|
|
|
|||
|
|
@ -25,39 +25,31 @@ export interface MemoViewProps {
|
|||
|
||||
/**
|
||||
* Props for the MemoHeader component
|
||||
* Note: Most data props now come from MemoViewContext
|
||||
*/
|
||||
export interface MemoHeaderProps {
|
||||
memo: Memo;
|
||||
creator: User | undefined;
|
||||
// Display options
|
||||
showCreator?: boolean;
|
||||
showVisibility?: boolean;
|
||||
showPinned?: boolean;
|
||||
isArchived: boolean;
|
||||
commentAmount: number;
|
||||
isInMemoDetailPage: boolean;
|
||||
parentPage: string;
|
||||
readonly: boolean;
|
||||
relativeTimeFormat: "datetime" | "auto";
|
||||
// Callbacks
|
||||
onEdit: () => void;
|
||||
onGotoDetail: () => void;
|
||||
onUnpin: () => void;
|
||||
onToggleNsfwVisibility?: () => void;
|
||||
nsfw?: boolean;
|
||||
showNSFWContent?: boolean;
|
||||
// Reaction state
|
||||
reactionSelectorOpen: boolean;
|
||||
onReactionSelectorOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the MemoBody component
|
||||
* Note: Most data props now come from MemoViewContext
|
||||
*/
|
||||
export interface MemoBodyProps {
|
||||
memo: Memo;
|
||||
readonly: boolean;
|
||||
// Display options
|
||||
compact?: boolean;
|
||||
parentPage: string;
|
||||
nsfw: boolean;
|
||||
showNSFWContent: boolean;
|
||||
// Callbacks
|
||||
onContentClick: (e: React.MouseEvent) => void;
|
||||
onContentDoubleClick: (e: React.MouseEvent) => void;
|
||||
onToggleNsfwVisibility: () => void;
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { useState } from "react";
|
|||
import type { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
||||
import MemoAttachment from "../MemoAttachment";
|
||||
import SortableItem from "../MemoEditor/SortableItem";
|
||||
import PreviewImageDialog from "../PreviewImageDialog";
|
||||
import AttachmentCard from "./AttachmentCard";
|
||||
import SortableItem from "./SortableItem";
|
||||
import type { AttachmentItem, BaseMetadataProps, LocalFile } from "./types";
|
||||
import { separateMediaAndDocs, toAttachmentItems } from "./types";
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue