1/** 2 * Transform-tool session lifecycle + floating popup wiring. 3 * 4 * _startTransform snapshot the active layer + open popup 5 * _openTransformPopup build the W/H/rotation popup, wire inputs 6 * _wireTransformDrag header drag, mobile + desktop position handling 7 * _reapplyTransform live preview re-render from the snapshot 8 * _confirmTransform commit + clear session state 9 * _cancelTransform restore via undo() + clear session state 10 * 11 * Handle-drag interactions on the CANVAS (corner / rotation grip) live 12 * in `editor/tools/transform-drag.js` â those mutate the same staged 13 * `state.transformPending*` fields that the popup inputs do, so both 14 * surfaces stay in sync via `_reapplyTransform()`. 15 * 16 * @param {{ 17 * activeLayer: () => object | null, 18 * saveState: (label?: string) => void, 19 * composite: () => void, 20 * fitZoom: () => void, 21 * drawTransformHandles: () => void, 22 * showCanvasLoading: (label: string) => void, 23 * hideCanvasLoading: () => void, 24 * undo: () => void, 25 * uiModule: object | null, 26 * }} deps 27 * 28 * @returns {{ 29 * startTransform, openTransformPopup, closeTransformPopup, 30 * reapplyTransform, confirmTransform, cancelTransform, 31 * }} 32 */ 33import { state } from '../state.js'; 34import { 35 transformPopupHTML, 36 attachSpinRepeat, 37} from '../build/transform-popup.js'; 38 39export function createTransformSession({ 40 activeLayer, saveState, composite, fitZoom, drawTransformHandles, 41 showCanvasLoading, hideCanvasLoading, undo, uiModule, 42}) { 43 function startTransform() { 44 const layer = activeLayer(); 45 if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; } 46 if (state.transformActive) { cancelTransform(); return; } // toggle off 47 state.transformActive = true; 48 state.transformLayer = layer; 49 state.transformOrigW = layer.canvas.width; 50 state.transformOrigH = layer.canvas.height; 51 state.transformPendingW = state.transformOrigW; 52 state.transformPendingH = state.transformOrigH; 53 state.transformPendingRot = 0; 54 state.transformPendingFlipH = false; 55 state.transformPendingFlipV = false; 56 // Snapshot the layer so live preview can re-derive from the 57 // original pixels on every keystroke instead of stacking 58 // destructive edits. 59 state.transformOrigCanvas = document.createElement('canvas'); 60 state.transformOrigCanvas.width = state.transformOrigW; 61 state.transformOrigCanvas.height = state.transformOrigH; 62 state.transformOrigCanvas.getContext('2d').drawImage(layer.canvas, 0, 0); 63 state.transformOrigOffset = { ...(state.layerOffsets.get(layer.id) || { x: 0, y: 0 }) }; 64 saveState(); 65 // Fit canvas to viewport so the corner handles are visible â 66 // without this, a layer larger than the viewport leaves the grab 67 // markers off-screen. 68 try { fitZoom(); } catch {} 69 composite(); 70 drawTransformHandles(); 71 openTransformPopup(); 72 } 73 74 function closeTransformPopup() { 75 if (state.transformPopup) { 76 try { state.transformPopup.remove(); } catch {} 77 state.transformPopup = null; 78 } 79 } 80 81 // Floating Transform popup â horizontal layout, draggable via its 82 // header, anchored over the right panel (layers area) by default 83 // so it doesn't cover the canvas. Lets the user type exact W/H/Rot 84 // and flip via negative values. 85 function openTransformPopup() { 86 closeTransformPopup(); 87 if (!state.container) return; 88 const pop = document.createElement('div'); 89 pop.className = 'ge-transform-popup'; 90 pop.innerHTML = transformPopupHTML(); 91 state.container.appendChild(pop); 92 state.transformPopup = pop; 93 wireTransformDrag(pop); 94 const wInput = pop.querySelector('#ge-transform-w'); 95 const hInput = pop.querySelector('#ge-transform-h'); 96 const rotInput = pop.querySelector('#ge-transform-rot'); 97 const aspectBtn = pop.querySelector('#ge-transform-aspect'); 98 wInput.value = String(state.transformOrigW); 99 hInput.value = String(state.transformOrigH); 100 rotInput.value = '0'; 101 aspectBtn.classList.toggle('active', state.transformAspectLock); 102 aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false'); 103 104 // Aspect-lock follower model: while the lock is engaged, ONE 105 // field is the "driver" and the other is read-only + dimmed. 106 // Driver = whichever field the user last typed in. Toggling the 107 // chain releases the follower. 108 let driver = null; 109 const applyAspectVisuals = () => { 110 if (!state.transformAspectLock || !driver) { 111 wInput.readOnly = false;
112 hInput.readOnly = false; 113 wInput.classList.remove('ge-transform-input-locked'); 114 hInput.classList.remove('ge-transform-input-locked'); 115 return; 116 } 117 const followerW = driver === 'h'; 118 const followerH = driver === 'w'; 119 wInput.readOnly = followerW; 120 hInput.readOnly = followerH; 121 wInput.classList.toggle('ge-transform-input-locked', followerW); 122 hInput.classList.toggle('ge-transform-input-locked', followerH); 123 }; 124 const refresh = () => { 125 let w = parseInt(wInput.value, 10); 126 let h = parseInt(hInput.value, 10); 127 const rot = parseInt(rotInput.value, 10) || 0; 128 state.transformPendingFlipH = w < 0; 129 state.transformPendingFlipV = h < 0; 130 w = Math.abs(w || state.transformOrigW); 131 h = Math.abs(h || state.transformOrigH); 132 state.transformPendingW = Math.max(1, w); 133 state.transformPendingH = Math.max(1, h); 134 state.transformPendingRot = rot; 135 reapplyTransform(); 136 }; 137 wInput.addEventListener('input', () => { 138 if (state.transformAspectLock) { 139 driver = 'w'; 140 const w = parseInt(wInput.value, 10); 141 if (!Number.isNaN(w) && state.transformOrigW > 0) { 142 const sign = (parseInt(hInput.value, 10) || 1) < 0 ? -1 : 1; 143 const newH = Math.round((Math.abs(w) / state.transformOrigW) * state.transformOrigH) * sign; 144 hInput.value = String(newH); 145 } 146 applyAspectVisuals(); 147 } 148 refresh(); 149 }); 150 hInput.addEventListener('input', () => { 151 if (state.transformAspectLock) { 152 driver = 'h'; 153 const h = parseInt(hInput.value, 10); 154 if (!Number.isNaN(h) && state.transformOrigH > 0) { 155 const sign = (parseInt(wInput.value, 10) || 1) < 0 ? -1 : 1; 156 const newW = Math.round((Math.abs(h) / state.transformOrigH) * state.transformOrigW) * sign; 157 wInput.value = String(newW); 158 } 159 applyAspectVisuals(); 160 } 161 refresh(); 162 }); 163 rotInput.addEventListener('input', refresh); 164 aspectBtn.addEventListener('click', () => { 165 state.transformAspectLock = !state.transformAspectLock; 166 aspectBtn.classList.toggle('active', state.transformAspectLock); 167 aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false'); 168 // Reset follower the moment the user breaks the lock so both 169 // fields go editable; re-engaging means "next type sets the driver". 170 driver = null; 171 applyAspectVisuals(); 172 }); 173 pop.querySelector('#ge-transform-apply').addEventListener('click', () => confirmTransform()); 174 pop.querySelector('#ge-transform-cancel').addEventListener('click', () => cancelTransform()); 175 pop.querySelector('#ge-transform-cancel-btn')?.addEventListener('click', () => cancelTransform()); 176 // Minimise â collapses the body so only the header is visible. 177 pop.querySelector('#ge-transform-min')?.addEventListener('click', (e) => { 178 e.stopPropagation(); 179 pop.classList.toggle('ge-transform-popup-minimised'); 180 }); 181 // Quick actions: flip W/H via sign so the reapply pipeline picks 182 // up the new orientation. Rotate-90 nudges rotation ±90°. 183 pop.querySelector('#ge-transform-flip-h')?.addEventListener('click', () => { 184 const wIn = pop.querySelector('#ge-transform-w'); 185 const cur = parseInt(wIn.value, 10) || state.transformOrigW; 186 wIn.value = String(-cur); 187 wIn.dispatchEvent(new Event('input', { bubbles: true })); 188 }); 189 pop.querySelector('#ge-transform-flip-v')?.addEventListener('click', () => { 190 const hIn = pop.querySelector('#ge-transform-h'); 191 const cur = parseInt(hIn.value, 10) || state.transformOrigH; 192 hIn.value = String(-cur); 193 hIn.dispatchEvent(new Event('input', { bubbles: true })); 194 }); 195 pop.querySelector('#ge-transform-rot-90')?.addEventListener('click', (e) => { 196 const rIn = pop.querySelector('#ge-transform-rot'); 197 const cur = parseInt(rIn.value, 10) || 0; 198 const delta = e.shiftKey ? -90 : 90; 199 let next = cur + delta; 200 while (next > 180) next -= 360; 201 while (next <= -180) next += 360;
202 rIn.value = String(next); 203 // Big images: rotation pass blocks UI ~0.5â2 s. Show a spinner 204 // so the user sees something happen. rAF defers the heavy work 205 // past the current frame so the overlay paints first. 206 showCanvasLoading('Rotatingâ¦'); 207 requestAnimationFrame(() => { 208 try { rIn.dispatchEvent(new Event('input', { bubbles: true })); } 209 finally { hideCanvasLoading(); } 210 }); 211 }); 212 attachSpinRepeat(pop); 213 } 214 215 // Header-drag for the Transform popup. Default position: over the 216 // right panel (layers area). Mobile pins via stylesheet so we use 217 // setProperty 'important' to override during drag. 218 function wireTransformDrag(pop) { 219 const isMobile = window.matchMedia('(max-width: 820px)').matches; 220 const defaultRight = 20; 221 const defaultTop = 60; 222 if (isMobile) { 223 pop.style.setProperty('position', 'fixed', 'important'); 224 } else { 225 pop.style.position = 'absolute'; 226 pop.style.right = defaultRight + 'px'; 227 pop.style.top = defaultTop + 'px'; 228 pop.style.left = 'auto'; 229 } 230 const dragSource = pop.querySelector('[data-transform-drag]') || pop; 231 let dragging = false; 232 let startX = 0, startY = 0, originLeft = 0, originTop = 0; 233 const NON_DRAG = 'input,button,select,textarea,a,[contenteditable]'; 234 235 const setPos = (x, y) => { 236 if (isMobile) { 237 pop.style.setProperty('left', x + 'px', 'important'); 238 pop.style.setProperty('top', y + 'px', 'important'); 239 pop.style.setProperty('right', 'auto', 'important'); 240 pop.style.setProperty('bottom', 'auto', 'important'); 241 pop.style.setProperty('width', 'auto', 'important'); 242 pop.style.setProperty('max-width', 'calc(100vw - 16px)', 'important'); 243 } else { 244 pop.style.left = x + 'px'; 245 pop.style.top = y + 'px'; 246 pop.style.right = 'auto'; 247 } 248 }; 249 250 const beginDrag = (clientX, clientY) => { 251 dragging = true; 252 const rect = pop.getBoundingClientRect(); 253 if (isMobile) { 254 originLeft = rect.left; 255 originTop = rect.top; 256 } else { 257 const parentRect = state.container.getBoundingClientRect(); 258 originLeft = rect.left - parentRect.left; 259 originTop = rect.top - parentRect.top; 260 } 261 startX = clientX; 262 startY = clientY; 263 setPos(originLeft, originTop); 264 pop.classList.add('ge-transform-popup-dragging'); 265 document.body.style.userSelect = 'none'; 266 }; 267 268 const moveDrag = (clientX, clientY) => { 269 if (!dragging) return; 270 const dx = clientX - startX; 271 const dy = clientY - startY; 272 let nx = originLeft + dx; 273 let ny = originTop + dy; 274 if (isMobile) { 275 const rect = pop.getBoundingClientRect(); 276 nx = Math.max(0, Math.min(window.innerWidth - rect.width, nx)); 277 ny = Math.max(0, Math.min(window.innerHeight - rect.height, ny)); 278 } 279 setPos(nx, ny); 280 }; 281 282 const endDrag = () => { 283 if (!dragging) return; 284 dragging = false; 285 document.body.style.userSelect = ''; 286 pop.classList.remove('ge-transform-popup-dragging'); 287 }; 288 289 dragSource.addEventListener('mousedown', (e) => { 290 if (e.target.closest(NON_DRAG)) return; 291 e.preventDefault(); 292 beginDrag(e.clientX, e.clientY); 293 }); 294 document.addEventListener('mousemove', (e) => moveDrag(e.clientX, e.clientY)); 295 document.addEventListener('mouseup', endDrag); 296 297 dragSource.addEventListener('touchstart', (e) => { 298 if (e.target.closest(NON_DRAG)) return; 299 if (!e.touches || e.touches.length !== 1) return; 300 e.preventDefault(); 301 beginDrag(e.touches[0].clientX, e.touches[0].clientY); 302 }, { passive: false }); 303 document.addEventListener('touchmove', (e) => { 304 if (!dragging) return; 305 if (!e.touches || e.touches.length !== 1) return; 306 e.preventDefault(); 307 moveDrag(e.touches[0].clientX, e.touches[0].clientY); 308 }, { passive: false }); 309 document.addEventListener('touchend', endDrag); 310 document.addEventListener('touchcancel', endDrag); 311 } 312 313 // Re-derive the active layer's pixels from the original snapshot 314 // with the popup's current W/H/flip/rotation applied. Cheap â 315 // paints into an off-screen canvas of the final size. 316 function reapplyTransform() { 317 const layer = state.transformLayer; 318 if (!layer || !state.transformOrigCanvas) return; 319 const w = state.transformPendingW; 320 const h = state.transformPendingH; 321 const rotDeg = state.transformPendingRot; 322 const rotRad = (rotDeg * Math.PI) / 180; 323 const cos = Math.abs(Math.cos(rotRad)); 324 const sin = Math.abs(Math.sin(rotRad)); 325 // Bounding box of the rotated WÃH â canvas grows so corners 326 // don't clip. 327 const finalW = Math.max(1, Math.round(w * cos + h * sin)); 328 const finalH = Math.max(1, Math.round(w * sin + h * cos)); 329 const tmp = document.createElement('canvas'); 330 tmp.width = finalW; tmp.height = finalH; 331 const tCtx = tmp.getContext('2d'); 332 tCtx.imageSmoothingEnabled = true; 333 tCtx.imageSmoothingQuality = 'high'; 334 tCtx.save(); 335 tCtx.translate(finalW / 2, finalH / 2); 336 if (rotDeg) tCtx.rotate(rotRad); 337 tCtx.scale(state.transformPendingFlipH ? -1 : 1, state.transformPendingFlipV ? -1 : 1); 338 tCtx.drawImage(state.transformOrigCanvas, -w / 2, -h / 2, w, h);
339 tCtx.restore(); 340 layer.canvas.width = finalW; 341 layer.canvas.height = finalH; 342 layer.ctx.clearRect(0, 0, finalW, finalH); 343 layer.ctx.drawImage(tmp, 0, 0); 344 // Recenter the layer so the rotation pivot stays put visually. 345 const origCenterX = state.transformOrigOffset.x + state.transformOrigW / 2; 346 const origCenterY = state.transformOrigOffset.y + state.transformOrigH / 2; 347 state.layerOffsets.set(layer.id, { 348 x: Math.round(origCenterX - finalW / 2), 349 y: Math.round(origCenterY - finalH / 2), 350 }); 351 composite(); 352 drawTransformHandles(); 353 } 354 355 function confirmTransform() { 356 closeTransformPopup(); 357 state.transformOrigCanvas = null; 358 state.transformOrigOffset = null; 359 state.transformActive = false; 360 state.transformLayer = null; 361 state.transformHandle = null; 362 composite(); 363 uiModule.showToast('Transform applied'); 364 } 365 366 function cancelTransform() { 367 closeTransformPopup(); 368 state.transformOrigCanvas = null; 369 state.transformOrigOffset = null; 370 if (state.transformLayer) undo(); // restore saved state 371 state.transformActive = false; 372 state.transformLayer = null; 373 state.transformHandle = null; 374 composite(); 375 } 376 377 return { 378 startTransform, openTransformPopup, closeTransformPopup, 379 reapplyTransform, confirmTransform, cancelTransform, 380 }; 381}
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.