feat(ui): land the centralized shortcuts core from #2532 with review fixes
The schema/config/bindings/registry/dispatcher module, useKeybind hooks, recording dialog, reworked shortcuts settings page, help dialog, and the localized action labels — re-based onto current main rather than merged (the branch predates 440+ commits including the session-tabs shortcuts). Review fixes applied on top of the original: - close_session_tab (alt+w) joins the schema with labels in every locale; it shipped on main after the PR's base and would otherwise silently die. - switch_context_surface's special-case in conflict resolution is now a declared prefixStyle config property instead of a magic id string. - Duplicate handler registration warns in dev builds. - The risky-browser-shortcut warning inspects every chord and covers mod+q/d/h/j/o/u plus mod+shift+w/q. - The dispatcher remembers which target armed a two-chord prefix so the window-level completion handler can distinguish a deliberate sequence from typing in an editable field (guard lands with the dispatch hook). - Schema tests: unique normalized default bindings enforced, and the flat-file-era override format proven to keep resolving.
This commit is contained in:
@@ -1,12 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsFieldRow,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsFieldRow, SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import {
|
||||
@@ -14,314 +9,124 @@ import {
|
||||
getCustomizableShortcutActions,
|
||||
getEffectiveShortcutCombo,
|
||||
getEffectiveShortcutPrefix,
|
||||
isRiskyBrowserShortcut,
|
||||
keyToShortcutToken,
|
||||
normalizeCombo,
|
||||
UNASSIGNED_SHORTCUT,
|
||||
type ShortcutActionId,
|
||||
type ShortcutCategory,
|
||||
type ShortcutCombo,
|
||||
type CustomizableShortcutAction,
|
||||
} from '@/lib/shortcuts';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { ShortcutRecordingDialog } from './ShortcutRecordingDialog';
|
||||
|
||||
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
|
||||
|
||||
const keyboardEventToCombo = (event: React.KeyboardEvent<HTMLInputElement>): ShortcutCombo | null => {
|
||||
if (MODIFIER_KEYS.has(event.key.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
parts.push('mod');
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
parts.push('shift');
|
||||
}
|
||||
if (event.altKey) {
|
||||
parts.push('alt');
|
||||
}
|
||||
|
||||
const keyToken = keyToShortcutToken(event.key);
|
||||
if (!keyToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
parts.push(keyToken);
|
||||
return normalizeCombo(parts.join('+'));
|
||||
};
|
||||
|
||||
// Prefix capture for chord-style shortcuts (e.g. "switch context panel
|
||||
// surface"): a bare modifier press is accepted so the prefix can be just the
|
||||
// primary modifier (default) or a modifier + key chord like `mod+p`.
|
||||
const keyboardEventToPrefixCombo = (event: React.KeyboardEvent<HTMLInputElement>): ShortcutCombo | null => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
parts.push('mod');
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
parts.push('shift');
|
||||
}
|
||||
if (event.altKey) {
|
||||
parts.push('alt');
|
||||
}
|
||||
|
||||
if (MODIFIER_KEYS.has(event.key.toLowerCase())) {
|
||||
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||
}
|
||||
|
||||
const keyToken = keyToShortcutToken(event.key);
|
||||
if (!keyToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
parts.push(keyToken);
|
||||
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||
};
|
||||
const CATEGORIES: ShortcutCategory[] = ['session', 'models', 'panels', 'navigation', 'application'];
|
||||
|
||||
export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const setShortcutOverride = useUIStore((state) => state.setShortcutOverride);
|
||||
const clearShortcutOverride = useUIStore((state) => state.clearShortcutOverride);
|
||||
const resetAllShortcutOverrides = useUIStore((state) => state.resetAllShortcutOverrides);
|
||||
const [editingAction, setEditingAction] = React.useState<CustomizableShortcutAction | null>(null);
|
||||
|
||||
const actions = React.useMemo(() => {
|
||||
const all = getCustomizableShortcutActions();
|
||||
if (!isVSCodeRuntime()) {
|
||||
return all;
|
||||
}
|
||||
return all.filter((action) => action.id !== 'toggle_prompt_navigator');
|
||||
return isVSCodeRuntime() ? all.filter((action) => action.id !== 'toggle_prompt_navigator') : all;
|
||||
}, []);
|
||||
const actionLabel = React.useCallback((id: string, fallbackLabel: string): string => {
|
||||
const key = `settings.openchamber.keyboardShortcuts.action.${id}.label`;
|
||||
const translated = tUnsafe(key);
|
||||
return translated === key ? fallbackLabel : translated;
|
||||
}, [tUnsafe]);
|
||||
|
||||
const [capturingActionId, setCapturingActionId] = React.useState<string | null>(null);
|
||||
const [draftByAction, setDraftByAction] = React.useState<Record<string, ShortcutCombo>>({});
|
||||
const [errorText, setErrorText] = React.useState<string>('');
|
||||
const [warningText, setWarningText] = React.useState<string>('');
|
||||
const [pendingOverwrite, setPendingOverwrite] = React.useState<{
|
||||
actionId: string;
|
||||
combo: ShortcutCombo;
|
||||
conflictActionId: string;
|
||||
} | null>(null);
|
||||
|
||||
const persistShortcutOverrides = React.useCallback((nextOverrides: Record<string, ShortcutCombo>) => {
|
||||
const persist = (nextOverrides: Record<string, ShortcutCombo>) => {
|
||||
void updateDesktopSettings({ shortcutOverrides: nextOverrides });
|
||||
}, []);
|
||||
|
||||
const findConflict = React.useCallback((actionId: string, combo: ShortcutCombo): string | null => {
|
||||
const normalized = normalizeCombo(combo);
|
||||
for (const action of actions) {
|
||||
if (action.id === actionId) {
|
||||
continue;
|
||||
}
|
||||
const existing = getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
||||
if (normalizeCombo(existing) === normalized) {
|
||||
return action.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [actions, shortcutOverrides]);
|
||||
|
||||
const saveCombo = React.useCallback((actionId: string, combo: ShortcutCombo) => {
|
||||
const normalized = normalizeCombo(combo);
|
||||
const conflictActionId = findConflict(actionId, normalized);
|
||||
if (conflictActionId) {
|
||||
setPendingOverwrite({ actionId, combo: normalized, conflictActionId });
|
||||
setErrorText('');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOverrides = { ...shortcutOverrides, [actionId]: normalized };
|
||||
setShortcutOverride(actionId, normalized);
|
||||
persistShortcutOverrides(nextOverrides);
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText(isRiskyBrowserShortcut(normalized) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : '');
|
||||
setDraftByAction((current) => {
|
||||
const rest = { ...current };
|
||||
delete rest[actionId];
|
||||
return rest;
|
||||
});
|
||||
}, [findConflict, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]);
|
||||
|
||||
const confirmOverwrite = React.useCallback(() => {
|
||||
if (!pendingOverwrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOverrides = {
|
||||
...shortcutOverrides,
|
||||
[pendingOverwrite.conflictActionId]: UNASSIGNED_SHORTCUT,
|
||||
[pendingOverwrite.actionId]: pendingOverwrite.combo,
|
||||
};
|
||||
setShortcutOverride(pendingOverwrite.conflictActionId, UNASSIGNED_SHORTCUT);
|
||||
setShortcutOverride(pendingOverwrite.actionId, pendingOverwrite.combo);
|
||||
persistShortcutOverrides(nextOverrides);
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText(isRiskyBrowserShortcut(pendingOverwrite.combo) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : '');
|
||||
setDraftByAction((current) => {
|
||||
const rest = { ...current };
|
||||
delete rest[pendingOverwrite.actionId];
|
||||
return rest;
|
||||
});
|
||||
}, [pendingOverwrite, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]);
|
||||
|
||||
const resetOne = React.useCallback((actionId: string) => {
|
||||
};
|
||||
const save = (
|
||||
actionId: ShortcutActionId,
|
||||
combo: ShortcutCombo,
|
||||
replaceActionId?: ShortcutActionId,
|
||||
) => {
|
||||
const nextOverrides = { ...shortcutOverrides, [actionId]: combo };
|
||||
if (replaceActionId) nextOverrides[replaceActionId] = UNASSIGNED_SHORTCUT;
|
||||
setShortcutOverride(actionId, combo);
|
||||
if (replaceActionId) setShortcutOverride(replaceActionId, UNASSIGNED_SHORTCUT);
|
||||
persist(nextOverrides);
|
||||
};
|
||||
const resetOne = (actionId: ShortcutActionId) => {
|
||||
const nextOverrides = { ...shortcutOverrides };
|
||||
delete nextOverrides[actionId];
|
||||
clearShortcutOverride(actionId);
|
||||
persistShortcutOverrides(nextOverrides);
|
||||
setDraftByAction((current) => {
|
||||
const rest = { ...current };
|
||||
delete rest[actionId];
|
||||
return rest;
|
||||
});
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText('');
|
||||
}, [clearShortcutOverride, persistShortcutOverrides, shortcutOverrides]);
|
||||
persist(nextOverrides);
|
||||
};
|
||||
const shortcutDisplay = (action: CustomizableShortcutAction): string => {
|
||||
const isSurfaceSwitch = action.id === 'switch_context_surface';
|
||||
const combo = isSurfaceSwitch
|
||||
? getEffectiveShortcutPrefix(action.id, shortcutOverrides)
|
||||
: getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
||||
const formatted = formatShortcutForDisplay(
|
||||
combo,
|
||||
t('settings.openchamber.keyboardShortcuts.unassigned'),
|
||||
);
|
||||
return isSurfaceSwitch && combo && combo !== UNASSIGNED_SHORTCUT
|
||||
? `${formatted}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}`
|
||||
: formatted;
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
settingsItem="shortcuts.keyboard-shortcuts"
|
||||
title={t('settings.openchamber.keyboardShortcuts.title')}
|
||||
divider={false}
|
||||
info={t('settings.openchamber.keyboardShortcuts.tooltip')}
|
||||
headerAction={(
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
resetAllShortcutOverrides();
|
||||
persistShortcutOverrides({});
|
||||
setDraftByAction({});
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText('');
|
||||
}}
|
||||
>
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{(errorText || warningText || pendingOverwrite) && (
|
||||
<div className="mb-2 space-y-2">
|
||||
{pendingOverwrite && (
|
||||
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 flex flex-col @xl:flex-row @xl:items-center justify-between gap-3">
|
||||
<span className="typography-meta text-foreground">
|
||||
{t('settings.openchamber.keyboardShortcuts.overwritePrompt')}
|
||||
</span>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={confirmOverwrite}>{t('settings.openchamber.keyboardShortcuts.actions.overwrite')}</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => setPendingOverwrite(null)}>{t('settings.common.actions.cancel')}</Button>
|
||||
</div>
|
||||
<>
|
||||
{CATEGORIES.map((category, categoryIndex) => {
|
||||
const categoryActions = actions.filter((action) => action.category === category);
|
||||
if (categoryActions.length === 0) return null;
|
||||
return (
|
||||
<SettingsSection
|
||||
key={category}
|
||||
settingsItem={categoryIndex === 0 ? 'shortcuts.keyboard-shortcuts' : undefined}
|
||||
title={t(`settings.openchamber.keyboardShortcuts.category.${category}`)}
|
||||
divider={categoryIndex !== 0}
|
||||
info={categoryIndex === 0 ? t('settings.openchamber.keyboardShortcuts.tooltip') : undefined}
|
||||
headerAction={categoryIndex === 0 ? (
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => {
|
||||
resetAllShortcutOverrides();
|
||||
persist({});
|
||||
}}>
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
|
||||
</Button>
|
||||
) : undefined}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{categoryActions.map((action) => (
|
||||
<SettingsFieldRow key={action.id} label={t(action.settingsLabelKey)}>
|
||||
<kbd
|
||||
className="min-w-32 rounded-md border border-border bg-muted px-2 py-1 text-center typography-meta font-mono text-foreground"
|
||||
>
|
||||
{shortcutDisplay(action)}
|
||||
</kbd>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setEditingAction(action)}
|
||||
>
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => resetOne(action.id)}
|
||||
>
|
||||
{t('settings.common.actions.reset')}
|
||||
</Button>
|
||||
</SettingsFieldRow>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{errorText && (
|
||||
<div className="rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] p-3 typography-meta text-foreground">
|
||||
{errorText}
|
||||
</div>
|
||||
)}
|
||||
{warningText && (
|
||||
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 typography-meta text-foreground">
|
||||
{warningText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{actions.map((action, index) => {
|
||||
const isSurfaceSwitch = action.id === 'switch_context_surface';
|
||||
const effective = isSurfaceSwitch
|
||||
? getEffectiveShortcutPrefix(action.id, shortcutOverrides)
|
||||
: getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
||||
const draft = draftByAction[action.id];
|
||||
const displayCombo = draft ?? effective;
|
||||
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
|
||||
const isUnassignedDisplay = displayCombo === '' || normalizeCombo(displayCombo) === UNASSIGNED_SHORTCUT;
|
||||
const displayValue = capturingActionId === action.id
|
||||
? t('settings.openchamber.keyboardShortcuts.field.pressKeys')
|
||||
: isSurfaceSwitch && !isUnassignedDisplay
|
||||
? `${formatShortcutForDisplay(displayCombo)}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}`
|
||||
: formatShortcutForDisplay(displayCombo);
|
||||
|
||||
return (
|
||||
<div key={action.id} className={cn("py-1.5", index > 0 && "border-t border-border/40")}>
|
||||
<SettingsFieldRow
|
||||
label={actionLabel(action.id, action.label)}
|
||||
alignEnd={false}
|
||||
>
|
||||
<Input
|
||||
readOnly
|
||||
value={displayValue}
|
||||
onFocus={() => {
|
||||
setCapturingActionId(action.id);
|
||||
setErrorText('');
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (capturingActionId === action.id) {
|
||||
setCapturingActionId(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
setCapturingActionId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const combo = isSurfaceSwitch ? keyboardEventToPrefixCombo(event) : keyboardEventToCombo(event);
|
||||
if (!combo) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftByAction((current) => ({
|
||||
...current,
|
||||
[action.id]: combo,
|
||||
}));
|
||||
setCapturingActionId(null);
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
}}
|
||||
className="h-7 w-40 min-w-0 typography-ui-label text-center"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
const next = draftByAction[action.id];
|
||||
if (!next) {
|
||||
setErrorText(t('settings.openchamber.keyboardShortcuts.error.captureFirst'));
|
||||
return;
|
||||
}
|
||||
saveCombo(action.id, next);
|
||||
}}
|
||||
disabled={!hasDraft}
|
||||
>
|
||||
{t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
|
||||
{t('settings.common.actions.reset')}
|
||||
</Button>
|
||||
</SettingsFieldRow>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsSection>
|
||||
);
|
||||
})}
|
||||
<ShortcutRecordingDialog
|
||||
action={editingAction}
|
||||
overrides={shortcutOverrides}
|
||||
onSave={save}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingAction(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { settleShortcutRecordingState, updateShortcutRecordingState } from './ShortcutRecordingDialog';
|
||||
|
||||
const emptyState = { chords: [], livePreview: null, settled: false };
|
||||
|
||||
function keyEvent(key: string, modifiers: Partial<Record<'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey', boolean>> = {}) {
|
||||
return { key, repeat: false, isComposing: false, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers };
|
||||
}
|
||||
|
||||
describe('ShortcutRecordingDialog recording state', () => {
|
||||
test('previews modifiers and clears the preview when they are released', () => {
|
||||
const pressed = updateShortcutRecordingState(emptyState, keyEvent('Control', { ctrlKey: true, shiftKey: true }), 'keydown');
|
||||
expect(pressed.livePreview).toBe('mod+shift');
|
||||
expect(updateShortcutRecordingState(pressed, keyEvent('Control'), 'keyup').livePreview).toBeNull();
|
||||
});
|
||||
|
||||
test('waits after the first chord and settles when a second chord is recorded', () => {
|
||||
const first = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown');
|
||||
const second = updateShortcutRecordingState(first, keyEvent('p'), 'keydown');
|
||||
const third = updateShortcutRecordingState(second, keyEvent('x'), 'keydown');
|
||||
expect(first.chords).toEqual(['mod+s']);
|
||||
expect(first.settled).toBe(false);
|
||||
expect(second.chords).toEqual(['mod+s', 'p']);
|
||||
expect(second.settled).toBe(true);
|
||||
expect(third.chords).toEqual(['x']);
|
||||
expect(third.settled).toBe(false);
|
||||
});
|
||||
|
||||
test('settles a single chord for timeout and Confirm validation', () => {
|
||||
const waiting = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown');
|
||||
expect(settleShortcutRecordingState(waiting)).toEqual({ chords: ['mod+s'], livePreview: null, settled: true });
|
||||
});
|
||||
|
||||
test('records at most three simultaneous keys', () => {
|
||||
const previous = { chords: ['mod+k'], livePreview: null, settled: false };
|
||||
const threeKeys = updateShortcutRecordingState(
|
||||
previous,
|
||||
keyEvent('s', { ctrlKey: true, shiftKey: true }),
|
||||
'keydown',
|
||||
);
|
||||
const fourKeys = updateShortcutRecordingState(
|
||||
previous,
|
||||
keyEvent('s', { ctrlKey: true, metaKey: true, shiftKey: true }),
|
||||
'keydown',
|
||||
);
|
||||
|
||||
expect(threeKeys.chords).toEqual(['mod+k', 'mod+shift+s']);
|
||||
expect(fourKeys.chords).toEqual(['mod+k']);
|
||||
});
|
||||
|
||||
test('ignores repeat and IME events', () => {
|
||||
expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), repeat: true }, 'keydown')).toEqual(emptyState);
|
||||
expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), isComposing: true }, 'keydown')).toEqual(emptyState);
|
||||
});
|
||||
|
||||
test('records Enter and Escape while Backspace removes the final chord', () => {
|
||||
const state = { chords: ['mod+k', 'mod+p'], livePreview: null, settled: true };
|
||||
expect(updateShortcutRecordingState(emptyState, keyEvent('Enter'), 'keydown').chords).toEqual(['enter']);
|
||||
expect(updateShortcutRecordingState(emptyState, keyEvent('Escape'), 'keydown').chords).toEqual(['escape']);
|
||||
expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').chords).toEqual(['mod+k']);
|
||||
expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').settled).toBe(false);
|
||||
expect(updateShortcutRecordingState({ chords: ['mod+k'], livePreview: null, settled: false }, keyEvent('Backspace'), 'keydown')).toEqual(emptyState);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
formatShortcutForDisplay,
|
||||
getShortcutBindingConflicts,
|
||||
isRiskyBrowserShortcut,
|
||||
keyToShortcutToken,
|
||||
normalizeCombo,
|
||||
type ShortcutActionId,
|
||||
type ShortcutBindingConflict,
|
||||
type ShortcutCombo,
|
||||
type CustomizableShortcutAction,
|
||||
} from '@/lib/shortcuts';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
|
||||
const MAX_SHORTCUT_KEY_COUNT = 3;
|
||||
const SECOND_CHORD_TIMEOUT_MS = 3000;
|
||||
|
||||
interface RecordingKeyboardEvent {
|
||||
altKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
isComposing: boolean;
|
||||
key: string;
|
||||
metaKey: boolean;
|
||||
repeat: boolean;
|
||||
shiftKey: boolean;
|
||||
}
|
||||
|
||||
interface ShortcutRecordingState {
|
||||
chords: ShortcutCombo[];
|
||||
livePreview: ShortcutCombo | null;
|
||||
settled: boolean;
|
||||
}
|
||||
|
||||
interface ShortcutRecordingDialogProps {
|
||||
action: CustomizableShortcutAction | null;
|
||||
overrides: Record<string, string>;
|
||||
onSave: (
|
||||
actionId: ShortcutActionId,
|
||||
combo: ShortcutCombo,
|
||||
replaceActionId?: ShortcutActionId,
|
||||
) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function getPhysicalKeyCount(
|
||||
event: Pick<RecordingKeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
|
||||
includeEventKey = false,
|
||||
): number {
|
||||
const keys = new Set<string>();
|
||||
if (event.altKey) keys.add('alt');
|
||||
if (event.ctrlKey) keys.add('control');
|
||||
if (event.metaKey) keys.add('meta');
|
||||
if (event.shiftKey) keys.add('shift');
|
||||
if (includeEventKey) keys.add(event.key.toLowerCase());
|
||||
return keys.size;
|
||||
}
|
||||
|
||||
function isCustomizableConflict(
|
||||
conflict: ShortcutBindingConflict,
|
||||
): conflict is ShortcutBindingConflict & { action: CustomizableShortcutAction } {
|
||||
return conflict.action.customizable;
|
||||
}
|
||||
|
||||
function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null {
|
||||
if (getPhysicalKeyCount(event) > MAX_SHORTCUT_KEY_COUNT) return null;
|
||||
const parts: string[] = [];
|
||||
if (event.metaKey || event.ctrlKey) parts.push('mod');
|
||||
if (event.shiftKey) parts.push('shift');
|
||||
if (event.altKey) parts.push('alt');
|
||||
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||
}
|
||||
|
||||
function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | null {
|
||||
if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null;
|
||||
if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null;
|
||||
|
||||
const key = keyToShortcutToken(event.key);
|
||||
if (!key) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (event.metaKey || event.ctrlKey) parts.push('mod');
|
||||
if (event.shiftKey) parts.push('shift');
|
||||
if (event.altKey) parts.push('alt');
|
||||
parts.push(key);
|
||||
return normalizeCombo(parts.join('+'));
|
||||
}
|
||||
|
||||
function modifierKeyUpToCombo(event: React.KeyboardEvent<HTMLDivElement>): ShortcutCombo | null {
|
||||
const key = event.key.toLowerCase();
|
||||
if (!MODIFIER_KEYS.has(key)) return null;
|
||||
if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (event.metaKey || event.ctrlKey || key === 'meta' || key === 'control') parts.push('mod');
|
||||
if (event.shiftKey || key === 'shift') parts.push('shift');
|
||||
if (event.altKey || key === 'alt') parts.push('alt');
|
||||
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition
|
||||
export function settleShortcutRecordingState(state: ShortcutRecordingState): ShortcutRecordingState {
|
||||
return state.chords.length > 0 ? { ...state, livePreview: null, settled: true } : state;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition
|
||||
export function updateShortcutRecordingState(
|
||||
state: ShortcutRecordingState,
|
||||
event: RecordingKeyboardEvent,
|
||||
phase: 'keydown' | 'keyup',
|
||||
): ShortcutRecordingState {
|
||||
if (event.repeat || event.isComposing) return state;
|
||||
if (phase === 'keyup') {
|
||||
return { ...state, livePreview: getModifierPreview(event) };
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace') {
|
||||
return { chords: state.chords.slice(0, -1), livePreview: null, settled: false };
|
||||
}
|
||||
|
||||
const chord = keyboardEventToCombo(event);
|
||||
if (chord) {
|
||||
if (state.settled) {
|
||||
return { chords: [chord], livePreview: null, settled: false };
|
||||
}
|
||||
const chords = state.chords.length < 2 ? [...state.chords, chord] : state.chords;
|
||||
return {
|
||||
chords,
|
||||
livePreview: null,
|
||||
settled: chords.length === 2,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...state, livePreview: getModifierPreview(event) };
|
||||
}
|
||||
|
||||
export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = ({
|
||||
action,
|
||||
overrides,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey);
|
||||
const conflictActionLabel = (conflict: ShortcutBindingConflict) => (
|
||||
conflict.action.customizable
|
||||
? actionLabel(conflict.action)
|
||||
: formatShortcutForDisplay(conflict.action.defaultBinding)
|
||||
);
|
||||
const [recording, setRecording] = React.useState<ShortcutRecordingState>({ chords: [], livePreview: null, settled: false });
|
||||
const recordingRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!action) return;
|
||||
setRecording({ chords: [], livePreview: null, settled: false });
|
||||
recordingRef.current?.focus();
|
||||
}, [action]);
|
||||
|
||||
const waitingForSecondChord = recording.chords.length === 1 && !recording.settled;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!waitingForSecondChord) return;
|
||||
const timeout = window.setTimeout(
|
||||
() => setRecording(settleShortcutRecordingState),
|
||||
SECOND_CHORD_TIMEOUT_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [waitingForSecondChord]);
|
||||
|
||||
const combo = normalizeCombo(recording.chords.join(' '));
|
||||
const conflicts = React.useMemo(
|
||||
() => action && combo ? getShortcutBindingConflicts(action.id, combo, overrides) : [],
|
||||
[action, combo, overrides],
|
||||
);
|
||||
const protectedConflict = conflicts.find((conflict) => (
|
||||
!conflict.action.customizable && conflict.kind !== 'contextual-prefix'
|
||||
));
|
||||
const customizableConflicts = conflicts.filter(isCustomizableConflict);
|
||||
const prefixConflict = customizableConflicts.find((conflict) => conflict.kind === 'prefix');
|
||||
const exactConflict = customizableConflicts.find((conflict) => conflict.kind === 'exact');
|
||||
const contextualPrefixConflict = conflicts.find((conflict) => conflict.kind === 'contextual-prefix');
|
||||
|
||||
const close = () => onOpenChange(false);
|
||||
const confirm = () => {
|
||||
if (!recording.settled) setRecording(settleShortcutRecordingState);
|
||||
if (!action || !combo || protectedConflict || prefixConflict) return;
|
||||
onSave(action.id, combo, exactConflict?.action.id);
|
||||
close();
|
||||
};
|
||||
const handleRecordingEvent = (event: React.KeyboardEvent<HTMLDivElement>, phase: 'keydown' | 'keyup') => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (phase === 'keyup' && action?.id === 'switch_context_surface' && recording.chords.length === 0) {
|
||||
const modifierCombo = modifierKeyUpToCombo(event);
|
||||
if (modifierCombo) {
|
||||
setRecording({ chords: [modifierCombo], livePreview: null, settled: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const nextRecording = updateShortcutRecordingState(recording, {
|
||||
altKey: event.altKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
isComposing: event.nativeEvent.isComposing,
|
||||
key: event.key,
|
||||
metaKey: event.metaKey,
|
||||
repeat: event.repeat,
|
||||
shiftKey: event.shiftKey,
|
||||
}, phase);
|
||||
setRecording(action?.id === 'switch_context_surface' && nextRecording.chords.length > 1
|
||||
? recording
|
||||
: nextRecording);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={action !== null}
|
||||
onOpenChange={(open, eventDetails) => {
|
||||
if (!open) {
|
||||
eventDetails.cancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md" initialFocus={recordingRef} showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{action ? t('settings.openchamber.keyboardShortcuts.dialog.title', { action: actionLabel(action) }) : ''}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('settings.openchamber.keyboardShortcuts.dialog.instructions')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
className="flex min-h-28 items-center justify-center rounded-lg border border-border bg-[var(--surface-elevated)] px-4 py-5 text-center outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
tabIndex={0}
|
||||
ref={recordingRef}
|
||||
onKeyDown={(event) => handleRecordingEvent(event, 'keydown')}
|
||||
onKeyUp={(event) => handleRecordingEvent(event, 'keyup')}
|
||||
onBlur={() => setRecording((current) => ({ ...current, livePreview: null }))}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{recording.chords.map((chord, index) => (
|
||||
<kbd key={`${chord}-${index}`} className="rounded-md border border-border bg-muted px-3 py-2 typography-ui-label font-mono text-foreground">
|
||||
{formatShortcutForDisplay(chord)}
|
||||
</kbd>
|
||||
))}
|
||||
{recording.livePreview ? (
|
||||
<kbd className="rounded-md border border-dashed border-border bg-muted px-3 py-2 typography-ui-label font-mono text-muted-foreground">
|
||||
{formatShortcutForDisplay(recording.livePreview)}
|
||||
</kbd>
|
||||
) : null}
|
||||
{recording.chords.length === 0 && !recording.livePreview ? (
|
||||
<span className="typography-ui-label text-muted-foreground">
|
||||
{t('settings.openchamber.keyboardShortcuts.dialog.recording')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recording.settled && protectedConflict ? (
|
||||
<p className="typography-meta text-[var(--status-error)]">
|
||||
{t('settings.openchamber.keyboardShortcuts.error.internalConflict')}
|
||||
</p>
|
||||
) : recording.settled && prefixConflict ? (
|
||||
<p className="typography-meta text-[var(--status-error)]">
|
||||
{t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })}
|
||||
</p>
|
||||
) : null}
|
||||
{recording.settled && exactConflict && !protectedConflict && !prefixConflict ? (
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
{t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })}
|
||||
</p>
|
||||
) : null}
|
||||
{recording.settled && contextualPrefixConflict && !protectedConflict && !prefixConflict ? (
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
{t('settings.openchamber.keyboardShortcuts.warning.contextualPrefix', {
|
||||
action: conflictActionLabel(contextualPrefixConflict),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{recording.settled && combo && isRiskyBrowserShortcut(combo) ? (
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
{t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={close}>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!combo || (recording.settled && (Boolean(protectedConflict) || Boolean(prefixConflict)))}
|
||||
onClick={confirm}
|
||||
>
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -11,17 +11,17 @@ import { useUIStore } from "@/stores/useUIStore";
|
||||
import {
|
||||
getEffectiveShortcutCombo,
|
||||
getShortcutAction,
|
||||
getModifierLabel,
|
||||
formatShortcutForDisplay,
|
||||
type ShortcutActionId,
|
||||
} from "@/lib/shortcuts";
|
||||
import { useI18n, type I18nKey } from "@/lib/i18n";
|
||||
import { isVSCodeRuntime } from "@/lib/desktop";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
|
||||
type ShortcutItem = {
|
||||
id?: string;
|
||||
id?: ShortcutActionId;
|
||||
keys: string | string[];
|
||||
descriptionKey: I18nKey;
|
||||
descriptionKey?: I18nKey;
|
||||
icon: IconName | null;
|
||||
};
|
||||
|
||||
@@ -30,9 +30,12 @@ type ShortcutSection = {
|
||||
items: ShortcutItem[];
|
||||
};
|
||||
|
||||
const renderShortcut = (id: string, fallbackCombo: string, overrides: Record<string, string>) => {
|
||||
const action = getShortcutAction(id);
|
||||
return action ? formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides)) : fallbackCombo;
|
||||
const renderShortcut = (
|
||||
id: ShortcutActionId,
|
||||
overrides: Record<string, string>,
|
||||
unassignedLabel: string,
|
||||
) => {
|
||||
return formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides), unassignedLabel);
|
||||
};
|
||||
|
||||
export const HelpDialog: React.FC = () => {
|
||||
@@ -40,7 +43,6 @@ export const HelpDialog: React.FC = () => {
|
||||
const isHelpDialogOpen = useUIStore((state) => state.isHelpDialogOpen);
|
||||
const setHelpDialogOpen = useUIStore((state) => state.setHelpDialogOpen);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const mod = getModifierLabel();
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
|
||||
const shortcuts: ShortcutSection[] = [
|
||||
@@ -100,7 +102,7 @@ export const HelpDialog: React.FC = () => {
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
keys: [`Shift + Alt + ${mod} + N`],
|
||||
keys: [formatShortcutForDisplay('mod+shift+alt+n')],
|
||||
descriptionKey: "helpDialog.item.newWindow",
|
||||
icon: "window",
|
||||
},
|
||||
@@ -121,6 +123,21 @@ export const HelpDialog: React.FC = () => {
|
||||
icon: "git-branch",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_draft_project_picker',
|
||||
icon: 'folder',
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_draft_worktree_picker',
|
||||
icon: 'git-branch',
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_session_list',
|
||||
icon: 'list-unordered',
|
||||
keys: '',
|
||||
},
|
||||
{ id: 'focus_input', descriptionKey: "helpDialog.item.focusChatInput", icon: "text", keys: '' },
|
||||
{
|
||||
id: 'toggle_prompt_navigator',
|
||||
@@ -176,7 +193,7 @@ export const HelpDialog: React.FC = () => {
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
keys: [`${mod} + 1...0`],
|
||||
keys: [`${formatShortcutForDisplay('mod')} + 1...0`],
|
||||
descriptionKey: "helpDialog.item.switchContextSurface",
|
||||
icon: "layout-right",
|
||||
},
|
||||
@@ -214,7 +231,7 @@ export const HelpDialog: React.FC = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isHelpDialogOpen} onOpenChange={setHelpDialogOpen}>
|
||||
<Dialog open={isHelpDialogOpen} onOpenChange={setHelpDialogOpen}>
|
||||
<DialogContent className="max-w-2xl w-[min(42rem,calc(100vw-1.5rem))] max-h-[calc(100dvh-2rem)] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
@@ -237,40 +254,48 @@ export const HelpDialog: React.FC = () => {
|
||||
{section.items
|
||||
.filter((shortcut) => !(isVSCode && shortcut.id === 'toggle_prompt_navigator'))
|
||||
.map((shortcut) => {
|
||||
const displayKeys = shortcut.id
|
||||
? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides)
|
||||
: (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / "));
|
||||
const action = shortcut.id ? getShortcutAction(shortcut.id) : undefined;
|
||||
const descriptionKey = shortcut.descriptionKey
|
||||
?? (action?.customizable ? action.settingsLabelKey : undefined);
|
||||
if (!descriptionKey) return null;
|
||||
const displayKeys = shortcut.id
|
||||
? renderShortcut(
|
||||
shortcut.id,
|
||||
shortcutOverrides,
|
||||
t('settings.openchamber.keyboardShortcuts.unassigned'),
|
||||
)
|
||||
: (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / "));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={shortcut.id || shortcut.descriptionKey}
|
||||
className="flex items-center justify-between py-1 px-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{shortcut.icon && (
|
||||
<Icon name={shortcut.icon} className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="typography-meta">
|
||||
{t(shortcut.descriptionKey)}
|
||||
</span>
|
||||
return (
|
||||
<div
|
||||
key={shortcut.id || descriptionKey}
|
||||
className="flex items-center justify-between py-1 px-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{shortcut.icon && (
|
||||
<Icon name={shortcut.icon} className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="typography-meta">
|
||||
{t(descriptionKey)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => (
|
||||
<React.Fragment key={`${keyCombo}-${i}`}>
|
||||
{i > 0 && (
|
||||
<span className="typography-meta text-muted-foreground mx-1">
|
||||
{t('helpDialog.keyCombiner.or')}
|
||||
</span>
|
||||
)}
|
||||
<kbd className="inline-flex items-center gap-1 px-1.5 py-0.5 typography-meta font-mono bg-muted rounded border border-border/20">
|
||||
{keyCombo}
|
||||
</kbd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => (
|
||||
<React.Fragment key={`${keyCombo}-${i}`}>
|
||||
{i > 0 && (
|
||||
<span className="typography-meta text-muted-foreground mx-1">
|
||||
{t('helpDialog.keyCombiner.or')}
|
||||
</span>
|
||||
)}
|
||||
<kbd className="inline-flex items-center gap-1 px-1.5 py-0.5 typography-meta font-mono bg-muted rounded border border-border/20">
|
||||
{keyCombo}
|
||||
</kbd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -284,7 +309,11 @@ export const HelpDialog: React.FC = () => {
|
||||
<ul className="space-y-0.5 typography-meta">
|
||||
<li>
|
||||
• {t('helpDialog.proTips.commandPalette', {
|
||||
shortcut: renderShortcut('open_command_palette', `${mod} P`, shortcutOverrides),
|
||||
shortcut: renderShortcut(
|
||||
'open_command_palette',
|
||||
shortcutOverrides,
|
||||
t('settings.openchamber.keyboardShortcuts.unassigned'),
|
||||
),
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type React from 'react';
|
||||
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
|
||||
export function getDropdownNavigationKey(event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): 'ArrowDown' | 'ArrowUp' | null {
|
||||
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
|
||||
if (event.key.toLowerCase() === 'n') return 'ArrowDown';
|
||||
if (event.key.toLowerCase() === 'p') return 'ArrowUp';
|
||||
return null;
|
||||
}
|
||||
|
||||
type DropdownNavigationEvent = Pick<
|
||||
React.KeyboardEvent<HTMLElement>,
|
||||
| 'altKey'
|
||||
| 'ctrlKey'
|
||||
| 'defaultPrevented'
|
||||
| 'isPropagationStopped'
|
||||
| 'key'
|
||||
| 'metaKey'
|
||||
| 'preventDefault'
|
||||
| 'shiftKey'
|
||||
| 'stopPropagation'
|
||||
>;
|
||||
|
||||
export function handleDropdownNavigationKey(
|
||||
event: DropdownNavigationEvent,
|
||||
navigate: (key: 'ArrowDown' | 'ArrowUp') => void,
|
||||
): boolean {
|
||||
if (event.defaultPrevented || event.isPropagationStopped()) return false;
|
||||
const navigationKey = getDropdownNavigationKey(event);
|
||||
if (!navigationKey) return false;
|
||||
|
||||
// Do not add an IME guard: exact Ctrl+N/P remain intentional commands, while
|
||||
// every other composing key falls through without being handled.
|
||||
navigate(navigationKey);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldDismissDropdown(
|
||||
event: KeyboardEvent | React.KeyboardEvent,
|
||||
): boolean {
|
||||
return event.key === 'Escape' && !isIMECompositionEvent(event);
|
||||
}
|
||||
Reference in New Issue
Block a user