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:
Maciej Kasprzyk 2023-08-16 02:54:30 +02:00 committed by GitHub
parent 95588542f9
commit 077cfeb831
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23

View file

@ -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>