Commit d5375910 authored by Johnny's avatar Johnny

feat: add slash commands tooltip to InsertMenu

parent f9dd7ad8
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import OverflowTip from "@/components/kit/OverflowTip";
import type { EditorRefActions } from "."; import type { EditorRefActions } from ".";
import type { Command } from "./commands"; import type { Command } from "./commands";
import { SuggestionsPopup } from "./SuggestionsPopup"; import { SuggestionsPopup } from "./SuggestionsPopup";
import { useSuggestions } from "./useSuggestions"; import { useSuggestions } from "./useSuggestions";
interface CommandSuggestionsProps { interface SlashCommandsProps {
editorRef: React.RefObject<HTMLTextAreaElement>; editorRef: React.RefObject<HTMLTextAreaElement>;
editorActions: React.ForwardedRef<EditorRefActions>; editorActions: React.ForwardedRef<EditorRefActions>;
commands: Command[]; commands: Command[];
} }
const CommandSuggestions = observer(({ editorRef, editorActions, commands }: CommandSuggestionsProps) => { const SlashCommands = observer(({ editorRef, editorActions, commands }: SlashCommandsProps) => {
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({ const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
editorRef, editorRef,
editorActions, editorActions,
triggerChar: "/", triggerChar: "/",
items: commands, items: commands,
filterItems: (items, searchQuery) => { filterItems: (items, query) => (!query ? items : items.filter((cmd) => cmd.name.toLowerCase().startsWith(query))),
if (!searchQuery) return items;
// Filter commands by prefix match for intuitive searching
return items.filter((cmd) => cmd.name.toLowerCase().startsWith(searchQuery));
},
onAutocomplete: (cmd, word, index, actions) => { onAutocomplete: (cmd, word, index, actions) => {
// Replace the trigger word with the command output
actions.removeText(index, word.length); actions.removeText(index, word.length);
actions.insertText(cmd.run()); actions.insertText(cmd.run());
// Position cursor if command specifies an offset
if (cmd.cursorOffset) { if (cmd.cursorOffset) {
actions.setCursorPosition(actions.getCursorPosition() + cmd.cursorOffset); actions.setCursorPosition(actions.getCursorPosition() + cmd.cursorOffset);
} }
...@@ -42,9 +35,14 @@ const CommandSuggestions = observer(({ editorRef, editorActions, commands }: Com ...@@ -42,9 +35,14 @@ const CommandSuggestions = observer(({ editorRef, editorActions, commands }: Com
selectedIndex={selectedIndex} selectedIndex={selectedIndex}
onItemSelect={handleItemSelect} onItemSelect={handleItemSelect}
getItemKey={(cmd) => cmd.name} 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> { ...@@ -11,6 +11,12 @@ interface SuggestionsPopupProps<T> {
getItemKey: (item: T, index: number) => string; 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>({ export function SuggestionsPopup<T>({
position, position,
suggestions, suggestions,
...@@ -22,32 +28,18 @@ export function SuggestionsPopup<T>({ ...@@ -22,32 +28,18 @@ export function SuggestionsPopup<T>({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const selectedItemRef = useRef<HTMLDivElement>(null); const selectedItemRef = useRef<HTMLDivElement>(null);
// Scroll selected item into view when selection changes
useEffect(() => { useEffect(() => {
if (selectedItemRef.current && containerRef.current) { selectedItemRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
selectedItemRef.current.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [selectedIndex]); }, [selectedIndex]);
return ( return (
<div <div ref={containerRef} className={POPUP_STYLES.container} style={{ left: position.left, top: position.top + position.height }}>
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 }}
>
{suggestions.map((item, i) => ( {suggestions.map((item, i) => (
<div <div
key={getItemKey(item, i)} key={getItemKey(item, i)}
ref={i === selectedIndex ? selectedItemRef : null} ref={i === selectedIndex ? selectedItemRef : null}
onMouseDown={() => onItemSelect(item)} onMouseDown={() => onItemSelect(item)}
className={cn( className={cn(POPUP_STYLES.item, i === selectedIndex && "bg-accent text-accent-foreground")}
"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" : "",
)}
> >
{renderItem(item, i === selectedIndex)} {renderItem(item, i === selectedIndex)}
</div> </div>
......
...@@ -2,7 +2,7 @@ import { observer } from "mobx-react-lite"; ...@@ -2,7 +2,7 @@ import { observer } from "mobx-react-lite";
import { useMemo } from "react"; import { useMemo } from "react";
import OverflowTip from "@/components/kit/OverflowTip"; import OverflowTip from "@/components/kit/OverflowTip";
import { userStore } from "@/store"; import { userStore } from "@/store";
import { EditorRefActions } from "."; import type { EditorRefActions } from ".";
import { SuggestionsPopup } from "./SuggestionsPopup"; import { SuggestionsPopup } from "./SuggestionsPopup";
import { useSuggestions } from "./useSuggestions"; import { useSuggestions } from "./useSuggestions";
...@@ -12,28 +12,21 @@ interface TagSuggestionsProps { ...@@ -12,28 +12,21 @@ interface TagSuggestionsProps {
} }
const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsProps) => { const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsProps) => {
// Sort tags by usage count (descending), then alphabetically for ties const sortedTags = useMemo(() => {
const sortedTags = useMemo( const tags = Object.entries(userStore.state.tagCount)
() => .sort((a, b) => b[1] - a[1]) // Sort by usage count (descending)
Object.entries(userStore.state.tagCount) .map(([tag]) => tag);
.sort((a, b) => a[0].localeCompare(b[0])) // Secondary sort by name for stable ordering
.sort((a, b) => b[1] - a[1]) return tags.sort((a, b) => (userStore.state.tagCount[a] === userStore.state.tagCount[b] ? a.localeCompare(b) : 0));
.map(([tag]) => tag), }, [userStore.state.tagCount]);
[userStore.state.tagCount],
);
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({ const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
editorRef, editorRef,
editorActions, editorActions,
triggerChar: "#", triggerChar: "#",
items: sortedTags, items: sortedTags,
filterItems: (items, searchQuery) => { filterItems: (items, query) => (!query ? items : items.filter((tag) => tag.toLowerCase().includes(query))),
if (!searchQuery) return items;
// Filter tags by substring match for flexible searching
return items.filter((tag) => tag.toLowerCase().includes(searchQuery));
},
onAutocomplete: (tag, word, index, actions) => { onAutocomplete: (tag, word, index, actions) => {
// Replace the trigger word with the complete tag and add a trailing space
actions.removeText(index, word.length); actions.removeText(index, word.length);
actions.insertText(`#${tag} `); actions.insertText(`#${tag} `);
}, },
...@@ -48,7 +41,12 @@ const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsPro ...@@ -48,7 +41,12 @@ const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsPro
selectedIndex={selectedIndex} selectedIndex={selectedIndex}
onItemSelect={handleItemSelect} onItemSelect={handleItemSelect}
getItemKey={(tag) => tag} 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[] = [ ...@@ -8,21 +8,21 @@ export const editorCommands: Command[] = [
{ {
name: "todo", name: "todo",
run: () => "- [ ] ", run: () => "- [ ] ",
cursorOffset: 6, // Places cursor after "- [ ] " to start typing task cursorOffset: 6,
}, },
{ {
name: "code", name: "code",
run: () => "```\n\n```", run: () => "```\n\n```",
cursorOffset: 4, // Places cursor on empty line between code fences cursorOffset: 4,
}, },
{ {
name: "link", name: "link",
run: () => "[text](url)", run: () => "[text](url)",
cursorOffset: 1, // Places cursor after "[" to type link text cursorOffset: 1,
}, },
{ {
name: "table", name: "table",
run: () => "| Header | Header |\n| ------ | ------ |\n| Cell | Cell |", run: () => "| Header | Header |\n| ------ | ------ |\n| Cell | Cell |",
cursorOffset: 1, // Places cursor after first "|" to edit first header cursorOffset: 1,
}, },
]; ];
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EDITOR_HEIGHT } from "../constants"; import { EDITOR_HEIGHT } from "../constants";
import CommandSuggestions from "./CommandSuggestions";
import { editorCommands } from "./commands"; import { editorCommands } from "./commands";
import SlashCommands from "./SlashCommands";
import TagSuggestions from "./TagSuggestions"; import TagSuggestions from "./TagSuggestions";
import { useListAutoCompletion } from "./useListAutoCompletion"; import { useListCompletion } from "./useListCompletion";
export interface EditorRefActions { export interface EditorRefActions {
getEditor: () => HTMLTextAreaElement | null; getEditor: () => HTMLTextAreaElement | null;
...@@ -56,108 +56,94 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef< ...@@ -56,108 +56,94 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
} }
}, []); }, []);
const editorActions = { const updateEditorHeight = () => {
getEditor: () => { if (editorRef.current) {
return editorRef.current; editorRef.current.style.height = "auto";
}, editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px";
focus: () => { }
editorRef.current?.focus(); };
},
const updateContent = () => {
if (editorRef.current) {
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
}
};
const editorActions: EditorRefActions = {
getEditor: () => editorRef.current,
focus: () => editorRef.current?.focus(),
scrollToCursor: () => { scrollToCursor: () => {
if (editorRef.current) { editorRef.current && (editorRef.current.scrollTop = editorRef.current.scrollHeight);
editorRef.current.scrollTop = editorRef.current.scrollHeight;
}
}, },
insertText: (content = "", prefix = "", suffix = "") => { insertText: (content = "", prefix = "", suffix = "") => {
if (!editorRef.current) { const editor = editorRef.current;
return; if (!editor) return;
}
const cursorPosition = editorRef.current.selectionStart; const cursorPos = editor.selectionStart;
const endPosition = editorRef.current.selectionEnd; const endPos = editor.selectionEnd;
const prevValue = editorRef.current.value; const prev = editor.value;
const actualContent = content || prevValue.slice(cursorPosition, endPosition); const actual = content || prev.slice(cursorPos, endPos);
const value = prevValue.slice(0, cursorPosition) + prefix + actualContent + suffix + prevValue.slice(endPosition); editor.value = prev.slice(0, cursorPos) + prefix + actual + suffix + prev.slice(endPos);
editorRef.current.value = value; editor.focus();
editorRef.current.focus(); editor.setSelectionRange(cursorPos + prefix.length + actual.length, cursorPos + prefix.length + actual.length);
// Place cursor at the end of inserted content updateContent();
const newCursorPosition = cursorPosition + prefix.length + actualContent.length + suffix.length;
editorRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
}, },
removeText: (start: number, length: number) => { removeText: (start: number, length: number) => {
if (!editorRef.current) { const editor = editorRef.current;
return; if (!editor) return;
}
const prevValue = editorRef.current.value; editor.value = editor.value.slice(0, start) + editor.value.slice(start + length);
const value = prevValue.slice(0, start) + prevValue.slice(start + length); editor.focus();
editorRef.current.value = value; editor.selectionEnd = start;
editorRef.current.focus(); updateContent();
editorRef.current.selectionEnd = start;
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
}, },
setContent: (text: string) => { setContent: (text: string) => {
if (editorRef.current) { const editor = editorRef.current;
editorRef.current.value = text; if (editor) {
handleContentChangeCallback(editorRef.current.value); editor.value = text;
updateEditorHeight(); updateContent();
} }
}, },
getContent: (): string => { getContent: () => editorRef.current?.value ?? "",
return editorRef.current?.value ?? ""; getCursorPosition: () => editorRef.current?.selectionStart ?? 0,
},
getCursorPosition: (): number => {
return editorRef.current?.selectionStart ?? 0;
},
getSelectedContent: () => { getSelectedContent: () => {
const start = editorRef.current?.selectionStart; const editor = editorRef.current;
const end = editorRef.current?.selectionEnd; if (!editor) return "";
return editorRef.current?.value.slice(start, end) ?? ""; return editor.value.slice(editor.selectionStart, editor.selectionEnd);
}, },
setCursorPosition: (startPos: number, endPos?: number) => { setCursorPosition: (startPos: number, endPos?: number) => {
const _endPos = isNaN(endPos as number) ? startPos : (endPos as number); const endPosition = isNaN(endPos as number) ? startPos : (endPos as number);
editorRef.current?.setSelectionRange(startPos, _endPos); editorRef.current?.setSelectionRange(startPos, endPosition);
}, },
getCursorLineNumber: () => { getCursorLineNumber: () => {
const cursorPosition = editorRef.current?.selectionStart ?? 0; const editor = editorRef.current;
const lines = editorRef.current?.value.slice(0, cursorPosition).split("\n") ?? []; if (!editor) return 0;
const lines = editor.value.slice(0, editor.selectionStart).split("\n");
return lines.length - 1; return lines.length - 1;
}, },
getLine: (lineNumber: number) => { getLine: (lineNumber: number) => editorRef.current?.value.split("\n")[lineNumber] ?? "",
return editorRef.current?.value.split("\n")[lineNumber] ?? "";
},
setLine: (lineNumber: number, text: string) => { 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; lines[lineNumber] = text;
if (editorRef.current) { editor.value = lines.join("\n");
editorRef.current.value = lines.join("\n"); editor.focus();
editorRef.current.focus(); updateContent();
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
}
}, },
}; };
useImperativeHandle(ref, () => editorActions, []); 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(() => { const handleEditorInput = useCallback(() => {
handleContentChangeCallback(editorRef.current?.value ?? ""); handleContentChangeCallback(editorRef.current?.value ?? "");
updateEditorHeight(); updateEditorHeight();
}, []); }, []);
// Auto-complete markdown lists when pressing Enter // Auto-complete markdown lists when pressing Enter
useListAutoCompletion({ useListCompletion({
editorRef, editorRef,
editorActions, editorActions,
isInIME, isInIME,
...@@ -185,7 +171,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef< ...@@ -185,7 +171,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
onCompositionEnd={onCompositionEnd} onCompositionEnd={onCompositionEnd}
></textarea> ></textarea>
<TagSuggestions editorRef={editorRef} editorActions={ref} /> <TagSuggestions editorRef={editorRef} editorActions={ref} />
<CommandSuggestions editorRef={editorRef} editorActions={ref} commands={editorCommands} /> <SlashCommands editorRef={editorRef} editorActions={ref} commands={editorCommands} />
</div> </div>
); );
}); });
......
import type { EditorRefActions } from "./index"; 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 { export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: EditorRefActions): void {
switch (event.key.toLowerCase()) { const key = event.key.toLowerCase();
case "b": if (key === SHORTCUTS.BOLD.key) {
event.preventDefault(); event.preventDefault();
toggleTextStyle(editor, "**"); toggleTextStyle(editor, SHORTCUTS.BOLD.delimiter);
break; } else if (key === SHORTCUTS.ITALIC.key) {
case "i": event.preventDefault();
event.preventDefault(); toggleTextStyle(editor, SHORTCUTS.ITALIC.delimiter);
toggleTextStyle(editor, "*"); } else if (key === SHORTCUTS.LINK.key) {
break; event.preventDefault();
case "k": insertHyperlink(editor);
event.preventDefault();
insertHyperlink(editor);
break;
} }
} }
export function insertHyperlink(editor: EditorRefActions, url?: string): void { export function insertHyperlink(editor: EditorRefActions, url?: string): void {
const cursorPosition = editor.getCursorPosition(); const cursorPosition = editor.getCursorPosition();
const selectedContent = editor.getSelectedContent(); const selectedContent = editor.getSelectedContent();
const placeholderUrl = "url"; const isUrlSelected = !url && URL_REGEX.test(selectedContent.trim());
const urlRegex = /^https?:\/\/[^\s]+$/;
if (!url && urlRegex.test(selectedContent.trim())) { if (isUrlSelected) {
editor.insertText(`[](${selectedContent})`); editor.insertText(`[](${selectedContent})`);
editor.setCursorPosition(cursorPosition + 1, cursorPosition + 1); editor.setCursorPosition(cursorPosition + 1, cursorPosition + 1);
return; return;
} }
const href = url ?? placeholderUrl; const href = url ?? URL_PLACEHOLDER;
editor.insertText(`[${selectedContent}](${href})`); editor.insertText(`[${selectedContent}](${href})`);
if (href === placeholderUrl) { if (href === URL_PLACEHOLDER) {
const urlStart = cursorPosition + selectedContent.length + 3; const urlStart = cursorPosition + selectedContent.length + LINK_OFFSET;
editor.setCursorPosition(urlStart, urlStart + href.length); editor.setCursorPosition(urlStart, urlStart + href.length);
} }
} }
...@@ -41,8 +47,9 @@ export function insertHyperlink(editor: EditorRefActions, url?: string): void { ...@@ -41,8 +47,9 @@ export function insertHyperlink(editor: EditorRefActions, url?: string): void {
function toggleTextStyle(editor: EditorRefActions, delimiter: string): void { function toggleTextStyle(editor: EditorRefActions, delimiter: string): void {
const cursorPosition = editor.getCursorPosition(); const cursorPosition = editor.getCursorPosition();
const selectedContent = editor.getSelectedContent(); 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); const unstyled = selectedContent.slice(delimiter.length, -delimiter.length);
editor.insertText(unstyled); editor.insertText(unstyled);
editor.setCursorPosition(cursorPosition, cursorPosition + unstyled.length); editor.setCursorPosition(cursorPosition, cursorPosition + unstyled.length);
......
...@@ -2,14 +2,22 @@ import { useEffect, useRef } from "react"; ...@@ -2,14 +2,22 @@ import { useEffect, useRef } from "react";
import { detectLastListItem, generateListContinuation } from "@/utils/markdown-list-detection"; import { detectLastListItem, generateListContinuation } from "@/utils/markdown-list-detection";
import { EditorRefActions } from "."; import { EditorRefActions } from ".";
interface UseListAutoCompletionOptions { interface UseListCompletionOptions {
editorRef: React.RefObject<HTMLTextAreaElement>; editorRef: React.RefObject<HTMLTextAreaElement>;
editorActions: EditorRefActions; editorActions: EditorRefActions;
isInIME: boolean; isInIME: boolean;
} }
export function useListAutoCompletion({ editorRef, editorActions, isInIME }: UseListAutoCompletionOptions) { // Patterns to detect empty list items
// Use refs to avoid stale closures in event handlers 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); const isInIMERef = useRef(isInIME);
isInIMERef.current = isInIME; isInIMERef.current = isInIME;
...@@ -21,52 +29,32 @@ export function useListAutoCompletion({ editorRef, editorActions, isInIME }: Use ...@@ -21,52 +29,32 @@ export function useListAutoCompletion({ editorRef, editorActions, isInIME }: Use
if (!editor) return; if (!editor) return;
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
// Only handle Enter key if (event.key !== "Enter" || isInIMERef.current || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) {
if (event.key !== "Enter") return; 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 actions = editorActionsRef.current;
const cursorPosition = actions.getCursorPosition(); const cursorPosition = actions.getCursorPosition();
const contentBeforeCursor = actions.getContent().substring(0, cursorPosition); const contentBeforeCursor = actions.getContent().substring(0, cursorPosition);
// Detect if we're on a list item
const listInfo = detectLastListItem(contentBeforeCursor); const listInfo = detectLastListItem(contentBeforeCursor);
if (listInfo.type) { if (!listInfo.type) return;
event.preventDefault();
// Check if current list item is empty (GitHub-style behavior) event.preventDefault();
// 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 lines = contentBeforeCursor.split("\n");
const isEmptyListItem = const currentLine = lines[lines.length - 1];
/^(\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) { if (isEmptyListItem(currentLine)) {
// Remove the empty list marker and exit list mode const lineStartPos = cursorPosition - currentLine.length;
const lineStartPos = cursorPosition - currentLine.length; actions.removeText(lineStartPos, currentLine.length);
actions.removeText(lineStartPos, currentLine.length); } else {
} else { const continuation = generateListContinuation(listInfo);
// Continue the list with the next item actions.insertText("\n" + continuation);
const continuation = generateListContinuation(listInfo);
actions.insertText("\n" + continuation);
}
} }
}; };
editor.addEventListener("keydown", handleKeyDown); editor.addEventListener("keydown", handleKeyDown);
return () => editor.removeEventListener("keydown", handleKeyDown);
return () => { }, []);
editor.removeEventListener("keydown", handleKeyDown);
};
}, []); // Editor ref is stable; state accessed via refs to avoid stale closures
} }
...@@ -36,7 +36,6 @@ export function useSuggestions<T>({ ...@@ -36,7 +36,6 @@ export function useSuggestions<T>({
const [position, setPosition] = useState<Position | null>(null); const [position, setPosition] = useState<Position | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
// Use refs to avoid stale closures in event handlers
const selectedRef = useRef(selectedIndex); const selectedRef = useRef(selectedIndex);
selectedRef.current = selectedIndex; selectedRef.current = selectedIndex;
...@@ -51,7 +50,6 @@ export function useSuggestions<T>({ ...@@ -51,7 +50,6 @@ export function useSuggestions<T>({
const hide = () => setPosition(null); const hide = () => setPosition(null);
// Filter items based on the current word after the trigger character
const suggestionsRef = useRef<T[]>([]); const suggestionsRef = useRef<T[]>([]);
suggestionsRef.current = (() => { suggestionsRef.current = (() => {
const [word] = getCurrentWord(); const [word] = getCurrentWord();
...@@ -65,7 +63,7 @@ export function useSuggestions<T>({ ...@@ -65,7 +63,7 @@ export function useSuggestions<T>({
const handleAutocomplete = (item: T) => { const handleAutocomplete = (item: T) => {
if (!editorActions || !("current" in editorActions) || !editorActions.current) { if (!editorActions || !("current" in editorActions) || !editorActions.current) {
console.warn("useSuggestions: editorActions not available for autocomplete"); console.warn("useSuggestions: editorActions not available");
return; return;
} }
const [word, index] = getCurrentWord(); const [word, index] = getCurrentWord();
...@@ -73,39 +71,37 @@ export function useSuggestions<T>({ ...@@ -73,39 +71,37 @@ export function useSuggestions<T>({
hide(); 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) => { const handleKeyDown = (e: KeyboardEvent) => {
if (!isVisibleRef.current) return; if (!isVisibleRef.current) return;
const suggestions = suggestionsRef.current; const suggestions = suggestionsRef.current;
const selected = selectedRef.current; const selected = selectedRef.current;
// Hide on Escape or horizontal arrows
if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) { if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) {
hide(); hide();
return; return;
} }
// Navigate down if (["ArrowDown", "ArrowUp"].includes(e.code)) {
if (e.code === "ArrowDown") { handleNavigation(e, selected, suggestions.length);
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();
return; return;
} }
// Accept suggestion
if (["Enter", "Tab"].includes(e.code)) { if (["Enter", "Tab"].includes(e.code)) {
handleAutocomplete(suggestions[selected]); handleAutocomplete(suggestions[selected]);
e.preventDefault(); e.preventDefault();
// Prevent other listeners to be executed
e.stopImmediatePropagation(); e.stopImmediatePropagation();
} }
}; };
...@@ -120,31 +116,29 @@ export function useSuggestions<T>({ ...@@ -120,31 +116,29 @@ export function useSuggestions<T>({
const isActive = word.startsWith(triggerChar) && currentChar !== triggerChar; const isActive = word.startsWith(triggerChar) && currentChar !== triggerChar;
if (isActive) { if (isActive) {
const caretCoordinates = getCaretCoordinates(editor, index); const coords = getCaretCoordinates(editor, index);
caretCoordinates.top -= editor.scrollTop; coords.top -= editor.scrollTop;
setPosition(caretCoordinates); setPosition(coords);
} else { } else {
hide(); hide();
} }
}; };
// Register event listeners
useEffect(() => { useEffect(() => {
const editor = editorRef.current; const editor = editorRef.current;
if (!editor) return; if (!editor) return;
editor.addEventListener("click", hide); const handlers = { click: hide, blur: hide, keydown: handleKeyDown, input: handleInput };
editor.addEventListener("blur", hide); Object.entries(handlers).forEach(([event, handler]) => {
editor.addEventListener("keydown", handleKeyDown); editor.addEventListener(event, handler as EventListener);
editor.addEventListener("input", handleInput); });
return () => { return () => {
editor.removeEventListener("click", hide); Object.entries(handlers).forEach(([event, handler]) => {
editor.removeEventListener("blur", hide); editor.removeEventListener(event, handler as EventListener);
editor.removeEventListener("keydown", handleKeyDown); });
editor.removeEventListener("input", handleInput);
}; };
}, []); // Empty deps - editor ref is stable, handlers use refs for fresh values }, []);
return { return {
position, position,
......
...@@ -183,6 +183,7 @@ const InsertMenu = observer((props: Props) => { ...@@ -183,6 +183,7 @@ const InsertMenu = observer((props: Props) => {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
<div className="px-2 py-1 text-xs text-muted-foreground opacity-80">{t("editor.slash-commands")}</div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
......
import { useCallback } from "react"; import { useCallback } from "react";
import { isValidUrl } from "@/helpers/utils"; import { isValidUrl } from "@/helpers/utils";
import type { EditorRefActions } from "../Editor"; import type { EditorRefActions } from "../Editor";
import { hyperlinkHighlightedText } from "../Editor/markdownShortcuts"; import { hyperlinkHighlightedText } from "../Editor/shortcuts";
export interface UseMemoEditorHandlersOptions { export interface UseMemoEditorHandlersOptions {
editorRef: React.RefObject<EditorRefActions>; editorRef: React.RefObject<EditorRefActions>;
......
...@@ -2,7 +2,7 @@ import { useCallback } from "react"; ...@@ -2,7 +2,7 @@ import { useCallback } from "react";
import { TAB_SPACE_WIDTH } from "@/helpers/consts"; import { TAB_SPACE_WIDTH } from "@/helpers/consts";
import { FOCUS_MODE_EXIT_KEY, FOCUS_MODE_TOGGLE_KEY } from "../constants"; import { FOCUS_MODE_EXIT_KEY, FOCUS_MODE_TOGGLE_KEY } from "../constants";
import type { EditorRefActions } from "../Editor"; import type { EditorRefActions } from "../Editor";
import { handleMarkdownShortcuts } from "../Editor/markdownShortcuts"; import { handleMarkdownShortcuts } from "../Editor/shortcuts";
export interface UseMemoEditorKeyboardOptions { export interface UseMemoEditorKeyboardOptions {
editorRef: React.RefObject<EditorRefActions>; editorRef: React.RefObject<EditorRefActions>;
......
...@@ -122,7 +122,8 @@ ...@@ -122,7 +122,8 @@
"save": "Save", "save": "Save",
"no-changes-detected": "No changes detected", "no-changes-detected": "No changes detected",
"focus-mode": "Focus Mode", "focus-mode": "Focus Mode",
"exit-focus-mode": "Exit Focus Mode" "exit-focus-mode": "Exit Focus Mode",
"slash-commands": "Type `/` for commands"
}, },
"filters": { "filters": {
"has-code": "hasCode", "has-code": "hasCode",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment