mirror of
https://github.com/usememos/memos.git
synced 2025-12-12 16:25:41 +08:00
refactor(web): restructure MemoEditor with custom hooks and improved error handling
Extract reusable logic into custom hooks (useLocalFileManager, useDragAndDrop, useDebounce, useAbortController), add ErrorBoundary for resilience, and centralize constants. Fix cursor positioning bugs, useEffect dependency issues, and add geocoding request cancellation. Improve performance with debounced localStorage writes and ref-based flags. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1832b59190
commit
ef6456a4f5
15 changed files with 776 additions and 320 deletions
|
|
@ -16,6 +16,8 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { GEOCODING } from "../constants";
|
||||
import { useAbortController } from "../hooks/useAbortController";
|
||||
import { MemoEditorContext } from "../types";
|
||||
import { LinkMemoDialog } from "./InsertMenu/LinkMemoDialog";
|
||||
import { LocationDialog } from "./InsertMenu/LocationDialog";
|
||||
|
|
@ -37,6 +39,9 @@ const InsertMenu = observer((props: Props) => {
|
|||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
||||
|
||||
// Abort controller for canceling geocoding requests
|
||||
const { abort: abortGeocoding, abortAndCreate: createGeocodingSignal } = useAbortController();
|
||||
|
||||
const { fileInputRef, selectingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((newFiles: LocalFile[]) => {
|
||||
if (context.addLocalFiles) {
|
||||
context.addLocalFiles(newFiles);
|
||||
|
|
@ -82,35 +87,59 @@ const InsertMenu = observer((props: Props) => {
|
|||
};
|
||||
|
||||
const handleLocationCancel = () => {
|
||||
abortGeocoding(); // Cancel any pending geocoding request
|
||||
location.reset();
|
||||
setLocationDialogOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches human-readable address from coordinates using reverse geocoding
|
||||
* Falls back to coordinate string if geocoding fails
|
||||
*/
|
||||
const fetchReverseGeocode = async (position: LatLng, signal: AbortSignal): Promise<string> => {
|
||||
const coordString = `${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`;
|
||||
try {
|
||||
const url = `${GEOCODING.endpoint}?lat=${position.lat}&lon=${position.lng}&format=${GEOCODING.format}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": GEOCODING.userAgent,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data?.display_name || coordString;
|
||||
} catch (error) {
|
||||
// Silently return coordinates for abort errors
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw error; // Re-throw to handle in caller
|
||||
}
|
||||
console.error("Failed to fetch reverse geocoding data:", error);
|
||||
return coordString;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePositionChange = (position: LatLng) => {
|
||||
location.handlePositionChange(position);
|
||||
|
||||
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${position.lat}&lon=${position.lng}&format=json`, {
|
||||
headers: {
|
||||
"User-Agent": "Memos/1.0 (https://github.com/usememos/memos)",
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data?.display_name) {
|
||||
location.setPlaceholder(data.display_name);
|
||||
} else {
|
||||
location.setPlaceholder(`${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`);
|
||||
}
|
||||
// Abort previous and create new signal for this request
|
||||
const signal = createGeocodingSignal();
|
||||
|
||||
fetchReverseGeocode(position, signal)
|
||||
.then((displayName) => {
|
||||
location.setPlaceholder(displayName);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to fetch reverse geocoding data:", error);
|
||||
location.setPlaceholder(`${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`);
|
||||
// Ignore abort errors (user canceled the request)
|
||||
if (error.name !== "AbortError") {
|
||||
// Set coordinate fallback for other errors
|
||||
location.setPlaceholder(`${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { useRef } from "react";
|
||||
import type { LocalFile } from "@/components/memo-metadata";
|
||||
|
||||
export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectingFlag, setSelectingFlag] = useState(false);
|
||||
const selectingFlagRef = useRef(false);
|
||||
|
||||
const handleFileInputChange = (event?: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(fileInputRef.current?.files || event?.target.files || []);
|
||||
if (files.length === 0 || selectingFlag) {
|
||||
if (files.length === 0 || selectingFlagRef.current) {
|
||||
return;
|
||||
}
|
||||
setSelectingFlag(true);
|
||||
selectingFlagRef.current = true;
|
||||
const localFiles: LocalFile[] = files.map((file) => ({
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
}));
|
||||
onFilesSelected(localFiles);
|
||||
setSelectingFlag(false);
|
||||
selectingFlagRef.current = false;
|
||||
// Optionally clear input value to allow re-selecting the same file
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
|
@ -27,7 +27,7 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void
|
|||
|
||||
return {
|
||||
fileInputRef,
|
||||
selectingFlag,
|
||||
selectingFlag: selectingFlagRef.current,
|
||||
handleFileInputChange,
|
||||
handleUploadClick,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,24 +1,12 @@
|
|||
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EDITOR_HEIGHT } from "../constants";
|
||||
import { Command } from "../types/command";
|
||||
import CommandSuggestions from "./CommandSuggestions";
|
||||
import { editorCommands } from "./commands";
|
||||
import TagSuggestions from "./TagSuggestions";
|
||||
import { useListAutoCompletion } from "./useListAutoCompletion";
|
||||
|
||||
/**
|
||||
* Editor height constraints
|
||||
* - Normal mode: Limited to 50% viewport height to avoid excessive scrolling
|
||||
* - Focus mode: Minimum 50vh on mobile, 60vh on desktop for immersive writing
|
||||
*/
|
||||
const EDITOR_HEIGHT = {
|
||||
normal: "max-h-[50vh]",
|
||||
focusMode: {
|
||||
mobile: "min-h-[50vh]",
|
||||
desktop: "md:min-h-[60vh]",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface EditorRefActions {
|
||||
getEditor: () => HTMLTextAreaElement | null;
|
||||
focus: FunctionType;
|
||||
|
|
@ -45,25 +33,35 @@ interface Props {
|
|||
onPaste: (event: React.ClipboardEvent) => void;
|
||||
/** Whether Focus Mode is active - adjusts height constraints for immersive writing */
|
||||
isFocusMode?: boolean;
|
||||
/** Whether IME composition is in progress (for Asian language input) */
|
||||
isInIME?: boolean;
|
||||
/** Called when IME composition starts */
|
||||
onCompositionStart?: () => void;
|
||||
/** Called when IME composition ends */
|
||||
onCompositionEnd?: () => void;
|
||||
}
|
||||
|
||||
const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<EditorRefActions>) {
|
||||
const { className, initialContent, placeholder, onPaste, onContentChange: handleContentChangeCallback, isFocusMode } = props;
|
||||
const [isInIME, setIsInIME] = useState(false);
|
||||
const {
|
||||
className,
|
||||
initialContent,
|
||||
placeholder,
|
||||
onPaste,
|
||||
onContentChange: handleContentChangeCallback,
|
||||
isFocusMode,
|
||||
isInIME = false,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
} = props;
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && initialContent) {
|
||||
editorRef.current.value = initialContent;
|
||||
handleContentChangeCallback(initialContent);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
updateEditorHeight();
|
||||
}
|
||||
}, [editorRef.current?.value]);
|
||||
}, []);
|
||||
|
||||
const editorActions = {
|
||||
getEditor: () => {
|
||||
|
|
@ -85,16 +83,14 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
|||
const cursorPosition = editorRef.current.selectionStart;
|
||||
const endPosition = editorRef.current.selectionEnd;
|
||||
const prevValue = editorRef.current.value;
|
||||
const value =
|
||||
prevValue.slice(0, cursorPosition) +
|
||||
prefix +
|
||||
(content || prevValue.slice(cursorPosition, endPosition)) +
|
||||
suffix +
|
||||
prevValue.slice(endPosition);
|
||||
const actualContent = content || prevValue.slice(cursorPosition, endPosition);
|
||||
const value = prevValue.slice(0, cursorPosition) + prefix + actualContent + suffix + prevValue.slice(endPosition);
|
||||
|
||||
editorRef.current.value = value;
|
||||
editorRef.current.focus();
|
||||
editorRef.current.selectionEnd = endPosition + prefix.length + content.length;
|
||||
// Place cursor at the end of inserted content
|
||||
const newCursorPosition = cursorPosition + prefix.length + actualContent.length + suffix.length;
|
||||
editorRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
updateEditorHeight();
|
||||
},
|
||||
|
|
@ -192,8 +188,8 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
|||
ref={editorRef}
|
||||
onPaste={onPaste}
|
||||
onInput={handleEditorInput}
|
||||
onCompositionStart={() => setIsInIME(true)}
|
||||
onCompositionEnd={() => setTimeout(() => setIsInIME(false))}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
></textarea>
|
||||
<TagSuggestions editorRef={editorRef} editorActions={ref} />
|
||||
<CommandSuggestions editorRef={editorRef} editorActions={ref} commands={editorCommands} />
|
||||
|
|
|
|||
|
|
@ -164,25 +164,22 @@ export function useSuggestions<T>({
|
|||
};
|
||||
|
||||
// Register event listeners
|
||||
const listenersRegisteredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor || listenersRegisteredRef.current) return;
|
||||
if (!editor) return;
|
||||
|
||||
editor.addEventListener("click", hide);
|
||||
editor.addEventListener("blur", hide);
|
||||
editor.addEventListener("keydown", handleKeyDown);
|
||||
editor.addEventListener("input", handleInput);
|
||||
listenersRegisteredRef.current = true;
|
||||
|
||||
return () => {
|
||||
editor.removeEventListener("click", hide);
|
||||
editor.removeEventListener("blur", hide);
|
||||
editor.removeEventListener("keydown", handleKeyDown);
|
||||
editor.removeEventListener("input", handleInput);
|
||||
listenersRegisteredRef.current = false;
|
||||
};
|
||||
}, [editorRef.current]);
|
||||
}, []); // Empty deps - editor ref is stable, handlers use refs for fresh values
|
||||
|
||||
return {
|
||||
position,
|
||||
|
|
|
|||
75
web/src/components/MemoEditor/ErrorBoundary.tsx
Normal file
75
web/src/components/MemoEditor/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { AlertCircle } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Boundary for MemoEditor
|
||||
* Catches JavaScript errors anywhere in the editor component tree,
|
||||
* logs the error, and displays a fallback UI instead of crashing the entire app.
|
||||
*/
|
||||
class MemoEditorErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// Log the error to console for debugging
|
||||
console.error("MemoEditor Error:", error, errorInfo);
|
||||
// You can also log the error to an error reporting service here
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Custom fallback UI
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default fallback UI
|
||||
return (
|
||||
<div className="w-full flex flex-col justify-center items-center bg-card px-4 py-8 rounded-lg border border-destructive/50">
|
||||
<AlertCircle className="w-8 h-8 text-destructive mb-3" />
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">Editor Error</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center max-w-md">
|
||||
Something went wrong with the memo editor. Please try refreshing the page.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<details className="text-xs text-muted-foreground mb-4 max-w-md">
|
||||
<summary className="cursor-pointer hover:text-foreground">Error details</summary>
|
||||
<pre className="mt-2 p-2 bg-muted rounded text-xs overflow-x-auto">{this.state.error.toString()}</pre>
|
||||
</details>
|
||||
)}
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default MemoEditorErrorBoundary;
|
||||
60
web/src/components/MemoEditor/constants.ts
Normal file
60
web/src/components/MemoEditor/constants.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* MemoEditor Constants
|
||||
* Centralized configuration for the memo editor component
|
||||
*/
|
||||
|
||||
/**
|
||||
* Debounce delay for localStorage writes (in milliseconds)
|
||||
* Prevents excessive writes on every keystroke
|
||||
*/
|
||||
export const LOCALSTORAGE_DEBOUNCE_DELAY = 500;
|
||||
|
||||
/**
|
||||
* Focus Mode styling constants
|
||||
* Centralized to make it easy to adjust appearance
|
||||
*/
|
||||
export const FOCUS_MODE_STYLES = {
|
||||
backdrop: "fixed inset-0 bg-black/20 backdrop-blur-sm z-40",
|
||||
container: {
|
||||
base: "fixed z-50 w-auto max-w-5xl mx-auto shadow-2xl border-border h-auto overflow-y-auto",
|
||||
/**
|
||||
* Responsive spacing using explicit positioning:
|
||||
* - Mobile (< 640px): 8px margin
|
||||
* - Tablet (640-768px): 16px margin
|
||||
* - Desktop (> 768px): 32px margin
|
||||
*/
|
||||
spacing: "top-2 left-2 right-2 bottom-2 sm:top-4 sm:left-4 sm:right-4 sm:bottom-4 md:top-8 md:left-8 md:right-8 md:bottom-8",
|
||||
},
|
||||
transition: "transition-all duration-300 ease-in-out",
|
||||
exitButton: "absolute top-2 right-2 z-10 opacity-60 hover:opacity-100",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Focus Mode keyboard shortcuts
|
||||
* - Toggle: Cmd/Ctrl + Shift + F (matches GitHub, Google Docs convention)
|
||||
* - Exit: Escape key
|
||||
*/
|
||||
export const FOCUS_MODE_TOGGLE_KEY = "f";
|
||||
export const FOCUS_MODE_EXIT_KEY = "Escape";
|
||||
|
||||
/**
|
||||
* Editor height constraints
|
||||
* - Normal mode: Limited to 50% viewport height to avoid excessive scrolling
|
||||
* - Focus mode: Minimum 50vh on mobile, 60vh on desktop for immersive writing
|
||||
*/
|
||||
export const EDITOR_HEIGHT = {
|
||||
normal: "max-h-[50vh]",
|
||||
focusMode: {
|
||||
mobile: "min-h-[50vh]",
|
||||
desktop: "md:min-h-[60vh]",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Geocoding API configuration
|
||||
*/
|
||||
export const GEOCODING = {
|
||||
endpoint: "https://nominatim.openstreetmap.org/reverse",
|
||||
userAgent: "Memos/1.0 (https://github.com/usememos/memos)",
|
||||
format: "json",
|
||||
} as const;
|
||||
|
|
@ -25,16 +25,21 @@ export const hyperlinkHighlightedText = (editor: EditorRefActions, url?: string)
|
|||
const urlRegex = /^(https?:\/\/[^\s]+)$/;
|
||||
if (!url && urlRegex.test(selectedContent.trim())) {
|
||||
editor.insertText(`[](${selectedContent})`);
|
||||
editor.setCursorPosition(cursorPosition + 1, cursorPosition + 1);
|
||||
// insertText places cursor at end, move it between the brackets
|
||||
const linkTextPosition = cursorPosition + 1; // After the opening bracket
|
||||
editor.setCursorPosition(linkTextPosition, linkTextPosition);
|
||||
} else {
|
||||
url = url ?? blankURL;
|
||||
|
||||
editor.insertText(`[${selectedContent}](${url})`);
|
||||
|
||||
if (url === blankURL) {
|
||||
const newCursorStart = cursorPosition + selectedContent.length + 3;
|
||||
editor.setCursorPosition(newCursorStart, newCursorStart + url.length);
|
||||
// insertText places cursor at end, select the placeholder URL
|
||||
const urlStart = cursorPosition + selectedContent.length + 3; // After "]("
|
||||
const urlEnd = urlStart + url.length;
|
||||
editor.setCursorPosition(urlStart, urlEnd);
|
||||
}
|
||||
// If url is provided, cursor stays at end (default insertText behavior)
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
78
web/src/components/MemoEditor/hooks/useAbortController.ts
Normal file
78
web/src/components/MemoEditor/hooks/useAbortController.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Custom hook for managing AbortController lifecycle
|
||||
* Useful for canceling async operations like fetch requests
|
||||
*
|
||||
* @returns Object with methods to create and abort requests
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { getSignal, abort, abortAndCreate } = useAbortController();
|
||||
*
|
||||
* // Create signal for fetch
|
||||
* const signal = getSignal();
|
||||
* fetch(url, { signal });
|
||||
*
|
||||
* // Cancel on user action
|
||||
* abort();
|
||||
*
|
||||
* // Or cancel previous and create new
|
||||
* const newSignal = abortAndCreate();
|
||||
* fetch(newUrl, { signal: newSignal });
|
||||
* ```
|
||||
*/
|
||||
export function useAbortController() {
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Aborts the current request if one exists
|
||||
*/
|
||||
const abort = (): void => {
|
||||
controllerRef.current?.abort();
|
||||
controllerRef.current = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new AbortController and returns its signal
|
||||
* Does not abort previous controller
|
||||
*/
|
||||
const create = (): AbortSignal => {
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
return controller.signal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Aborts current request and creates a new AbortController
|
||||
* Useful for debounced requests
|
||||
*/
|
||||
const abortAndCreate = (): AbortSignal => {
|
||||
abort();
|
||||
return create();
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the signal from the current controller, or creates new one
|
||||
*/
|
||||
const getSignal = (): AbortSignal => {
|
||||
if (!controllerRef.current) {
|
||||
return create();
|
||||
}
|
||||
return controllerRef.current.signal;
|
||||
};
|
||||
|
||||
return {
|
||||
abort,
|
||||
create,
|
||||
abortAndCreate,
|
||||
getSignal,
|
||||
};
|
||||
}
|
||||
65
web/src/components/MemoEditor/hooks/useBlobUrls.ts
Normal file
65
web/src/components/MemoEditor/hooks/useBlobUrls.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Custom hook for managing blob URLs lifecycle
|
||||
* Automatically tracks and cleans up all blob URLs on unmount to prevent memory leaks
|
||||
*
|
||||
* @returns Object with methods to create, revoke, and manage blob URLs
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { createBlobUrl, revokeBlobUrl, revokeAll } = useBlobUrls();
|
||||
*
|
||||
* // Create blob URL (automatically tracked)
|
||||
* const url = createBlobUrl(file);
|
||||
*
|
||||
* // Manually revoke when needed
|
||||
* revokeBlobUrl(url);
|
||||
*
|
||||
* // All URLs are automatically revoked on unmount
|
||||
* ```
|
||||
*/
|
||||
export function useBlobUrls() {
|
||||
const blobUrlsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Clean up all blob URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
blobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
blobUrlsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Creates a blob URL from a file or blob and tracks it for automatic cleanup
|
||||
*/
|
||||
const createBlobUrl = (blob: Blob | File): string => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlsRef.current.add(url);
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Revokes a specific blob URL and removes it from tracking
|
||||
*/
|
||||
const revokeBlobUrl = (url: string): void => {
|
||||
if (blobUrlsRef.current.has(url)) {
|
||||
URL.revokeObjectURL(url);
|
||||
blobUrlsRef.current.delete(url);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Revokes all tracked blob URLs
|
||||
*/
|
||||
const revokeAll = (): void => {
|
||||
blobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
blobUrlsRef.current.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
createBlobUrl,
|
||||
revokeBlobUrl,
|
||||
revokeAll,
|
||||
};
|
||||
}
|
||||
49
web/src/components/MemoEditor/hooks/useDebounce.ts
Normal file
49
web/src/components/MemoEditor/hooks/useDebounce.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Custom hook for debouncing function calls
|
||||
*
|
||||
* @param callback - Function to debounce
|
||||
* @param delay - Delay in milliseconds before invoking the callback
|
||||
* @returns Debounced version of the callback function
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const debouncedSearch = useDebounce((query: string) => {
|
||||
* performSearch(query);
|
||||
* }, 300);
|
||||
*
|
||||
* // Call multiple times, only last call executes after 300ms
|
||||
* debouncedSearch("hello");
|
||||
* ```
|
||||
*/
|
||||
export function useDebounce<T extends (...args: any[]) => void>(callback: T, delay: number): (...args: Parameters<T>) => void {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const callbackRef = useRef(callback);
|
||||
|
||||
// Keep callback ref up to date
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callbackRef.current(...args);
|
||||
}, delay);
|
||||
},
|
||||
[delay],
|
||||
);
|
||||
}
|
||||
59
web/src/components/MemoEditor/hooks/useDragAndDrop.ts
Normal file
59
web/src/components/MemoEditor/hooks/useDragAndDrop.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useState } from "react";
|
||||
|
||||
interface UseDragAndDropOptions {
|
||||
onDrop: (files: FileList) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for handling drag-and-drop file uploads
|
||||
* Manages drag state and event handlers
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @returns Drag state and event handlers
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { isDragging, dragHandlers } = useDragAndDrop({
|
||||
* onDrop: (files) => handleFiles(files),
|
||||
* });
|
||||
*
|
||||
* <div {...dragHandlers} className={isDragging ? 'border-dashed' : ''}>
|
||||
* Drop files here
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
export function useDragAndDrop({ onDrop }: UseDragAndDropOptions) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDragOver = (event: React.DragEvent): void => {
|
||||
if (event.dataTransfer && event.dataTransfer.types.includes("Files")) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
if (!isDragging) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: React.DragEvent): void => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent): void => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
onDrop(event.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
dragHandlers: {
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
68
web/src/components/MemoEditor/hooks/useLocalFileManager.ts
Normal file
68
web/src/components/MemoEditor/hooks/useLocalFileManager.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useState } from "react";
|
||||
import type { LocalFile } from "@/components/memo-metadata";
|
||||
import { useBlobUrls } from "./useBlobUrls";
|
||||
|
||||
/**
|
||||
* Custom hook for managing local file uploads with preview
|
||||
* Handles file state, blob URL creation, and cleanup
|
||||
*
|
||||
* @returns Object with file state and management functions
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { localFiles, addFiles, removeFile, clearFiles } = useLocalFileManager();
|
||||
*
|
||||
* // Add files from input or drag-drop
|
||||
* addFiles(fileList);
|
||||
*
|
||||
* // Remove specific file
|
||||
* removeFile(previewUrl);
|
||||
*
|
||||
* // Clear all (e.g., after successful upload)
|
||||
* clearFiles();
|
||||
* ```
|
||||
*/
|
||||
export function useLocalFileManager() {
|
||||
const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
|
||||
const { createBlobUrl, revokeBlobUrl } = useBlobUrls();
|
||||
|
||||
/**
|
||||
* Adds files to local state with blob URL previews
|
||||
*/
|
||||
const addFiles = (files: FileList | File[]): void => {
|
||||
const fileArray = Array.from(files);
|
||||
const newLocalFiles: LocalFile[] = fileArray.map((file) => ({
|
||||
file,
|
||||
previewUrl: createBlobUrl(file),
|
||||
}));
|
||||
setLocalFiles((prev) => [...prev, ...newLocalFiles]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a specific file by preview URL
|
||||
*/
|
||||
const removeFile = (previewUrl: string): void => {
|
||||
setLocalFiles((prev) => {
|
||||
const toRemove = prev.find((f) => f.previewUrl === previewUrl);
|
||||
if (toRemove) {
|
||||
revokeBlobUrl(toRemove.previewUrl);
|
||||
}
|
||||
return prev.filter((f) => f.previewUrl !== previewUrl);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears all files and revokes their blob URLs
|
||||
*/
|
||||
const clearFiles = (): void => {
|
||||
localFiles.forEach(({ previewUrl }) => revokeBlobUrl(previewUrl));
|
||||
setLocalFiles([]);
|
||||
};
|
||||
|
||||
return {
|
||||
localFiles,
|
||||
addFiles,
|
||||
removeFile,
|
||||
clearFiles,
|
||||
};
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import copy from "copy-to-clipboard";
|
|||
import { isEqual } from "lodash-es";
|
||||
import { LoaderIcon, Minimize2Icon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { 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";
|
||||
|
|
@ -24,78 +24,30 @@ import type { LocalFile } from "../memo-metadata";
|
|||
import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata";
|
||||
import InsertMenu from "./ActionButton/InsertMenu";
|
||||
import VisibilitySelector from "./ActionButton/VisibilitySelector";
|
||||
import { FOCUS_MODE_EXIT_KEY, FOCUS_MODE_STYLES, FOCUS_MODE_TOGGLE_KEY, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants";
|
||||
import Editor, { EditorRefActions } from "./Editor";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
|
||||
import { useDebounce } from "./hooks/useDebounce";
|
||||
import { useDragAndDrop } from "./hooks/useDragAndDrop";
|
||||
import { useLocalFileManager } from "./hooks/useLocalFileManager";
|
||||
import { MemoEditorContext } from "./types";
|
||||
import type { MemoEditorProps, MemoEditorState } from "./types/memo-editor";
|
||||
|
||||
/**
|
||||
* Focus Mode keyboard shortcuts
|
||||
* - Toggle: Cmd/Ctrl + Shift + F (matches GitHub, Google Docs convention)
|
||||
* - Exit: Escape key
|
||||
*/
|
||||
const FOCUS_MODE_TOGGLE_KEY = "f";
|
||||
const FOCUS_MODE_EXIT_KEY = "Escape";
|
||||
// Re-export for backward compatibility
|
||||
export type { MemoEditorProps as Props };
|
||||
|
||||
/**
|
||||
* Focus Mode styling constants
|
||||
* Centralized to make it easy to adjust the appearance and maintain consistency
|
||||
*/
|
||||
const FOCUS_MODE_STYLES = {
|
||||
backdrop: "fixed inset-0 bg-black/20 backdrop-blur-sm z-40",
|
||||
container: {
|
||||
base: "fixed z-50 w-auto max-w-5xl mx-auto shadow-2xl border-border h-auto overflow-y-auto",
|
||||
/**
|
||||
* Responsive spacing using explicit positioning to avoid width conflicts:
|
||||
* - Mobile (< 640px): 8px margin (0.5rem)
|
||||
* - Tablet (640-768px): 16px margin (1rem)
|
||||
* - Desktop (> 768px): 32px margin (2rem)
|
||||
*/
|
||||
spacing: "top-2 left-2 right-2 bottom-2 sm:top-4 sm:left-4 sm:right-4 sm:bottom-4 md:top-8 md:left-8 md:right-8 md:bottom-8",
|
||||
},
|
||||
transition: "transition-all duration-300 ease-in-out",
|
||||
exitButton: "absolute top-2 right-2 z-10 opacity-60 hover:opacity-100",
|
||||
} as const;
|
||||
|
||||
export interface Props {
|
||||
className?: string;
|
||||
cacheKey?: string;
|
||||
placeholder?: string;
|
||||
// The name of the memo to be edited.
|
||||
memoName?: string;
|
||||
// The name of the parent memo if the memo is a comment.
|
||||
parentMemoName?: string;
|
||||
autoFocus?: boolean;
|
||||
onConfirm?: (memoName: string) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
memoVisibility: Visibility;
|
||||
attachmentList: Attachment[];
|
||||
relationList: MemoRelation[];
|
||||
location: Location | undefined;
|
||||
isUploadingAttachment: boolean;
|
||||
isRequesting: boolean;
|
||||
isComposing: boolean;
|
||||
isDraggingFile: boolean;
|
||||
/** Whether Focus Mode (distraction-free writing) is enabled */
|
||||
isFocusMode: boolean;
|
||||
}
|
||||
|
||||
const MemoEditor = observer((props: Props) => {
|
||||
// Local files for preview and upload
|
||||
const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
|
||||
// Clean up blob URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
localFiles.forEach(({ previewUrl }) => URL.revokeObjectURL(previewUrl));
|
||||
};
|
||||
}, [localFiles]);
|
||||
const MemoEditor = observer((props: MemoEditorProps) => {
|
||||
const { className, cacheKey, memoName, parentMemoName, autoFocus, onConfirm, onCancel } = props;
|
||||
const t = useTranslate();
|
||||
const { i18n } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const [state, setState] = useState<State>({
|
||||
|
||||
// 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: [],
|
||||
|
|
@ -262,18 +214,18 @@ const MemoEditor = observer((props: Props) => {
|
|||
};
|
||||
|
||||
// Add local files from InsertMenu
|
||||
const handleAddLocalFiles = (newFiles: LocalFile[]) => {
|
||||
setLocalFiles((prev) => [...prev, ...newFiles]);
|
||||
};
|
||||
// Drag-and-drop for file uploads
|
||||
const { isDragging, dragHandlers } = useDragAndDrop({
|
||||
onDrop: (files) => addFiles(files),
|
||||
});
|
||||
|
||||
// Remove a local file (e.g. on user remove)
|
||||
const handleRemoveLocalFile = (previewUrl: string) => {
|
||||
setLocalFiles((prev) => {
|
||||
const toRemove = prev.find((f) => f.previewUrl === previewUrl);
|
||||
if (toRemove) URL.revokeObjectURL(toRemove.previewUrl);
|
||||
return prev.filter((f) => f.previewUrl !== previewUrl);
|
||||
});
|
||||
};
|
||||
// Sync drag state with component state
|
||||
useEffect(() => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
isDraggingFile: isDragging,
|
||||
}));
|
||||
}, [isDragging]);
|
||||
|
||||
const handleSetRelationList = (relationList: MemoRelation[]) => {
|
||||
setState((prevState) => ({
|
||||
|
|
@ -282,53 +234,10 @@ const MemoEditor = observer((props: Props) => {
|
|||
}));
|
||||
};
|
||||
|
||||
// Add files to local state for preview (no upload yet)
|
||||
const addFilesToLocal = (files: FileList | File[]) => {
|
||||
const fileArray = Array.from(files);
|
||||
const newLocalFiles: LocalFile[] = fileArray.map((file) => ({
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
}));
|
||||
setLocalFiles((prev) => [...prev, ...newLocalFiles]);
|
||||
};
|
||||
|
||||
const handleDropEvent = async (event: React.DragEvent) => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
event.preventDefault();
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
isDraggingFile: false,
|
||||
}));
|
||||
|
||||
addFilesToLocal(event.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent) => {
|
||||
if (event.dataTransfer && event.dataTransfer.types.includes("Files")) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
if (!state.isDraggingFile) {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
isDraggingFile: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
isDraggingFile: false,
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePasteEvent = async (event: React.ClipboardEvent) => {
|
||||
if (event.clipboardData && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault();
|
||||
addFilesToLocal(event.clipboardData.files);
|
||||
addFiles(event.clipboardData.files);
|
||||
} else if (
|
||||
editorRef.current != null &&
|
||||
editorRef.current.getSelectedContent().length != 0 &&
|
||||
|
|
@ -339,13 +248,18 @@ const MemoEditor = observer((props: Props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
setHasContent(content !== "");
|
||||
// Debounced cache setter to avoid writing to localStorage on every keystroke
|
||||
const saveContentToCache = useDebounce((content: string) => {
|
||||
if (content !== "") {
|
||||
setContentCache(content);
|
||||
} else {
|
||||
localStorage.removeItem(contentCacheKey);
|
||||
}
|
||||
}, LOCALSTORAGE_DEBOUNCE_DELAY);
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
setHasContent(content !== "");
|
||||
saveContentToCache(content);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
|
|
@ -465,8 +379,7 @@ const MemoEditor = observer((props: Props) => {
|
|||
}
|
||||
editorRef.current?.setContent("");
|
||||
// Clean up local files after successful save
|
||||
localFiles.forEach(({ previewUrl }) => URL.revokeObjectURL(previewUrl));
|
||||
setLocalFiles([]);
|
||||
clearFiles();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
|
|
@ -497,152 +410,152 @@ const MemoEditor = observer((props: Props) => {
|
|||
onContentChange: handleContentChange,
|
||||
onPaste: handlePasteEvent,
|
||||
isFocusMode: state.isFocusMode,
|
||||
isInIME: state.isComposing,
|
||||
onCompositionStart: handleCompositionStart,
|
||||
onCompositionEnd: handleCompositionEnd,
|
||||
}),
|
||||
[i18n.language, state.isFocusMode],
|
||||
[i18n.language, state.isFocusMode, state.isComposing],
|
||||
);
|
||||
|
||||
const allowSave =
|
||||
(hasContent || state.attachmentList.length > 0 || localFiles.length > 0) && !state.isUploadingAttachment && !state.isRequesting;
|
||||
|
||||
return (
|
||||
<MemoEditorContext.Provider
|
||||
value={{
|
||||
attachmentList: state.attachmentList,
|
||||
relationList: state.relationList,
|
||||
setAttachmentList: handleSetAttachmentList,
|
||||
addLocalFiles: handleAddLocalFiles,
|
||||
removeLocalFile: handleRemoveLocalFile,
|
||||
localFiles,
|
||||
setRelationList: (relationList: MemoRelation[]) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
relationList,
|
||||
}));
|
||||
},
|
||||
memoName,
|
||||
}}
|
||||
>
|
||||
{/* Focus Mode Backdrop */}
|
||||
{state.isFocusMode && <div className={FOCUS_MODE_STYLES.backdrop} onClick={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),
|
||||
className,
|
||||
)}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDrop={handleDropEvent}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onFocus={handleEditorFocus}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
>
|
||||
{/* Focus Mode Exit Button */}
|
||||
{state.isFocusMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={FOCUS_MODE_STYLES.exitButton}
|
||||
onClick={toggleFocusMode}
|
||||
title={t("editor.exit-focus-mode")}
|
||||
>
|
||||
<Minimize2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Editor ref={editorRef} {...editorConfig} />
|
||||
<LocationDisplay
|
||||
mode="edit"
|
||||
location={state.location}
|
||||
onRemove={() =>
|
||||
<ErrorBoundary>
|
||||
<MemoEditorContext.Provider
|
||||
value={{
|
||||
attachmentList: state.attachmentList,
|
||||
relationList: state.relationList,
|
||||
setAttachmentList: handleSetAttachmentList,
|
||||
addLocalFiles: (files) => addFiles(Array.from(files.map((f) => f.file))),
|
||||
removeLocalFile: removeFile,
|
||||
localFiles,
|
||||
setRelationList: (relationList: MemoRelation[]) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
location: undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{/* Show attachments and pending files together */}
|
||||
<AttachmentList
|
||||
mode="edit"
|
||||
attachments={state.attachmentList}
|
||||
onAttachmentsChange={handleSetAttachmentList}
|
||||
localFiles={localFiles}
|
||||
onRemoveLocalFile={handleRemoveLocalFile}
|
||||
/>
|
||||
<RelationList mode="edit" relations={referenceRelations} onRelationsChange={handleSetRelationList} />
|
||||
<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,
|
||||
}))
|
||||
}
|
||||
onToggleFocusMode={toggleFocusMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-row justify-end items-center">
|
||||
<VisibilitySelector value={state.memoVisibility} onChange={(visibility) => handleMemoVisibilityChange(visibility)} />
|
||||
<div className="flex flex-row justify-end gap-1">
|
||||
{props.onCancel && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={state.isRequesting}
|
||||
onClick={() => {
|
||||
localFiles.forEach(({ previewUrl }) => URL.revokeObjectURL(previewUrl));
|
||||
setLocalFiles([]);
|
||||
if (props.onCancel) props.onCancel();
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
relationList,
|
||||
}));
|
||||
},
|
||||
memoName,
|
||||
}}
|
||||
>
|
||||
{/* Focus Mode Backdrop */}
|
||||
{state.isFocusMode && <div className={FOCUS_MODE_STYLES.backdrop} onClick={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),
|
||||
className,
|
||||
)}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...dragHandlers}
|
||||
onFocus={handleEditorFocus}
|
||||
>
|
||||
{/* Focus Mode Exit Button */}
|
||||
{state.isFocusMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={FOCUS_MODE_STYLES.exitButton}
|
||||
onClick={toggleFocusMode}
|
||||
title={t("editor.exit-focus-mode")}
|
||||
>
|
||||
<Minimize2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Editor ref={editorRef} {...editorConfig} />
|
||||
<LocationDisplay
|
||||
mode="edit"
|
||||
location={state.location}
|
||||
onRemove={() =>
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
location: undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{/* Show attachments and pending files together */}
|
||||
<AttachmentList
|
||||
mode="edit"
|
||||
attachments={state.attachmentList}
|
||||
onAttachmentsChange={handleSetAttachmentList}
|
||||
localFiles={localFiles}
|
||||
onRemoveLocalFile={removeFile}
|
||||
/>
|
||||
<RelationList mode="edit" relations={referenceRelations} onRelationsChange={handleSetRelationList} />
|
||||
<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,
|
||||
}))
|
||||
}
|
||||
onToggleFocusMode={toggleFocusMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-row justify-end items-center">
|
||||
<VisibilitySelector value={state.memoVisibility} onChange={(visibility) => handleMemoVisibilityChange(visibility)} />
|
||||
<div className="flex flex-row justify-end gap-1">
|
||||
{props.onCancel && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={state.isRequesting}
|
||||
onClick={() => {
|
||||
clearFiles();
|
||||
if (props.onCancel) props.onCancel();
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<Button disabled={!allowSave || state.isRequesting} onClick={handleSaveBtnClick}>
|
||||
{state.isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show memo metadata if memoName is provided */}
|
||||
{memoName && (
|
||||
<div className="w-full -mt-1 mb-4 text-xs leading-5 px-4 opacity-60 font-mono text-muted-foreground">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 items-center">
|
||||
{!isEqual(createTime, updateTime) && updateTime && (
|
||||
<>
|
||||
<span className="text-left">Updated</span>
|
||||
<DateTimeInput value={updateTime} onChange={setUpdateTime} />
|
||||
</>
|
||||
)}
|
||||
{createTime && (
|
||||
<>
|
||||
<span className="text-left">Created</span>
|
||||
<DateTimeInput value={createTime} onChange={setCreateTime} />
|
||||
</>
|
||||
)}
|
||||
<span className="text-left">ID</span>
|
||||
<span
|
||||
className="px-1 border border-transparent cursor-default"
|
||||
onClick={() => {
|
||||
copy(extractMemoIdFromName(memoName));
|
||||
toast.success(t("message.copied"));
|
||||
}}
|
||||
>
|
||||
{extractMemoIdFromName(memoName)}
|
||||
</span>
|
||||
{/* Show memo metadata if memoName is provided */}
|
||||
{memoName && (
|
||||
<div className="w-full -mt-1 mb-4 text-xs leading-5 px-4 opacity-60 font-mono text-muted-foreground">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 items-center">
|
||||
{!isEqual(createTime, updateTime) && updateTime && (
|
||||
<>
|
||||
<span className="text-left">Updated</span>
|
||||
<DateTimeInput value={updateTime} onChange={setUpdateTime} />
|
||||
</>
|
||||
)}
|
||||
{createTime && (
|
||||
<>
|
||||
<span className="text-left">Created</span>
|
||||
<DateTimeInput value={createTime} onChange={setCreateTime} />
|
||||
</>
|
||||
)}
|
||||
<span className="text-left">ID</span>
|
||||
<span
|
||||
className="px-1 border border-transparent cursor-default"
|
||||
onClick={() => {
|
||||
copy(extractMemoIdFromName(memoName));
|
||||
toast.success(t("message.copied"));
|
||||
}}
|
||||
>
|
||||
{extractMemoIdFromName(memoName)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MemoEditorContext.Provider>
|
||||
)}
|
||||
</MemoEditorContext.Provider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
63
web/src/components/MemoEditor/types/memo-editor.ts
Normal file
63
web/src/components/MemoEditor/types/memo-editor.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import type { Location, MemoRelation, Visibility } from "@/types/proto/api/v1/memo_service";
|
||||
|
||||
/**
|
||||
* Props for the MemoEditor component
|
||||
*/
|
||||
export interface MemoEditorProps {
|
||||
/** Optional CSS class name */
|
||||
className?: string;
|
||||
/** Cache key for localStorage persistence */
|
||||
cacheKey?: string;
|
||||
/** Placeholder text for empty editor */
|
||||
placeholder?: string;
|
||||
/** Name of the memo being edited (for edit mode) */
|
||||
memoName?: string;
|
||||
/** Name of parent memo (for comment/reply mode) */
|
||||
parentMemoName?: string;
|
||||
/** Whether to auto-focus the editor on mount */
|
||||
autoFocus?: boolean;
|
||||
/** Callback when memo is saved successfully */
|
||||
onConfirm?: (memoName: string) => void;
|
||||
/** Callback when editing is canceled */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state for MemoEditor component
|
||||
*/
|
||||
export interface MemoEditorState {
|
||||
/** Visibility level of the memo */
|
||||
memoVisibility: Visibility;
|
||||
/** List of attachments */
|
||||
attachmentList: Attachment[];
|
||||
/** List of related memos */
|
||||
relationList: MemoRelation[];
|
||||
/** Geographic location */
|
||||
location: Location | undefined;
|
||||
/** Whether attachments are currently being uploaded */
|
||||
isUploadingAttachment: boolean;
|
||||
/** Whether save/update request is in progress */
|
||||
isRequesting: boolean;
|
||||
/** Whether IME composition is active (for Asian languages) */
|
||||
isComposing: boolean;
|
||||
/** Whether files are being dragged over the editor */
|
||||
isDraggingFile: boolean;
|
||||
/** Whether Focus Mode is enabled */
|
||||
isFocusMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the Editor sub-component
|
||||
*/
|
||||
export interface EditorConfig {
|
||||
className: string;
|
||||
initialContent: string;
|
||||
placeholder: string;
|
||||
onContentChange: (content: string) => void;
|
||||
onPaste: (event: React.ClipboardEvent) => void;
|
||||
isFocusMode: boolean;
|
||||
isInIME: boolean;
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
}
|
||||
|
|
@ -228,8 +228,7 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
|
|||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"relative group flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-2 gap-2 text-card-foreground rounded-lg border border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
shortcutActive && !showEditor && "border-ring ring-2 ring-ring bg-accent/10",
|
||||
"relative group flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-2 gap-2 text-card-foreground rounded-lg border border-border transition-colors",
|
||||
className,
|
||||
)}
|
||||
ref={cardRef}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue