mirror of
https://github.com/usememos/memos.git
synced 2025-04-13 17:12:07 +02: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,3 +1,4 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import getCaretCoordinates from "textarea-caret";
|
import getCaretCoordinates from "textarea-caret";
|
||||||
import { useTagStore } from "@/store/module";
|
import { useTagStore } from "@/store/module";
|
||||||
@ -13,60 +14,97 @@ const TagSuggestions = ({ editorRef, editorActions }: Props) => {
|
|||||||
const [position, setPosition] = useState<Position | null>(null);
|
const [position, setPosition] = useState<Position | null>(null);
|
||||||
const hide = () => setPosition(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] => {
|
const getCurrentWord = (): [word: string, startIndex: number] => {
|
||||||
if (!editorRef.current) return ["", 0];
|
const editor = editorRef.current;
|
||||||
const cursorPos = editorRef.current.selectionEnd;
|
if (!editor) return ["", 0];
|
||||||
const before = editorRef.current.value.slice(0, cursorPos).match(/\S*$/) || { 0: "", index: cursorPos };
|
const cursorPos = editor.selectionEnd;
|
||||||
const ahead = editorRef.current.value.slice(cursorPos).match(/^\S*/) || { 0: "" };
|
const before = editor.value.slice(0, cursorPos).match(/\S*$/) || { 0: "", index: cursorPos };
|
||||||
return [before[0] + ahead[0], before.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 handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const isArrowKey = ["ArrowLeft", "ArrowRight", "ArrowDown", "ArrowUp"].includes(e.code);
|
if (!isVisibleRef.current) return;
|
||||||
if (isArrowKey || ["Tab", "Escape"].includes(e.code)) hide();
|
const suggestions = suggestionsRef.current;
|
||||||
};
|
const selected = selectedRef.current;
|
||||||
const handleInput = () => {
|
if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) hide();
|
||||||
if (!editorRef.current) return;
|
if ("ArrowDown" === e.code) {
|
||||||
const [word, index] = getCurrentWord();
|
select((selected + 1) % suggestions.length);
|
||||||
if (!word.startsWith("#") || word.slice(1).includes("#")) return hide();
|
e.preventDefault();
|
||||||
setPosition(getCaretCoordinates(editorRef.current, index));
|
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 = () => {
|
const registerListeners = () => {
|
||||||
if (!editorRef.current || areListenersRegistered.current) return;
|
const editor = editorRef.current;
|
||||||
editorRef.current.addEventListener("click", hide);
|
if (!editor || listenersAreRegisteredRef.current) return;
|
||||||
editorRef.current.addEventListener("blur", hide);
|
editor.addEventListener("click", hide);
|
||||||
editorRef.current.addEventListener("keydown", handleKeyDown);
|
editor.addEventListener("blur", hide);
|
||||||
editorRef.current.addEventListener("input", handleInput);
|
editor.addEventListener("keydown", handleKeyDown);
|
||||||
areListenersRegistered.current = true;
|
editor.addEventListener("input", handleInput);
|
||||||
|
listenersAreRegisteredRef.current = true;
|
||||||
};
|
};
|
||||||
useEffect(registerListeners, [!!editorRef.current]);
|
useEffect(registerListeners, [!!editorRef.current]);
|
||||||
|
|
||||||
const { tags } = useTagStore().state;
|
if (!isVisibleRef.current || !position) return null;
|
||||||
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;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="z-2 p-1 absolute max-w-[12rem] rounded font-mono shadow bg-zinc-200 dark:bg-zinc-600"
|
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 }}
|
style={{ left: position.left - 6, top: position.top + position.height + 2 }}
|
||||||
>
|
>
|
||||||
{suggestions.map((tag) => (
|
{suggestionsRef.current.map((tag, i) => (
|
||||||
<div
|
<div
|
||||||
key={tag}
|
key={tag}
|
||||||
onMouseDown={() => handleSelection(tag)}
|
onMouseDown={() => autocomplete(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"
|
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}
|
#{tag}
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user