/** * All shortcut definitions — the single source of truth for keyboard shortcuts. * Used by the keyboard handler AND the ShortcutsHelp overlay. * * Ported to PixiJS: uses SceneManager/SelectionManager instead of Fabric.js Canvas. * * BUGS FIXED in previous revision (preserved): * - Ctrl+Y conflict (was both redo AND overlay-compare) → overlay moved to Ctrl+Shift+Y * - Ctrl+P (browser print) → changed to Ctrl+Shift+P for arrange optimal * - '?' key (Shift+/) → e.key is '?' not '/', fixed matcher key * - ArrowUp/Down with selection → layer ordering uses ] / [ again (arrows cycle/nudge) * - ArrowLeft/Right bare → only cycle when nothing selected, otherwise no-op * - Added Ctrl+S (prevent browser save-as) */ import { ShortcutDef, ShortcutContext } from './shortcuts'; import type { SceneItem, SceneManager } from './SceneManager'; import type { GroupObject } from './scene-format'; import * as ops from './operations'; import { onArrangeAnimationDone } from './operations'; // Tracks when the last internal copy happened so paste can decide // whether to use internal clipboard (just copied) vs system clipboard (external app). let _lastInternalCopyTime = 0; // Tracks when the last internal paste happened so setupPaste can skip the native event. let _lastInternalPasteTime = 0; /** Mark that an internal copy just happened. Called by copy/cut handlers. */ export function markInternalCopy(): void { _lastInternalCopyTime = Date.now(); } /** Check if an internal paste just happened (within last 500ms). Used by setupPaste to skip. */ export function wasRecentInternalPaste(): boolean { return Date.now() - _lastInternalPasteTime < 500; } /** Collect selected items + their group children (for deep clone). */ function _collectGroupChildren(items: SceneItem[], scene: SceneManager): SceneItem[] { const all: SceneItem[] = []; const seen = new Set(); for (const item of items) { if (seen.has(item.id)) continue; seen.add(item.id); all.push(item); if (item.data.type === 'group') { const gd = item.data as GroupObject; for (const cid of gd.children) { if (seen.has(cid)) continue; seen.add(cid); const child = scene.getById(cid); if (child) all.push(child); } } } return all; } /** Deep-clone a list of scene items, remapping group children IDs. */ function _cloneItemsWithOffset( items: { data: any }[], scene: { nextZ: () => number }, offsetX = 20, offsetY = 20, ): any[] { // First pass: build old→new ID mapping for all items const idMap = new Map(); for (const item of items) { idMap.set(item.data.id, crypto.randomUUID()); } // Second pass: clone data with new IDs and remapped children const clones: any[] = []; for (const item of items) { const newId = idMap.get(item.data.id)!; const newData = { ...item.data, id: newId, x: item.data.x + offsetX, y: item.data.y + offsetY, z: scene.nextZ(), }; // Remap group children references if (newData.type === 'group' && Array.isArray(newData.children)) { newData.children = newData.children .map((cid: string) => idMap.get(cid) ?? cid) .filter((cid: string) => idMap.has(cid) || items.some((i) => i.data.id === cid)); } clones.push(newData); } return clones; } /** Paste from internal clipboard — duplicates scene items with offset. */ async function _pasteInternal(ctx: ShortcutContext): Promise { const clones = _cloneItemsWithOffset(ctx.clipboardRef.current, ctx.scene); const newItems: typeof ctx.clipboardRef.current = []; // Create children first, then groups (so children exist when group is created) const sorted = [...clones].sort((a, b) => (a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0)); for (const newData of sorted) { await ctx.scene._createItem(newData, true); const newItem = ctx.scene.getById(newData.id); if (newItem) newItems.push(newItem); } ctx.clipboardRef.current = newItems; ctx.scene._applyZOrder(); // Select the newly pasted items ctx.selection.selectedIds.clear(); for (const item of newItems) { ctx.selection.selectedIds.add(item.id); } ctx.selection.transformBox.update(newItems); ctx.onChange(); } /** Run op on selected items → update transform box → fire onChange with IDs. */ function _opUpdate(ctx: ShortcutContext, op: (items: SceneItem[]) => void): void { const items = ctx.selection.getSelectedItems(); op(items); ctx.selection.transformBox.update(items); ctx.onChange(items.map(i => i.id)); } /** Like _opUpdate but defers transformBox update until animation completes */ function _opUpdateAnimated(ctx: ShortcutContext, op: (items: SceneItem[]) => void): void { const items = ctx.selection.getSelectedItems(); onArrangeAnimationDone(() => ctx.selection.transformBox.update(items)); op(items); ctx.onChange(items.map(i => i.id)); } export const shortcuts: ShortcutDef[] = [ // ═══════════════════════════════════════ // ALIGNMENT (Ctrl + Arrow) // ═══════════════════════════════════════ { id: 'align-left', keys: { key: 'arrowleft', ctrl: true }, category: 'alignment', description: 'Align left', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdate(ctx, ops.alignLeft), }, { id: 'align-right', keys: { key: 'arrowright', ctrl: true }, category: 'alignment', description: 'Align right', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdate(ctx, ops.alignRight), }, { id: 'align-top', keys: { key: 'arrowup', ctrl: true }, category: 'alignment', description: 'Align top', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdate(ctx, ops.alignTop), }, { id: 'align-bottom', keys: { key: 'arrowdown', ctrl: true }, category: 'alignment', description: 'Align bottom', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdate(ctx, ops.alignBottom), }, // ═══════════════════════════════════════ // DISTRIBUTE (Ctrl + Alt + Shift + Arrow) // ═══════════════════════════════════════ { id: 'distribute-h', keys: { key: 'arrowup', ctrl: true, alt: true, shift: true }, category: 'alignment', description: 'Distribute horizontal', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdate(ctx, ops.distributeHorizontal), }, { id: 'distribute-v', keys: { key: 'arrowdown', ctrl: true, alt: true, shift: true }, category: 'alignment', description: 'Distribute vertical', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdate(ctx, ops.distributeVertical), }, // ═══════════════════════════════════════ // NORMALIZE (Ctrl + Alt + Arrow) // ═══════════════════════════════════════ { id: 'normalize-size', keys: { key: 'arrowup', ctrl: true, alt: true }, category: 'normalize', description: 'Normalize size (same area)', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdate(ctx, ops.normalizeSize), }, { id: 'normalize-scale', keys: { key: 'arrowdown', ctrl: true, alt: true }, category: 'normalize', description: 'Normalize scale', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdate(ctx, ops.normalizeScale), }, { id: 'normalize-height', keys: { key: 'arrowleft', ctrl: true, alt: true }, category: 'normalize', description: 'Normalize height', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdate(ctx, ops.normalizeHeight), }, { id: 'normalize-width', keys: { key: 'arrowright', ctrl: true, alt: true }, category: 'normalize', description: 'Normalize width', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdate(ctx, ops.normalizeWidth), }, // ═══════════════════════════════════════ // ARRANGEMENT // ═══════════════════════════════════════ { id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true }, category: 'arrangement', description: 'Arrange optimal (pack)', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdateAnimated(ctx, ops.arrangeOptimal), }, { id: 'arrange-by-name', keys: { key: 'n', ctrl: true, alt: true }, category: 'arrangement', description: 'Arrange by name', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdateAnimated(ctx, ops.arrangeByName), }, { id: 'arrange-by-order', keys: { key: 'o', ctrl: true, alt: true }, category: 'arrangement', description: 'Arrange by z-order', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdateAnimated(ctx, ops.arrangeByZOrder), }, { id: 'arrange-random', keys: { key: 'r', ctrl: true, alt: true }, category: 'arrangement', description: 'Arrange randomly', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdateAnimated(ctx, ops.arrangeRandomly), }, { id: 'stack', keys: { key: 's', ctrl: true, alt: true }, category: 'arrangement', description: 'Stack (pile on top)', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdateAnimated(ctx, ops.stackObjects), }, // ═══════════════════════════════════════ // IMAGE MANIPULATION // ═══════════════════════════════════════ { id: 'flip-h', keys: { key: 'h', alt: true, shift: true }, category: 'image', description: 'Flip horizontal', needsSelection: true, handler: (ctx) => { const items = ctx.selection.getSelectedItems(); if (items.some(i => ['pdf-page', 'sticky', 'markdown', 'text'].includes(i.data.type))) return; _opUpdate(ctx, ops.flipHorizontal); }, }, { id: 'flip-v', keys: { key: 'v', alt: true, shift: true }, category: 'image', description: 'Flip vertical', needsSelection: true, handler: (ctx) => { const items = ctx.selection.getSelectedItems(); if (items.some(i => ['pdf-page', 'sticky', 'markdown', 'text'].includes(i.data.type))) return; _opUpdate(ctx, ops.flipVertical); }, }, { id: 'reset-transform', keys: { key: 't', ctrl: true, shift: true }, category: 'image', description: 'Reset transform', needsSelection: true, handler: (ctx) => _opUpdate(ctx, ops.resetTransform), }, { id: 'toggle-grayscale', keys: { key: 'g', alt: true }, category: 'image', description: 'Toggle grayscale', needsSelection: true, handler: (ctx) => { ops.toggleGrayscale(ctx.selection.getSelectedItems()); ctx.onChange(); }, }, { id: 'toggle-locked', keys: { key: 'l', alt: true }, category: 'image', description: 'Toggle locked', needsSelection: true, handler: (ctx) => { ops.toggleLocked(ctx.selection.getSelectedItems()); ctx.onChange(); ctx.refreshLayers(); }, }, { id: 'overlay-compare', keys: { key: 'y', ctrl: true, shift: true }, category: 'image', description: 'Overlay / compare', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdate(ctx, ops.overlayCompare), }, // ═══════════════════════════════════════ // NUDGE (Arrow keys with selection) // ═══════════════════════════════════════ // Shift+Arrow = 10px nudge (more specific, checked first) { id: 'nudge-left-10', keys: { key: 'arrowleft', shift: true }, category: 'arrangement', description: 'Nudge left 10px', needsSelection: true, handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, -10, 0)), }, { id: 'nudge-right-10', keys: { key: 'arrowright', shift: true }, category: 'arrangement', description: 'Nudge right 10px', needsSelection: true, handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 10, 0)), }, { id: 'nudge-up-10', keys: { key: 'arrowup', shift: true }, category: 'arrangement', description: 'Nudge up 10px', needsSelection: true, handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, -10)), }, { id: 'nudge-down-10', keys: { key: 'arrowdown', shift: true }, category: 'arrangement', description: 'Nudge down 10px', needsSelection: true, handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, 10)), }, // ═══════════════════════════════════════ // SCALE & ROTATE (selection) // ═══════════════════════════════════════ { id: 'scale-up', keys: { key: '=', alt: true }, category: 'image', description: 'Scale up 10%', needsSelection: true, handler: (ctx) => _opUpdate(ctx, (items) => ops.scaleBy(items, 1.1)), }, { id: 'scale-down', keys: { key: '-', alt: true }, category: 'image', description: 'Scale down 10%', needsSelection: true, handler: (ctx) => _opUpdate(ctx, (items) => ops.scaleBy(items, 1 / 1.1)), }, { id: 'rotate-cw', keys: { key: 'r' }, category: 'image', description: 'Rotate 90° clockwise', needsSelection: true, handler: (ctx) => { const items = ctx.selection.getSelectedItems(); if (items.some(i => ['pdf-page', 'sticky', 'markdown', 'text'].includes(i.data.type))) return; _opUpdate(ctx, (items) => ops.rotate90(items, true)); }, }, { id: 'rotate-ccw', keys: { key: 'r', shift: true }, category: 'image', description: 'Rotate 90° counter-clockwise', needsSelection: true, handler: (ctx) => { const items = ctx.selection.getSelectedItems(); if (items.some(i => ['pdf-page', 'sticky', 'markdown', 'text'].includes(i.data.type))) return; _opUpdate(ctx, (items) => ops.rotate90(items, false)); }, }, // ═══════════════════════════════════════ // ALIGN CENTER // ═══════════════════════════════════════ { id: 'align-center-h', keys: { key: 'arrowleft', ctrl: true, shift: true }, category: 'alignment', description: 'Align center horizontal', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdate(ctx, ops.alignCenterH), }, { id: 'align-center-v', keys: { key: 'arrowup', ctrl: true, shift: true }, category: 'alignment', description: 'Align center vertical', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdate(ctx, ops.alignCenterV), }, // ═══════════════════════════════════════ // EQUAL SPACING // ═══════════════════════════════════════ { id: 'equal-spacing-h', keys: { key: 'h', ctrl: true, shift: true }, category: 'arrangement', description: 'Equal horizontal spacing', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdateAnimated(ctx, ops.equalSpacingH), }, { id: 'equal-spacing-v', keys: { key: 'v', ctrl: true, shift: true }, category: 'arrangement', description: 'Equal vertical spacing', needsSelection: true, minSelection: 2, handler: (ctx) => _opUpdateAnimated(ctx, ops.equalSpacingV), }, // ═══════════════════════════════════════ // NAVIGATION // ═══════════════════════════════════════ { id: 'clear-selection', keys: { key: 'escape' }, category: 'navigation', description: 'Clear selection', handler: (ctx) => { ctx.selection.clear(); }, }, { id: 'focus-all', keys: { key: '0', ctrl: true }, category: 'navigation', description: 'Fit all in view', handler: (ctx) => ctx.fitAll(), }, { id: 'focus-selection', keys: { key: 'f' }, category: 'navigation', description: 'Fit selection in view', needsSelection: true, handler: (ctx) => ctx.fitSelection(), }, { id: 'toggle-focus-mode', keys: { key: 'tab' }, category: 'view', description: 'Toggle focus mode (hide UI)', handler: (ctx) => ctx.toggleFocusMode(), }, // Bare arrows: nudge 1px if selection, cycle if no selection { id: 'nudge-or-cycle-right', keys: { key: 'arrowright' }, category: 'navigation', description: 'Nudge 1px / Select next', handler: (ctx) => { if (ctx.selection.selectedIds.size > 0) { _opUpdate(ctx, (items) => ops.nudge(items, 1, 0)); } else { const all = ctx.scene.getAllItems(); if (all.length === 0) return; all.sort((a, b) => a.data.z - b.data.z); ctx.selection.selectOnly(all[0].id); } }, }, { id: 'nudge-or-cycle-left', keys: { key: 'arrowleft' }, category: 'navigation', description: 'Nudge 1px / Select prev', handler: (ctx) => { if (ctx.selection.selectedIds.size > 0) { _opUpdate(ctx, (items) => ops.nudge(items, -1, 0)); } else { const all = ctx.scene.getAllItems(); if (all.length === 0) return; all.sort((a, b) => a.data.z - b.data.z); ctx.selection.selectOnly(all[all.length - 1].id); } }, }, { id: 'nudge-up', keys: { key: 'arrowup' }, category: 'navigation', description: 'Nudge up 1px', needsSelection: true, handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, -1)), }, { id: 'nudge-down', keys: { key: 'arrowdown' }, category: 'navigation', description: 'Nudge down 1px', needsSelection: true, handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, 1)), }, // Layer ordering: ] brings forward, [ sends backward { id: 'send-to-front', keys: { key: ']' }, category: 'navigation', description: 'Bring forward', needsSelection: true, handler: (ctx) => { const selected = ctx.selection.getSelectedItems(); const all = ctx.scene.getAllItems().sort((a, b) => a.data.z - b.data.z); // For each selected item, swap z with the next non-selected item above it const selectedIds = new Set(selected.map((s) => s.id)); for (let i = all.length - 2; i >= 0; i--) { if (selectedIds.has(all[i].id) && !selectedIds.has(all[i + 1].id)) { const tmp = all[i].data.z; all[i].data.z = all[i + 1].data.z; all[i + 1].data.z = tmp; } } ctx.scene._applyZOrder(); ctx.onChange(); }, }, { id: 'send-to-back', keys: { key: '[' }, category: 'navigation', description: 'Send backward', needsSelection: true, handler: (ctx) => { const selected = ctx.selection.getSelectedItems(); const all = ctx.scene.getAllItems().sort((a, b) => a.data.z - b.data.z); const selectedIds = new Set(selected.map((s) => s.id)); for (let i = 1; i < all.length; i++) { if (selectedIds.has(all[i].id) && !selectedIds.has(all[i - 1].id)) { const tmp = all[i].data.z; all[i].data.z = all[i - 1].data.z; all[i - 1].data.z = tmp; } } ctx.scene._applyZOrder(); ctx.onChange(); }, }, // ═══════════════════════════════════════ // EDITING // ═══════════════════════════════════════ { id: 'select-all', keys: { key: 'a', ctrl: true }, category: 'editing', description: 'Select all', handler: (ctx) => { ctx.selection.selectAll(); }, }, { id: 'copy', keys: { key: 'c', ctrl: true }, category: 'editing', description: 'Copy', handler: async (ctx) => { const selected = ctx.selection.getSelectedItems(); if (selected.length > 0) { // Include group children in clipboard for proper paste ctx.clipboardRef.current = _collectGroupChildren(selected, ctx.scene); markInternalCopy(); await ctx.writeCanvasToClipboard(selected); ctx.showToast('Copied'); } else { await ctx.writeCanvasToClipboard(); ctx.showToast('Copied canvas'); } }, }, { id: 'copy-as-image', keys: { key: 'c', ctrl: true, shift: true }, category: 'editing', description: 'Copy as image to clipboard', handler: async (ctx) => { const selected = ctx.selection.getSelectedItems(); await ctx.writeCanvasToClipboard(selected.length > 0 ? selected : undefined); }, }, { id: 'paste', keys: { key: 'v', ctrl: true }, category: 'editing', description: 'Paste', preventDefault: false, handler: async (ctx) => { // If we have items in the internal clipboard, always prefer internal paste // (duplicates the scene items with offset). This avoids re-uploading the // PNG screenshot that Ctrl+C also wrote to the system clipboard. const hasInternalItems = ctx.clipboardRef.current.length > 0; if (hasInternalItems) { _lastInternalPasteTime = Date.now(); await _pasteInternal(ctx); return; } // No internal items — let native paste event fire through to setupPaste // in image-drop.ts for external clipboard content (images, text, HTML). }, }, { id: 'cut', keys: { key: 'x', ctrl: true }, category: 'editing', description: 'Cut', handler: async (ctx) => { const selected = ctx.selection.getSelectedItems(); if (selected.length === 0) return; ctx.clipboardRef.current = _collectGroupChildren(selected, ctx.scene); markInternalCopy(); await ctx.writeCanvasToClipboard(selected); for (const item of selected) { ctx.scene.removeItem(item.id, true); } ctx.selection.clear(); ctx.onChange(); ctx.showToast('Cut'); }, }, { id: 'duplicate', keys: { key: 'd', ctrl: true }, category: 'editing', description: 'Duplicate', handler: async (ctx) => { const selected = ctx.selection.getSelectedItems(); if (selected.length === 0) return; // Collect group children and clone with remapped IDs const allItems = _collectGroupChildren(selected, ctx.scene); const clones = _cloneItemsWithOffset(allItems, ctx.scene); const sorted = [...clones].sort((a: any, b: any) => (a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0)); for (const newData of sorted) { await ctx.scene._createItem(newData, true); } ctx.selection.clear(); ctx.scene._applyZOrder(); ctx.onChange(); }, }, { id: 'delete', keys: { key: 'delete' }, category: 'editing', description: 'Delete selected', needsSelection: true, handler: (ctx) => { const selected = ctx.selection.getSelectedItems(); for (const item of selected) { ctx.scene.removeItem(item.id, true); } ctx.selection.clear(); ctx.onChange(); }, }, { id: 'delete-backspace', keys: { key: 'backspace' }, category: 'editing', description: 'Delete selected', needsSelection: true, handler: (ctx) => { const selected = ctx.selection.getSelectedItems(); for (const item of selected) { ctx.scene.removeItem(item.id, true); } ctx.selection.clear(); ctx.onChange(); }, }, { id: 'undo', keys: { key: 'z', ctrl: true }, category: 'editing', description: 'Undo', handler: (ctx) => { ctx.history.undo(); ctx.setCanUndo(ctx.history.canUndo()); ctx.setCanRedo(ctx.history.canRedo()); ctx.onChange(); }, }, { id: 'redo', keys: { key: 'z', ctrl: true, shift: true }, category: 'editing', description: 'Redo', handler: (ctx) => { ctx.history.redo(); ctx.setCanUndo(ctx.history.canUndo()); ctx.setCanRedo(ctx.history.canRedo()); ctx.onChange(); }, }, { id: 'redo-y', keys: { key: 'y', ctrl: true }, category: 'editing', description: 'Redo (alt)', handler: (ctx) => { ctx.history.redo(); ctx.setCanUndo(ctx.history.canUndo()); ctx.setCanRedo(ctx.history.canRedo()); ctx.onChange(); }, }, { id: 'group', keys: { key: 'g', ctrl: true }, category: 'editing', description: 'Group', needsSelection: true, minSelection: 2, handler: (ctx) => ctx.handleGroup(), }, { id: 'ungroup', keys: { key: 'g', ctrl: true, shift: true }, category: 'editing', description: 'Ungroup', needsSelection: true, handler: (ctx) => ctx.handleUngroup(), }, // Opacity: [ and ] with Alt { id: 'opacity-down', keys: { key: '[', alt: true }, category: 'image', description: 'Decrease opacity 10%', needsSelection: true, handler: (ctx) => { const items = ctx.selection.getSelectedItems(); const current = items[0]?.data.opacity ?? 1; ops.setOpacity(items, Math.max(0.1, current - 0.1)); ctx.onChange(); }, }, { id: 'opacity-up', keys: { key: ']', alt: true }, category: 'image', description: 'Increase opacity 10%', needsSelection: true, handler: (ctx) => { const items = ctx.selection.getSelectedItems(); const current = items[0]?.data.opacity ?? 1; ops.setOpacity(items, Math.min(1, current + 0.1)); ctx.onChange(); }, }, // Block Ctrl+S from opening browser save-as dialog { id: 'save', keys: { key: 's', ctrl: true }, category: 'editing', description: 'Save (auto-saves)', handler: (ctx) => { ctx.showToast('Auto-saved'); }, }, // ═══════════════════════════════════════ // VIEW // ═══════════════════════════════════════ { id: 'zoom-in', keys: { key: '=', ctrl: true }, category: 'view', description: 'Zoom in', handler: (ctx) => { const current = ctx.viewport.scale.x; const z = Math.min(current * 1.2, 5); ctx.viewport.setZoom(z, true); }, }, { id: 'zoom-in-plus', keys: { key: '+', ctrl: true }, category: 'view', description: 'Zoom in', handler: (ctx) => { const current = ctx.viewport.scale.x; const z = Math.min(current * 1.2, 5); ctx.viewport.setZoom(z, true); }, }, { id: 'zoom-out', keys: { key: '-', ctrl: true }, category: 'view', description: 'Zoom out', handler: (ctx) => { const current = ctx.viewport.scale.x; const z = Math.max(current / 1.2, 0.1); ctx.viewport.setZoom(z, true); }, }, { id: 'toggle-grid', keys: { key: 'g' }, category: 'view', description: 'Toggle grid', handler: (ctx) => ctx.toggleGrid(), }, // '?' key — when Shift+/ is pressed, e.key is '?' not '/' { id: 'show-help', keys: { key: '?' }, category: 'view', description: 'Show shortcuts help', handler: (ctx) => ctx.toggleShowHelp(), }, { id: 'show-help-f1', keys: { key: 'f1' }, category: 'view', description: 'Show shortcuts help', handler: (ctx) => ctx.toggleShowHelp(), }, { id: 'toggle-review', keys: { key: '.' }, category: 'view', description: 'Toggle review mode', needsSelection: false, handler: (ctx: ShortcutContext) => { ctx.toggleReviewMode?.(); }, }, { id: 'crop', keys: { key: 'c' }, category: 'image', description: 'Crop image', needsSelection: true, handler: (ctx: ShortcutContext) => { ctx.startCrop?.(); }, }, ];