CI / Build & Quality Checks (pull_request) Successful in 4m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 24s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m52s
The ? dialog showed 7 shortcuts and left out Ctrl+K, ↑ to edit, all composer formatting keys and the push-to-talk / deafen keys. Add them: formatting rows are generated from the editor's INLINE_HOTKEYS / BLOCK_HOTKEYS so they can't drift, and the call keys show the user's own bindings. Fixes #334 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
256 lines
9.0 KiB
TypeScript
256 lines
9.0 KiB
TypeScript
import React, { useCallback } from 'react';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import { atom, useAtom, useSetAtom } from 'jotai';
|
|
import {
|
|
Box,
|
|
Dialog,
|
|
Header,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Line,
|
|
Overlay,
|
|
OverlayBackdrop,
|
|
OverlayCenter,
|
|
Scroll,
|
|
Text,
|
|
config,
|
|
} from 'folds';
|
|
import { stopPropagation } from '../../utils/keyboard';
|
|
import { editableActiveElement } from '../../utils/dom';
|
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
|
import { useSetting } from '../../state/hooks/settings';
|
|
import { settingsAtom } from '../../state/settings';
|
|
import { isMacOS } from '../../utils/user-agent';
|
|
import { KeySymbol } from '../../utils/key-symbol';
|
|
import * as css from './KeyboardShortcutsDialog.css';
|
|
import { BLOCK_HOTKEYS, INLINE_HOTKEYS } from '../../components/editor/keyboard';
|
|
import { MarkType } from '../../components/editor/types';
|
|
import { BLOCK_LABELS, MARK_LABELS, hotkeyKeys, keyCodeLabel } from './shortcutLabels';
|
|
|
|
/** Global open-state for the keyboard shortcuts help dialog. */
|
|
export const keyboardShortcutsDialogAtom = atom<boolean>(false);
|
|
|
|
/** Read/control the keyboard shortcuts dialog open-state. */
|
|
export function useKeyboardShortcutsDialog() {
|
|
const [open, setOpen] = useAtom(keyboardShortcutsDialogAtom);
|
|
const openDialog = useCallback(() => setOpen(true), [setOpen]);
|
|
const closeDialog = useCallback(() => setOpen(false), [setOpen]);
|
|
return { open, openDialog, closeDialog };
|
|
}
|
|
|
|
/**
|
|
* Registers the global `Shift + /` (`?`) shortcut that opens the keyboard
|
|
* shortcuts help dialog. Ignored while the user is typing into an input,
|
|
* textarea or contenteditable so it never steals a literal `?` character.
|
|
*
|
|
* Mount once in the client shell (e.g. `ClientNonUIFeatures`).
|
|
*/
|
|
export function useKeyboardShortcutsTrigger() {
|
|
const setOpen = useSetAtom(keyboardShortcutsDialogAtom);
|
|
useKeyDown(
|
|
window,
|
|
useCallback(
|
|
(evt: KeyboardEvent) => {
|
|
// Never intercept `?` while the user is typing into a field/editor.
|
|
if (editableActiveElement()) return;
|
|
// `?` is produced by Shift + `/` on the common layouts.
|
|
if (evt.key === '?') {
|
|
evt.preventDefault();
|
|
// Stop RoomView's window-level "type any char → focus composer"
|
|
// handler from also firing — otherwise focus lands in the composer
|
|
// behind the dialog and Escape gets swallowed by the contenteditable.
|
|
evt.stopImmediatePropagation();
|
|
setOpen(true);
|
|
}
|
|
},
|
|
[setOpen],
|
|
),
|
|
);
|
|
}
|
|
|
|
type ShortcutRow = {
|
|
description: string;
|
|
keys: string[];
|
|
};
|
|
|
|
type ShortcutSection = {
|
|
title: string;
|
|
rows: ShortcutRow[];
|
|
};
|
|
|
|
function ShortcutKeys({ keys }: { keys: string[] }) {
|
|
return (
|
|
<Box as="dd" className={css.ShortcutKeys}>
|
|
{keys.map((key, index) => (
|
|
<kbd key={`${key}-${index}`} className={css.Kbd}>
|
|
{key}
|
|
</kbd>
|
|
))}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Accessible keyboard shortcuts help dialog. Renders (as a modal overlay) only
|
|
* while `keyboardShortcutsDialogAtom` is `true`. Open it with the `?` shortcut
|
|
* (see `useKeyboardShortcutsTrigger`) or via `useKeyboardShortcutsDialog`.
|
|
*/
|
|
export function KeyboardShortcutsDialog() {
|
|
const { open, closeDialog } = useKeyboardShortcutsDialog();
|
|
const modalStyle = useModalStyle(480);
|
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
|
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
|
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
|
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
|
|
|
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
|
|
|
|
if (!open) return null;
|
|
|
|
const sections: ShortcutSection[] = [
|
|
{
|
|
title: 'General',
|
|
rows: [
|
|
{ description: 'Show keyboard shortcuts', keys: ['?'] },
|
|
{ description: 'Close open panel, otherwise mark room as read', keys: [KeySymbol.Escape] },
|
|
// [Gitea #334] the quick switcher / search was missing
|
|
{ description: 'Quick switcher: jump to a room or person', keys: [modKey, 'K'] },
|
|
],
|
|
},
|
|
{
|
|
title: 'Composer',
|
|
rows: [
|
|
{ description: 'Focus the message composer', keys: ['Any character'] },
|
|
{
|
|
description: 'Send message',
|
|
keys: enterForNewline ? [modKey, 'Enter'] : ['Enter'],
|
|
},
|
|
{
|
|
description: 'Insert a new line',
|
|
keys: enterForNewline ? ['Enter'] : [KeySymbol.Shift, 'Enter'],
|
|
},
|
|
{ description: 'Send message (always)', keys: [modKey, 'Enter'] },
|
|
{ description: 'Edit your last message (empty composer)', keys: ['↑'] },
|
|
],
|
|
},
|
|
{
|
|
// [Gitea #334] generated from the editor's own bindings so they can't drift
|
|
title: 'Composer formatting',
|
|
rows: [
|
|
...Object.entries(INLINE_HOTKEYS).map(([hotkey, mark]) => ({
|
|
description:
|
|
mark === MarkType.StrikeThrough
|
|
? `${MARK_LABELS[mark]} (not the browser's Save)`
|
|
: MARK_LABELS[mark],
|
|
keys: hotkeyKeys(hotkey, modKey),
|
|
})),
|
|
{ description: 'Heading 1 / 2 / 3', keys: [modKey, '1 / 2 / 3'] },
|
|
...Object.entries(BLOCK_HOTKEYS).map(([hotkey, block]) => ({
|
|
description: BLOCK_LABELS[block] ?? block,
|
|
keys: hotkeyKeys(hotkey, modKey),
|
|
})),
|
|
{ description: 'Clear formatting', keys: [modKey, 'E'] },
|
|
],
|
|
},
|
|
{
|
|
title: 'Messages',
|
|
rows: [
|
|
{ description: 'Reveal message actions (react, reply, more)', keys: ['Hover / focus'] },
|
|
],
|
|
},
|
|
{
|
|
// [Gitea #334] the user's own bindings (Settings → Calls)
|
|
title: 'Calls',
|
|
rows: [
|
|
{
|
|
description: pttMode
|
|
? 'Push to talk (hold)'
|
|
: 'Push to talk (hold; turn on in Settings → Calls)',
|
|
keys: [keyCodeLabel(pttKey)],
|
|
},
|
|
{ description: 'Deafen / undeafen', keys: [keyCodeLabel(deafenKey)] },
|
|
],
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
// #185 P2: move focus into the dialog so a screen reader announces
|
|
// it (it opened silently with focus left behind in the timeline).
|
|
initialFocus: '#keyboard-shortcuts-dialog',
|
|
onDeactivate: closeDialog,
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Dialog
|
|
variant="Surface"
|
|
id="keyboard-shortcuts-dialog"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="keyboard-shortcuts-dialog-title"
|
|
tabIndex={-1}
|
|
style={{ ...modalStyle, outline: 'none' }}
|
|
>
|
|
<Header
|
|
style={{
|
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
|
borderBottomWidth: config.borderWidth.B300,
|
|
}}
|
|
variant="Surface"
|
|
size="500"
|
|
>
|
|
<Box grow="Yes">
|
|
<Text as="h2" size="H4" id="keyboard-shortcuts-dialog-title">
|
|
Keyboard Shortcuts
|
|
</Text>
|
|
</Box>
|
|
<IconButton size="300" onClick={closeDialog} radii="300" aria-label="Close">
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
</Header>
|
|
<Scroll size="300" hideTrack visibility="Hover">
|
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="500">
|
|
{sections.map((section, sectionIndex) => (
|
|
<Box key={section.title} direction="Column" gap="300">
|
|
{sectionIndex > 0 && <Line variant="Surface" size="300" />}
|
|
<Text size="L400" priority="400">
|
|
{section.title}
|
|
</Text>
|
|
<Box as="dl" className={css.ShortcutList} direction="Column">
|
|
{section.rows.map((row) => (
|
|
<Box
|
|
key={row.description}
|
|
className={css.ShortcutRow}
|
|
direction="Row"
|
|
alignItems="Center"
|
|
gap="200"
|
|
>
|
|
<Text as="dt" className={css.ShortcutTerm} size="T300">
|
|
{row.description}
|
|
</Text>
|
|
<ShortcutKeys keys={row.keys} />
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
<Text size="T200" priority="300">
|
|
{enterForNewline
|
|
? 'Enter inserts a new line while “Enter for newline” is enabled in Settings.'
|
|
: 'Enter sends the message. Enable “Enter for newline” in Settings to swap this.'}
|
|
</Text>
|
|
</Box>
|
|
</Scroll>
|
|
</Dialog>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
);
|
|
}
|