mirror of
https://github.com/usememos/memos.git
synced 2025-03-03 16:53:30 +08:00
feat: improve tag suggestions (#2126)
* feat: make filtering case insensitive * fix: wrong letter case when accepting suggestion * refactor: wrap textarea in TagSuggestions * fix: less styles not matching common-editor-inputer * refactor: use explanatory const names for tested value in conditional checks * feat: style highlighted option * feat: handle down/up arrow keys * feat: handle enter or tab to trigger autocomplete * fix: wrong import * fix: tab key adding whitespace after auto-completion * fix: starting a note with a tag * fix: close on escape * refactor: early version of removed wrapping and children prop * refactor: remove unnecessary return false * refactor: finished rewriting to not wrap editor
This commit is contained in:
parent
95588542f9
commit
077cfeb831
1 changed files with 75 additions and 37 deletions
|
@ -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<Position | null>(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<string[]>([]);
|
||||
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 (
|
||||
<div
|
||||
className="z-2 p-1 absolute max-w-[12rem] rounded font-mono shadow bg-zinc-200 dark:bg-zinc-600"
|
||||
style={{ left: position.left - 6, top: position.top + position.height + 2 }}
|
||||
>
|
||||
{suggestions.map((tag) => (
|
||||
{suggestionsRef.current.map((tag, i) => (
|
||||
<div
|
||||
key={tag}
|
||||
onMouseDown={() => 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}
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue