feat(web): honor the Enter-to-send preference in the comment composer

The preview comment popover never handled Enter, so typing a comment and
pressing Enter did nothing. Wire it to the same Settings → General
"Enter to send" preference as the chat composer via a small useEnterToSend
hook: bare Enter sends the comment (current draft + queued notes) when the
setting is on, ⌘/Ctrl+Enter sends when it is off, Shift/Alt always insert a
newline, and an in-progress IME composition is never treated as a send.
This commit is contained in:
nicejames 2026-05-22 22:15:27 +09:00
parent fd71e2e2de
commit 28b53e191b
3 changed files with 136 additions and 15 deletions

View file

@ -1,8 +1,8 @@
import type { CSSProperties } from 'react';
import { useRef } from 'react';
import { useRef, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { PreviewCommentSnapshot } from '../comments';
import type { Dict } from '../i18n/types';
import { useEnterToSend } from '../state/useEnterToSend';
import type { PreviewComment, PreviewCommentMember } from '../types';
import { isImeComposing } from '../utils/imeComposing';
@ -264,8 +264,24 @@ export function BoardComposerPopover({
const pendingCount = notes.length + (draft.trim() ? 1 : 0);
const hasCommentChange = !existing || draft.trim() !== existing.note.trim();
const podMembers = target.podMembers ?? [];
const enterToSend = useEnterToSend();
const composingRef = useRef(false);
const sendDisabled = pendingCount === 0 || sending;
// Send the comment (current draft + any queued notes) on the configured
// key: bare Enter when "Enter to send" is on, ⌘/Ctrl + Enter when off.
// Shift / Alt always insert a newline, and an in-progress IME composition
// (e.g. a Korean syllable) is never treated as a send.
const handleInputKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== 'Enter') return;
if (isImeComposing(event, composingRef.current)) return;
const sends = enterToSend
? !event.shiftKey && !event.altKey && !event.metaKey && !event.ctrlKey
: (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
if (!sends) return;
event.preventDefault();
if (sendDisabled) return;
void onSendBatch();
};
return (
<div
className={`comment-popover${docked ? ' comment-popover-docked' : ''}`}
@ -345,19 +361,7 @@ export function BoardComposerPopover({
onCompositionEnd={() => {
composingRef.current = false;
}}
onKeyDown={(event) => {
if (isImeComposing(event, composingRef.current)) return;
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.altKey &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
if (sendDisabled) return;
void onSendBatch();
}
}}
onKeyDown={handleInputKeyDown}
/>
<div className="comment-popover-actions">
<div className="comment-popover-actions-start">

View file

@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
import { loadConfig } from './config';
const STORAGE_KEY = 'open-design:config';
/**
* Reads the Settings General "Enter to send" preference.
*
* Defaults to `true` (Enter sends) so it matches the composer's default. The
* value is read from the persisted `open-design:config` blob at mount and kept
* in sync across tabs via the platform `storage` event. Same-tab consumers that
* mount fresh after a Settings change (e.g. a comment popover opened after the
* dialog closes) pick up the latest value on their next mount.
*/
export function useEnterToSend(): boolean {
const [value, setValue] = useState<boolean>(() => loadConfig().enterToSend ?? true);
useEffect(() => {
if (typeof window === 'undefined') return;
const onStorage = (event: StorageEvent): void => {
if (event.key !== null && event.key !== STORAGE_KEY) return;
setValue(loadConfig().enterToSend ?? true);
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
return value;
}

View file

@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BoardComposerPopover } from '../../src/components/BoardComposerPopover';
import type { PreviewCommentSnapshot } from '../../src/comments';
const STORAGE_KEY = 'open-design:config';
afterEach(() => {
cleanup();
window.localStorage.clear();
});
function target(): PreviewCommentSnapshot {
return {
filePath: 'index.html',
elementId: 'el-1',
selector: '#el-1',
label: 'Button',
text: '',
position: { x: 0, y: 0, width: 100, height: 40 },
htmlHint: '',
selectionKind: 'element',
memberCount: 1,
podMembers: [],
};
}
function renderPopover(onSendBatch: () => void) {
return render(
<BoardComposerPopover
target={target()}
existing={null}
draft="looks good"
notes={[]}
onDraft={() => {}}
onAddDraft={() => {}}
onRemoveQueuedNote={() => {}}
onClose={() => {}}
onSaveComment={() => {}}
onSendBatch={onSendBatch}
onRemove={() => {}}
onRemoveMember={() => {}}
sending={false}
t={((key: string) => String(key)) as never}
/>,
);
}
describe('BoardComposerPopover send key (honors Settings → General)', () => {
it('default (Enter to send): bare Enter sends, Shift+Enter does not', () => {
const onSendBatch = vi.fn();
renderPopover(onSendBatch);
const input = screen.getByTestId('comment-popover-input');
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
expect(onSendBatch).not.toHaveBeenCalled();
fireEvent.keyDown(input, { key: 'Enter' });
expect(onSendBatch).toHaveBeenCalledTimes(1);
});
it('does not send while an IME composition is in progress', () => {
const onSendBatch = vi.fn();
renderPopover(onSendBatch);
const input = screen.getByTestId('comment-popover-input');
fireEvent.keyDown(input, { key: 'Enter', isComposing: true });
expect(onSendBatch).not.toHaveBeenCalled();
fireEvent.keyDown(input, { key: 'Enter' });
expect(onSendBatch).toHaveBeenCalledTimes(1);
});
it('legacy (enterToSend=false): ⌘/Ctrl+Enter sends, bare Enter does not', () => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ enterToSend: false }));
const onSendBatch = vi.fn();
renderPopover(onSendBatch);
const input = screen.getByTestId('comment-popover-input');
fireEvent.keyDown(input, { key: 'Enter' });
expect(onSendBatch).not.toHaveBeenCalled();
fireEvent.keyDown(input, { key: 'Enter', metaKey: true });
expect(onSendBatch).toHaveBeenCalledTimes(1);
});
});