diff --git a/web/src/components/MemoEditor/Editor/CommandSuggestions.tsx b/web/src/components/MemoEditor/Editor/CommandSuggestions.tsx index a1d463f00..db1c99a89 100644 --- a/web/src/components/MemoEditor/Editor/CommandSuggestions.tsx +++ b/web/src/components/MemoEditor/Editor/CommandSuggestions.tsx @@ -11,6 +11,15 @@ interface CommandSuggestionsProps { commands: Command[]; } +/** + * Command suggestions popup that appears when typing "/" in the editor. + * Shows available editor commands like formatting options, insertions, etc. + * + * Usage: + * - Type "/" to trigger + * - Continue typing to filter commands + * - Use Arrow keys to navigate, Enter/Tab to select + */ const CommandSuggestions = observer(({ editorRef, editorActions, commands }: CommandSuggestionsProps) => { const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({ editorRef, @@ -18,14 +27,15 @@ const CommandSuggestions = observer(({ editorRef, editorActions, commands }: Com triggerChar: "/", items: commands, filterItems: (items, searchQuery) => { - // Show all commands when no search query if (!searchQuery) return items; - // Filter commands that start with the search query + // Filter commands by prefix match for intuitive searching return items.filter((cmd) => cmd.name.toLowerCase().startsWith(searchQuery)); }, onAutocomplete: (cmd, word, index, actions) => { + // Replace the trigger word with the command output actions.removeText(index, word.length); actions.insertText(cmd.run()); + // Position cursor if command specifies an offset if (cmd.cursorOffset) { actions.setCursorPosition(actions.getCursorPosition() + cmd.cursorOffset); } diff --git a/web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx b/web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx index c5674b5c3..e165e88a1 100644 --- a/web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx +++ b/web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import { ReactNode, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { Position } from "./useSuggestions"; @@ -14,6 +14,12 @@ interface SuggestionsPopupProps { /** * Shared popup component for displaying suggestion items. * Provides consistent styling and behavior across different suggestion types. + * + * Features: + * - Automatically scrolls selected item into view + * - Handles keyboard navigation highlighting + * - Prevents text selection during mouse interaction + * - Consistent styling with max height constraints */ export function SuggestionsPopup({ position, @@ -23,17 +29,33 @@ export function SuggestionsPopup({ renderItem, getItemKey, }: SuggestionsPopupProps) { + const containerRef = useRef(null); + const selectedItemRef = useRef(null); + + // Scroll selected item into view when selection changes + useEffect(() => { + if (selectedItemRef.current && containerRef.current) { + selectedItemRef.current.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, [selectedIndex]); + return (
{suggestions.map((item, i) => (
onItemSelect(item)} className={cn( - "rounded p-1 px-2 w-full truncate text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground", + "rounded p-1 px-2 w-full text-sm cursor-pointer transition-colors select-none", + "hover:bg-accent hover:text-accent-foreground", i === selectedIndex ? "bg-accent text-accent-foreground" : "", )} > diff --git a/web/src/components/MemoEditor/Editor/TagSuggestions.tsx b/web/src/components/MemoEditor/Editor/TagSuggestions.tsx index 003c3f7c7..780cae3aa 100644 --- a/web/src/components/MemoEditor/Editor/TagSuggestions.tsx +++ b/web/src/components/MemoEditor/Editor/TagSuggestions.tsx @@ -11,8 +11,18 @@ interface TagSuggestionsProps { editorActions: React.ForwardedRef; } +/** + * Tag suggestions popup that appears when typing "#" in the editor. + * Shows previously used tags sorted by frequency. + * + * Usage: + * - Type "#" to trigger + * - Continue typing to filter tags + * - Use Arrow keys to navigate, Enter/Tab to select + * - Tags are sorted by usage count (most used first) + */ const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsProps) => { - // Sort tags by usage count (descending), then alphabetically + // Sort tags by usage count (descending), then alphabetically for ties const sortedTags = useMemo( () => Object.entries(userStore.state.tagCount) @@ -28,12 +38,12 @@ const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsPro triggerChar: "#", items: sortedTags, filterItems: (items, searchQuery) => { - // Show all tags when no search query if (!searchQuery) return items; - // Filter tags that contain the search query + // Filter tags by substring match for flexible searching return items.filter((tag) => tag.toLowerCase().includes(searchQuery)); }, onAutocomplete: (tag, word, index, actions) => { + // Replace the trigger word with the complete tag actions.removeText(index, word.length); actions.insertText(`#${tag}`); }, diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index 3f7d724e9..2b685e003 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -152,17 +152,13 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef< updateEditorHeight(); }, []); - const { handleEnterKey } = useListAutoCompletion({ + // Auto-complete markdown lists when pressing Enter + useListAutoCompletion({ + editorRef, editorActions, isInIME, }); - const handleEditorKeyDown = (event: React.KeyboardEvent) => { - if (event.key === "Enter") { - handleEnterKey(event); - } - }; - return (
diff --git a/web/src/components/MemoEditor/Editor/useListAutoCompletion.ts b/web/src/components/MemoEditor/Editor/useListAutoCompletion.ts index eddc326db..2fd3aa703 100644 --- a/web/src/components/MemoEditor/Editor/useListAutoCompletion.ts +++ b/web/src/components/MemoEditor/Editor/useListAutoCompletion.ts @@ -1,8 +1,9 @@ -import { useCallback } from "react"; +import { useEffect, useRef } from "react"; import { detectLastListItem, generateListContinuation } from "@/utils/markdown-list-detection"; import { EditorRefActions } from "."; interface UseListAutoCompletionOptions { + editorRef: React.RefObject; editorActions: EditorRefActions; isInIME: boolean; } @@ -17,26 +18,34 @@ interface UseListAutoCompletionOptions { * - Unordered lists (- item, * item, + item) * - Task lists (- [ ] task, - [x] task) * - Nested lists with proper indentation + * + * This hook manages its own event listeners and cleanup. */ -export function useListAutoCompletion({ editorActions, isInIME }: UseListAutoCompletionOptions) { - /** - * Handles the Enter key press to auto-complete list items. - * Returns true if the event was handled, false otherwise. - */ - const handleEnterKey = useCallback( - (event: React.KeyboardEvent): boolean => { +export function useListAutoCompletion({ editorRef, editorActions, isInIME }: UseListAutoCompletionOptions) { + // Use refs to avoid stale closures in event handlers + const isInIMERef = useRef(isInIME); + isInIMERef.current = isInIME; + + const editorActionsRef = useRef(editorActions); + editorActionsRef.current = editorActions; + + useEffect(() => { + const editor = editorRef.current; + if (!editor) return; + + const handleKeyDown = (event: KeyboardEvent) => { + // Only handle Enter key + if (event.key !== "Enter") return; + // Don't handle if in IME composition (for Asian languages) - if (isInIME) { - return false; - } + if (isInIMERef.current) return; // Don't handle if modifier keys are pressed (user wants manual control) - if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) { - return false; - } + if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return; - const cursorPosition = editorActions.getCursorPosition(); - const contentBeforeCursor = editorActions.getContent().substring(0, cursorPosition); + const actions = editorActionsRef.current; + const cursorPosition = actions.getCursorPosition(); + const contentBeforeCursor = actions.getContent().substring(0, cursorPosition); // Detect if we're on a list item const listInfo = detectLastListItem(contentBeforeCursor); @@ -44,14 +53,14 @@ export function useListAutoCompletion({ editorActions, isInIME }: UseListAutoCom if (listInfo.type) { event.preventDefault(); const continuation = generateListContinuation(listInfo); - editorActions.insertText("\n" + continuation); - return true; + actions.insertText("\n" + continuation); } + }; - return false; - }, - [editorActions, isInIME], - ); + editor.addEventListener("keydown", handleKeyDown); - return { handleEnterKey }; + return () => { + editor.removeEventListener("keydown", handleKeyDown); + }; + }, [editorRef.current]); } diff --git a/web/src/components/MemoEditor/Editor/useSuggestions.ts b/web/src/components/MemoEditor/Editor/useSuggestions.ts index c8b97d6b0..1d35efcf4 100644 --- a/web/src/components/MemoEditor/Editor/useSuggestions.ts +++ b/web/src/components/MemoEditor/Editor/useSuggestions.ts @@ -2,28 +2,64 @@ import { useEffect, useRef, useState } from "react"; import getCaretCoordinates from "textarea-caret"; import { EditorRefActions } from "."; -export type Position = { left: number; top: number; height: number }; +export interface Position { + left: number; + top: number; + height: number; +} export interface UseSuggestionsOptions { + /** Reference to the textarea element */ editorRef: React.RefObject; + /** Reference to editor actions for text manipulation */ editorActions: React.ForwardedRef; + /** Character that triggers the suggestions (e.g., '/', '#', '@') */ triggerChar: string; + /** Array of items to show in suggestions */ items: T[]; + /** Function to filter items based on search query */ filterItems: (items: T[], searchQuery: string) => T[]; + /** Callback when an item is selected for autocomplete */ onAutocomplete: (item: T, word: string, startIndex: number, actions: EditorRefActions) => void; } export interface UseSuggestionsReturn { + /** Current position of the popup, or null if hidden */ position: Position | null; + /** Filtered suggestions based on current search */ suggestions: T[]; + /** Index of the currently selected suggestion */ selectedIndex: number; + /** Whether the suggestions popup is visible */ isVisible: boolean; + /** Handler to select a suggestion item */ handleItemSelect: (item: T) => void; } /** * Shared hook for managing suggestion popups in the editor. * Handles positioning, keyboard navigation, filtering, and autocomplete logic. + * + * Features: + * - Auto-positioning based on caret location + * - Keyboard navigation (Arrow Up/Down, Enter, Tab, Escape) + * - Smart filtering based on trigger character + * - Proper event cleanup + * + * @example + * ```tsx + * const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({ + * editorRef, + * editorActions, + * triggerChar: '#', + * items: tags, + * filterItems: (items, query) => items.filter(tag => tag.includes(query)), + * onAutocomplete: (tag, word, index, actions) => { + * actions.removeText(index, word.length); + * actions.insertText(`#${tag}`); + * }, + * }); + * ``` */ export function useSuggestions({ editorRef, @@ -64,7 +100,10 @@ export function useSuggestions({ isVisibleRef.current = !!(position && suggestionsRef.current.length > 0); const handleAutocomplete = (item: T) => { - if (!editorActions || !("current" in editorActions) || !editorActions.current) return; + if (!editorActions || !("current" in editorActions) || !editorActions.current) { + console.warn("useSuggestions: editorActions not available for autocomplete"); + return; + } const [word, index] = getCurrentWord(); onAutocomplete(item, word, index, editorActions.current); hide();