asdf98 commited on
Commit
9665ef1
·
verified ·
1 Parent(s): 680ab59

feat: add customizable shortcut registry, normalization, conflict detection and persistence

Browse files
Files changed (1) hide show
  1. src/shortcutSystem.ts +55 -0
src/shortcutSystem.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type ShortcutCategory = 'Panels' | 'Canvas' | 'Editing' | 'View' | 'Window';
2
+ export type ShortcutId =
3
+ | 'edit.undo' | 'edit.redo' | 'file.save' | 'capture.screen'
4
+ | 'view.focus' | 'view.valueMirror' | 'view.zoomLens' | 'view.fitAll' | 'view.zoom100'
5
+ | 'selection.duplicate' | 'selection.group' | 'selection.ungroup' | 'selection.delete' | 'selection.selectAll' | 'selection.flipH' | 'selection.desaturate'
6
+ | 'panel.browser' | 'panel.library' | 'panel.settings' | 'panel.closeAll'
7
+ | 'tool.annotate' | 'tool.globalDesaturate' | 'window.alwaysOnTop';
8
+
9
+ export type ShortcutCombo = { primary?: boolean; ctrl?: boolean; meta?: boolean; alt?: boolean; shift?: boolean; code: string; displayKey?: string };
10
+ export type ShortcutDef = { id: ShortcutId; label: string; category: ShortcutCategory; description?: string; defaultCombos: ShortcutCombo[]; allowInEditable?: boolean; preventDefault?: boolean; singleKey?: boolean };
11
+ export type ShortcutSettings = { version: 1; shortcuts: Partial<Record<ShortcutId, ShortcutCombo[] | null>> };
12
+
13
+ export const DEFAULT_SHORTCUTS: ShortcutDef[] = [
14
+ { id:'edit.undo', label:'Undo', category:'Editing', defaultCombos:[{primary:true, code:'KeyZ', displayKey:'Z'}] },
15
+ { id:'edit.redo', label:'Redo', category:'Editing', defaultCombos:[{primary:true, shift:true, code:'KeyZ', displayKey:'Z'}] },
16
+ { id:'file.save', label:'Save board', category:'Editing', defaultCombos:[{primary:true, code:'KeyS', displayKey:'S'}] },
17
+ { id:'capture.screen', label:'Screen capture', category:'Canvas', defaultCombos:[{shift:true, code:'KeyS', displayKey:'S'}] },
18
+ { id:'view.focus', label:'Focus selected image', category:'View', defaultCombos:[{code:'KeyF', displayKey:'F'}], singleKey:true },
19
+ { id:'view.valueMirror', label:'Value mirror split', category:'View', defaultCombos:[{code:'KeyV', displayKey:'V'}], singleKey:true },
20
+ { id:'view.zoomLens', label:'Temporary zoom lens', category:'View', defaultCombos:[{code:'KeyZ', displayKey:'Z'}], singleKey:true },
21
+ { id:'view.fitAll', label:'Fit all images', category:'View', defaultCombos:[{primary:true, code:'Digit0', displayKey:'0'}] },
22
+ { id:'view.zoom100', label:'Zoom to 100%', category:'View', defaultCombos:[{primary:true, code:'Digit1', displayKey:'1'}] },
23
+ { id:'selection.duplicate', label:'Duplicate selection', category:'Editing', defaultCombos:[{primary:true, code:'KeyD', displayKey:'D'}] },
24
+ { id:'selection.group', label:'Group selection', category:'Editing', defaultCombos:[{primary:true, code:'KeyG', displayKey:'G'}] },
25
+ { id:'selection.ungroup', label:'Ungroup selection', category:'Editing', defaultCombos:[{primary:true, shift:true, code:'KeyG', displayKey:'G'}] },
26
+ { id:'selection.delete', label:'Delete selection', category:'Editing', defaultCombos:[{code:'Delete', displayKey:'Delete'}, {code:'Backspace', displayKey:'Backspace'}] },
27
+ { id:'selection.selectAll', label:'Select all images', category:'Editing', defaultCombos:[{primary:true, code:'KeyA', displayKey:'A'}] },
28
+ { id:'selection.flipH', label:'Flip selection horizontally', category:'Editing', defaultCombos:[{code:'KeyH', displayKey:'H'}], singleKey:true },
29
+ { id:'selection.desaturate', label:'Desaturate selection', category:'Canvas', defaultCombos:[{code:'KeyD', displayKey:'D'}], singleKey:true },
30
+ { id:'panel.browser', label:'Toggle browser panel', category:'Panels', defaultCombos:[{code:'KeyB', displayKey:'B'}], singleKey:true },
31
+ { id:'panel.library', label:'Toggle library panel', category:'Panels', defaultCombos:[{code:'KeyL', displayKey:'L'}], singleKey:true },
32
+ { id:'panel.settings', label:'Open settings', category:'Panels', defaultCombos:[{primary:true, code:'Comma', displayKey:','}] },
33
+ { id:'panel.closeAll', label:'Close panels / clear selection', category:'Panels', defaultCombos:[{code:'Escape', displayKey:'Esc'}] },
34
+ { id:'tool.annotate', label:'Toggle annotation mode', category:'Canvas', defaultCombos:[{code:'KeyA', displayKey:'A'}], singleKey:true },
35
+ { id:'tool.globalDesaturate', label:'Desaturate all images', category:'Canvas', defaultCombos:[{shift:true, code:'KeyD', displayKey:'D'}] },
36
+ { id:'window.alwaysOnTop', label:'Toggle always on top', category:'Window', defaultCombos:[{code:'KeyT', displayKey:'T'}], singleKey:true },
37
+ ];
38
+
39
+ const STORAGE_KEY = 'refstudio.shortcuts.v1';
40
+ const MODIFIER_CODES = new Set(['ShiftLeft','ShiftRight','ControlLeft','ControlRight','AltLeft','AltRight','MetaLeft','MetaRight']);
41
+ export const isMac = () => navigator.platform.toLowerCase().includes('mac');
42
+
43
+ export function normalizeEventToCombo(e: KeyboardEvent): ShortcutCombo | null {
44
+ if (e.isComposing || e.key === 'Dead' || e.getModifierState?.('AltGraph') || MODIFIER_CODES.has(e.code)) return null;
45
+ const mac = isMac();
46
+ 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 };
47
+ }
48
+ 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('+'); }
49
+ 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('+'); }
50
+ export function loadShortcutSettings(): ShortcutSettings { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '') || {version:1, shortcuts:{}}; } catch { return {version:1, shortcuts:{}}; } }
51
+ export function saveShortcutSettings(s: ShortcutSettings) { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); window.dispatchEvent(new CustomEvent('refstudio:shortcuts-updated')); }
52
+ export function effectiveCombos(def: ShortcutDef, settings = loadShortcutSettings()): ShortcutCombo[] { const override = settings.shortcuts[def.id]; if (override === null || override === undefined) return def.defaultCombos; return override; }
53
+ 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); }
54
+ 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); }
55
+ 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})); }