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:
Alejandro
2026-01-12 12:02:47 +01:00
committed by GitHub
parent 385f599b67
commit 035fd829d5
3 changed files with 61 additions and 20 deletions

View File

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

View File

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

View File

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