feat: add slash commands tooltip to InsertMenu

pull/5354/merge
Johnny 3 days ago
parent f9dd7ad853
commit d537591005

@ -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<HTMLTextAreaElement>;
editorActions: React.ForwardedRef<EditorRefActions>;
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) => <OverflowTip>/{cmd.name}</OverflowTip>}
renderItem={(cmd) => (
<span className="font-medium tracking-wide">
<span className="text-muted-foreground">/</span>
{cmd.name}
</span>
)}
/>
);
});
export default CommandSuggestions;
export default SlashCommands;

@ -11,6 +11,12 @@ interface SuggestionsPopupProps<T> {
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<T>({
position,
suggestions,
@ -22,32 +28,18 @@ export function SuggestionsPopup<T>({
const containerRef = useRef<HTMLDivElement>(null);
const selectedItemRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className="z-20 p-1 mt-1 -ml-2 absolute max-w-48 max-h-60 gap-px rounded font-mono flex flex-col overflow-y-auto overflow-x-hidden shadow-lg border bg-popover text-popover-foreground"
style={{ left: position.left, top: position.top + position.height }}
>
<div ref={containerRef} className={POPUP_STYLES.container} style={{ left: position.left, top: position.top + position.height }}>
{suggestions.map((item, i) => (
<div
key={getItemKey(item, i)}
ref={i === selectedIndex ? selectedItemRef : null}
onMouseDown={() => 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)}
</div>

@ -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) => <OverflowTip>#{tag}</OverflowTip>}
renderItem={(tag) => (
<OverflowTip>
<span className="text-muted-foreground mr-1">#</span>
{tag}
</OverflowTip>
)}
/>
);
});

@ -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,
},
];

@ -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,108 +56,94 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
}
}, []);
const editorActions = {
getEditor: () => {
return editorRef.current;
},
focus: () => {
editorRef.current?.focus();
},
const updateEditorHeight = () => {
if (editorRef.current) {
editorRef.current.style.height = "auto";
editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px";
}
};
const updateContent = () => {
if (editorRef.current) {
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
}
};
const editorActions: EditorRefActions = {
getEditor: () => editorRef.current,
focus: () => editorRef.current?.focus(),
scrollToCursor: () => {
if (editorRef.current) {
editorRef.current.scrollTop = editorRef.current.scrollHeight;
}
editorRef.current && (editorRef.current.scrollTop = editorRef.current.scrollHeight);
},
insertText: (content = "", prefix = "", suffix = "") => {
if (!editorRef.current) {
return;
}
const editor = editorRef.current;
if (!editor) 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);
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);
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();
editor.focus();
editor.setSelectionRange(cursorPos + prefix.length + actual.length, cursorPos + prefix.length + actual.length);
updateContent();
},
removeText: (start: number, length: number) => {
if (!editorRef.current) {
return;
}
const editor = editorRef.current;
if (!editor) 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();
editor.value = editor.value.slice(0, start) + editor.value.slice(start + length);
editor.focus();
editor.selectionEnd = start;
updateContent();
},
setContent: (text: string) => {
if (editorRef.current) {
editorRef.current.value = text;
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
const editor = editorRef.current;
if (editor) {
editor.value = text;
updateContent();
}
},
getContent: (): string => {
return editorRef.current?.value ?? "";
},
getCursorPosition: (): number => {
return editorRef.current?.selectionStart ?? 0;
},
getContent: () => editorRef.current?.value ?? "",
getCursorPosition: () => editorRef.current?.selectionStart ?? 0,
getSelectedContent: () => {
const start = editorRef.current?.selectionStart;
const end = editorRef.current?.selectionEnd;
return editorRef.current?.value.slice(start, end) ?? "";
const editor = editorRef.current;
if (!editor) return "";
return editor.value.slice(editor.selectionStart, editor.selectionEnd);
},
setCursorPosition: (startPos: number, endPos?: number) => {
const _endPos = isNaN(endPos as number) ? startPos : (endPos as number);
editorRef.current?.setSelectionRange(startPos, _endPos);
const endPosition = isNaN(endPos as number) ? startPos : (endPos as number);
editorRef.current?.setSelectionRange(startPos, endPosition);
},
getCursorLineNumber: () => {
const cursorPosition = editorRef.current?.selectionStart ?? 0;
const lines = editorRef.current?.value.slice(0, cursorPosition).split("\n") ?? [];
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) => {
return editorRef.current?.value.split("\n")[lineNumber] ?? "";
},
getLine: (lineNumber: number) => editorRef.current?.value.split("\n")[lineNumber] ?? "",
setLine: (lineNumber: number, text: string) => {
const lines = editorRef.current?.value.split("\n") ?? [];
const editor = editorRef.current;
if (!editor) return;
const lines = editor.value.split("\n");
lines[lineNumber] = text;
if (editorRef.current) {
editorRef.current.value = lines.join("\n");
editorRef.current.focus();
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
}
editor.value = lines.join("\n");
editor.focus();
updateContent();
},
};
useImperativeHandle(ref, () => editorActions, []);
const updateEditorHeight = () => {
if (editorRef.current) {
editorRef.current.style.height = "auto";
editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px";
}
};
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}
></textarea>
<TagSuggestions editorRef={editorRef} editorActions={ref} />
<CommandSuggestions editorRef={editorRef} editorActions={ref} commands={editorCommands} />
<SlashCommands editorRef={editorRef} editorActions={ref} commands={editorCommands} />
</div>
);
});

@ -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);

@ -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<HTMLTextAreaElement>;
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
}

@ -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<HTMLTextAreaElement>;
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);
}, []);
}

@ -36,7 +36,6 @@ export function useSuggestions<T>({
const [position, setPosition] = useState<Position | null>(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<T>({
const hide = () => setPosition(null);
// Filter items based on the current word after the trigger character
const suggestionsRef = useRef<T[]>([]);
suggestionsRef.current = (() => {
const [word] = getCurrentWord();
@ -65,7 +63,7 @@ export function useSuggestions<T>({
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<T>({
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();
return;
}
// Navigate up
if (e.code === "ArrowUp") {
setSelectedIndex((selected - 1 + suggestions.length) % suggestions.length);
e.preventDefault();
e.stopPropagation();
if (["ArrowDown", "ArrowUp"].includes(e.code)) {
handleNavigation(e, selected, suggestions.length);
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<T>({
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,

@ -183,6 +183,7 @@ const InsertMenu = observer((props: Props) => {
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<div className="px-2 py-1 text-xs text-muted-foreground opacity-80">{t("editor.slash-commands")}</div>
</DropdownMenuContent>
</DropdownMenu>

@ -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<EditorRefActions>;

@ -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<EditorRefActions>;

@ -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",

Loading…
Cancel
Save