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";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service";
|
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
import { LinkMemoDialog, LocationDialog } from "../components";
|
||||||
import { GEOCODING } from "../constants";
|
import { GEOCODING } from "../constants";
|
||||||
|
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
|
||||||
import { useAbortController } from "../hooks/useAbortController";
|
import { useAbortController } from "../hooks/useAbortController";
|
||||||
import { MemoEditorContext } from "../types";
|
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 {
|
interface Props {
|
||||||
isUploading?: boolean;
|
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 { Textarea } from "@/components/ui/textarea";
|
||||||
import { VisuallyHidden } from "@/components/ui/visually-hidden";
|
import { VisuallyHidden } from "@/components/ui/visually-hidden";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import { LocationState } from "./types";
|
import { LocationState } from "../types/insert-menu";
|
||||||
|
|
||||||
interface LocationDialogProps {
|
interface LocationDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -1,2 +1,5 @@
|
||||||
// UI components for MemoEditor
|
// UI components for MemoEditor
|
||||||
|
export { default as ErrorBoundary } from "./ErrorBoundary";
|
||||||
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
|
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 { useBlobUrls } from "./useBlobUrls";
|
||||||
export { useDebounce } from "./useDebounce";
|
export { useDebounce } from "./useDebounce";
|
||||||
export { useDragAndDrop } from "./useDragAndDrop";
|
export { useDragAndDrop } from "./useDragAndDrop";
|
||||||
|
export { useFileUpload } from "./useFileUpload";
|
||||||
export { useFocusMode } from "./useFocusMode";
|
export { useFocusMode } from "./useFocusMode";
|
||||||
|
export { useLinkMemo } from "./useLinkMemo";
|
||||||
export { useLocalFileManager } from "./useLocalFileManager";
|
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";
|
export { useMemoSave } from "./useMemoSave";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { LatLng } from "leaflet";
|
import { LatLng } from "leaflet";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Location } from "@/types/proto/api/v1/memo_service";
|
import { Location } from "@/types/proto/api/v1/memo_service";
|
||||||
import { LocationState } from "./types";
|
import { LocationState } from "../types/insert-menu";
|
||||||
|
|
||||||
export const useLocation = (initialLocation?: Location) => {
|
export const useLocation = (initialLocation?: Location) => {
|
||||||
const [locationInitialized, setLocationInitialized] = useState(false);
|
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 { isEqual } from "lodash-es";
|
||||||
import { LoaderIcon } from "lucide-react";
|
import { LoaderIcon } from "lucide-react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||||
import { Button } from "@/components/ui/button";
|
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 useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { instanceStore, memoStore, userStore } from "@/store";
|
|
||||||
import { extractMemoIdFromName } from "@/store/common";
|
import { extractMemoIdFromName } from "@/store/common";
|
||||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service";
|
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
|
||||||
import { type Location, type MemoRelation, MemoRelation_Type, Visibility } from "@/types/proto/api/v1/memo_service";
|
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import { convertVisibilityFromString } from "@/utils/memo";
|
|
||||||
import DateTimeInput from "../DateTimeInput";
|
import DateTimeInput from "../DateTimeInput";
|
||||||
import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata";
|
import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata";
|
||||||
import { FocusModeExitButton, FocusModeOverlay } from "./components";
|
import { ErrorBoundary, FocusModeExitButton, FocusModeOverlay } from "./components";
|
||||||
import { FOCUS_MODE_EXIT_KEY, FOCUS_MODE_STYLES, FOCUS_MODE_TOGGLE_KEY, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants";
|
import { FOCUS_MODE_STYLES, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants";
|
||||||
import Editor, { type EditorRefActions } from "./Editor";
|
import Editor, { type EditorRefActions } from "./Editor";
|
||||||
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./Editor/markdownShortcuts";
|
import {
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
useDebounce,
|
||||||
import { useDebounce, useDragAndDrop, useFocusMode, useLocalFileManager, useMemoSave } from "./hooks";
|
useDragAndDrop,
|
||||||
|
useFocusMode,
|
||||||
|
useLocalFileManager,
|
||||||
|
useMemoEditorHandlers,
|
||||||
|
useMemoEditorInit,
|
||||||
|
useMemoEditorKeyboard,
|
||||||
|
useMemoEditorState,
|
||||||
|
useMemoSave,
|
||||||
|
} from "./hooks";
|
||||||
import InsertMenu from "./Toolbar/InsertMenu";
|
import InsertMenu from "./Toolbar/InsertMenu";
|
||||||
import VisibilitySelector from "./Toolbar/VisibilitySelector";
|
import VisibilitySelector from "./Toolbar/VisibilitySelector";
|
||||||
import { MemoEditorContext } from "./types";
|
import { MemoEditorContext } from "./types";
|
||||||
import type { MemoEditorProps, MemoEditorState } from "./types/memo-editor";
|
import type { MemoEditorProps } from "./types/memo-editor";
|
||||||
|
|
||||||
// Re-export for backward compatibility
|
// Re-export for backward compatibility
|
||||||
export type { MemoEditorProps as Props };
|
export type { MemoEditorProps as Props };
|
||||||
|
|
@ -40,45 +41,68 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const currentUser = useCurrentUser();
|
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
|
// Custom hooks for file management
|
||||||
const { localFiles, addFiles, removeFile, clearFiles } = useLocalFileManager();
|
const { localFiles, addFiles, removeFile, clearFiles } = useLocalFileManager();
|
||||||
|
|
||||||
// Internal component state
|
// Custom hooks for state management
|
||||||
const [state, setState] = useState<MemoEditorState>({
|
const {
|
||||||
memoVisibility: Visibility.PRIVATE,
|
memoVisibility,
|
||||||
isFocusMode: false,
|
attachmentList,
|
||||||
attachmentList: [],
|
relationList,
|
||||||
relationList: [],
|
location,
|
||||||
location: undefined,
|
isFocusMode,
|
||||||
isUploadingAttachment: false,
|
isUploadingAttachment,
|
||||||
isRequesting: false,
|
isRequesting,
|
||||||
isComposing: false,
|
isComposing,
|
||||||
isDraggingFile: false,
|
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
|
// Memo save hook - handles create/update logic
|
||||||
const { saveMemo } = useMemoSave({
|
const { saveMemo } = useMemoSave({
|
||||||
onUploadingChange: useCallback((uploading: boolean) => {
|
onUploadingChange: setUploadingAttachment,
|
||||||
setState((s) => ({ ...s, isUploadingAttachment: uploading }));
|
onRequestingChange: setRequesting,
|
||||||
}, []),
|
|
||||||
onRequestingChange: useCallback((requesting: boolean) => {
|
|
||||||
setState((s) => ({ ...s, isRequesting: requesting }));
|
|
||||||
}, []),
|
|
||||||
onSuccess: useCallback(
|
onSuccess: useCallback(
|
||||||
(savedMemoName: string) => {
|
(savedMemoName: string) => {
|
||||||
editorRef.current?.setContent("");
|
editorRef.current?.setContent("");
|
||||||
|
|
@ -91,187 +115,65 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
||||||
onCancel: useCallback(() => {
|
onCancel: useCallback(() => {
|
||||||
if (onCancel) onCancel();
|
if (onCancel) onCancel();
|
||||||
}, [onCancel]),
|
}, [onCancel]),
|
||||||
onReset: useCallback(() => {
|
onReset: resetState,
|
||||||
setState((s) => ({
|
|
||||||
...s,
|
|
||||||
isRequesting: false,
|
|
||||||
attachmentList: [],
|
|
||||||
relationList: [],
|
|
||||||
location: undefined,
|
|
||||||
isDraggingFile: false,
|
|
||||||
}));
|
|
||||||
}, []),
|
|
||||||
t,
|
t,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
// Save memo handler
|
||||||
editorRef.current?.setContent(contentCache || "");
|
const handleSaveBtnClick = useCallback(async () => {
|
||||||
}, []);
|
if (isRequesting) {
|
||||||
|
|
||||||
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) {
|
|
||||||
return;
|
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);
|
// Keyboard shortcuts hook
|
||||||
if (memo) {
|
const { handleKeyDown } = useMemoEditorKeyboard({
|
||||||
handleEditorFocus();
|
editorRef,
|
||||||
setCreateTime(memo.createTime);
|
isFocusMode,
|
||||||
setUpdateTime(memo.updateTime);
|
isComposing,
|
||||||
setState((prevState) => ({
|
onSave: handleSaveBtnClick,
|
||||||
...prevState,
|
onToggleFocusMode: toggleFocusMode,
|
||||||
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,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCompositionStart = () => {
|
// Focus mode management with body scroll lock
|
||||||
setState((prevState) => ({
|
useFocusMode({
|
||||||
...prevState,
|
isFocusMode,
|
||||||
isComposing: true,
|
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
|
// Drag-and-drop for file uploads
|
||||||
const { isDragging, dragHandlers } = useDragAndDrop({
|
const { isDragging, dragHandlers } = useDragAndDrop({
|
||||||
onDrop: (files) => addFiles(files),
|
onDrop: addFiles,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync drag state with component state
|
// Sync drag state with component state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setState((prevState) => ({
|
setDraggingFile(isDragging);
|
||||||
...prevState,
|
}, [isDragging, setDraggingFile]);
|
||||||
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"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Debounced cache setter to avoid writing to localStorage on every keystroke
|
// Debounced cache setter to avoid writing to localStorage on every keystroke
|
||||||
const saveContentToCache = useDebounce((content: string) => {
|
const saveContentToCache = useDebounce((content: string) => {
|
||||||
|
|
@ -282,79 +184,60 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
||||||
}
|
}
|
||||||
}, LOCALSTORAGE_DEBOUNCE_DELAY);
|
}, LOCALSTORAGE_DEBOUNCE_DELAY);
|
||||||
|
|
||||||
const handleContentChange = (content: string) => {
|
// Compute reference relations
|
||||||
setHasContent(content !== "");
|
const referenceRelations = useMemo(() => {
|
||||||
saveContentToCache(content);
|
if (memoName) {
|
||||||
};
|
return relationList.filter(
|
||||||
|
(relation) =>
|
||||||
const handleSaveBtnClick = async () => {
|
relation.memo?.name === memoName && relation.relatedMemo?.name !== memoName && relation.type === MemoRelation_Type.REFERENCE,
|
||||||
if (state.isRequesting) {
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const content = editorRef.current?.getContent() ?? "";
|
return relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
||||||
await saveMemo(content, {
|
}, [memoName, relationList]);
|
||||||
memoName,
|
|
||||||
parentMemoName,
|
|
||||||
visibility: state.memoVisibility,
|
|
||||||
attachmentList: state.attachmentList,
|
|
||||||
relationList: state.relationList,
|
|
||||||
location: state.location,
|
|
||||||
localFiles,
|
|
||||||
createTime,
|
|
||||||
updateTime,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditorFocus = () => {
|
|
||||||
editorRef.current?.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
const editorConfig = useMemo(
|
const editorConfig = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
className: "",
|
className: "",
|
||||||
initialContent: "",
|
initialContent: "",
|
||||||
placeholder: props.placeholder ?? t("editor.any-thoughts"),
|
placeholder: props.placeholder ?? t("editor.any-thoughts"),
|
||||||
onContentChange: handleContentChange,
|
onContentChange: (content: string) => {
|
||||||
|
setHasContent(content !== "");
|
||||||
|
saveContentToCache(content);
|
||||||
|
},
|
||||||
onPaste: handlePasteEvent,
|
onPaste: handlePasteEvent,
|
||||||
isFocusMode: state.isFocusMode,
|
isFocusMode,
|
||||||
isInIME: state.isComposing,
|
isInIME: isComposing,
|
||||||
onCompositionStart: handleCompositionStart,
|
onCompositionStart: handleCompositionStart,
|
||||||
onCompositionEnd: handleCompositionEnd,
|
onCompositionEnd: handleCompositionEnd,
|
||||||
}),
|
}),
|
||||||
[i18n.language, state.isFocusMode, state.isComposing],
|
[i18n.language, isFocusMode, isComposing, handlePasteEvent, handleCompositionStart, handleCompositionEnd, saveContentToCache],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allowSave =
|
const allowSave = (hasContent || attachmentList.length > 0 || localFiles.length > 0) && !isUploadingAttachment && !isRequesting;
|
||||||
(hasContent || state.attachmentList.length > 0 || localFiles.length > 0) && !state.isUploadingAttachment && !state.isRequesting;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<MemoEditorContext.Provider
|
<MemoEditorContext.Provider
|
||||||
value={{
|
value={{
|
||||||
attachmentList: state.attachmentList,
|
attachmentList,
|
||||||
relationList: state.relationList,
|
relationList,
|
||||||
setAttachmentList: handleSetAttachmentList,
|
setAttachmentList,
|
||||||
addLocalFiles: (files) => addFiles(Array.from(files.map((f) => f.file))),
|
addLocalFiles: (files) => addFiles(Array.from(files.map((f) => f.file))),
|
||||||
removeLocalFile: removeFile,
|
removeLocalFile: removeFile,
|
||||||
localFiles,
|
localFiles,
|
||||||
setRelationList: (relationList: MemoRelation[]) => {
|
setRelationList,
|
||||||
setState((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
relationList,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
memoName,
|
memoName,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Focus Mode Backdrop */}
|
{/* Focus Mode Backdrop */}
|
||||||
<FocusModeOverlay isActive={state.isFocusMode} onToggle={toggleFocusMode} />
|
<FocusModeOverlay isActive={isFocusMode} onToggle={toggleFocusMode} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative w-full flex flex-col justify-start items-start bg-card px-4 pt-3 pb-2 rounded-lg border",
|
"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,
|
FOCUS_MODE_STYLES.transition,
|
||||||
state.isDraggingFile ? "border-dashed border-muted-foreground cursor-copy" : "border-border cursor-auto",
|
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),
|
isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -363,49 +246,35 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
||||||
onFocus={handleEditorFocus}
|
onFocus={handleEditorFocus}
|
||||||
>
|
>
|
||||||
{/* Focus Mode Exit Button */}
|
{/* 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} />
|
<Editor ref={editorRef} {...editorConfig} />
|
||||||
<LocationDisplay
|
<LocationDisplay mode="edit" location={location} onRemove={() => setLocation(undefined)} />
|
||||||
mode="edit"
|
|
||||||
location={state.location}
|
|
||||||
onRemove={() =>
|
|
||||||
setState((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
location: undefined,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{/* Show attachments and pending files together */}
|
{/* Show attachments and pending files together */}
|
||||||
<AttachmentList
|
<AttachmentList
|
||||||
mode="edit"
|
mode="edit"
|
||||||
attachments={state.attachmentList}
|
attachments={attachmentList}
|
||||||
onAttachmentsChange={handleSetAttachmentList}
|
onAttachmentsChange={setAttachmentList}
|
||||||
localFiles={localFiles}
|
localFiles={localFiles}
|
||||||
onRemoveLocalFile={removeFile}
|
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="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">
|
<div className="flex flex-row justify-start items-center gap-1">
|
||||||
<InsertMenu
|
<InsertMenu
|
||||||
isUploading={state.isUploadingAttachment}
|
isUploading={isUploadingAttachment}
|
||||||
location={state.location}
|
location={location}
|
||||||
onLocationChange={(location) =>
|
onLocationChange={setLocation}
|
||||||
setState((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
location,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
onToggleFocusMode={toggleFocusMode}
|
onToggleFocusMode={toggleFocusMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 flex flex-row justify-end items-center">
|
<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">
|
<div className="flex flex-row justify-end gap-1">
|
||||||
{props.onCancel && (
|
{props.onCancel && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={state.isRequesting}
|
disabled={isRequesting}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearFiles();
|
clearFiles();
|
||||||
if (props.onCancel) props.onCancel();
|
if (props.onCancel) props.onCancel();
|
||||||
|
|
@ -414,8 +283,8 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button disabled={!allowSave || state.isRequesting} onClick={handleSaveBtnClick}>
|
<Button disabled={!allowSave || isRequesting} onClick={handleSaveBtnClick}>
|
||||||
{state.isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
|
{isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// MemoEditor type exports
|
// MemoEditor type exports
|
||||||
export type { Command } from "./command";
|
export type { Command } from "./command";
|
||||||
export { MemoEditorContext, type MemoEditorContextValue } from "./context";
|
export { MemoEditorContext, type MemoEditorContextValue } from "./context";
|
||||||
|
export type { LinkMemoState, LocationState } from "./insert-menu";
|
||||||
export type { EditorConfig, MemoEditorProps, MemoEditorState } from "./memo-editor";
|
export type { EditorConfig, MemoEditorProps, MemoEditorState } from "./memo-editor";
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { memo, useCallback, useRef, useState } from "react";
|
import { memo, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
|
||||||
import { cn } from "@/lib/utils";
|
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 MemoEditor from "../MemoEditor";
|
||||||
import PreviewImageDialog from "../PreviewImageDialog";
|
import PreviewImageDialog from "../PreviewImageDialog";
|
||||||
import { MemoBody, MemoHeader } from "./components";
|
import { MemoBody, MemoHeader } from "./components";
|
||||||
import { MEMO_CARD_BASE_CLASSES, RELATIVE_TIME_THRESHOLD_MS } from "./constants";
|
import { MEMO_CARD_BASE_CLASSES } from "./constants";
|
||||||
import { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./hooks";
|
import {
|
||||||
|
useImagePreview,
|
||||||
|
useKeyboardShortcuts,
|
||||||
|
useMemoActions,
|
||||||
|
useMemoCreator,
|
||||||
|
useMemoEditor,
|
||||||
|
useMemoHandlers,
|
||||||
|
useMemoViewDerivedState,
|
||||||
|
useNsfwContent,
|
||||||
|
} from "./hooks";
|
||||||
|
import { MemoViewContext } from "./MemoViewContext";
|
||||||
import type { MemoViewProps } from "./types";
|
import type { MemoViewProps } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -27,34 +30,34 @@ import type { MemoViewProps } from "./types";
|
||||||
*/
|
*/
|
||||||
const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
|
const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
|
||||||
const { memo: memoData, className } = props;
|
const { memo: memoData, className } = props;
|
||||||
const location = useLocation();
|
|
||||||
const navigateTo = useNavigateTo();
|
|
||||||
const user = useCurrentUser();
|
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [showEditor, setShowEditor] = useState(false);
|
|
||||||
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
|
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
|
||||||
|
|
||||||
// Fetch creator data
|
// Custom hooks for data fetching
|
||||||
const creator = useMemoCreator(memoData.creator);
|
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 { nsfw, showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent);
|
||||||
const { previewState, openPreview, setPreviewOpen } = useImagePreview();
|
const { previewState, openPreview, setPreviewOpen } = useImagePreview();
|
||||||
const { archiveMemo, unpinMemo } = useMemoActions(memoData);
|
const { showEditor, openEditor, handleEditorConfirm, handleEditorCancel } = useMemoEditor();
|
||||||
|
|
||||||
// Derived state
|
// Custom hooks for actions
|
||||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
const { archiveMemo, unpinMemo } = useMemoActions(memoData);
|
||||||
const commentAmount = memoData.relations.filter(
|
const { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({
|
||||||
(relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memoData.name,
|
memoName: memoData.name,
|
||||||
).length;
|
parentPage,
|
||||||
const relativeTimeFormat =
|
readonly,
|
||||||
memoData.displayTime && Date.now() - memoData.displayTime.getTime() > RELATIVE_TIME_THRESHOLD_MS ? "datetime" : "auto";
|
openEditor,
|
||||||
const isArchived = memoData.state === State.ARCHIVED;
|
openPreview,
|
||||||
const readonly = memoData.creator !== user?.name && !isSuperUser(user);
|
});
|
||||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`);
|
|
||||||
const parentPage = props.parentPage || location.pathname;
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
const { handleShortcutActivation } = useKeyboardShortcuts(cardRef, {
|
const { handleShortcutActivation } = useKeyboardShortcuts(cardRef, {
|
||||||
|
|
@ -62,59 +65,28 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
|
||||||
readonly,
|
readonly,
|
||||||
showEditor,
|
showEditor,
|
||||||
isArchived,
|
isArchived,
|
||||||
onEdit: () => setShowEditor(true),
|
onEdit: openEditor,
|
||||||
onArchive: archiveMemo,
|
onArchive: archiveMemo,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handlers
|
// Memoize context value to prevent unnecessary re-renders
|
||||||
const handleGotoMemoDetailPage = useCallback(() => {
|
// IMPORTANT: This must be before the early return to satisfy Rules of Hooks
|
||||||
navigateTo(`/${memoData.name}`, {
|
const contextValue = useMemo(
|
||||||
state: { from: parentPage },
|
() => ({
|
||||||
});
|
memo: memoData,
|
||||||
}, [memoData.name, parentPage, navigateTo]);
|
creator,
|
||||||
|
isArchived,
|
||||||
const handleMemoContentClick = useCallback(
|
readonly,
|
||||||
(e: React.MouseEvent) => {
|
isInMemoDetailPage,
|
||||||
const targetEl = e.target as HTMLElement;
|
parentPage,
|
||||||
|
commentAmount,
|
||||||
if (targetEl.tagName === "IMG") {
|
relativeTimeFormat,
|
||||||
// Check if the image is inside a link
|
nsfw,
|
||||||
const linkElement = targetEl.closest("a");
|
showNSFWContent,
|
||||||
if (linkElement) {
|
}),
|
||||||
// If image is inside a link, only navigate to the link (don't show preview)
|
[memoData, creator, isArchived, readonly, isInMemoDetailPage, parentPage, commentAmount, relativeTimeFormat, nsfw, showNSFWContent],
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const imgUrl = targetEl.getAttribute("src");
|
|
||||||
if (imgUrl) {
|
|
||||||
openPreview(imgUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[openPreview],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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
|
// Render inline editor when editing
|
||||||
if (showEditor) {
|
if (showEditor) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -131,6 +103,7 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
|
||||||
|
|
||||||
// Render memo card
|
// Render memo card
|
||||||
return (
|
return (
|
||||||
|
<MemoViewContext.Provider value={contextValue}>
|
||||||
<article
|
<article
|
||||||
className={cn(MEMO_CARD_BASE_CLASSES, className)}
|
className={cn(MEMO_CARD_BASE_CLASSES, className)}
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
|
|
@ -139,34 +112,19 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
|
||||||
onBlur={() => handleShortcutActivation(false)}
|
onBlur={() => handleShortcutActivation(false)}
|
||||||
>
|
>
|
||||||
<MemoHeader
|
<MemoHeader
|
||||||
memo={memoData}
|
|
||||||
creator={creator}
|
|
||||||
showCreator={props.showCreator}
|
showCreator={props.showCreator}
|
||||||
showVisibility={props.showVisibility}
|
showVisibility={props.showVisibility}
|
||||||
showPinned={props.showPinned}
|
showPinned={props.showPinned}
|
||||||
isArchived={isArchived}
|
onEdit={openEditor}
|
||||||
commentAmount={commentAmount}
|
|
||||||
isInMemoDetailPage={isInMemoDetailPage}
|
|
||||||
parentPage={parentPage}
|
|
||||||
readonly={readonly}
|
|
||||||
relativeTimeFormat={relativeTimeFormat}
|
|
||||||
onEdit={() => setShowEditor(true)}
|
|
||||||
onGotoDetail={handleGotoMemoDetailPage}
|
onGotoDetail={handleGotoMemoDetailPage}
|
||||||
onUnpin={unpinMemo}
|
onUnpin={unpinMemo}
|
||||||
onToggleNsfwVisibility={toggleNsfwVisibility}
|
onToggleNsfwVisibility={toggleNsfwVisibility}
|
||||||
nsfw={nsfw}
|
|
||||||
showNSFWContent={showNSFWContent}
|
|
||||||
reactionSelectorOpen={reactionSelectorOpen}
|
reactionSelectorOpen={reactionSelectorOpen}
|
||||||
onReactionSelectorOpenChange={setReactionSelectorOpen}
|
onReactionSelectorOpenChange={setReactionSelectorOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MemoBody
|
<MemoBody
|
||||||
memo={memoData}
|
|
||||||
readonly={readonly}
|
|
||||||
compact={props.compact}
|
compact={props.compact}
|
||||||
parentPage={parentPage}
|
|
||||||
nsfw={nsfw}
|
|
||||||
showNSFWContent={showNSFWContent}
|
|
||||||
onContentClick={handleMemoContentClick}
|
onContentClick={handleMemoContentClick}
|
||||||
onContentDoubleClick={handleMemoContentDoubleClick}
|
onContentDoubleClick={handleMemoContentDoubleClick}
|
||||||
onToggleNsfwVisibility={toggleNsfwVisibility}
|
onToggleNsfwVisibility={toggleNsfwVisibility}
|
||||||
|
|
@ -179,6 +137,7 @@ const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
|
||||||
initialIndex={previewState.index}
|
initialIndex={previewState.index}
|
||||||
/>
|
/>
|
||||||
</article>
|
</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 MemoContent from "../../MemoContent";
|
||||||
import { MemoReactionListView } from "../../MemoReactionListView";
|
import { MemoReactionListView } from "../../MemoReactionListView";
|
||||||
import { AttachmentList, LocationDisplay, RelationList } from "../../memo-metadata";
|
import { AttachmentList, LocationDisplay, RelationList } from "../../memo-metadata";
|
||||||
|
import { useMemoViewContext } from "../MemoViewContext";
|
||||||
import type { MemoBodyProps } from "../types";
|
import type { MemoBodyProps } from "../types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -15,18 +16,12 @@ import type { MemoBodyProps } from "../types";
|
||||||
* - Reactions
|
* - Reactions
|
||||||
* - NSFW content overlay
|
* - NSFW content overlay
|
||||||
*/
|
*/
|
||||||
const MemoBody: React.FC<MemoBodyProps> = ({
|
const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentDoubleClick, onToggleNsfwVisibility }) => {
|
||||||
memo,
|
|
||||||
readonly,
|
|
||||||
compact,
|
|
||||||
parentPage,
|
|
||||||
nsfw,
|
|
||||||
showNSFWContent,
|
|
||||||
onContentClick,
|
|
||||||
onContentDoubleClick,
|
|
||||||
onToggleNsfwVisibility,
|
|
||||||
}) => {
|
|
||||||
const t = useTranslate();
|
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);
|
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Visibility } from "@/types/proto/api/v1/memo_service";
|
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 { useTranslate } from "@/utils/i18n";
|
||||||
import { convertVisibilityToString } from "@/utils/memo";
|
import { convertVisibilityToString } from "@/utils/memo";
|
||||||
import MemoActionMenu from "../../MemoActionMenu";
|
import MemoActionMenu from "../../MemoActionMenu";
|
||||||
import { ReactionSelector } from "../../reactions";
|
import { ReactionSelector } from "../../reactions";
|
||||||
import UserAvatar from "../../UserAvatar";
|
import UserAvatar from "../../UserAvatar";
|
||||||
import VisibilityIcon from "../../VisibilityIcon";
|
import VisibilityIcon from "../../VisibilityIcon";
|
||||||
|
import { useMemoViewContext } from "../MemoViewContext";
|
||||||
import type { MemoHeaderProps } from "../types";
|
import type { MemoHeaderProps } from "../types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,28 +26,22 @@ import type { MemoHeaderProps } from "../types";
|
||||||
* - Action menu
|
* - Action menu
|
||||||
*/
|
*/
|
||||||
const MemoHeader: React.FC<MemoHeaderProps> = ({
|
const MemoHeader: React.FC<MemoHeaderProps> = ({
|
||||||
memo,
|
|
||||||
creator,
|
|
||||||
showCreator,
|
showCreator,
|
||||||
showVisibility,
|
showVisibility,
|
||||||
showPinned,
|
showPinned,
|
||||||
isArchived,
|
|
||||||
commentAmount,
|
|
||||||
isInMemoDetailPage,
|
|
||||||
parentPage,
|
|
||||||
readonly,
|
|
||||||
relativeTimeFormat,
|
|
||||||
onEdit,
|
onEdit,
|
||||||
onGotoDetail,
|
onGotoDetail,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
onToggleNsfwVisibility,
|
onToggleNsfwVisibility,
|
||||||
nsfw,
|
|
||||||
showNSFWContent,
|
|
||||||
reactionSelectorOpen,
|
reactionSelectorOpen,
|
||||||
onReactionSelectorOpenChange,
|
onReactionSelectorOpenChange,
|
||||||
}) => {
|
}) => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
|
||||||
|
// Get shared state from context
|
||||||
|
const { memo, creator, isArchived, commentAmount, isInMemoDetailPage, parentPage, readonly, relativeTimeFormat, nsfw, showNSFWContent } =
|
||||||
|
useMemoViewContext();
|
||||||
|
|
||||||
const displayTime = isArchived ? (
|
const displayTime = isArchived ? (
|
||||||
memo.displayTime?.toLocaleString(i18n.language)
|
memo.displayTime?.toLocaleString(i18n.language)
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -138,7 +134,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({
|
||||||
* Creator display with avatar and name
|
* Creator display with avatar and name
|
||||||
*/
|
*/
|
||||||
interface CreatorDisplayProps {
|
interface CreatorDisplayProps {
|
||||||
creator: NonNullable<MemoHeaderProps["creator"]>;
|
creator: User;
|
||||||
displayTime: React.ReactNode;
|
displayTime: React.ReactNode;
|
||||||
onGotoDetail: () => void;
|
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";
|
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 { MemoBody, MemoHeader } from "./components";
|
||||||
export * from "./constants";
|
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 { default, default as MemoView } from "./MemoView";
|
||||||
|
export type { MemoViewContextValue } from "./MemoViewContext";
|
||||||
|
export { MemoViewContext, useMemoViewContext } from "./MemoViewContext";
|
||||||
export type {
|
export type {
|
||||||
ImagePreviewState,
|
ImagePreviewState,
|
||||||
MemoBodyProps,
|
MemoBodyProps,
|
||||||
|
|
|
||||||
|
|
@ -25,39 +25,31 @@ export interface MemoViewProps {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the MemoHeader component
|
* Props for the MemoHeader component
|
||||||
|
* Note: Most data props now come from MemoViewContext
|
||||||
*/
|
*/
|
||||||
export interface MemoHeaderProps {
|
export interface MemoHeaderProps {
|
||||||
memo: Memo;
|
// Display options
|
||||||
creator: User | undefined;
|
|
||||||
showCreator?: boolean;
|
showCreator?: boolean;
|
||||||
showVisibility?: boolean;
|
showVisibility?: boolean;
|
||||||
showPinned?: boolean;
|
showPinned?: boolean;
|
||||||
isArchived: boolean;
|
// Callbacks
|
||||||
commentAmount: number;
|
|
||||||
isInMemoDetailPage: boolean;
|
|
||||||
parentPage: string;
|
|
||||||
readonly: boolean;
|
|
||||||
relativeTimeFormat: "datetime" | "auto";
|
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onGotoDetail: () => void;
|
onGotoDetail: () => void;
|
||||||
onUnpin: () => void;
|
onUnpin: () => void;
|
||||||
onToggleNsfwVisibility?: () => void;
|
onToggleNsfwVisibility?: () => void;
|
||||||
nsfw?: boolean;
|
// Reaction state
|
||||||
showNSFWContent?: boolean;
|
|
||||||
reactionSelectorOpen: boolean;
|
reactionSelectorOpen: boolean;
|
||||||
onReactionSelectorOpenChange: (open: boolean) => void;
|
onReactionSelectorOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the MemoBody component
|
* Props for the MemoBody component
|
||||||
|
* Note: Most data props now come from MemoViewContext
|
||||||
*/
|
*/
|
||||||
export interface MemoBodyProps {
|
export interface MemoBodyProps {
|
||||||
memo: Memo;
|
// Display options
|
||||||
readonly: boolean;
|
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
parentPage: string;
|
// Callbacks
|
||||||
nsfw: boolean;
|
|
||||||
showNSFWContent: boolean;
|
|
||||||
onContentClick: (e: React.MouseEvent) => void;
|
onContentClick: (e: React.MouseEvent) => void;
|
||||||
onContentDoubleClick: (e: React.MouseEvent) => void;
|
onContentDoubleClick: (e: React.MouseEvent) => void;
|
||||||
onToggleNsfwVisibility: () => void;
|
onToggleNsfwVisibility: () => void;
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import { useState } from "react";
|
||||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service";
|
import type { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||||
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
||||||
import MemoAttachment from "../MemoAttachment";
|
import MemoAttachment from "../MemoAttachment";
|
||||||
import SortableItem from "../MemoEditor/SortableItem";
|
|
||||||
import PreviewImageDialog from "../PreviewImageDialog";
|
import PreviewImageDialog from "../PreviewImageDialog";
|
||||||
import AttachmentCard from "./AttachmentCard";
|
import AttachmentCard from "./AttachmentCard";
|
||||||
|
import SortableItem from "./SortableItem";
|
||||||
import type { AttachmentItem, BaseMetadataProps, LocalFile } from "./types";
|
import type { AttachmentItem, BaseMetadataProps, LocalFile } from "./types";
|
||||||
import { separateMediaAndDocs, toAttachmentItems } from "./types";
|
import { separateMediaAndDocs, toAttachmentItems } from "./types";
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue