mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
Add Ctrl+Enter shortcut to send messages (#55)
* fix: allow Ctrl/Cmd+Enter to submit messages when suggestion popup is open The suggestion list components were intercepting all Enter key presses, including Ctrl+Enter and Cmd+Enter, preventing message submission when the autocomplete popup was visible. Now these modifier combinations pass through to the editor's submit handler. * fix: make Ctrl/Cmd+Enter work reliably and prevent text overflow - Add TipTap Extension with addKeyboardShortcuts for Mod-Enter and Enter which has higher priority than the suggestion plugin, ensuring Ctrl/Cmd+Enter always submits even when autocomplete is open - Use ref to access handleSubmit from extension without recreating it - Add whitespace-nowrap to prevent text wrapping in single-line input - Add overflow-hidden to container and overflow-x-auto to editor content to handle long text gracefully with horizontal scrolling * fix: handle Ctrl/Cmd+Enter directly in suggestion onKeyDown handlers The TipTap suggestion plugin intercepts key events at the plugin level, which runs before extension keyboard shortcuts. Even returning false from the suggestion's onKeyDown didn't properly propagate events. Now the suggestion handlers directly handle Ctrl/Cmd+Enter by: 1. Capturing the editor reference from onStart (where it's available) 2. Checking for Ctrl/Cmd+Enter in onKeyDown 3. Closing the popup and calling handleSubmitRef.current() Also moved handleSubmitRef to the top of the component so it can be accessed from within the suggestion config closures. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -58,7 +58,7 @@ export const EmojiSuggestionList = forwardRef<
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (event.key === "Enter" && !event.ctrlKey && !event.metaKey) {
|
||||
if (items[selectedIndex]) {
|
||||
command(items[selectedIndex]);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
@@ -155,6 +157,9 @@ export const MentionEditor = forwardRef<
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
// Ref to access handleSubmit from suggestion plugins (defined early so useMemo can access it)
|
||||
const handleSubmitRef = useRef<(editor: any) => void>(() => {});
|
||||
|
||||
// Create mention suggestion configuration for @ mentions
|
||||
const mentionSuggestion: Omit<SuggestionOptions, "editor"> = useMemo(
|
||||
() => ({
|
||||
@@ -166,9 +171,11 @@ export const MentionEditor = forwardRef<
|
||||
render: () => {
|
||||
let component: ReactRenderer<ProfileSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
let editorRef: any;
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
editorRef = props.editor;
|
||||
component = new ReactRenderer(ProfileSuggestionList, {
|
||||
props: {
|
||||
items: props.items,
|
||||
@@ -216,6 +223,16 @@ export const MentionEditor = forwardRef<
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Enter submits the message
|
||||
if (
|
||||
props.event.key === "Enter" &&
|
||||
(props.event.ctrlKey || props.event.metaKey)
|
||||
) {
|
||||
popup[0]?.hide();
|
||||
handleSubmitRef.current(editorRef);
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props.event) ?? false;
|
||||
},
|
||||
|
||||
@@ -242,9 +259,11 @@ export const MentionEditor = forwardRef<
|
||||
render: () => {
|
||||
let component: ReactRenderer<EmojiSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
let editorRef: any;
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
editorRef = props.editor;
|
||||
component = new ReactRenderer(EmojiSuggestionList, {
|
||||
props: {
|
||||
items: props.items,
|
||||
@@ -292,6 +311,16 @@ export const MentionEditor = forwardRef<
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Enter submits the message
|
||||
if (
|
||||
props.event.key === "Enter" &&
|
||||
(props.event.ctrlKey || props.event.metaKey)
|
||||
) {
|
||||
popup[0]?.hide();
|
||||
handleSubmitRef.current(editorRef);
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props.event) ?? false;
|
||||
},
|
||||
|
||||
@@ -375,11 +404,34 @@ export const MentionEditor = forwardRef<
|
||||
[onSubmit, serializeContent],
|
||||
);
|
||||
|
||||
// Keep ref updated with latest handleSubmit
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
|
||||
// Build extensions array
|
||||
const extensions = useMemo(() => {
|
||||
// Custom extension for keyboard shortcuts (runs before suggestion plugins)
|
||||
const SubmitShortcut = Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// Ctrl/Cmd+Enter always submits
|
||||
"Mod-Enter": ({ editor }) => {
|
||||
handleSubmitRef.current(editor);
|
||||
return true;
|
||||
},
|
||||
// Plain Enter submits (Shift+Enter handled by hardBreak for newlines)
|
||||
Enter: ({ editor }) => {
|
||||
handleSubmitRef.current(editor);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const exts = [
|
||||
SubmitShortcut,
|
||||
StarterKit.configure({
|
||||
// Disable Enter to submit via Mod-Enter instead
|
||||
// Shift+Enter inserts hard break (newline)
|
||||
hardBreak: {
|
||||
keepMarks: false,
|
||||
},
|
||||
@@ -459,21 +511,7 @@ export const MentionEditor = forwardRef<
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm max-w-none focus:outline-none min-h-[2rem] px-3 py-1.5",
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
// Submit on Enter (without Shift) or Ctrl/Cmd+Enter
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
(!event.shiftKey || event.ctrlKey || event.metaKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
// Get editor from view state
|
||||
const editorInstance = (view as any).editor;
|
||||
handleSubmit(editorInstance);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
"prose prose-sm max-w-none focus:outline-none min-h-[2rem] px-3 py-1.5 whitespace-nowrap",
|
||||
},
|
||||
},
|
||||
autofocus: autoFocus,
|
||||
@@ -513,9 +551,12 @@ export const MentionEditor = forwardRef<
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border bg-background transition-colors focus-within:border-primary h-[2.5rem] flex items-center ${className}`}
|
||||
className={`rounded-md border bg-background transition-colors focus-within:border-primary h-[2.5rem] flex items-center overflow-hidden ${className}`}
|
||||
>
|
||||
<EditorContent editor={editor} className="flex-1" />
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="flex-1 min-w-0 overflow-x-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ProfileSuggestionList = forwardRef<
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (event.key === "Enter" && !event.ctrlKey && !event.metaKey) {
|
||||
if (items[selectedIndex]) {
|
||||
command(items[selectedIndex]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user