feat: enhance shortcuts configuration with conflict detection and fixed shortcuts

This commit is contained in:
FabLrc
2026-02-28 11:11:12 +01:00
parent 9bc2c78b4d
commit d76f38fb35
2 changed files with 123 additions and 27 deletions
@@ -6,36 +6,33 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '
import { Button } from '@/components/ui/button';
import {
DEFAULT_SHORTCUTS,
FIXED_SHORTCUTS,
SHORTCUT_ACTIONS,
SHORTCUT_LABELS,
findConflict,
formatBinding,
type ShortcutAction,
type ShortcutBinding,
type ShortcutConflict,
type ShortcutsConfig,
} from '@/lib/shortcuts';
import { useShortcuts } from '@/contexts/ShortcutsContext';
const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta']);
const FIXED_SHORTCUTS = [
{ label: 'Cycle Annotations Forward', display: 'Tab' },
{ label: 'Cycle Annotations Backward', display: 'Shift + Tab' },
{ label: 'Delete Selected (alt)', display: 'Del / ⌫' },
{ label: 'Pan Timeline', display: 'Shift + Ctrl + Scroll' },
{ label: 'Zoom Timeline', display: 'Ctrl + Scroll' },
] as const;
export function ShortcutsConfigDialog() {
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
useShortcuts();
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
const [conflict, setConflict] = useState<{ forAction: ShortcutAction; pending: ShortcutBinding; conflictWith: ShortcutConflict } | null>(null);
useEffect(() => {
if (isConfigOpen) {
setDraft(shortcuts);
setCaptureFor(null);
setConflict(null);
}
}, [isConfigOpen, shortcuts]);
@@ -60,14 +57,39 @@ export function ShortcutsConfigDialog() {
...(e.altKey ? { alt: true } : {}),
};
setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding }));
const found = findConflict(binding, captureFor, draft);
setCaptureFor(null);
if (found?.type === 'fixed') {
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
return;
}
if (found?.type === 'configurable') {
setConflict({ forAction: captureFor, pending: binding, conflictWith: found });
return;
}
setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding }));
};
window.addEventListener('keydown', handleCapture, { capture: true });
return () => window.removeEventListener('keydown', handleCapture, { capture: true });
}, [captureFor]);
const handleSwap = useCallback(() => {
if (!conflict || conflict.conflictWith.type !== 'configurable') return;
const { forAction, pending, conflictWith } = conflict;
setDraft((prev: ShortcutsConfig) => ({
...prev,
[forAction]: pending,
[conflictWith.action]: prev[forAction],
}));
setConflict(null);
}, [conflict]);
const handleCancelConflict = useCallback(() => setConflict(null), []);
const handleSave = useCallback(async () => {
setShortcuts(draft);
await persistShortcuts(draft);
@@ -82,6 +104,7 @@ export function ShortcutsConfigDialog() {
const handleClose = useCallback(() => {
setCaptureFor(null);
setConflict(null);
closeConfig();
}, [closeConfig]);
@@ -99,25 +122,53 @@ export function ShortcutsConfigDialog() {
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">Configurable</p>
{SHORTCUT_ACTIONS.map((action) => {
const isCapturing = captureFor === action;
const hasConflict = conflict?.forAction === action;
return (
<div
key={action}
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
>
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
<button
type="button"
onClick={() => setCaptureFor(isCapturing ? null : action)}
title={isCapturing ? 'Press Esc to cancel' : 'Click to change'}
className={[
'px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none',
isCapturing
? 'bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse'
: 'bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer',
].join(' ')}
>
{isCapturing ? 'Press a key…' : formatBinding(draft[action], isMac)}
</button>
<div key={action}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
<button
type="button"
onClick={() => {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? 'Press Esc to cancel' : 'Click to change'}
className={[
'px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none',
isCapturing
? 'bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse'
: hasConflict
? 'bg-amber-500/10 border-amber-500/50 text-amber-400'
: 'bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer',
].join(' ')}
>
{isCapturing ? 'Press a key…' : formatBinding(draft[action], isMac)}
</button>
</div>
{hasConflict && conflict?.conflictWith.type === 'configurable' && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
Already used by <strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
Swap
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
})}
+45
View File
@@ -19,6 +19,51 @@ export interface ShortcutBinding {
export type ShortcutsConfig = Record<ShortcutAction, ShortcutBinding>;
export interface FixedShortcut {
label: string;
display: string;
bindings: ShortcutBinding[];
}
export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ label: 'Cycle Annotations Forward', display: 'Tab', bindings: [{ key: 'tab' }] },
{ label: 'Cycle Annotations Backward', display: 'Shift + Tab', bindings: [{ key: 'tab', shift: true }] },
{ label: 'Delete Selected (alt)', display: 'Del / ⌫', bindings: [{ key: 'delete' }, { key: 'backspace' }] },
{ label: 'Pan Timeline', display: 'Shift + Ctrl + Scroll', bindings: [] },
{ label: 'Zoom Timeline', display: 'Ctrl + Scroll', bindings: [] },
];
export type ShortcutConflict =
| { type: 'configurable'; action: ShortcutAction }
| { type: 'fixed'; label: string };
export function bindingsEqual(a: ShortcutBinding, b: ShortcutBinding): boolean {
return (
a.key.toLowerCase() === b.key.toLowerCase() &&
!!a.ctrl === !!b.ctrl &&
!!a.shift === !!b.shift &&
!!a.alt === !!b.alt
);
}
export function findConflict(
binding: ShortcutBinding,
forAction: ShortcutAction,
config: ShortcutsConfig,
): ShortcutConflict | null {
for (const fixed of FIXED_SHORTCUTS) {
if (fixed.bindings.some((b) => bindingsEqual(b, binding))) {
return { type: 'fixed', label: fixed.label };
}
}
for (const action of SHORTCUT_ACTIONS) {
if (action !== forAction && bindingsEqual(config[action], binding)) {
return { type: 'configurable', action };
}
}
return null;
}
export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
addZoom: { key: 'z' },
addTrim: { key: 't' },