diff --git a/web/src/components/MemoEditor/Editor/CommandSuggestions.tsx b/web/src/components/MemoEditor/Editor/CommandSuggestions.tsx index be73e1111..db1c99a89 100644 --- a/web/src/components/MemoEditor/Editor/CommandSuggestions.tsx +++ b/web/src/components/MemoEditor/Editor/CommandSuggestions.tsx @@ -1,130 +1,63 @@ import { observer } from "mobx-react-lite"; -import { useEffect, useRef, useState } from "react"; -import getCaretCoordinates from "textarea-caret"; import OverflowTip from "@/components/kit/OverflowTip"; -import { cn } from "@/lib/utils"; import { EditorRefActions } from "."; import { Command } from "../types/command"; +import { SuggestionsPopup } from "./SuggestionsPopup"; +import { useSuggestions } from "./useSuggestions"; -type Props = { +interface CommandSuggestionsProps { editorRef: React.RefObject; editorActions: React.ForwardedRef; commands: Command[]; -}; +} -type Position = { left: number; top: number; height: number }; +/** + * 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, + editorActions, + triggerChar: "/", + items: commands, + filterItems: (items, searchQuery) => { + if (!searchQuery) return items; + // 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); + } + }, + }); -const CommandSuggestions = observer(({ editorRef, editorActions, commands }: Props) => { - const [position, setPosition] = useState(null); - const [selected, select] = useState(0); - const selectedRef = useRef(selected); - selectedRef.current = selected; + if (!isVisible || !position) return null; - const hide = () => setPosition(null); - - const getCurrentWord = (): [word: string, startIndex: number] => { - const editor = editorRef.current; - if (!editor) return ["", 0]; - const cursorPos = editor.selectionEnd; - const before = editor.value.slice(0, cursorPos).match(/\S*$/) || { 0: "", index: cursorPos }; - const after = editor.value.slice(cursorPos).match(/^\S*/) || { 0: "" }; - return [before[0] + after[0], before.index ?? cursorPos]; - }; - - // Filter commands based on the current word after the slash - const suggestionsRef = useRef([]); - suggestionsRef.current = (() => { - const [word] = getCurrentWord(); - if (!word.startsWith("/")) return []; - const search = word.slice(1).toLowerCase(); - if (!search) return commands; - return commands.filter((cmd) => cmd.name.toLowerCase().startsWith(search)); - })(); - - const isVisibleRef = useRef(false); - isVisibleRef.current = !!(position && suggestionsRef.current.length > 0); - - const autocomplete = (cmd: Command) => { - if (!editorActions || !("current" in editorActions) || !editorActions.current) return; - const [word, index] = getCurrentWord(); - editorActions.current.removeText(index, word.length); - editorActions.current.insertText(cmd.run()); - if (cmd.cursorOffset) { - editorActions.current.setCursorPosition(editorActions.current.getCursorPosition() + cmd.cursorOffset); - } - hide(); - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (!isVisibleRef.current) return; - const suggestions = suggestionsRef.current; - const selected = selectedRef.current; - if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) hide(); - if ("ArrowDown" === e.code) { - select((selected + 1) % suggestions.length); - e.preventDefault(); - e.stopPropagation(); - } - if ("ArrowUp" === e.code) { - select((selected - 1 + suggestions.length) % suggestions.length); - e.preventDefault(); - e.stopPropagation(); - } - if (["Enter", "Tab"].includes(e.code)) { - autocomplete(suggestions[selected]); - e.preventDefault(); - e.stopPropagation(); - } - }; - - const handleInput = () => { - const editor = editorRef.current; - if (!editor) return; - select(0); - const [word, index] = getCurrentWord(); - const currentChar = editor.value[editor.selectionEnd]; - const isActive = word.startsWith("/") && currentChar !== "/"; - const caretCordinates = getCaretCoordinates(editor, index); - caretCordinates.top -= editor.scrollTop; - if (isActive) { - setPosition(caretCordinates); - } else { - hide(); - } - }; - - const listenersAreRegisteredRef = useRef(false); - const registerListeners = () => { - const editor = editorRef.current; - if (!editor || listenersAreRegisteredRef.current) return; - editor.addEventListener("click", hide); - editor.addEventListener("blur", hide); - editor.addEventListener("keydown", handleKeyDown); - editor.addEventListener("input", handleInput); - listenersAreRegisteredRef.current = true; - }; - useEffect(registerListeners, [!!editorRef.current]); - - if (!isVisibleRef.current || !position) return null; return ( -
- {suggestionsRef.current.map((cmd, i) => ( -
autocomplete(cmd)} - className={cn( - "rounded p-1 px-2 w-full truncate text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground", - i === selected ? "bg-accent text-accent-foreground" : "", - )} - > + cmd.name} + renderItem={(cmd) => ( + <> /{cmd.name} {cmd.description && {cmd.description}} -
- ))} -
+ + )} + /> ); }); diff --git a/web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx b/web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx new file mode 100644 index 000000000..e165e88a1 --- /dev/null +++ b/web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx @@ -0,0 +1,67 @@ +import { ReactNode, useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { Position } from "./useSuggestions"; + +interface SuggestionsPopupProps { + position: Position; + suggestions: T[]; + selectedIndex: number; + onItemSelect: (item: T) => void; + renderItem: (item: T, isSelected: boolean) => ReactNode; + getItemKey: (item: T, index: number) => string; +} + +/** + * 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, + suggestions, + selectedIndex, + onItemSelect, + 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 text-sm cursor-pointer transition-colors select-none", + "hover:bg-accent hover:text-accent-foreground", + i === selectedIndex ? "bg-accent text-accent-foreground" : "", + )} + > + {renderItem(item, i === selectedIndex)} +
+ ))} +
+ ); +} diff --git a/web/src/components/MemoEditor/Editor/TagSuggestions.tsx b/web/src/components/MemoEditor/Editor/TagSuggestions.tsx index c016a4164..780cae3aa 100644 --- a/web/src/components/MemoEditor/Editor/TagSuggestions.tsx +++ b/web/src/components/MemoEditor/Editor/TagSuggestions.tsx @@ -1,132 +1,65 @@ -import Fuse from "fuse.js"; import { observer } from "mobx-react-lite"; -import { useEffect, useRef, useState } from "react"; -import getCaretCoordinates from "textarea-caret"; +import { useMemo } from "react"; import OverflowTip from "@/components/kit/OverflowTip"; -import { cn } from "@/lib/utils"; import { userStore } from "@/store"; import { EditorRefActions } from "."; +import { SuggestionsPopup } from "./SuggestionsPopup"; +import { useSuggestions } from "./useSuggestions"; -type Props = { +interface TagSuggestionsProps { editorRef: React.RefObject; editorActions: React.ForwardedRef; -}; +} -type Position = { left: number; top: number; height: number }; +/** + * 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 for ties + const sortedTags = useMemo( + () => + Object.entries(userStore.state.tagCount) + .sort((a, b) => a[0].localeCompare(b[0])) + .sort((a, b) => b[1] - a[1]) + .map(([tag]) => tag), + [userStore.state.tagCount], + ); -const TagSuggestions = observer(({ editorRef, editorActions }: Props) => { - const [position, setPosition] = useState(null); - const [selected, select] = useState(0); - const selectedRef = useRef(selected); - selectedRef.current = selected; - const tags = Object.entries(userStore.state.tagCount) - .sort((a, b) => a[0].localeCompare(b[0])) - .sort((a, b) => b[1] - a[1]) - .map(([tag]) => tag); + const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({ + editorRef, + editorActions, + triggerChar: "#", + items: sortedTags, + filterItems: (items, searchQuery) => { + if (!searchQuery) return items; + // 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}`); + }, + }); - const hide = () => setPosition(null); + if (!isVisible || !position) return null; - const getCurrentWord = (): [word: string, startIndex: number] => { - const editor = editorRef.current; - if (!editor) return ["", 0]; - const cursorPos = editor.selectionEnd; - const before = editor.value.slice(0, cursorPos).match(/\S*$/) || { 0: "", index: cursorPos }; - const after = editor.value.slice(cursorPos).match(/^\S*/) || { 0: "" }; - return [before[0] + after[0], before.index ?? cursorPos]; - }; - - const suggestionsRef = useRef([]); - suggestionsRef.current = (() => { - const search = getCurrentWord()[0].slice(1).toLowerCase(); - if (search === "") { - return tags; // Show all tags when no search term - } - const fuse = new Fuse(tags); - return fuse.search(search).map((result) => result.item); - })(); - - const isVisibleRef = useRef(false); - isVisibleRef.current = !!(position && suggestionsRef.current.length > 0); - - const autocomplete = (tag: string) => { - if (!editorActions || !("current" in editorActions) || !editorActions.current) return; - const [word, index] = getCurrentWord(); - editorActions.current.removeText(index, word.length); - editorActions.current.insertText(`#${tag}`); - hide(); - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (!isVisibleRef.current) return; - const suggestions = suggestionsRef.current; - const selected = selectedRef.current; - if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) hide(); - if ("ArrowDown" === e.code) { - select((selected + 1) % suggestions.length); - e.preventDefault(); - e.stopPropagation(); - } - if ("ArrowUp" === e.code) { - select((selected - 1 + suggestions.length) % suggestions.length); - e.preventDefault(); - e.stopPropagation(); - } - if (["Enter", "Tab"].includes(e.code)) { - autocomplete(suggestions[selected]); - e.preventDefault(); - e.stopPropagation(); - } - }; - - const handleInput = () => { - const editor = editorRef.current; - if (!editor) return; - - select(0); - const [word, index] = getCurrentWord(); - const currentChar = editor.value[editor.selectionEnd]; - const isActive = word.startsWith("#") && currentChar !== "#"; - - const caretCordinates = getCaretCoordinates(editor, index); - caretCordinates.top -= editor.scrollTop; - if (isActive) { - setPosition(caretCordinates); - } else { - hide(); - } - }; - - const listenersAreRegisteredRef = useRef(false); - const registerListeners = () => { - const editor = editorRef.current; - if (!editor || listenersAreRegisteredRef.current) return; - editor.addEventListener("click", hide); - editor.addEventListener("blur", hide); - editor.addEventListener("keydown", handleKeyDown); - editor.addEventListener("input", handleInput); - listenersAreRegisteredRef.current = true; - }; - useEffect(registerListeners, [!!editorRef.current]); - - if (!isVisibleRef.current || !position) return null; return ( -
- {suggestionsRef.current.map((tag, i) => ( -
autocomplete(tag)} - className={cn( - "rounded p-1 px-2 w-full truncate text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground", - i === selected ? "bg-accent text-accent-foreground" : "", - )} - > - #{tag} -
- ))} -
+ tag} + renderItem={(tag) => #{tag}} + /> ); }); diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index d64d1881f..2b685e003 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -1,10 +1,10 @@ import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { cn } from "@/lib/utils"; -import { detectLastListItem, generateListContinuation } from "@/utils/markdown-list-detection"; import { Command } from "../types/command"; import CommandSuggestions from "./CommandSuggestions"; import TagSuggestions from "./TagSuggestions"; import { editorCommands } from "./commands"; +import { useListAutoCompletion } from "./useListAutoCompletion"; export interface EditorRefActions { getEditor: () => HTMLTextAreaElement | null; @@ -152,24 +152,12 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef< updateEditorHeight(); }, []); - const handleEditorKeyDown = async (event: React.KeyboardEvent) => { - if (event.key === "Enter" && !isInIME) { - if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) { - return; - } - - const cursorPosition = editorActions.getCursorPosition(); - const prevContent = editorActions.getContent().substring(0, cursorPosition); - - // Detect list item using regex-based detection - const listInfo = detectLastListItem(prevContent); - if (listInfo.type) { - event.preventDefault(); - const insertText = "\n" + generateListContinuation(listInfo); - editorActions.insertText(insertText); - } - } - }; + // Auto-complete markdown lists when pressing Enter + useListAutoCompletion({ + editorRef, + editorActions, + isInIME, + }); return (
@@ -180,7 +168,6 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef< ref={editorRef} onPaste={onPaste} onInput={handleEditorInput} - onKeyDown={handleEditorKeyDown} onCompositionStart={() => setIsInIME(true)} onCompositionEnd={() => setTimeout(() => setIsInIME(false))} > diff --git a/web/src/components/MemoEditor/Editor/useListAutoCompletion.ts b/web/src/components/MemoEditor/Editor/useListAutoCompletion.ts new file mode 100644 index 000000000..2fd3aa703 --- /dev/null +++ b/web/src/components/MemoEditor/Editor/useListAutoCompletion.ts @@ -0,0 +1,66 @@ +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; +} + +/** + * Custom hook for handling markdown list auto-completion. + * When the user presses Enter on a list item, this hook automatically + * continues the list with the appropriate formatting. + * + * Supports: + * - Ordered lists (1. item, 2. item, etc.) + * - 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({ 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 (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; + + 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); + + if (listInfo.type) { + event.preventDefault(); + const continuation = generateListContinuation(listInfo); + actions.insertText("\n" + continuation); + } + }; + + editor.addEventListener("keydown", handleKeyDown); + + 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 new file mode 100644 index 000000000..1d35efcf4 --- /dev/null +++ b/web/src/components/MemoEditor/Editor/useSuggestions.ts @@ -0,0 +1,194 @@ +import { useEffect, useRef, useState } from "react"; +import getCaretCoordinates from "textarea-caret"; +import { EditorRefActions } from "."; + +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, + editorActions, + triggerChar, + items, + filterItems, + onAutocomplete, +}: UseSuggestionsOptions): UseSuggestionsReturn { + const [position, setPosition] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(0); + + // Use refs to avoid stale closures in event handlers + const selectedRef = useRef(selectedIndex); + selectedRef.current = selectedIndex; + + const getCurrentWord = (): [word: string, startIndex: number] => { + const editor = editorRef.current; + if (!editor) return ["", 0]; + const cursorPos = editor.selectionEnd; + const before = editor.value.slice(0, cursorPos).match(/\S*$/) || { 0: "", index: cursorPos }; + const after = editor.value.slice(cursorPos).match(/^\S*/) || { 0: "" }; + return [before[0] + after[0], before.index ?? cursorPos]; + }; + + const hide = () => setPosition(null); + + // Filter items based on the current word after the trigger character + const suggestionsRef = useRef([]); + suggestionsRef.current = (() => { + const [word] = getCurrentWord(); + if (!word.startsWith(triggerChar)) return []; + const searchQuery = word.slice(triggerChar.length).toLowerCase(); + return filterItems(items, searchQuery); + })(); + + const isVisibleRef = useRef(false); + isVisibleRef.current = !!(position && suggestionsRef.current.length > 0); + + const handleAutocomplete = (item: T) => { + 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(); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (!isVisibleRef.current) return; + + const suggestions = suggestionsRef.current; + const selected = selectedRef.current; + + // Hide on Escape or horizontal arrows + if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) { + hide(); + return; + } + + // Navigate down + if (e.code === "ArrowDown") { + setSelectedIndex((selected + 1) % suggestions.length); + e.preventDefault(); + e.stopPropagation(); + return; + } + + // Navigate up + if (e.code === "ArrowUp") { + setSelectedIndex((selected - 1 + suggestions.length) % suggestions.length); + e.preventDefault(); + e.stopPropagation(); + return; + } + + // Accept suggestion + if (["Enter", "Tab"].includes(e.code)) { + handleAutocomplete(suggestions[selected]); + e.preventDefault(); + e.stopPropagation(); + } + }; + + const handleInput = () => { + const editor = editorRef.current; + if (!editor) return; + + setSelectedIndex(0); + const [word, index] = getCurrentWord(); + const currentChar = editor.value[editor.selectionEnd]; + const isActive = word.startsWith(triggerChar) && currentChar !== triggerChar; + + if (isActive) { + const caretCoordinates = getCaretCoordinates(editor, index); + caretCoordinates.top -= editor.scrollTop; + setPosition(caretCoordinates); + } else { + hide(); + } + }; + + // Register event listeners + const listenersRegisteredRef = useRef(false); + useEffect(() => { + const editor = editorRef.current; + if (!editor || listenersRegisteredRef.current) 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]); + + return { + position, + suggestions: suggestionsRef.current, + selectedIndex, + isVisible: isVisibleRef.current, + handleItemSelect: handleAutocomplete, + }; +}