1/** 2 * Editor keyboard shortcuts â bound to `document` so shortcuts work 3 * without first clicking into the canvas. Gated by `state.editorOpen` 4 * so they don't leak into chat input when the editor is closed. 5 * 6 * Covers: 7 * ? toggle the shortcuts cheatsheet 8 * Enter confirm in-progress transform 9 * Esc cancel transform / lasso / crop (in priority order) 10 * Ctrl+Z undo (Shift adds redo) 11 * Ctrl+Shift+D deselect (clears wand + lasso) 12 * Ctrl+S save (Shift = save as / export to gallery) 13 * Ctrl+Shift+T open resize popup 14 * Ctrl+Alt+T start free transform 15 * Ctrl+Alt+I invert wand / lasso selection 16 * Ctrl+Alt+J new empty layer 17 * Ctrl+Alt+A select all canvas (lasso polygon = full bounds) 18 * Ctrl+C/X copy / cut wand or lasso selection (image clipboard 19 * + internal clipboard) 20 * Ctrl+V (handled by the paste event listener) 21 * Tool keys (V, B, E, L, â¦) â toolbar click 22 * [ / ] shrink / grow brush size proportionally 23 * D, C, M (when lasso has 3+ points) â delete / copy / convert mask 24 * Delete / Backspace (wand or lasso) â delete pixels 25 * 26 * @param {{ 27 * toolbar: HTMLDivElement, 28 * toolKeyMap: Record<string, string>, 29 * composite: () => void, 30 * saveState: (label?: string) => void, 31 * undo: () => void, 32 * redo: () => void, 33 * toggleShortcuts: (show?: boolean) => void, 34 * confirmTransform: () => void, 35 * cancelTransform: () => void, 36 * startTransform: () => void, 37 * resizeCustomPrompt: () => void, 38 * addEmptyLayer: () => void, 39 * brushSizeSync: (source: HTMLInputElement | null) => void, 40 * invertSelection: () => boolean, 41 * wandDeleteSelection: () => void, 42 * wandCopyToNewLayer: () => void, 43 * lassoDeleteSelection: () => void, 44 * lassoCopyToLayer: () => void, 45 * lassoToMask: () => void, 46 * buildLassoMask: (w: number, h: number, offX: number, offY: number, feather: number, grow: number) => HTMLCanvasElement, 47 * drawLassoOverlay: () => void, 48 * activeLayer: () => object | null, 49 * uiModule: object, 50 * }} deps 51 */ 52import { state } from './state.js'; 53import { isAltGrEvent } from '../platform.js'; 54 55export function wireKeyboardShortcuts(deps) { 56 const { 57 toolbar, toolKeyMap, 58 composite, saveState, undo, redo, 59 toggleShortcuts, confirmTransform, cancelTransform, startTransform, 60 resizeCustomPrompt, addEmptyLayer, brushSizeSync, 61 invertSelection, 62 wandDeleteSelection, wandCopyToNewLayer, 63 lassoDeleteSelection, lassoCopyToLayer, lassoToMask, 64 buildLassoMask, drawLassoOverlay, 65 activeLayer, uiModule, 66 } = deps; 67 68 document.addEventListener('keydown', (e) => { 69 if (!state.editorOpen) return; 70 // `?` toggles the cheatsheet. Don't fire while typing in a text 71 // field â the user might be typing a prompt with a `?`. 72 if (e.key === '?' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') { 73 e.preventDefault(); 74 toggleShortcuts(); 75 return; 76 } 77 if (e.key === 'Enter' && state.transformActive) { 78 e.preventDefault(); 79 confirmTransform(); 80 return; 81 } 82 if (e.key === 'Escape') return; 83 // Skip the Ctrl+Alt editor chords for an AltGr keystroke (see platform.js); 84 // only the chord block is skipped, so the layout-character handlers below 85 // still act â AltGr+5 / AltGr+8 stay as the [ ] brush-size shortcut on 86 // AZERTY / QWERTZ. 87 if ((e.ctrlKey || e.metaKey) && !isAltGrEvent(e)) { 88 if (e.key === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); } 89 // Ctrl+Shift+D = Deselect: clears the wand selection (and 90 // lasso if active) without affecting layers. 91 if (e.shiftKey && (e.key === 'D' || e.key === 'd')) { 92 if (state.wandMask || state.lassoPoints.length) { 93 e.preventDefault(); 94 if (state.wandMask) { 95 saveState(); 96 state.wandMask = null; 97 state.wandLayerId = null; 98 state.wandLastSeed = null; 99 } 100 if (state.lassoPoints.length) { 101 state.lassoPoints = []; 102 state.lassoActive = false;
103 } 104 composite(); 105 } 106 } 107 // Save shortcuts â match the hints shown in the Save dropdown. 108 if ((e.key === 's' || e.key === 'S') && !e.altKey) { 109 e.preventDefault(); 110 document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click(); 111 } 112 if (e.shiftKey && e.key === 'T') { e.preventDefault(); resizeCustomPrompt(); } 113 if (e.altKey && e.key === 't') { e.preventDefault(); startTransform(); } 114 // Ctrl+Alt+I â invert current selection. Uses e.code so 115 // Alt-modified key values (e.g. `Ë` on Mac with Option+I) 116 // don't break the match. 117 if (e.altKey && e.code === 'KeyI') { 118 if (invertSelection()) { 119 e.preventDefault(); 120 e.stopPropagation(); 121 } 122 } 123 // Ctrl+Alt+J â new empty layer. 124 if (e.altKey && e.code === 'KeyJ') { 125 e.preventDefault(); 126 e.stopPropagation(); 127 addEmptyLayer(); 128 } 129 // Wand selection: Delete = erase pixels. Ctrl+X = cut to 130 // clipboard + new layer + erase. Ctrl+C = copy. 131 // (Legacy `&& !_wandActive` clause referenced an undeclared 132 // variable â removed; the wand is selection-only and has no 133 // "active drag" state.) 134 if (state.wandMask) { 135 if (e.key === 'Delete' || e.key === 'Backspace') { 136 e.preventDefault(); 137 wandDeleteSelection(); 138 return; 139 } 140 if ((e.ctrlKey || e.metaKey) && (e.key === 'x' || e.key === 'c')) { 141 e.preventDefault(); 142 const isCut = e.key === 'x'; 143 const src = state.layers.find(l => l.id === state.wandLayerId); 144 if (!src) return; 145 // Clip source by wand mask into a temp canvas. 146 const w = src.canvas.width, h = src.canvas.height; 147 const tmp = document.createElement('canvas'); 148 tmp.width = w; tmp.height = h; 149 const tCtx = tmp.getContext('2d'); 150 tCtx.drawImage(src.canvas, 0, 0); 151 tCtx.globalCompositeOperation = 'destination-in'; 152 tCtx.drawImage(state.wandMask, 0, 0); 153 state.internalClipboard = tmp; 154 tmp.toBlob(blob => { 155 if (blob && navigator.clipboard?.write) { 156 navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => { 157 uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard'); 158 }).catch(() => uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)')); 159 } 160 }, 'image/png'); 161 if (isCut) { 162 // Cut also moves the selection to a new layer + erases source. 163 wandCopyToNewLayer(); 164 wandDeleteSelection(); 165 } 166 return; 167 } 168 } 169 if ((e.key === 'x' || e.key === 'c') && state.lassoPoints.length >= 3) { 170 e.preventDefault(); 171 const layer = activeLayer(); 172 if (!layer) return; 173 const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; 174 const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0'); 175 const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0'); 176 const w = layer.canvas.width, h = layer.canvas.height; 177 const mask = buildLassoMask(w, h, off.x, off.y, feather, grow); 178 const srcData = layer.ctx.getImageData(0, 0, w, h); 179 const maskData = mask.getContext('2d').getImageData(0, 0, w, h); 180 // Build clipped image. 181 const tmp = document.createElement('canvas'); 182 tmp.width = w; tmp.height = h; 183 const tCtx = tmp.getContext('2d'); 184 const outData = tCtx.createImageData(w, h); 185 for (let i = 0; i < w * h; i++) { 186 const mv = maskData.data[i * 4] / 255; 187 if (mv > 0) { 188 outData.data[i*4] = srcData.data[i*4]; 189 outData.data[i*4+1] = srcData.data[i*4+1]; 190 outData.data[i*4+2] = srcData.data[i*4+2]; 191 outData.data[i*4+3] = Math.round(srcData.data[i*4+3] * mv); 192 } 193 } 194 tCtx.putImageData(outData, 0, 0); 195 state.internalClipboard = tmp; 196 const isCut = e.key === 'x'; 197 tmp.toBlob(blob => { 198 if (blob && navigator.clipboard?.write) { 199 navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => { 200 uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard'); 201 }).catch(() =>
201 uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)')); 202 } 203 }, 'image/png'); 204 if (e.key === 'x') { 205 const savedPts = [...state.lassoPoints]; 206 state.lassoPoints = savedPts; 207 lassoDeleteSelection(); 208 } else { 209 state.lassoPoints = []; 210 composite(); 211 } 212 } 213 // Ctrl+C with no active selection â copy the entire active layer 214 // to the system clipboard as a PNG. Gives a "just copy this image" 215 // shortcut without having to lasso-select-all first. The 216 // selection-aware Ctrl+C paths above run first (wand + lasso), 217 // so this only fires when neither is active. 218 if (e.key === 'c' && !e.shiftKey && !state.wandMask && state.lassoPoints.length < 3) { 219 const layer = activeLayer(); 220 if (layer && layer.canvas && layer.canvas.width > 0) { 221 e.preventDefault(); 222 layer.canvas.toBlob(blob => { 223 if (blob && navigator.clipboard?.write) { 224 navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) 225 .then(() => uiModule.showToast('Layer copied to clipboard')) 226 .catch(() => uiModule.showToast('Copy failed (clipboard permission denied?)')); 227 } 228 }, 'image/png'); 229 return; 230 } 231 } 232 // Ctrl+Alt+A = select all canvas. 233 if (e.altKey && e.key === 'a' && state.imgWidth > 0 && state.imgHeight > 0) { 234 e.preventDefault(); 235 state.lassoPoints = [ 236 { x: 0, y: 0 }, { x: state.imgWidth, y: 0 }, 237 { x: state.imgWidth, y: state.imgHeight }, { x: 0, y: state.imgHeight }, 238 ]; 239 state.lassoActive = false; 240 composite(); 241 drawLassoOverlay(); 242 uiModule.showToast('All selected â Ctrl+C to copy, Del to delete'); 243 } 244 // Ctrl+V handled by the paste event listener. 245 if (e.key === 'v') { /* no-op here */ } 246 return; 247 } 248 // Tool shortcuts (only when not typing in an input). 249 if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; 250 const toolId = toolKeyMap[e.key.toLowerCase()]; 251 if (toolId) { 252 const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`); 253 if (toolBtn) toolBtn.click(); 254 } 255 // Bracket keys for brush size â ±10% multiplier mirrors the 256 // exponential slider curve so each press feels the same at any 257 // size. 258 if (e.key === '[' || e.key === ']') { 259 const factor = e.key === '[' ? 0.9 : 1.1; 260 state.brushSize = Math.max(1, Math.min(800, Math.round(state.brushSize * factor))); 261 try { brushSizeSync(null); } catch {} 262 } 263 // Lasso shortcuts (when selection exists). 264 if (state.lassoPoints.length >= 3) { 265 if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); lassoDeleteSelection(); } 266 if (e.key === 'd') { e.preventDefault(); lassoDeleteSelection(); } 267 if (e.key === 'c') { e.preventDefault(); lassoCopyToLayer(); } 268 if (e.key === 'm') { e.preventDefault(); lassoToMask(); } 269 } 270 }); 271}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.