Spaces:
Paused
Paused
| /** | |
| * Transform-tool session lifecycle + floating popup wiring. | |
| * | |
| * _startTransform snapshot the active layer + open popup | |
| * _openTransformPopup build the W/H/rotation popup, wire inputs | |
| * _wireTransformDrag header drag, mobile + desktop position handling | |
| * _reapplyTransform live preview re-render from the snapshot | |
| * _confirmTransform commit + clear session state | |
| * _cancelTransform restore via undo() + clear session state | |
| * | |
| * Handle-drag interactions on the CANVAS (corner / rotation grip) live | |
| * in `editor/tools/transform-drag.js` — those mutate the same staged | |
| * `state.transformPending*` fields that the popup inputs do, so both | |
| * surfaces stay in sync via `_reapplyTransform()`. | |
| * | |
| * @param {{ | |
| * activeLayer: () => object | null, | |
| * saveState: (label?: string) => void, | |
| * composite: () => void, | |
| * fitZoom: () => void, | |
| * drawTransformHandles: () => void, | |
| * showCanvasLoading: (label: string) => void, | |
| * hideCanvasLoading: () => void, | |
| * undo: () => void, | |
| * uiModule: object | null, | |
| * }} deps | |
| * | |
| * @returns {{ | |
| * startTransform, openTransformPopup, closeTransformPopup, | |
| * reapplyTransform, confirmTransform, cancelTransform, | |
| * }} | |
| */ | |
| import { state } from '../state.js'; | |
| import { | |
| transformPopupHTML, | |
| attachSpinRepeat, | |
| } from '../build/transform-popup.js'; | |
| export function createTransformSession({ | |
| activeLayer, saveState, composite, fitZoom, drawTransformHandles, | |
| showCanvasLoading, hideCanvasLoading, undo, uiModule, | |
| }) { | |
| function startTransform() { | |
| const layer = activeLayer(); | |
| if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; } | |
| if (state.transformActive) { cancelTransform(); return; } // toggle off | |
| state.transformActive = true; | |
| state.transformLayer = layer; | |
| state.transformOrigW = layer.canvas.width; | |
| state.transformOrigH = layer.canvas.height; | |
| state.transformPendingW = state.transformOrigW; | |
| state.transformPendingH = state.transformOrigH; | |
| state.transformPendingRot = 0; | |
| state.transformPendingFlipH = false; | |
| state.transformPendingFlipV = false; | |
| // Snapshot the layer so live preview can re-derive from the | |
| // original pixels on every keystroke instead of stacking | |
| // destructive edits. | |
| state.transformOrigCanvas = document.createElement('canvas'); | |
| state.transformOrigCanvas.width = state.transformOrigW; | |
| state.transformOrigCanvas.height = state.transformOrigH; | |
| state.transformOrigCanvas.getContext('2d').drawImage(layer.canvas, 0, 0); | |
| state.transformOrigOffset = { ...(state.layerOffsets.get(layer.id) || { x: 0, y: 0 }) }; | |
| saveState(); | |
| // Fit canvas to viewport so the corner handles are visible — | |
| // without this, a layer larger than the viewport leaves the grab | |
| // markers off-screen. | |
| try { fitZoom(); } catch {} | |
| composite(); | |
| drawTransformHandles(); | |
| openTransformPopup(); | |
| } | |
| function closeTransformPopup() { | |
| if (state.transformPopup) { | |
| try { state.transformPopup.remove(); } catch {} | |
| state.transformPopup = null; | |
| } | |
| } | |
| // Floating Transform popup — horizontal layout, draggable via its | |
| // header, anchored over the right panel (layers area) by default | |
| // so it doesn't cover the canvas. Lets the user type exact W/H/Rot | |
| // and flip via negative values. | |
| function openTransformPopup() { | |
| closeTransformPopup(); | |
| if (!state.container) return; | |
| const pop = document.createElement('div'); | |
| pop.className = 'ge-transform-popup'; | |
| pop.innerHTML = transformPopupHTML(); | |
| state.container.appendChild(pop); | |
| state.transformPopup = pop; | |
| wireTransformDrag(pop); | |
| const wInput = pop.querySelector('#ge-transform-w'); | |
| const hInput = pop.querySelector('#ge-transform-h'); | |
| const rotInput = pop.querySelector('#ge-transform-rot'); | |
| const aspectBtn = pop.querySelector('#ge-transform-aspect'); | |
| wInput.value = String(state.transformOrigW); | |
| hInput.value = String(state.transformOrigH); | |
| rotInput.value = '0'; | |
| aspectBtn.classList.toggle('active', state.transformAspectLock); | |
| aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false'); | |
| // Aspect-lock follower model: while the lock is engaged, ONE | |
| // field is the "driver" and the other is read-only + dimmed. | |
| // Driver = whichever field the user last typed in. Toggling the | |
| // chain releases the follower. | |
| let driver = null; | |
| const applyAspectVisuals = () => { | |
| if (!state.transformAspectLock || !driver) { | |
| wInput.readOnly = false; | |
| hInput.readOnly = false; | |
| wInput.classList.remove('ge-transform-input-locked'); | |
| hInput.classList.remove('ge-transform-input-locked'); | |
| return; | |
| } | |
| const followerW = driver === 'h'; | |
| const followerH = driver === 'w'; | |
| wInput.readOnly = followerW; | |
| hInput.readOnly = followerH; | |
| wInput.classList.toggle('ge-transform-input-locked', followerW); | |
| hInput.classList.toggle('ge-transform-input-locked', followerH); | |
| }; | |
| const refresh = () => { | |
| let w = parseInt(wInput.value, 10); | |
| let h = parseInt(hInput.value, 10); | |
| const rot = parseInt(rotInput.value, 10) || 0; | |
| state.transformPendingFlipH = w < 0; | |
| state.transformPendingFlipV = h < 0; | |
| w = Math.abs(w || state.transformOrigW); | |
| h = Math.abs(h || state.transformOrigH); | |
| state.transformPendingW = Math.max(1, w); | |
| state.transformPendingH = Math.max(1, h); | |
| state.transformPendingRot = rot; | |
| reapplyTransform(); | |
| }; | |
| wInput.addEventListener('input', () => { | |
| if (state.transformAspectLock) { | |
| driver = 'w'; | |
| const w = parseInt(wInput.value, 10); | |
| if (!Number.isNaN(w) && state.transformOrigW > 0) { | |
| const sign = (parseInt(hInput.value, 10) || 1) < 0 ? -1 : 1; | |
| const newH = Math.round((Math.abs(w) / state.transformOrigW) * state.transformOrigH) * sign; | |
| hInput.value = String(newH); | |
| } | |
| applyAspectVisuals(); | |
| } | |
| refresh(); | |
| }); | |
| hInput.addEventListener('input', () => { | |
| if (state.transformAspectLock) { | |
| driver = 'h'; | |
| const h = parseInt(hInput.value, 10); | |
| if (!Number.isNaN(h) && state.transformOrigH > 0) { | |
| const sign = (parseInt(wInput.value, 10) || 1) < 0 ? -1 : 1; | |
| const newW = Math.round((Math.abs(h) / state.transformOrigH) * state.transformOrigW) * sign; | |
| wInput.value = String(newW); | |
| } | |
| applyAspectVisuals(); | |
| } | |
| refresh(); | |
| }); | |
| rotInput.addEventListener('input', refresh); | |
| aspectBtn.addEventListener('click', () => { | |
| state.transformAspectLock = !state.transformAspectLock; | |
| aspectBtn.classList.toggle('active', state.transformAspectLock); | |
| aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false'); | |
| // Reset follower the moment the user breaks the lock so both | |
| // fields go editable; re-engaging means "next type sets the driver". | |
| driver = null; | |
| applyAspectVisuals(); | |
| }); | |
| pop.querySelector('#ge-transform-apply').addEventListener('click', () => confirmTransform()); | |
| pop.querySelector('#ge-transform-cancel').addEventListener('click', () => cancelTransform()); | |
| pop.querySelector('#ge-transform-cancel-btn')?.addEventListener('click', () => cancelTransform()); | |
| // Minimise — collapses the body so only the header is visible. | |
| pop.querySelector('#ge-transform-min')?.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| pop.classList.toggle('ge-transform-popup-minimised'); | |
| }); | |
| // Quick actions: flip W/H via sign so the reapply pipeline picks | |
| // up the new orientation. Rotate-90 nudges rotation ±90°. | |
| pop.querySelector('#ge-transform-flip-h')?.addEventListener('click', () => { | |
| const wIn = pop.querySelector('#ge-transform-w'); | |
| const cur = parseInt(wIn.value, 10) || state.transformOrigW; | |
| wIn.value = String(-cur); | |
| wIn.dispatchEvent(new Event('input', { bubbles: true })); | |
| }); | |
| pop.querySelector('#ge-transform-flip-v')?.addEventListener('click', () => { | |
| const hIn = pop.querySelector('#ge-transform-h'); | |
| const cur = parseInt(hIn.value, 10) || state.transformOrigH; | |
| hIn.value = String(-cur); | |
| hIn.dispatchEvent(new Event('input', { bubbles: true })); | |
| }); | |
| pop.querySelector('#ge-transform-rot-90')?.addEventListener('click', (e) => { | |
| const rIn = pop.querySelector('#ge-transform-rot'); | |
| const cur = parseInt(rIn.value, 10) || 0; | |
| const delta = e.shiftKey ? -90 : 90; | |
| let next = cur + delta; | |
| while (next > 180) next -= 360; | |
| while (next <= -180) next += 360; | |
| rIn.value = String(next); | |
| // Big images: rotation pass blocks UI ~0.5–2 s. Show a spinner | |
| // so the user sees something happen. rAF defers the heavy work | |
| // past the current frame so the overlay paints first. | |
| showCanvasLoading('Rotating…'); | |
| requestAnimationFrame(() => { | |
| try { rIn.dispatchEvent(new Event('input', { bubbles: true })); } | |
| finally { hideCanvasLoading(); } | |
| }); | |
| }); | |
| attachSpinRepeat(pop); | |
| } | |
| // Header-drag for the Transform popup. Default position: over the | |
| // right panel (layers area). Mobile pins via stylesheet so we use | |
| // setProperty 'important' to override during drag. | |
| function wireTransformDrag(pop) { | |
| const isMobile = window.matchMedia('(max-width: 820px)').matches; | |
| const defaultRight = 20; | |
| const defaultTop = 60; | |
| if (isMobile) { | |
| pop.style.setProperty('position', 'fixed', 'important'); | |
| } else { | |
| pop.style.position = 'absolute'; | |
| pop.style.right = defaultRight + 'px'; | |
| pop.style.top = defaultTop + 'px'; | |
| pop.style.left = 'auto'; | |
| } | |
| const dragSource = pop.querySelector('[data-transform-drag]') || pop; | |
| let dragging = false; | |
| let startX = 0, startY = 0, originLeft = 0, originTop = 0; | |
| const NON_DRAG = 'input,button,select,textarea,a,[contenteditable]'; | |
| const setPos = (x, y) => { | |
| if (isMobile) { | |
| pop.style.setProperty('left', x + 'px', 'important'); | |
| pop.style.setProperty('top', y + 'px', 'important'); | |
| pop.style.setProperty('right', 'auto', 'important'); | |
| pop.style.setProperty('bottom', 'auto', 'important'); | |
| pop.style.setProperty('width', 'auto', 'important'); | |
| pop.style.setProperty('max-width', 'calc(100vw - 16px)', 'important'); | |
| } else { | |
| pop.style.left = x + 'px'; | |
| pop.style.top = y + 'px'; | |
| pop.style.right = 'auto'; | |
| } | |
| }; | |
| const beginDrag = (clientX, clientY) => { | |
| dragging = true; | |
| const rect = pop.getBoundingClientRect(); | |
| if (isMobile) { | |
| originLeft = rect.left; | |
| originTop = rect.top; | |
| } else { | |
| const parentRect = state.container.getBoundingClientRect(); | |
| originLeft = rect.left - parentRect.left; | |
| originTop = rect.top - parentRect.top; | |
| } | |
| startX = clientX; | |
| startY = clientY; | |
| setPos(originLeft, originTop); | |
| pop.classList.add('ge-transform-popup-dragging'); | |
| document.body.style.userSelect = 'none'; | |
| }; | |
| const moveDrag = (clientX, clientY) => { | |
| if (!dragging) return; | |
| const dx = clientX - startX; | |
| const dy = clientY - startY; | |
| let nx = originLeft + dx; | |
| let ny = originTop + dy; | |
| if (isMobile) { | |
| const rect = pop.getBoundingClientRect(); | |
| nx = Math.max(0, Math.min(window.innerWidth - rect.width, nx)); | |
| ny = Math.max(0, Math.min(window.innerHeight - rect.height, ny)); | |
| } | |
| setPos(nx, ny); | |
| }; | |
| const endDrag = () => { | |
| if (!dragging) return; | |
| dragging = false; | |
| document.body.style.userSelect = ''; | |
| pop.classList.remove('ge-transform-popup-dragging'); | |
| }; | |
| dragSource.addEventListener('mousedown', (e) => { | |
| if (e.target.closest(NON_DRAG)) return; | |
| e.preventDefault(); | |
| beginDrag(e.clientX, e.clientY); | |
| }); | |
| document.addEventListener('mousemove', (e) => moveDrag(e.clientX, e.clientY)); | |
| document.addEventListener('mouseup', endDrag); | |
| dragSource.addEventListener('touchstart', (e) => { | |
| if (e.target.closest(NON_DRAG)) return; | |
| if (!e.touches || e.touches.length !== 1) return; | |
| e.preventDefault(); | |
| beginDrag(e.touches[0].clientX, e.touches[0].clientY); | |
| }, { passive: false }); | |
| document.addEventListener('touchmove', (e) => { | |
| if (!dragging) return; | |
| if (!e.touches || e.touches.length !== 1) return; | |
| e.preventDefault(); | |
| moveDrag(e.touches[0].clientX, e.touches[0].clientY); | |
| }, { passive: false }); | |
| document.addEventListener('touchend', endDrag); | |
| document.addEventListener('touchcancel', endDrag); | |
| } | |
| // Re-derive the active layer's pixels from the original snapshot | |
| // with the popup's current W/H/flip/rotation applied. Cheap — | |
| // paints into an off-screen canvas of the final size. | |
| function reapplyTransform() { | |
| const layer = state.transformLayer; | |
| if (!layer || !state.transformOrigCanvas) return; | |
| const w = state.transformPendingW; | |
| const h = state.transformPendingH; | |
| const rotDeg = state.transformPendingRot; | |
| const rotRad = (rotDeg * Math.PI) / 180; | |
| const cos = Math.abs(Math.cos(rotRad)); | |
| const sin = Math.abs(Math.sin(rotRad)); | |
| // Bounding box of the rotated W×H — canvas grows so corners | |
| // don't clip. | |
| const finalW = Math.max(1, Math.round(w * cos + h * sin)); | |
| const finalH = Math.max(1, Math.round(w * sin + h * cos)); | |
| const tmp = document.createElement('canvas'); | |
| tmp.width = finalW; tmp.height = finalH; | |
| const tCtx = tmp.getContext('2d'); | |
| tCtx.imageSmoothingEnabled = true; | |
| tCtx.imageSmoothingQuality = 'high'; | |
| tCtx.save(); | |
| tCtx.translate(finalW / 2, finalH / 2); | |
| if (rotDeg) tCtx.rotate(rotRad); | |
| tCtx.scale(state.transformPendingFlipH ? -1 : 1, state.transformPendingFlipV ? -1 : 1); | |
| tCtx.drawImage(state.transformOrigCanvas, -w / 2, -h / 2, w, h); | |
| tCtx.restore(); | |
| layer.canvas.width = finalW; | |
| layer.canvas.height = finalH; | |
| layer.ctx.clearRect(0, 0, finalW, finalH); | |
| layer.ctx.drawImage(tmp, 0, 0); | |
| // Recenter the layer so the rotation pivot stays put visually. | |
| const origCenterX = state.transformOrigOffset.x + state.transformOrigW / 2; | |
| const origCenterY = state.transformOrigOffset.y + state.transformOrigH / 2; | |
| state.layerOffsets.set(layer.id, { | |
| x: Math.round(origCenterX - finalW / 2), | |
| y: Math.round(origCenterY - finalH / 2), | |
| }); | |
| composite(); | |
| drawTransformHandles(); | |
| } | |
| function confirmTransform() { | |
| closeTransformPopup(); | |
| state.transformOrigCanvas = null; | |
| state.transformOrigOffset = null; | |
| state.transformActive = false; | |
| state.transformLayer = null; | |
| state.transformHandle = null; | |
| composite(); | |
| uiModule.showToast('Transform applied'); | |
| } | |
| function cancelTransform() { | |
| closeTransformPopup(); | |
| state.transformOrigCanvas = null; | |
| state.transformOrigOffset = null; | |
| if (state.transformLayer) undo(); // restore saved state | |
| state.transformActive = false; | |
| state.transformLayer = null; | |
| state.transformHandle = null; | |
| composite(); | |
| } | |
| return { | |
| startTransform, openTransformPopup, closeTransformPopup, | |
| reapplyTransform, confirmTransform, cancelTransform, | |
| }; | |
| } | |