feat(editor): create text-based autocompleting commands (#4971)

This commit is contained in:
Tobias Waslowski 2025-08-08 12:55:59 +02:00 committed by GitHub
parent f4bdfa28a0
commit 15c146cfc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 176 additions and 0 deletions

View file

@ -0,0 +1,131 @@
import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react";
import getCaretCoordinates from "textarea-caret";
import OverflowTip from "@/components/kit/OverflowTip";
import { cn } from "@/lib/utils";
import { EditorRefActions } from ".";
import { Command } from "../types/command";
type Props = {
editorRef: React.RefObject<HTMLTextAreaElement>;
editorActions: React.ForwardedRef<EditorRefActions>;
commands: Command[];
};
type Position = { left: number; top: number; height: number };
const CommandSuggestions = observer(({ editorRef, editorActions, commands }: Props) => {
const [position, setPosition] = useState<Position | null>(null);
const [selected, select] = useState(0);
const selectedRef = useRef(selected);
selectedRef.current = selected;
const hide = () => setPosition(null);
const getCurrentWord = (): [word: string, startIndex: number] => {
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];
};
// Filter commands based on the current word after the slash
const suggestionsRef = useRef<Command[]>([]);
suggestionsRef.current = (() => {
const [word] = getCurrentWord();
if (!word.startsWith("/")) return [];
const search = word.slice(1).toLowerCase();
if (!search) return commands;
return commands.filter((cmd) => cmd.name.toLowerCase().startsWith(search));
})();
const isVisibleRef = useRef(false);
isVisibleRef.current = !!(position && suggestionsRef.current.length > 0);
const autocomplete = (cmd: Command) => {
if (!editorActions || !("current" in editorActions) || !editorActions.current) return;
const [word, index] = getCurrentWord();
editorActions.current.removeText(index, word.length);
editorActions.current.insertText(cmd.run());
if (cmd.cursorOffset) {
editorActions.current.setCursorPosition(editorActions.current.getCursorPosition() + cmd.cursorOffset);
}
hide();
};
const handleKeyDown = (e: KeyboardEvent) => {
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 handleInput = () => {
const editor = editorRef.current;
if (!editor) return;
select(0);
const [word, index] = getCurrentWord();
const currentChar = editor.value[editor.selectionEnd];
const isActive = word.startsWith("/") && currentChar !== "/";
const caretCordinates = getCaretCoordinates(editor, index);
caretCordinates.top -= editor.scrollTop;
if (isActive) {
setPosition(caretCordinates);
} else {
hide();
}
};
const listenersAreRegisteredRef = useRef(false);
const registerListeners = () => {
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]);
if (!isVisibleRef.current || !position) return null;
return (
<div
className="z-20 p-1 mt-1 -ml-2 absolute max-w-48 gap-px rounded font-mono flex flex-col justify-start items-start overflow-auto shadow bg-popover"
style={{ left: position.left, top: position.top + position.height }}
>
{suggestionsRef.current.map((cmd, i) => (
<div
key={cmd.name}
onMouseDown={() => autocomplete(cmd)}
className={cn(
"rounded p-1 px-2 w-full truncate text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground",
i === selected ? "bg-accent text-accent-foreground" : "",
)}
>
<OverflowTip>/{cmd.name}</OverflowTip>
{cmd.description && <span className="ml-2 text-xs text-muted-foreground">{cmd.description}</span>}
</div>
))}
</div>
);
});
export default CommandSuggestions;

View file

@ -0,0 +1,34 @@
import { Command } from "@/components/MemoEditor/types/command";
export const editorCommands: Command[] = [
{
name: "todo",
description: "Insert a task checkbox",
run: () => "- [ ] ",
cursorOffset: 6,
},
{
name: "code",
description: "Insert a code block",
run: () => "```\n\n```",
cursorOffset: 4,
},
{
name: "link",
description: "Insert a link",
run: () => "[text](url)",
cursorOffset: 1,
},
{
name: "table",
description: "Insert a table",
run: () => "| Header | Header |\n| ------ | ------ |\n| Cell | Cell |",
cursorOffset: 1,
},
{
name: "highlight",
description: "Insert highlighted text",
run: () => "==text==",
cursorOffset: 2,
},
];

View file

@ -3,7 +3,10 @@ import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, use
import { markdownServiceClient } from "@/grpcweb";
import { cn } from "@/lib/utils";
import { Node, NodeType, OrderedListItemNode, TaskListItemNode, UnorderedListItemNode } from "@/types/proto/api/v1/markdown_service";
import { Command } from "../types/command";
import CommandSuggestions from "./CommandSuggestions";
import TagSuggestions from "./TagSuggestions";
import { editorCommands } from "./commands";
export interface EditorRefActions {
getEditor: () => HTMLTextAreaElement | null;
@ -26,6 +29,7 @@ interface Props {
initialContent: string;
placeholder: string;
tools?: ReactNode;
commands?: Command[];
onContentChange: (content: string) => void;
onPaste: (event: React.ClipboardEvent) => void;
}
@ -226,6 +230,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
onCompositionEnd={() => setTimeout(() => setIsInIME(false))}
></textarea>
<TagSuggestions editorRef={editorRef} editorActions={ref} />
<CommandSuggestions editorRef={editorRef} editorActions={ref} commands={editorCommands} />
</div>
);
});

View file

@ -0,0 +1,6 @@
export type Command = {
name: string;
description?: string;
run: () => string;
cursorOffset?: number;
};