diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx index 2bb2ebab6..691c34443 100644 --- a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx +++ b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx @@ -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 => { + 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)}`); + } }); }; diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu/useFileUpload.ts b/web/src/components/MemoEditor/ActionButton/InsertMenu/useFileUpload.ts index 76a1995f8..2778da0b7 100644 --- a/web/src/components/MemoEditor/ActionButton/InsertMenu/useFileUpload.ts +++ b/web/src/components/MemoEditor/ActionButton/InsertMenu/useFileUpload.ts @@ -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(null); - const [selectingFlag, setSelectingFlag] = useState(false); + const selectingFlagRef = useRef(false); const handleFileInputChange = (event?: React.ChangeEvent) => { 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, }; diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index c5cca4a5a..c9546617e 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -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) { - 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(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} > diff --git a/web/src/components/MemoEditor/Editor/useSuggestions.ts b/web/src/components/MemoEditor/Editor/useSuggestions.ts index 1d35efcf4..d3a318875 100644 --- a/web/src/components/MemoEditor/Editor/useSuggestions.ts +++ b/web/src/components/MemoEditor/Editor/useSuggestions.ts @@ -164,25 +164,22 @@ export function useSuggestions({ }; // 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, diff --git a/web/src/components/MemoEditor/ErrorBoundary.tsx b/web/src/components/MemoEditor/ErrorBoundary.tsx new file mode 100644 index 000000000..f82ba9987 --- /dev/null +++ b/web/src/components/MemoEditor/ErrorBoundary.tsx @@ -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 { + 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 ( +
+ +

Editor Error

+

+ Something went wrong with the memo editor. Please try refreshing the page. +

+ {this.state.error && ( +
+ Error details +
{this.state.error.toString()}
+
+ )} + +
+ ); + } + + return this.props.children; + } +} + +export default MemoEditorErrorBoundary; diff --git a/web/src/components/MemoEditor/constants.ts b/web/src/components/MemoEditor/constants.ts new file mode 100644 index 000000000..c3c4356fd --- /dev/null +++ b/web/src/components/MemoEditor/constants.ts @@ -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; diff --git a/web/src/components/MemoEditor/handlers.ts b/web/src/components/MemoEditor/handlers.ts index 2e0c2bb20..c3ae9e51f 100644 --- a/web/src/components/MemoEditor/handlers.ts +++ b/web/src/components/MemoEditor/handlers.ts @@ -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) } }; diff --git a/web/src/components/MemoEditor/hooks/useAbortController.ts b/web/src/components/MemoEditor/hooks/useAbortController.ts new file mode 100644 index 000000000..ec8843afa --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useAbortController.ts @@ -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(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, + }; +} diff --git a/web/src/components/MemoEditor/hooks/useBlobUrls.ts b/web/src/components/MemoEditor/hooks/useBlobUrls.ts new file mode 100644 index 000000000..b2cf6fbf9 --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useBlobUrls.ts @@ -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>(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, + }; +} diff --git a/web/src/components/MemoEditor/hooks/useDebounce.ts b/web/src/components/MemoEditor/hooks/useDebounce.ts new file mode 100644 index 000000000..fe9f91e73 --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useDebounce.ts @@ -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 void>(callback: T, delay: number): (...args: Parameters) => void { + const timeoutRef = useRef | 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) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + callbackRef.current(...args); + }, delay); + }, + [delay], + ); +} diff --git a/web/src/components/MemoEditor/hooks/useDragAndDrop.ts b/web/src/components/MemoEditor/hooks/useDragAndDrop.ts new file mode 100644 index 000000000..8a0e13975 --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useDragAndDrop.ts @@ -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), + * }); + * + *
+ * Drop files here + *
+ * ``` + */ +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, + }, + }; +} diff --git a/web/src/components/MemoEditor/hooks/useLocalFileManager.ts b/web/src/components/MemoEditor/hooks/useLocalFileManager.ts new file mode 100644 index 000000000..fc19adf7b --- /dev/null +++ b/web/src/components/MemoEditor/hooks/useLocalFileManager.ts @@ -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([]); + 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, + }; +} diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 84e77893b..468c657e9 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -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([]); - // 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({ + + // Custom hooks for file management + const { localFiles, addFiles, removeFile, clearFiles } = useLocalFileManager(); + + // Internal component state + const [state, setState] = useState({ 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 ( - { - setState((prevState) => ({ - ...prevState, - relationList, - })); - }, - memoName, - }} - > - {/* Focus Mode Backdrop */} - {state.isFocusMode &&
} - -
- {/* Focus Mode Exit Button */} - {state.isFocusMode && ( - - )} - - - + + 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 */} - - -
e.stopPropagation()}> -
- - setState((prevState) => ({ - ...prevState, - location, - })) - } - onToggleFocusMode={toggleFocusMode} - /> -
-
- handleMemoVisibilityChange(visibility)} /> -
- {props.onCancel && ( - + )} + + + + setState((prevState) => ({ + ...prevState, + location: undefined, + })) + } + /> + {/* Show attachments and pending files together */} + + +
e.stopPropagation()}> +
+ + setState((prevState) => ({ + ...prevState, + location, + })) + } + onToggleFocusMode={toggleFocusMode} + /> +
+
+ handleMemoVisibilityChange(visibility)} /> +
+ {props.onCancel && ( + + )} + - )} - +
-
- {/* Show memo metadata if memoName is provided */} - {memoName && ( -
-
- {!isEqual(createTime, updateTime) && updateTime && ( - <> - Updated - - - )} - {createTime && ( - <> - Created - - - )} - ID - { - copy(extractMemoIdFromName(memoName)); - toast.success(t("message.copied")); - }} - > - {extractMemoIdFromName(memoName)} - + {/* Show memo metadata if memoName is provided */} + {memoName && ( +
+
+ {!isEqual(createTime, updateTime) && updateTime && ( + <> + Updated + + + )} + {createTime && ( + <> + Created + + + )} + ID + { + copy(extractMemoIdFromName(memoName)); + toast.success(t("message.copied")); + }} + > + {extractMemoIdFromName(memoName)} + +
-
- )} - + )} + + ); }); diff --git a/web/src/components/MemoEditor/types/memo-editor.ts b/web/src/components/MemoEditor/types/memo-editor.ts new file mode 100644 index 000000000..6c0d17bd5 --- /dev/null +++ b/web/src/components/MemoEditor/types/memo-editor.ts @@ -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; +} diff --git a/web/src/components/MemoView.tsx b/web/src/components/MemoView.tsx index 741ffdd7f..1d09cc143 100644 --- a/web/src/components/MemoView.tsx +++ b/web/src/components/MemoView.tsx @@ -228,8 +228,7 @@ const MemoView: React.FC = observer((props: Props) => { ) : (