feat: configurable send shortcut (Enter vs Ctrl+Enter)
Add toggle in chat input to switch between Enter-to-send (default) and Ctrl+Enter-to-send modes. Preference persists in localStorage. Keyboard shortcuts modal reflects the current setting.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, lazy, Suspense } from 'react';
|
import { useState, useRef, useEffect, useCallback, lazy, Suspense } from 'react';
|
||||||
import { Send, Square, Paperclip, X, FileText, Eye, EyeOff } from 'lucide-react';
|
import { Send, Square, Paperclip, X, FileText, Eye, EyeOff } from 'lucide-react';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
import { useSendShortcut } from '../hooks/useSendShortcut';
|
||||||
|
|
||||||
const ReactMarkdown = lazy(() => import('react-markdown'));
|
const ReactMarkdown = lazy(() => import('react-markdown'));
|
||||||
const remarkGfm = import('remark-gfm').then(m => m.default);
|
const remarkGfm = import('remark-gfm').then(m => m.default);
|
||||||
@@ -87,6 +88,7 @@ function formatSize(bytes: number): string {
|
|||||||
|
|
||||||
export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey }: Props) {
|
export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey }: Props) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut();
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [files, setFiles] = useState<FileAttachment[]>([]);
|
const [files, setFiles] = useState<FileAttachment[]>([]);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
@@ -179,9 +181,20 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
if (sendOnEnter) {
|
||||||
handleSubmit();
|
// Enter sends, Shift+Enter for newline
|
||||||
|
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ctrl+Enter (or Cmd+Enter on Mac) sends, Enter for newline
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,6 +318,14 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
|
|||||||
rows={1}
|
rows={1}
|
||||||
className="flex-1 bg-transparent resize-none rounded-2xl border border-pc-border bg-pc-input/35 px-4 py-3 text-sm text-pc-text placeholder:text-pc-text-muted outline-none transition-all max-h-[200px]"
|
className="flex-1 bg-transparent resize-none rounded-2xl border border-pc-border bg-pc-input/35 px-4 py-3 text-sm text-pc-text placeholder:text-pc-text-muted outline-none transition-all max-h-[200px]"
|
||||||
/>
|
/>
|
||||||
|
{/* Send shortcut toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleSendShortcut}
|
||||||
|
className="hidden sm:flex shrink-0 h-7 rounded-xl border border-pc-border bg-pc-elevated/30 px-2 items-center gap-1 text-[10px] text-pc-text-muted hover:text-pc-text-secondary hover:bg-[var(--pc-hover)] transition-colors"
|
||||||
|
title={`${t('settings.sendShortcut')}: ${sendOnEnter ? t('settings.sendEnter') : t('settings.sendCtrlEnter')}`}
|
||||||
|
>
|
||||||
|
<span>{sendOnEnter ? '↵' : (navigator.userAgent.includes('Mac') ? '⌘↵' : 'Ctrl↵')}</span>
|
||||||
|
</button>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<button
|
<button
|
||||||
onClick={onAbort}
|
onClick={onAbort}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { X, Keyboard } from 'lucide-react';
|
import { X, Keyboard } from 'lucide-react';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
import { useSendShortcut } from '../hooks/useSendShortcut';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -37,6 +38,7 @@ const mod = isMac ? '⌘' : 'Ctrl';
|
|||||||
|
|
||||||
export function KeyboardShortcuts({ open, onClose }: Props) {
|
export function KeyboardShortcuts({ open, onClose }: Props) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const { sendOnEnter } = useSendShortcut();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -79,11 +81,11 @@ export function KeyboardShortcuts({ open, onClose }: Props) {
|
|||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<SectionTitle>{t('shortcuts.chatSection')}</SectionTitle>
|
<SectionTitle>{t('shortcuts.chatSection')}</SectionTitle>
|
||||||
<ShortcutRow
|
<ShortcutRow
|
||||||
keys={<Kbd>Enter</Kbd>}
|
keys={sendOnEnter ? <Kbd>Enter</Kbd> : <><Kbd>{mod}</Kbd><span className="text-pc-text-faint">+</span><Kbd>Enter</Kbd></>}
|
||||||
label={t('shortcuts.send')}
|
label={t('shortcuts.send')}
|
||||||
/>
|
/>
|
||||||
<ShortcutRow
|
<ShortcutRow
|
||||||
keys={<><Kbd>Shift</Kbd><span className="text-pc-text-faint">+</span><Kbd>Enter</Kbd></>}
|
keys={sendOnEnter ? <><Kbd>Shift</Kbd><span className="text-pc-text-faint">+</span><Kbd>Enter</Kbd></> : <Kbd>Enter</Kbd>}
|
||||||
label={t('shortcuts.newline')}
|
label={t('shortcuts.newline')}
|
||||||
/>
|
/>
|
||||||
<ShortcutRow
|
<ShortcutRow
|
||||||
|
|||||||
21
src/hooks/useSendShortcut.ts
Normal file
21
src/hooks/useSendShortcut.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'pinchchat-send-on-enter';
|
||||||
|
|
||||||
|
/** Hook to manage the send shortcut preference (Enter vs Ctrl+Enter). */
|
||||||
|
export function useSendShortcut() {
|
||||||
|
const [sendOnEnter, setSendOnEnter] = useState(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored === null ? true : stored === 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setSendOnEnter(prev => {
|
||||||
|
const next = !prev;
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { sendOnEnter, toggle };
|
||||||
|
}
|
||||||
@@ -137,6 +137,11 @@ const en = {
|
|||||||
'search.prev': 'Previous match',
|
'search.prev': 'Previous match',
|
||||||
'search.next': 'Next match',
|
'search.next': 'Next match',
|
||||||
'shortcuts.searchMessages': 'Search messages',
|
'shortcuts.searchMessages': 'Search messages',
|
||||||
|
|
||||||
|
// Send shortcut setting
|
||||||
|
'settings.sendShortcut': 'Send with',
|
||||||
|
'settings.sendEnter': 'Enter',
|
||||||
|
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const fr: Record<keyof typeof en, string> = {
|
const fr: Record<keyof typeof en, string> = {
|
||||||
@@ -255,6 +260,10 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'search.prev': 'Résultat précédent',
|
'search.prev': 'Résultat précédent',
|
||||||
'search.next': 'Résultat suivant',
|
'search.next': 'Résultat suivant',
|
||||||
'shortcuts.searchMessages': 'Rechercher dans les messages',
|
'shortcuts.searchMessages': 'Rechercher dans les messages',
|
||||||
|
|
||||||
|
'settings.sendShortcut': 'Envoyer avec',
|
||||||
|
'settings.sendEnter': 'Entrée',
|
||||||
|
'settings.sendCtrlEnter': 'Ctrl+Entrée',
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TranslationKey = keyof typeof en;
|
export type TranslationKey = keyof typeof en;
|
||||||
|
|||||||
Reference in New Issue
Block a user