From 28b53e191bd772c99e5f074a216021b806f3a441 Mon Sep 17 00:00:00 2001 From: nicejames Date: Fri, 22 May 2026 22:15:27 +0900 Subject: [PATCH] feat(web): honor the Enter-to-send preference in the comment composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/BoardComposerPopover.tsx | 34 +++---- apps/web/src/state/useEnterToSend.ts | 28 ++++++ .../BoardComposerPopover.send-key.test.tsx | 89 +++++++++++++++++++ 3 files changed, 136 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/state/useEnterToSend.ts create mode 100644 apps/web/tests/components/BoardComposerPopover.send-key.test.tsx diff --git a/apps/web/src/components/BoardComposerPopover.tsx b/apps/web/src/components/BoardComposerPopover.tsx index 29ff80e57..5f2fa157a 100644 --- a/apps/web/src/components/BoardComposerPopover.tsx +++ b/apps/web/src/components/BoardComposerPopover.tsx @@ -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) => { + 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 (
{ 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} />
diff --git a/apps/web/src/state/useEnterToSend.ts b/apps/web/src/state/useEnterToSend.ts new file mode 100644 index 000000000..13e2b75bb --- /dev/null +++ b/apps/web/src/state/useEnterToSend.ts @@ -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(() => 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; +} diff --git a/apps/web/tests/components/BoardComposerPopover.send-key.test.tsx b/apps/web/tests/components/BoardComposerPopover.send-key.test.tsx new file mode 100644 index 000000000..e0d5a042a --- /dev/null +++ b/apps/web/tests/components/BoardComposerPopover.send-key.test.tsx @@ -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( + {}} + 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); + }); +});