mirror of
https://github.com/usememos/memos.git
synced 2024-12-27 23:51:57 +08:00
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:
parent
c1cbfd5766
commit
5d3ea57d82
4 changed files with 963 additions and 723 deletions
|
@ -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",
|
||||
|
|
1604
web/pnpm-lock.yaml
1604
web/pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
78
web/src/components/MemoEditor/Editor/TagSuggestions.tsx
Normal file
78
web/src/components/MemoEditor/Editor/TagSuggestions.tsx
Normal 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;
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue