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:
Steven 2025-11-25 21:29:54 +08:00
parent 1832b59190
commit ef6456a4f5
15 changed files with 776 additions and 320 deletions

View file

@ -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)}`);
}
});
};

View file

@ -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,
};

View file

@ -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} />

View file

@ -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,

View 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;

View 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;

View file

@ -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)
}
};

View 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,
};
}

View 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,
};
}

View 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],
);
}

View 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,
},
};
}

View 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,
};
}

View file

@ -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>
);
});

View 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;
}

View file

@ -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}