File size: 13,589 Bytes
c1267f7 9665ef1 2d7331f 0b5d519 c1267f7 ad87e7f c1267f7 9665ef1 0b5d519 9665ef1 ad574d7 4a3ca9f 9665ef1 084dfe5 9665ef1 c1267f7 9665ef1 2d7331f ac4745f c1267f7 9665ef1 c1267f7 9665ef1 c1267f7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | export type ShortcutCategory = 'File' | 'Panels' | 'Canvas' | 'Annotation' | 'Editing' | 'View' | 'Window' | 'Layout';
export type ShortcutId =
| 'edit.undo' | 'edit.redo' | 'edit.paste'
| 'file.save' | 'file.newBoard' | 'file.exportRefs' | 'file.exportJson' | 'capture.screen'
| 'view.focus' | 'view.valueMirror' | 'view.zoomLens' | 'view.fitAll' | 'view.fitSelection' | 'view.zoom100' | 'view.zoomIn' | 'view.zoomOut' | 'view.panHome' | 'view.toggleGrid' | 'view.toggleMinimap'
| 'selection.duplicate' | 'selection.group' | 'selection.ungroup' | 'selection.delete' | 'selection.selectAll' | 'selection.flipH' | 'selection.flipV' | 'selection.rotateLeft' | 'selection.rotateRight' | 'selection.rotateReset' | 'selection.desaturate' | 'selection.bringFront' | 'selection.sendBack'
| 'layout.alignLeft' | 'layout.alignCenter' | 'layout.alignRight' | 'layout.alignTop' | 'layout.alignMiddle' | 'layout.alignBottom' | 'layout.distributeHorizontal' | 'layout.distributeVertical' | 'layout.grid' | 'layout.pack' | 'layout.normalizeSize'
| 'panel.browser' | 'panel.library' | 'panel.settings' | 'panel.closeAll'
| 'tool.annotate' | 'tool.annotationPen' | 'tool.annotationHighlighter' | 'tool.annotationEraser' | 'tool.annotationClear' | 'tool.globalDesaturate'
| 'window.alwaysOnTop' | 'window.clickThrough';
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:'file.save', label:'Save .lref board', category:'File', defaultCombos:[{primary:true, code:'KeyS', displayKey:'S'}] }, { id:'file.newBoard', label:'New board', category:'File', defaultCombos:[{primary:true, code:'KeyN', displayKey:'N'}] }, { id:'file.exportRefs', label:'Export .lref board', category:'File', defaultCombos:[{primary:true, shift:true, code:'KeyE', displayKey:'E'}] }, { id:'file.exportJson', label:'Export .lref board', category:'File', defaultCombos:[{primary:true, alt:true, code:'KeyE', displayKey:'E'}] }, { id:'capture.screen', label:'Screen capture', category:'File', defaultCombos:[{shift:true, code:'KeyS', displayKey:'S'}] },
{ 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:'edit.paste', label:'Paste image from clipboard', category:'Editing', defaultCombos:[] }, { 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.flipV', label:'Flip selection vertically', category:'Editing', defaultCombos:[{shift:true, code:'KeyH', displayKey:'H'}] }, { id:'selection.rotateLeft', label:'Rotate selection left 15°', category:'Editing', defaultCombos:[{code:'KeyQ', displayKey:'Q'}], singleKey:true }, { id:'selection.rotateRight', label:'Rotate selection right 15°', category:'Editing', defaultCombos:[{code:'KeyR', displayKey:'R'}], singleKey:true }, { id:'selection.rotateReset', label:'Reset selection rotation', category:'Editing', defaultCombos:[{shift:true, code:'KeyR', displayKey:'R'}] }, { id:'selection.desaturate', label:'Desaturate selection', category:'Editing', defaultCombos:[{code:'KeyD', displayKey:'D'}], singleKey:true }, { id:'selection.bringFront', label:'Bring selection to front', category:'Editing', defaultCombos:[{code:'BracketRight', displayKey:']'}], singleKey:true }, { id:'selection.sendBack', label:'Send selection to back', category:'Editing', defaultCombos:[{code:'BracketLeft', displayKey:'['}], singleKey:true },
{ 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.fitSelection', label:'Fit selection', category:'View', defaultCombos:[{primary:true, shift:true, code:'KeyF', displayKey:'F'}] }, { id:'view.zoom100', label:'Zoom to 100%', category:'View', defaultCombos:[{primary:true, code:'Digit1', displayKey:'1'}] }, { id:'view.zoomIn', label:'Zoom in', category:'View', defaultCombos:[{primary:true, code:'Equal', displayKey:'+'}] }, { id:'view.zoomOut', label:'Zoom out', category:'View', defaultCombos:[{primary:true, code:'Minus', displayKey:'-'}] }, { id:'view.panHome', label:'Center canvas origin', category:'View', defaultCombos:[{code:'Home', displayKey:'Home'}] }, { id:'view.toggleGrid', label:'Toggle grid', category:'View', defaultCombos:[{primary:true, code:'Quote', displayKey:"'"}] }, { id:'view.toggleMinimap', label:'Toggle minimap', category:'View', defaultCombos:[{code:'KeyM', displayKey:'M'}], singleKey:true },
{ id:'layout.alignLeft', label:'Align left', category:'Layout', defaultCombos:[{alt:true, shift:true, code:'ArrowLeft', displayKey:'←'}] }, { id:'layout.alignCenter', label:'Align horizontal centers', category:'Layout', defaultCombos:[{alt:true, shift:true, code:'KeyH', displayKey:'H'}] }, { id:'layout.alignRight', label:'Align right', category:'Layout', defaultCombos:[{alt:true, shift:true, code:'ArrowRight', displayKey:'→'}] }, { id:'layout.alignTop', label:'Align top', category:'Layout', defaultCombos:[{alt:true, shift:true, code:'ArrowUp', displayKey:'↑'}] }, { id:'layout.alignMiddle', label:'Align vertical centers', category:'Layout', defaultCombos:[{alt:true, shift:true, code:'KeyV', displayKey:'V'}] }, { id:'layout.alignBottom', label:'Align bottom', category:'Layout', defaultCombos:[{alt:true, shift:true, code:'ArrowDown', displayKey:'↓'}] }, { id:'layout.distributeHorizontal', label:'Distribute horizontally', category:'Layout', defaultCombos:[{primary:true, alt:true, code:'KeyH', displayKey:'H'}] }, { id:'layout.distributeVertical', label:'Distribute vertically', category:'Layout', defaultCombos:[{primary:true, alt:true, code:'KeyV', displayKey:'V'}] }, { id:'layout.grid', label:'Arrange selection in grid', category:'Layout', defaultCombos:[{primary:true, alt:true, code:'KeyG', displayKey:'G'}] }, { id:'layout.pack', label:'Pack selection into rows', category:'Layout', defaultCombos:[{primary:true, alt:true, code:'KeyP', displayKey:'P'}] }, { id:'layout.normalizeSize', label:'Normalize selected image sizes', category:'Layout', defaultCombos:[{primary:true, alt:true, code:'KeyN', displayKey:'N'}] },
{ 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:'Annotation', defaultCombos:[{code:'KeyA', displayKey:'A'}], singleKey:true }, { id:'tool.annotationPen', label:'Annotation pen', category:'Annotation', defaultCombos:[{code:'KeyP', displayKey:'P'}], singleKey:true }, { id:'tool.annotationHighlighter', label:'Annotation highlighter', category:'Annotation', defaultCombos:[{code:'KeyY', displayKey:'Y'}], singleKey:true }, { id:'tool.annotationEraser', label:'Annotation eraser', category:'Annotation', defaultCombos:[{code:'KeyE', displayKey:'E'}], singleKey:true }, { id:'tool.annotationClear', label:'Clear annotations', category:'Annotation', defaultCombos:[{primary:true, shift:true, code:'Backspace', displayKey:'Backspace'}] }, { 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 }, { id:'window.clickThrough', label:'Toggle click-through', category:'Window', defaultCombos:[{shift:true, code:'KeyT', displayKey:'T'}] },
];
const STORAGE_KEY = 'lumaref.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.repeat || 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('+'); }
let cachedSettings: ShortcutSettings | null = null;
function invalidateShortcutCache() { cachedSettings = null; }
if (typeof window !== 'undefined') { window.addEventListener('lumaref:shortcuts-updated', invalidateShortcutCache); window.addEventListener('storage', e => { if (e.key === STORAGE_KEY) invalidateShortcutCache(); }); }
export function loadShortcutSettings(): ShortcutSettings { if (cachedSettings) return cachedSettings; try { const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '') || {version:1, shortcuts:{}}; cachedSettings = parsed.version === 1 ? parsed : {version:1, shortcuts:{}}; } catch { cachedSettings = {version:1, shortcuts:{}}; } return cachedSettings!; }
export function saveShortcutSettings(s: ShortcutSettings) { localStorage.setItem(STORAGE_KEY, JSON.stringify({version:1, shortcuts:s.shortcuts || {}})); invalidateShortcutCache(); window.dispatchEvent(new CustomEvent('lumaref:shortcuts-updated')); }
export function resetShortcutSettings() { saveShortcutSettings({version:1, shortcuts:{}}); }
export function effectiveCombos(def: ShortcutDef, settings = loadShortcutSettings()): ShortcutCombo[] { const override = settings.shortcuts[def.id]; if (override === null) return []; if (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 || Boolean(el.closest?.('[contenteditable="true"],[contenteditable=""]'))); }
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})); }
export function conflictsForCombo(combo: ShortcutCombo, ownerId: ShortcutId, settings = loadShortcutSettings()) { const key = comboToCanonical(combo); return DEFAULT_SHORTCUTS.filter(def => def.id !== ownerId && effectiveCombos(def, settings).some(c => comboToCanonical(c) === key)); }
|