From 15c146cfc57f84f5525459b0f887ec92027ae500 Mon Sep 17 00:00:00 2001 From: Tobias Waslowski <50702446+twaslowski@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:55:59 +0200 Subject: [PATCH] feat(editor): create text-based autocompleting commands (#4971) --- .../MemoEditor/Editor/CommandSuggestions.tsx | 131 ++++++++++++++++++ .../components/MemoEditor/Editor/commands.ts | 34 +++++ .../components/MemoEditor/Editor/index.tsx | 5 + .../components/MemoEditor/types/command.ts | 6 + 4 files changed, 176 insertions(+) create mode 100644 web/src/components/MemoEditor/Editor/CommandSuggestions.tsx create mode 100644 web/src/components/MemoEditor/Editor/commands.ts create mode 100644 web/src/components/MemoEditor/types/command.ts diff --git a/web/src/components/MemoEditor/Editor/CommandSuggestions.tsx b/web/src/components/MemoEditor/Editor/CommandSuggestions.tsx new file mode 100644 index 000000000..be73e1111 --- /dev/null +++ b/web/src/components/MemoEditor/Editor/CommandSuggestions.tsx @@ -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; + editorActions: React.ForwardedRef; + commands: Command[]; +}; + +type Position = { left: number; top: number; height: number }; + +const CommandSuggestions = observer(({ editorRef, editorActions, commands }: Props) => { + const [position, setPosition] = useState(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([]); + 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 ( +
+ {suggestionsRef.current.map((cmd, i) => ( +
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" : "", + )} + > + /{cmd.name} + {cmd.description && {cmd.description}} +
+ ))} +
+ ); +}); + +export default CommandSuggestions; diff --git a/web/src/components/MemoEditor/Editor/commands.ts b/web/src/components/MemoEditor/Editor/commands.ts new file mode 100644 index 000000000..edc375842 --- /dev/null +++ b/web/src/components/MemoEditor/Editor/commands.ts @@ -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, + }, +]; diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index a5e45d22e..b87b97290 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -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))} > + ); }); diff --git a/web/src/components/MemoEditor/types/command.ts b/web/src/components/MemoEditor/types/command.ts new file mode 100644 index 000000000..8d472506c --- /dev/null +++ b/web/src/components/MemoEditor/types/command.ts @@ -0,0 +1,6 @@ +export type Command = { + name: string; + description?: string; + run: () => string; + cursorOffset?: number; +};