fix(room): update keyboard shortcuts — Ctrl+Enter sends, Shift+Enter newlines, Enter only for mention select

- Ctrl+Enter: send message (was plain Enter)
- Shift+Enter: insert newline (textarea default, passes through)
- Enter alone: only triggers mention selection when popover is open, otherwise does nothing
- Update MentionPopover footer/header hints to reflect new shortcuts
This commit is contained in:
ZhenYi 2026-04-18 00:11:13 +08:00
parent 9b966789fd
commit 14bcc04991
2 changed files with 38 additions and 4 deletions

View File

@ -61,6 +61,8 @@ interface MentionPopoverProps {
onSelect: (newValue: string, newCursorPosition: number) => void; onSelect: (newValue: string, newCursorPosition: number) => void;
textareaRef: React.RefObject<HTMLTextAreaElement | null>; textareaRef: React.RefObject<HTMLTextAreaElement | null>;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** Mutable ref: write the confirm fn here so ChatInputArea can call it on Enter */
onMentionConfirmRef?: React.MutableRefObject<() => void>;
} }
// Category configuration with icons and colors // Category configuration with icons and colors
@ -372,6 +374,7 @@ export function MentionPopover({
onSelect, onSelect,
textareaRef, textareaRef,
onOpenChange, onOpenChange,
onMentionConfirmRef,
}: MentionPopoverProps) { }: MentionPopoverProps) {
const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0 }); const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0 });
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
@ -521,6 +524,14 @@ export function MentionPopover({
handleSelectRef.current = handleSelect; handleSelectRef.current = handleSelect;
// Register confirm fn so ChatInputArea can call it on Enter
if (onMentionConfirmRef) {
onMentionConfirmRef.current = () => {
const item = visibleSuggestionsRef.current[selectedIndexRef.current];
if (item) handleSelectRef.current(item);
};
}
// Position calculation // Position calculation
useEffect(() => { useEffect(() => {
if (!mentionState || !textareaRef.current) return; if (!mentionState || !textareaRef.current) return;
@ -723,6 +734,11 @@ export function MentionPopover({
</kbd> </kbd>
<span>navigate</span> <span>navigate</span>
<span className="text-muted-foreground/50">|</span>
<kbd className="px-1.5 py-0.5 rounded bg-muted border border-border/50 font-mono">
</kbd>
<span>select</span>
</div> </div>
</div> </div>
@ -779,7 +795,7 @@ export function MentionPopover({
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-muted border border-border/50 font-mono text-[10px]"> <kbd className="px-1.5 py-0.5 rounded bg-muted border border-border/50 font-mono text-[10px]">
Enter Ctrl+Enter
</kbd> </kbd>
<span className="text-[10px] text-muted-foreground">to insert</span> <span className="text-[10px] text-muted-foreground">to insert</span>
</div> </div>

View File

@ -48,6 +48,8 @@ interface ChatInputAreaProps {
draft: string; draft: string;
onDraftChange: (content: string) => void; onDraftChange: (content: string) => void;
onClearDraft: () => void; onClearDraft: () => void;
/** Called when Enter is pressed with mention popover open — parent triggers mention selection */
onMentionConfirm?: () => void;
ref?: React.Ref<ChatInputAreaHandle>; ref?: React.Ref<ChatInputAreaHandle>;
} }
@ -65,6 +67,7 @@ const ChatInputArea = memo(function ChatInputArea({
draft, draft,
onDraftChange, onDraftChange,
onClearDraft, onClearDraft,
onMentionConfirm,
ref, ref,
}: ChatInputAreaProps) { }: ChatInputAreaProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -88,7 +91,6 @@ const ChatInputArea = memo(function ChatInputArea({
if (!textareaRef.current) return; if (!textareaRef.current) return;
const value = textareaRef.current.value; const value = textareaRef.current.value;
const cursorPos = textareaRef.current.selectionStart; const cursorPos = textareaRef.current.selectionStart;
// Build new HTML mention: <mention type="user" id="uuid">label</mention>
const escapedLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapedLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapedId = id.replace(/"/g, '&quot;'); const escapedId = id.replace(/"/g, '&quot;');
const mentionText = `<mention type="user" id="${escapedId}">${escapedLabel}</mention> `; const mentionText = `<mention type="user" id="${escapedId}">${escapedLabel}</mention> `;
@ -122,12 +124,18 @@ const ChatInputArea = memo(function ChatInputArea({
}; };
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showMentionPopover && MENTION_POPOVER_KEYS.includes(e.key)) { if (showMentionPopover && MENTION_POPOVER_KEYS.includes(e.key) && !e.ctrlKey && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
if ((e.key === 'Enter' || e.key === 'Tab') && onMentionConfirm) {
onMentionConfirm();
}
return; return;
} }
if (e.key === 'Enter' && !e.shiftKey) { // Shift+Enter → let textarea naturally insert newline (pass through)
// Ctrl+Enter → send message
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault(); e.preventDefault();
const content = e.currentTarget.value.trim(); const content = e.currentTarget.value.trim();
if (content && !isSending) { if (content && !isSending) {
@ -135,6 +143,12 @@ const ChatInputArea = memo(function ChatInputArea({
onClearDraft(); onClearDraft();
} }
} }
// Plain Enter (no modifiers) → only trigger mention select; otherwise do nothing
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
// Do nothing — Shift+Enter falls through to let textarea insert newline
}
}; };
return ( return (
@ -190,6 +204,7 @@ const ChatInputArea = memo(function ChatInputArea({
onSelect={handleMentionSelect} onSelect={handleMentionSelect}
textareaRef={textareaRef} textareaRef={textareaRef}
onOpenChange={setShowMentionPopover} onOpenChange={setShowMentionPopover}
onMentionConfirmRef={mentionConfirmRef}
/> />
)} )}
</div> </div>
@ -268,6 +283,8 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const chatInputRef = useRef<ChatInputAreaHandle>(null); const chatInputRef = useRef<ChatInputAreaHandle>(null);
/** Shared ref: MentionPopover writes the confirm fn here; ChatInputArea reads and calls it */
const mentionConfirmRef = useRef<() => void>(() => {});
const [replyingTo, setReplyingTo] = useState<MessageWithMeta | null>(null); const [replyingTo, setReplyingTo] = useState<MessageWithMeta | null>(null);
const [editingMessage, setEditingMessage] = useState<MessageWithMeta | null>(null); const [editingMessage, setEditingMessage] = useState<MessageWithMeta | null>(null);
@ -559,6 +576,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
draft={draft} draft={draft}
onDraftChange={setDraft} onDraftChange={setDraft}
onClearDraft={clearDraft} onClearDraft={clearDraft}
onMentionConfirm={() => mentionConfirmRef.current()}
/> />
</div> </div>