mirror of https://github.com/usememos/memos.git
feat: add slash commands tooltip to InsertMenu
This commit is contained in:
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,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}
|
||||
></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();
|
||||
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<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…
Reference in New Issue