diff --git a/web/src/components/MemoEditor/Editor/TagSuggestions.tsx b/web/src/components/MemoEditor/Editor/TagSuggestions.tsx index a16f5ff1..434c8db2 100644 --- a/web/src/components/MemoEditor/Editor/TagSuggestions.tsx +++ b/web/src/components/MemoEditor/Editor/TagSuggestions.tsx @@ -1,3 +1,4 @@ +import classNames from "classnames"; import { useEffect, useRef, useState } from "react"; import getCaretCoordinates from "textarea-caret"; import { useTagStore } from "@/store/module"; @@ -13,60 +14,97 @@ const TagSuggestions = ({ editorRef, editorActions }: Props) => { const [position, setPosition] = useState(null); const hide = () => setPosition(null); + const { state } = useTagStore(); + const tagsRef = useRef(state.tags); + tagsRef.current = state.tags; + + const [selected, select] = useState(0); + const selectedRef = useRef(selected); + selectedRef.current = selected; + const getCurrentWord = (): [word: string, startIndex: number] => { - if (!editorRef.current) return ["", 0]; - const cursorPos = editorRef.current.selectionEnd; - const before = editorRef.current.value.slice(0, cursorPos).match(/\S*$/) || { 0: "", index: cursorPos }; - const ahead = editorRef.current.value.slice(cursorPos).match(/^\S*/) || { 0: "" }; - return [before[0] + ahead[0], before.index || cursorPos]; + 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 partial = getCurrentWord()[0].slice(1).toLowerCase(); + const matches = (str: string) => str.startsWith(partial) && partial.length < str.length; + return tagsRef.current.filter((tag) => matches(tag.toLowerCase())).slice(0, 5); + })(); + + 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) => { - const isArrowKey = ["ArrowLeft", "ArrowRight", "ArrowDown", "ArrowUp"].includes(e.code); - if (isArrowKey || ["Tab", "Escape"].includes(e.code)) hide(); - }; - const handleInput = () => { - if (!editorRef.current) return; - const [word, index] = getCurrentWord(); - if (!word.startsWith("#") || word.slice(1).includes("#")) return hide(); - setPosition(getCaretCoordinates(editorRef.current, index)); + 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 areListenersRegistered = useRef(false); + const handleInput = () => { + if (!editorRef.current) return; + select(0); + const [word, index] = getCurrentWord(); + const isActive = word.startsWith("#") && !word.slice(1).includes("#"); + isActive ? setPosition(getCaretCoordinates(editorRef.current, index)) : hide(); + }; + + const listenersAreRegisteredRef = useRef(false); const registerListeners = () => { - if (!editorRef.current || areListenersRegistered.current) return; - editorRef.current.addEventListener("click", hide); - editorRef.current.addEventListener("blur", hide); - editorRef.current.addEventListener("keydown", handleKeyDown); - editorRef.current.addEventListener("input", handleInput); - areListenersRegistered.current = true; + 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]); - const { tags } = useTagStore().state; - const getSuggestions = () => { - const partial = getCurrentWord()[0].slice(1); - return tags.filter((tag) => tag.startsWith(partial)).slice(0, 5); - }; - const suggestions = getSuggestions(); - - const handleSelection = (tag: string) => { - if (!editorActions || !("current" in editorActions) || !editorActions.current) return; - const partial = getCurrentWord()[0].slice(1); - editorActions.current.insertText(tag.slice(partial.length)); - }; - - if (!position || !suggestions.length) return null; + if (!isVisibleRef.current || !position) return null; return (
- {suggestions.map((tag) => ( + {suggestionsRef.current.map((tag, i) => (
handleSelection(tag)} - className="rounded p-1 px-2 w-full truncate text-sm dark:text-gray-300 cursor-pointer hover:bg-zinc-300 dark:hover:bg-zinc-700" + onMouseDown={() => autocomplete(tag)} + className={classNames( + "rounded p-1 px-2 w-full truncate text-sm dark:text-gray-300 cursor-pointer hover:bg-zinc-300 dark:hover:bg-zinc-700", + i === selected ? "bg-zinc-300 dark:bg-zinc-700" : "" + )} > #{tag}