feat: tag suggestions (#2036)

* feat: figure out how to read caret position

* feat: figure out how to read caret position

* feat: create and style Editor/TagSuggestions.txs

* feat: progress on detect when to show and hide

* feat: progress on when to show and hide and setting position

* feat: toggling and exact placement done

* fix: pnpm lock problems

* feat: filter suggestions by partially typed tag name

* style: prettier

* chore: add types package for textarea-caret

* feat: handle option click

* style: prettier

* style: reorder imports

Co-authored-by: boojack <stevenlgtm@gmail.com>

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
This commit is contained in:
Maciej Kasprzyk 2023-07-30 16:55:45 +02:00 committed by GitHub
parent c1cbfd5766
commit 5d3ea57d82
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 963 additions and 723 deletions

View file

@ -31,6 +31,7 @@
"react-use": "^17.4.0",
"semver": "^7.3.8",
"tailwindcss": "^3.2.4",
"textarea-caret": "^3.1.0",
"uuid": "^9.0.0",
"zustand": "^4.3.6"
},
@ -41,6 +42,7 @@
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/semver": "^7.3.13",
"@types/textarea-caret": "^3.0.1",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,78 @@
import { useTagStore } from "@/store/module";
import { useEffect, useRef, useState } from "react";
import getCaretCoordinates from "textarea-caret";
import { EditorRefActions } from ".";
type Props = {
editorRef: React.RefObject<HTMLTextAreaElement>;
editorActions: React.ForwardedRef<EditorRefActions>;
};
type Position = { left: number; top: number; height: number };
const TagSuggestions = ({ editorRef, editorActions }: Props) => {
const [position, setPosition] = useState<Position | null>(null);
const hide = () => setPosition(null);
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 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));
};
const areListenersRegistered = 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;
};
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;
return (
<div
className="z-2 absolute rounded font-mono bg-zinc-200 dark:bg-zinc-600"
style={{ left: position.left - 6, top: position.top + position.height + 2 }}
>
{suggestions.map((tag) => (
<div
key={tag}
onMouseDown={() => handleSelection(tag)}
className="rounded p-1 px-2 z-1000 text-sm dark:text-gray-300 cursor-pointer hover:bg-zinc-300 dark:hover:bg-zinc-700"
>
#{tag}
</div>
))}
</div>
);
};
export default TagSuggestions;

View file

@ -1,4 +1,5 @@
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import TagSuggestions from "./TagSuggestions";
import "@/less/editor.less";
export interface EditorRefActions {
@ -134,6 +135,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
onPaste={onPaste}
onInput={handleEditorInput}
></textarea>
<TagSuggestions editorRef={editorRef} editorActions={ref} />
</div>
);
});