From d537591005556ff1e7cebd383104b9313aa065d7 Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 22 Dec 2025 22:42:23 +0800 Subject: [PATCH] feat: add slash commands tooltip to InsertMenu --- ...mmandSuggestions.tsx => SlashCommands.tsx} | 22 ++- .../MemoEditor/Editor/SuggestionsPopup.tsx | 26 +-- .../MemoEditor/Editor/TagSuggestions.tsx | 32 ++-- .../components/MemoEditor/Editor/commands.ts | 8 +- .../components/MemoEditor/Editor/index.tsx | 170 ++++++++---------- .../{markdownShortcuts.ts => shortcuts.ts} | 47 ++--- .../Editor/useListAutoCompletion.ts | 72 -------- .../MemoEditor/Editor/useListCompletion.ts | 60 +++++++ .../MemoEditor/Editor/useSuggestions.ts | 58 +++--- .../MemoEditor/Toolbar/InsertMenu.tsx | 1 + .../MemoEditor/hooks/useMemoEditorHandlers.ts | 2 +- .../MemoEditor/hooks/useMemoEditorKeyboard.ts | 2 +- web/src/locales/en.json | 3 +- 13 files changed, 234 insertions(+), 269 deletions(-) rename web/src/components/MemoEditor/Editor/{CommandSuggestions.tsx => SlashCommands.tsx} (62%) rename web/src/components/MemoEditor/Editor/{markdownShortcuts.ts => shortcuts.ts} (61%) delete mode 100644 web/src/components/MemoEditor/Editor/useListAutoCompletion.ts create mode 100644 web/src/components/MemoEditor/Editor/useListCompletion.ts diff --git a/web/src/components/MemoEditor/Editor/CommandSuggestions.tsx b/web/src/components/MemoEditor/Editor/SlashCommands.tsx similarity index 62% rename from web/src/components/MemoEditor/Editor/CommandSuggestions.tsx rename to web/src/components/MemoEditor/Editor/SlashCommands.tsx index 2abf56255..f4cf9d138 100644 --- a/web/src/components/MemoEditor/Editor/CommandSuggestions.tsx +++ b/web/src/components/MemoEditor/Editor/SlashCommands.tsx @@ -1,32 +1,25 @@ import { observer } from "mobx-react-lite"; -import OverflowTip from "@/components/kit/OverflowTip"; import type { EditorRefActions } from "."; import type { Command } from "./commands"; import { SuggestionsPopup } from "./SuggestionsPopup"; import { useSuggestions } from "./useSuggestions"; -interface CommandSuggestionsProps { +interface SlashCommandsProps { editorRef: React.RefObject; editorActions: React.ForwardedRef; commands: Command[]; } -const CommandSuggestions = observer(({ editorRef, editorActions, commands }: CommandSuggestionsProps) => { +const SlashCommands = observer(({ editorRef, editorActions, commands }: SlashCommandsProps) => { const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({ editorRef, editorActions, triggerChar: "/", items: commands, - filterItems: (items, searchQuery) => { - if (!searchQuery) return items; - // Filter commands by prefix match for intuitive searching - return items.filter((cmd) => cmd.name.toLowerCase().startsWith(searchQuery)); - }, + filterItems: (items, query) => (!query ? items : items.filter((cmd) => cmd.name.toLowerCase().startsWith(query))), onAutocomplete: (cmd, word, index, actions) => { - // Replace the trigger word with the command output actions.removeText(index, word.length); actions.insertText(cmd.run()); - // Position cursor if command specifies an offset if (cmd.cursorOffset) { actions.setCursorPosition(actions.getCursorPosition() + cmd.cursorOffset); } @@ -42,9 +35,14 @@ const CommandSuggestions = observer(({ editorRef, editorActions, commands }: Com selectedIndex={selectedIndex} onItemSelect={handleItemSelect} getItemKey={(cmd) => cmd.name} - renderItem={(cmd) => /{cmd.name}} + renderItem={(cmd) => ( + + / + {cmd.name} + + )} /> ); }); -export default CommandSuggestions; +export default SlashCommands; diff --git a/web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx b/web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx index e2b0c4362..1e7129b99 100644 --- a/web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx +++ b/web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx @@ -11,6 +11,12 @@ interface SuggestionsPopupProps { getItemKey: (item: T, index: number) => string; } +const POPUP_STYLES = { + container: + "z-20 absolute p-1 mt-1 -ml-2 max-w-48 max-h-60 rounded border bg-popover text-popover-foreground shadow-lg font-mono flex flex-col overflow-y-auto overflow-x-hidden", + item: "rounded p-1 px-2 w-full text-sm cursor-pointer transition-colors select-none hover:bg-accent hover:text-accent-foreground", +}; + export function SuggestionsPopup({ position, suggestions, @@ -22,32 +28,18 @@ export function SuggestionsPopup({ const containerRef = useRef(null); const selectedItemRef = useRef(null); - // Scroll selected item into view when selection changes useEffect(() => { - if (selectedItemRef.current && containerRef.current) { - selectedItemRef.current.scrollIntoView({ - block: "nearest", - behavior: "smooth", - }); - } + selectedItemRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); }, [selectedIndex]); return ( -
+
{suggestions.map((item, i) => (
onItemSelect(item)} - className={cn( - "rounded p-1 px-2 w-full text-sm cursor-pointer transition-colors select-none", - "hover:bg-accent hover:text-accent-foreground", - i === selectedIndex ? "bg-accent text-accent-foreground" : "", - )} + className={cn(POPUP_STYLES.item, i === selectedIndex && "bg-accent text-accent-foreground")} > {renderItem(item, i === selectedIndex)}
diff --git a/web/src/components/MemoEditor/Editor/TagSuggestions.tsx b/web/src/components/MemoEditor/Editor/TagSuggestions.tsx index 9204b24f1..84235505e 100644 --- a/web/src/components/MemoEditor/Editor/TagSuggestions.tsx +++ b/web/src/components/MemoEditor/Editor/TagSuggestions.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react-lite"; import { useMemo } from "react"; import OverflowTip from "@/components/kit/OverflowTip"; import { userStore } from "@/store"; -import { EditorRefActions } from "."; +import type { EditorRefActions } from "."; import { SuggestionsPopup } from "./SuggestionsPopup"; import { useSuggestions } from "./useSuggestions"; @@ -12,28 +12,21 @@ interface TagSuggestionsProps { } const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsProps) => { - // Sort tags by usage count (descending), then alphabetically for ties - const sortedTags = useMemo( - () => - Object.entries(userStore.state.tagCount) - .sort((a, b) => a[0].localeCompare(b[0])) - .sort((a, b) => b[1] - a[1]) - .map(([tag]) => tag), - [userStore.state.tagCount], - ); + const sortedTags = useMemo(() => { + const tags = Object.entries(userStore.state.tagCount) + .sort((a, b) => b[1] - a[1]) // Sort by usage count (descending) + .map(([tag]) => tag); + // Secondary sort by name for stable ordering + return tags.sort((a, b) => (userStore.state.tagCount[a] === userStore.state.tagCount[b] ? a.localeCompare(b) : 0)); + }, [userStore.state.tagCount]); const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({ editorRef, editorActions, triggerChar: "#", items: sortedTags, - filterItems: (items, searchQuery) => { - if (!searchQuery) return items; - // Filter tags by substring match for flexible searching - return items.filter((tag) => tag.toLowerCase().includes(searchQuery)); - }, + filterItems: (items, query) => (!query ? items : items.filter((tag) => tag.toLowerCase().includes(query))), onAutocomplete: (tag, word, index, actions) => { - // Replace the trigger word with the complete tag and add a trailing space actions.removeText(index, word.length); actions.insertText(`#${tag} `); }, @@ -48,7 +41,12 @@ const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsPro selectedIndex={selectedIndex} onItemSelect={handleItemSelect} getItemKey={(tag) => tag} - renderItem={(tag) => #{tag}} + renderItem={(tag) => ( + + # + {tag} + + )} /> ); }); diff --git a/web/src/components/MemoEditor/Editor/commands.ts b/web/src/components/MemoEditor/Editor/commands.ts index d293601c0..4aa58b44a 100644 --- a/web/src/components/MemoEditor/Editor/commands.ts +++ b/web/src/components/MemoEditor/Editor/commands.ts @@ -8,21 +8,21 @@ export const editorCommands: Command[] = [ { name: "todo", run: () => "- [ ] ", - cursorOffset: 6, // Places cursor after "- [ ] " to start typing task + cursorOffset: 6, }, { name: "code", run: () => "```\n\n```", - cursorOffset: 4, // Places cursor on empty line between code fences + cursorOffset: 4, }, { name: "link", run: () => "[text](url)", - cursorOffset: 1, // Places cursor after "[" to type link text + cursorOffset: 1, }, { name: "table", run: () => "| Header | Header |\n| ------ | ------ |\n| Cell | Cell |", - cursorOffset: 1, // Places cursor after first "|" to edit first header + cursorOffset: 1, }, ]; diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index 736a44f85..8d9bd0248 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -1,10 +1,10 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; import { cn } from "@/lib/utils"; import { EDITOR_HEIGHT } from "../constants"; -import CommandSuggestions from "./CommandSuggestions"; import { editorCommands } from "./commands"; +import SlashCommands from "./SlashCommands"; import TagSuggestions from "./TagSuggestions"; -import { useListAutoCompletion } from "./useListAutoCompletion"; +import { useListCompletion } from "./useListCompletion"; export interface EditorRefActions { getEditor: () => HTMLTextAreaElement | null; @@ -56,94 +56,6 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef< } }, []); - const editorActions = { - getEditor: () => { - return editorRef.current; - }, - focus: () => { - editorRef.current?.focus(); - }, - scrollToCursor: () => { - if (editorRef.current) { - editorRef.current.scrollTop = editorRef.current.scrollHeight; - } - }, - insertText: (content = "", prefix = "", suffix = "") => { - if (!editorRef.current) { - return; - } - - const cursorPosition = editorRef.current.selectionStart; - const endPosition = editorRef.current.selectionEnd; - const prevValue = editorRef.current.value; - const actualContent = content || prevValue.slice(cursorPosition, endPosition); - const value = prevValue.slice(0, cursorPosition) + prefix + actualContent + suffix + prevValue.slice(endPosition); - - editorRef.current.value = value; - editorRef.current.focus(); - // Place cursor at the end of inserted content - const newCursorPosition = cursorPosition + prefix.length + actualContent.length + suffix.length; - editorRef.current.setSelectionRange(newCursorPosition, newCursorPosition); - handleContentChangeCallback(editorRef.current.value); - updateEditorHeight(); - }, - removeText: (start: number, length: number) => { - if (!editorRef.current) { - return; - } - - const prevValue = editorRef.current.value; - const value = prevValue.slice(0, start) + prevValue.slice(start + length); - editorRef.current.value = value; - editorRef.current.focus(); - editorRef.current.selectionEnd = start; - handleContentChangeCallback(editorRef.current.value); - updateEditorHeight(); - }, - setContent: (text: string) => { - if (editorRef.current) { - editorRef.current.value = text; - handleContentChangeCallback(editorRef.current.value); - updateEditorHeight(); - } - }, - getContent: (): string => { - return editorRef.current?.value ?? ""; - }, - getCursorPosition: (): number => { - return editorRef.current?.selectionStart ?? 0; - }, - getSelectedContent: () => { - const start = editorRef.current?.selectionStart; - const end = editorRef.current?.selectionEnd; - return editorRef.current?.value.slice(start, end) ?? ""; - }, - setCursorPosition: (startPos: number, endPos?: number) => { - const _endPos = isNaN(endPos as number) ? startPos : (endPos as number); - editorRef.current?.setSelectionRange(startPos, _endPos); - }, - getCursorLineNumber: () => { - const cursorPosition = editorRef.current?.selectionStart ?? 0; - const lines = editorRef.current?.value.slice(0, cursorPosition).split("\n") ?? []; - return lines.length - 1; - }, - getLine: (lineNumber: number) => { - return editorRef.current?.value.split("\n")[lineNumber] ?? ""; - }, - setLine: (lineNumber: number, text: string) => { - const lines = editorRef.current?.value.split("\n") ?? []; - lines[lineNumber] = text; - if (editorRef.current) { - editorRef.current.value = lines.join("\n"); - editorRef.current.focus(); - handleContentChangeCallback(editorRef.current.value); - updateEditorHeight(); - } - }, - }; - - useImperativeHandle(ref, () => editorActions, []); - const updateEditorHeight = () => { if (editorRef.current) { editorRef.current.style.height = "auto"; @@ -151,13 +63,87 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef< } }; + const updateContent = () => { + if (editorRef.current) { + handleContentChangeCallback(editorRef.current.value); + updateEditorHeight(); + } + }; + + const editorActions: EditorRefActions = { + getEditor: () => editorRef.current, + focus: () => editorRef.current?.focus(), + scrollToCursor: () => { + editorRef.current && (editorRef.current.scrollTop = editorRef.current.scrollHeight); + }, + insertText: (content = "", prefix = "", suffix = "") => { + const editor = editorRef.current; + if (!editor) return; + + const cursorPos = editor.selectionStart; + const endPos = editor.selectionEnd; + const prev = editor.value; + const actual = content || prev.slice(cursorPos, endPos); + editor.value = prev.slice(0, cursorPos) + prefix + actual + suffix + prev.slice(endPos); + + editor.focus(); + editor.setSelectionRange(cursorPos + prefix.length + actual.length, cursorPos + prefix.length + actual.length); + updateContent(); + }, + removeText: (start: number, length: number) => { + const editor = editorRef.current; + if (!editor) return; + + editor.value = editor.value.slice(0, start) + editor.value.slice(start + length); + editor.focus(); + editor.selectionEnd = start; + updateContent(); + }, + setContent: (text: string) => { + const editor = editorRef.current; + if (editor) { + editor.value = text; + updateContent(); + } + }, + getContent: () => editorRef.current?.value ?? "", + getCursorPosition: () => editorRef.current?.selectionStart ?? 0, + getSelectedContent: () => { + const editor = editorRef.current; + if (!editor) return ""; + return editor.value.slice(editor.selectionStart, editor.selectionEnd); + }, + setCursorPosition: (startPos: number, endPos?: number) => { + const endPosition = isNaN(endPos as number) ? startPos : (endPos as number); + editorRef.current?.setSelectionRange(startPos, endPosition); + }, + getCursorLineNumber: () => { + const editor = editorRef.current; + if (!editor) return 0; + const lines = editor.value.slice(0, editor.selectionStart).split("\n"); + return lines.length - 1; + }, + getLine: (lineNumber: number) => editorRef.current?.value.split("\n")[lineNumber] ?? "", + setLine: (lineNumber: number, text: string) => { + const editor = editorRef.current; + if (!editor) return; + const lines = editor.value.split("\n"); + lines[lineNumber] = text; + editor.value = lines.join("\n"); + editor.focus(); + updateContent(); + }, + }; + + useImperativeHandle(ref, () => editorActions, []); + const handleEditorInput = useCallback(() => { handleContentChangeCallback(editorRef.current?.value ?? ""); updateEditorHeight(); }, []); // Auto-complete markdown lists when pressing Enter - useListAutoCompletion({ + useListCompletion({ editorRef, editorActions, isInIME, @@ -185,7 +171,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef< onCompositionEnd={onCompositionEnd} > - +
); }); diff --git a/web/src/components/MemoEditor/Editor/markdownShortcuts.ts b/web/src/components/MemoEditor/Editor/shortcuts.ts similarity index 61% rename from web/src/components/MemoEditor/Editor/markdownShortcuts.ts rename to web/src/components/MemoEditor/Editor/shortcuts.ts index ff3eb5a18..b1c4a2fcf 100644 --- a/web/src/components/MemoEditor/Editor/markdownShortcuts.ts +++ b/web/src/components/MemoEditor/Editor/shortcuts.ts @@ -1,39 +1,45 @@ import type { EditorRefActions } from "./index"; +const SHORTCUTS = { + BOLD: { key: "b", delimiter: "**" }, + ITALIC: { key: "i", delimiter: "*" }, + LINK: { key: "k" }, +} as const; + +const URL_PLACEHOLDER = "url"; +const URL_REGEX = /^https?:\/\/[^\s]+$/; +const LINK_OFFSET = 3; // Length of "]()" + export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: EditorRefActions): void { - switch (event.key.toLowerCase()) { - case "b": - event.preventDefault(); - toggleTextStyle(editor, "**"); - break; - case "i": - event.preventDefault(); - toggleTextStyle(editor, "*"); - break; - case "k": - event.preventDefault(); - insertHyperlink(editor); - break; + const key = event.key.toLowerCase(); + if (key === SHORTCUTS.BOLD.key) { + event.preventDefault(); + toggleTextStyle(editor, SHORTCUTS.BOLD.delimiter); + } else if (key === SHORTCUTS.ITALIC.key) { + event.preventDefault(); + toggleTextStyle(editor, SHORTCUTS.ITALIC.delimiter); + } else if (key === SHORTCUTS.LINK.key) { + event.preventDefault(); + insertHyperlink(editor); } } export function insertHyperlink(editor: EditorRefActions, url?: string): void { const cursorPosition = editor.getCursorPosition(); const selectedContent = editor.getSelectedContent(); - const placeholderUrl = "url"; - const urlRegex = /^https?:\/\/[^\s]+$/; + const isUrlSelected = !url && URL_REGEX.test(selectedContent.trim()); - if (!url && urlRegex.test(selectedContent.trim())) { + if (isUrlSelected) { editor.insertText(`[](${selectedContent})`); editor.setCursorPosition(cursorPosition + 1, cursorPosition + 1); return; } - const href = url ?? placeholderUrl; + const href = url ?? URL_PLACEHOLDER; editor.insertText(`[${selectedContent}](${href})`); - if (href === placeholderUrl) { - const urlStart = cursorPosition + selectedContent.length + 3; + if (href === URL_PLACEHOLDER) { + const urlStart = cursorPosition + selectedContent.length + LINK_OFFSET; editor.setCursorPosition(urlStart, urlStart + href.length); } } @@ -41,8 +47,9 @@ export function insertHyperlink(editor: EditorRefActions, url?: string): void { function toggleTextStyle(editor: EditorRefActions, delimiter: string): void { const cursorPosition = editor.getCursorPosition(); const selectedContent = editor.getSelectedContent(); + const isStyled = selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter); - if (selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter)) { + if (isStyled) { const unstyled = selectedContent.slice(delimiter.length, -delimiter.length); editor.insertText(unstyled); editor.setCursorPosition(cursorPosition, cursorPosition + unstyled.length); diff --git a/web/src/components/MemoEditor/Editor/useListAutoCompletion.ts b/web/src/components/MemoEditor/Editor/useListAutoCompletion.ts deleted file mode 100644 index 938650028..000000000 --- a/web/src/components/MemoEditor/Editor/useListAutoCompletion.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect, useRef } from "react"; -import { detectLastListItem, generateListContinuation } from "@/utils/markdown-list-detection"; -import { EditorRefActions } from "."; - -interface UseListAutoCompletionOptions { - editorRef: React.RefObject; - editorActions: EditorRefActions; - isInIME: boolean; -} - -export function useListAutoCompletion({ editorRef, editorActions, isInIME }: UseListAutoCompletionOptions) { - // Use refs to avoid stale closures in event handlers - const isInIMERef = useRef(isInIME); - isInIMERef.current = isInIME; - - const editorActionsRef = useRef(editorActions); - editorActionsRef.current = editorActions; - - useEffect(() => { - const editor = editorRef.current; - if (!editor) return; - - const handleKeyDown = (event: KeyboardEvent) => { - // Only handle Enter key - if (event.key !== "Enter") return; - - // Don't handle if in IME composition (for Asian languages) - if (isInIMERef.current) return; - - // Don't handle if modifier keys are pressed (user wants manual control) - if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return; - - const actions = editorActionsRef.current; - const cursorPosition = actions.getCursorPosition(); - const contentBeforeCursor = actions.getContent().substring(0, cursorPosition); - - // Detect if we're on a list item - const listInfo = detectLastListItem(contentBeforeCursor); - - if (listInfo.type) { - event.preventDefault(); - - // Check if current list item is empty (GitHub-style behavior) - // Extract the current line - const lines = contentBeforeCursor.split("\n"); - const currentLine = lines[lines.length - 1]; - - // Check if line only contains list marker (no content after it) - const isEmptyListItem = - /^(\s*)([-*+])\s*$/.test(currentLine) || // Empty unordered list - /^(\s*)([-*+])\s+\[([ xX])\]\s*$/.test(currentLine) || // Empty task list - /^(\s*)(\d+)[.)]\s*$/.test(currentLine); // Empty ordered list - - if (isEmptyListItem) { - // Remove the empty list marker and exit list mode - const lineStartPos = cursorPosition - currentLine.length; - actions.removeText(lineStartPos, currentLine.length); - } else { - // Continue the list with the next item - const continuation = generateListContinuation(listInfo); - actions.insertText("\n" + continuation); - } - } - }; - - editor.addEventListener("keydown", handleKeyDown); - - return () => { - editor.removeEventListener("keydown", handleKeyDown); - }; - }, []); // Editor ref is stable; state accessed via refs to avoid stale closures -} diff --git a/web/src/components/MemoEditor/Editor/useListCompletion.ts b/web/src/components/MemoEditor/Editor/useListCompletion.ts new file mode 100644 index 000000000..69c2d329e --- /dev/null +++ b/web/src/components/MemoEditor/Editor/useListCompletion.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef } from "react"; +import { detectLastListItem, generateListContinuation } from "@/utils/markdown-list-detection"; +import { EditorRefActions } from "."; + +interface UseListCompletionOptions { + editorRef: React.RefObject; + editorActions: EditorRefActions; + isInIME: boolean; +} + +// Patterns to detect empty list items +const EMPTY_LIST_PATTERNS = [ + /^(\s*)([-*+])\s*$/, // Empty unordered list + /^(\s*)([-*+])\s+\[([ xX])\]\s*$/, // Empty task list + /^(\s*)(\d+)[.)]\s*$/, // Empty ordered list +]; + +const isEmptyListItem = (line: string) => EMPTY_LIST_PATTERNS.some((pattern) => pattern.test(line)); + +export function useListCompletion({ editorRef, editorActions, isInIME }: UseListCompletionOptions) { + const isInIMERef = useRef(isInIME); + isInIMERef.current = isInIME; + + const editorActionsRef = useRef(editorActions); + editorActionsRef.current = editorActions; + + useEffect(() => { + const editor = editorRef.current; + if (!editor) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" || isInIMERef.current || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) { + return; + } + + const actions = editorActionsRef.current; + const cursorPosition = actions.getCursorPosition(); + const contentBeforeCursor = actions.getContent().substring(0, cursorPosition); + const listInfo = detectLastListItem(contentBeforeCursor); + + if (!listInfo.type) return; + + event.preventDefault(); + + const lines = contentBeforeCursor.split("\n"); + const currentLine = lines[lines.length - 1]; + + if (isEmptyListItem(currentLine)) { + const lineStartPos = cursorPosition - currentLine.length; + actions.removeText(lineStartPos, currentLine.length); + } else { + const continuation = generateListContinuation(listInfo); + actions.insertText("\n" + continuation); + } + }; + + editor.addEventListener("keydown", handleKeyDown); + return () => editor.removeEventListener("keydown", handleKeyDown); + }, []); +} diff --git a/web/src/components/MemoEditor/Editor/useSuggestions.ts b/web/src/components/MemoEditor/Editor/useSuggestions.ts index a7d84e971..c11d01ff4 100644 --- a/web/src/components/MemoEditor/Editor/useSuggestions.ts +++ b/web/src/components/MemoEditor/Editor/useSuggestions.ts @@ -36,7 +36,6 @@ export function useSuggestions({ const [position, setPosition] = useState(null); const [selectedIndex, setSelectedIndex] = useState(0); - // Use refs to avoid stale closures in event handlers const selectedRef = useRef(selectedIndex); selectedRef.current = selectedIndex; @@ -51,7 +50,6 @@ export function useSuggestions({ const hide = () => setPosition(null); - // Filter items based on the current word after the trigger character const suggestionsRef = useRef([]); suggestionsRef.current = (() => { const [word] = getCurrentWord(); @@ -65,7 +63,7 @@ export function useSuggestions({ const handleAutocomplete = (item: T) => { if (!editorActions || !("current" in editorActions) || !editorActions.current) { - console.warn("useSuggestions: editorActions not available for autocomplete"); + console.warn("useSuggestions: editorActions not available"); return; } const [word, index] = getCurrentWord(); @@ -73,39 +71,37 @@ export function useSuggestions({ hide(); }; + const handleNavigation = (e: KeyboardEvent, selected: number, suggestionsCount: number) => { + if (e.code === "ArrowDown") { + setSelectedIndex((selected + 1) % suggestionsCount); + e.preventDefault(); + e.stopPropagation(); + } else if (e.code === "ArrowUp") { + setSelectedIndex((selected - 1 + suggestionsCount) % suggestionsCount); + e.preventDefault(); + e.stopPropagation(); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { if (!isVisibleRef.current) return; const suggestions = suggestionsRef.current; const selected = selectedRef.current; - // Hide on Escape or horizontal arrows if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) { hide(); return; } - // Navigate down - if (e.code === "ArrowDown") { - setSelectedIndex((selected + 1) % suggestions.length); - e.preventDefault(); - e.stopPropagation(); + if (["ArrowDown", "ArrowUp"].includes(e.code)) { + handleNavigation(e, selected, suggestions.length); return; } - // Navigate up - if (e.code === "ArrowUp") { - setSelectedIndex((selected - 1 + suggestions.length) % suggestions.length); - e.preventDefault(); - e.stopPropagation(); - return; - } - - // Accept suggestion if (["Enter", "Tab"].includes(e.code)) { handleAutocomplete(suggestions[selected]); e.preventDefault(); - // Prevent other listeners to be executed e.stopImmediatePropagation(); } }; @@ -120,31 +116,29 @@ export function useSuggestions({ const isActive = word.startsWith(triggerChar) && currentChar !== triggerChar; if (isActive) { - const caretCoordinates = getCaretCoordinates(editor, index); - caretCoordinates.top -= editor.scrollTop; - setPosition(caretCoordinates); + const coords = getCaretCoordinates(editor, index); + coords.top -= editor.scrollTop; + setPosition(coords); } else { hide(); } }; - // Register event listeners useEffect(() => { const editor = editorRef.current; if (!editor) return; - editor.addEventListener("click", hide); - editor.addEventListener("blur", hide); - editor.addEventListener("keydown", handleKeyDown); - editor.addEventListener("input", handleInput); + const handlers = { click: hide, blur: hide, keydown: handleKeyDown, input: handleInput }; + Object.entries(handlers).forEach(([event, handler]) => { + editor.addEventListener(event, handler as EventListener); + }); return () => { - editor.removeEventListener("click", hide); - editor.removeEventListener("blur", hide); - editor.removeEventListener("keydown", handleKeyDown); - editor.removeEventListener("input", handleInput); + Object.entries(handlers).forEach(([event, handler]) => { + editor.removeEventListener(event, handler as EventListener); + }); }; - }, []); // Empty deps - editor ref is stable, handlers use refs for fresh values + }, []); return { position, diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index 7867f87f7..bc893c296 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -183,6 +183,7 @@ const InsertMenu = observer((props: Props) => { +
{t("editor.slash-commands")}
diff --git a/web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts b/web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts index 7de9cc657..f3ee019b3 100644 --- a/web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts +++ b/web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts @@ -1,7 +1,7 @@ import { useCallback } from "react"; import { isValidUrl } from "@/helpers/utils"; import type { EditorRefActions } from "../Editor"; -import { hyperlinkHighlightedText } from "../Editor/markdownShortcuts"; +import { hyperlinkHighlightedText } from "../Editor/shortcuts"; export interface UseMemoEditorHandlersOptions { editorRef: React.RefObject; diff --git a/web/src/components/MemoEditor/hooks/useMemoEditorKeyboard.ts b/web/src/components/MemoEditor/hooks/useMemoEditorKeyboard.ts index baad7eb46..b6620dbd5 100644 --- a/web/src/components/MemoEditor/hooks/useMemoEditorKeyboard.ts +++ b/web/src/components/MemoEditor/hooks/useMemoEditorKeyboard.ts @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { TAB_SPACE_WIDTH } from "@/helpers/consts"; import { FOCUS_MODE_EXIT_KEY, FOCUS_MODE_TOGGLE_KEY } from "../constants"; import type { EditorRefActions } from "../Editor"; -import { handleMarkdownShortcuts } from "../Editor/markdownShortcuts"; +import { handleMarkdownShortcuts } from "../Editor/shortcuts"; export interface UseMemoEditorKeyboardOptions { editorRef: React.RefObject; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 3a60b8a8e..60240180a 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -122,7 +122,8 @@ "save": "Save", "no-changes-detected": "No changes detected", "focus-mode": "Focus Mode", - "exit-focus-mode": "Exit Focus Mode" + "exit-focus-mode": "Exit Focus Mode", + "slash-commands": "Type `/` for commands" }, "filters": { "has-code": "hasCode",