mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
fd71e2e2de
commit
28b53e191b
3 changed files with 136 additions and 15 deletions
|
|
@ -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">
|
||||
|
|
|
|||
28
apps/web/src/state/useEnterToSend.ts
Normal file
28
apps/web/src/state/useEnterToSend.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue