musealpha / src /shortcutSystem.ts
asdf98's picture
feat: add customizable shortcut registry, normalization, conflict detection and persistence
9665ef1 verified
export type ShortcutCategory = 'Panels' | 'Canvas' | 'Editing' | 'View' | 'Window';
export type ShortcutId =
| 'edit.undo' | 'edit.redo' | 'file.save' | 'capture.screen'
| 'view.focus' | 'view.valueMirror' | 'view.zoomLens' | 'view.fitAll' | 'view.zoom100'
| 'selection.duplicate' | 'selection.group' | 'selection.ungroup' | 'selection.delete' | 'selection.selectAll' | 'selection.flipH' | 'selection.desaturate'
| 'panel.browser' | 'panel.library' | 'panel.settings' | 'panel.closeAll'
| 'tool.annotate' | 'tool.globalDesaturate' | 'window.alwaysOnTop';
export type ShortcutCombo = { primary?: boolean; ctrl?: boolean; meta?: boolean; alt?: boolean; shift?: boolean; code: string; displayKey?: string };
export type ShortcutDef = { id: ShortcutId; label: string; category: ShortcutCategory; description?: string; defaultCombos: ShortcutCombo[]; allowInEditable?: boolean; preventDefault?: boolean; singleKey?: boolean };
export type ShortcutSettings = { version: 1; shortcuts: Partial<Record<ShortcutId, ShortcutCombo[] | null>> };
export const DEFAULT_SHORTCUTS: ShortcutDef[] = [
{ id:'edit.undo', label:'Undo', category:'Editing', defaultCombos:[{primary:true, code:'KeyZ', displayKey:'Z'}] },
{ id:'edit.redo', label:'Redo', category:'Editing', defaultCombos:[{primary:true, shift:true, code:'KeyZ', displayKey:'Z'}] },
{ id:'file.save', label:'Save board', category:'Editing', defaultCombos:[{primary:true, code:'KeyS', displayKey:'S'}] },
{ id:'capture.screen', label:'Screen capture', category:'Canvas', defaultCombos:[{shift:true, code:'KeyS', displayKey:'S'}] },
{ id:'view.focus', label:'Focus selected image', category:'View', defaultCombos:[{code:'KeyF', displayKey:'F'}], singleKey:true },
{ id:'view.valueMirror', label:'Value mirror split', category:'View', defaultCombos:[{code:'KeyV', displayKey:'V'}], singleKey:true },
{ id:'view.zoomLens', label:'Temporary zoom lens', category:'View', defaultCombos:[{code:'KeyZ', displayKey:'Z'}], singleKey:true },
{ id:'view.fitAll', label:'Fit all images', category:'View', defaultCombos:[{primary:true, code:'Digit0', displayKey:'0'}] },
{ id:'view.zoom100', label:'Zoom to 100%', category:'View', defaultCombos:[{primary:true, code:'Digit1', displayKey:'1'}] },
{ id:'selection.duplicate', label:'Duplicate selection', category:'Editing', defaultCombos:[{primary:true, code:'KeyD', displayKey:'D'}] },
{ id:'selection.group', label:'Group selection', category:'Editing', defaultCombos:[{primary:true, code:'KeyG', displayKey:'G'}] },
{ id:'selection.ungroup', label:'Ungroup selection', category:'Editing', defaultCombos:[{primary:true, shift:true, code:'KeyG', displayKey:'G'}] },
{ id:'selection.delete', label:'Delete selection', category:'Editing', defaultCombos:[{code:'Delete', displayKey:'Delete'}, {code:'Backspace', displayKey:'Backspace'}] },
{ id:'selection.selectAll', label:'Select all images', category:'Editing', defaultCombos:[{primary:true, code:'KeyA', displayKey:'A'}] },
{ id:'selection.flipH', label:'Flip selection horizontally', category:'Editing', defaultCombos:[{code:'KeyH', displayKey:'H'}], singleKey:true },
{ id:'selection.desaturate', label:'Desaturate selection', category:'Canvas', defaultCombos:[{code:'KeyD', displayKey:'D'}], singleKey:true },
{ id:'panel.browser', label:'Toggle browser panel', category:'Panels', defaultCombos:[{code:'KeyB', displayKey:'B'}], singleKey:true },
{ id:'panel.library', label:'Toggle library panel', category:'Panels', defaultCombos:[{code:'KeyL', displayKey:'L'}], singleKey:true },
{ id:'panel.settings', label:'Open settings', category:'Panels', defaultCombos:[{primary:true, code:'Comma', displayKey:','}] },
{ id:'panel.closeAll', label:'Close panels / clear selection', category:'Panels', defaultCombos:[{code:'Escape', displayKey:'Esc'}] },
{ id:'tool.annotate', label:'Toggle annotation mode', category:'Canvas', defaultCombos:[{code:'KeyA', displayKey:'A'}], singleKey:true },
{ id:'tool.globalDesaturate', label:'Desaturate all images', category:'Canvas', defaultCombos:[{shift:true, code:'KeyD', displayKey:'D'}] },
{ id:'window.alwaysOnTop', label:'Toggle always on top', category:'Window', defaultCombos:[{code:'KeyT', displayKey:'T'}], singleKey:true },
];
const STORAGE_KEY = 'refstudio.shortcuts.v1';
const MODIFIER_CODES = new Set(['ShiftLeft','ShiftRight','ControlLeft','ControlRight','AltLeft','AltRight','MetaLeft','MetaRight']);
export const isMac = () => navigator.platform.toLowerCase().includes('mac');
export function normalizeEventToCombo(e: KeyboardEvent): ShortcutCombo | null {
if (e.isComposing || e.key === 'Dead' || e.getModifierState?.('AltGraph') || MODIFIER_CODES.has(e.code)) return null;
const mac = isMac();
return { primary: mac ? e.metaKey || undefined : e.ctrlKey || undefined, ctrl: mac && e.ctrlKey || undefined, meta: !mac && e.metaKey || undefined, alt: e.altKey || undefined, shift: e.shiftKey || undefined, code: e.code, displayKey: e.code === 'Space' ? 'Space' : e.key.length === 1 ? e.key.toUpperCase() : e.key };
}
export function comboToCanonical(c: ShortcutCombo): string { return [c.primary?'Primary':'', c.ctrl?'Ctrl':'', c.meta?'Meta':'', c.alt?'Alt':'', c.shift?'Shift':'', c.code].filter(Boolean).join('+'); }
export function comboToDisplay(c: ShortcutCombo): string { const mac=isMac(); const parts:string[]=[]; if(c.primary)parts.push(mac?'⌘':'Ctrl'); if(c.ctrl)parts.push('Ctrl'); if(c.meta)parts.push(mac?'⌘':'Meta'); if(c.alt)parts.push(mac?'⌥':'Alt'); if(c.shift)parts.push(mac?'⇧':'Shift'); parts.push(c.displayKey || c.code.replace(/^Key/,'').replace(/^Digit/,'')); return mac?parts.join(''):parts.join('+'); }
export function loadShortcutSettings(): ShortcutSettings { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '') || {version:1, shortcuts:{}}; } catch { return {version:1, shortcuts:{}}; } }
export function saveShortcutSettings(s: ShortcutSettings) { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); window.dispatchEvent(new CustomEvent('refstudio:shortcuts-updated')); }
export function effectiveCombos(def: ShortcutDef, settings = loadShortcutSettings()): ShortcutCombo[] { const override = settings.shortcuts[def.id]; if (override === null || override === undefined) return def.defaultCombos; return override; }
export function matchesShortcut(id: ShortcutId, e: KeyboardEvent, settings = loadShortcutSettings()): boolean { const combo = normalizeEventToCombo(e); if(!combo) return false; const def = DEFAULT_SHORTCUTS.find(d=>d.id===id); if(!def) return false; const key = comboToCanonical(combo); return effectiveCombos(def, settings).some(c=>comboToCanonical(c)===key); }
export function isEditableTarget(t: EventTarget | null) { const el = t as HTMLElement | null; return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable); }
export function findConflicts(settings = loadShortcutSettings()) { const map = new Map<string, ShortcutDef[]>(); for(const def of DEFAULT_SHORTCUTS){ for(const combo of effectiveCombos(def, settings)){ const k = comboToCanonical(combo); const arr = map.get(k) || []; arr.push(def); map.set(k, arr); } } return [...map.entries()].filter(([,defs])=>defs.length>1).map(([combo, actions])=>({combo, actions})); }