/** * Whole-document transforms: rotate by 90/180/270° or flip horizontal/ * vertical. These mutate every layer's canvas + the offset map + the * document's overall width/height so the result feels like the whole * image rotated as one piece. * * Pure-ish — reads/writes shared state directly; the factory takes a * small dep bag for the orchestration plumbing (undo snapshot, canvas * loading overlay, fit-zoom-to-viewport, composite redraw). * * @param {{ * saveState: (label?: string) => void, * composite: () => void, * fitZoom: () => void, * showCanvasLoading: (label: string) => void, * hideCanvasLoading: () => void, * }} deps */ import { state } from './state.js'; export function createCanvasTransforms({ saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading }) { return { /** * Rotate the entire document by `deg` (90 / 180 / 270). 90 and 270 * swap canvas dimensions. Each layer is rotated around its own * centre, then its centre is rotated around the old image centre * and translated into the new image's frame. * * Wrapped in requestAnimationFrame because the rotation pass can * block the UI for 0.5–2 s on big images — the spinner overlay * paints before we block. */ rotateAll(deg) { if (!state.layers.length) return; saveState(`Rotate ${deg}°`); showCanvasLoading('Rotating…'); const oldW = state.imgWidth, oldH = state.imgHeight; const swap = (deg === 90 || deg === 270); const newW = swap ? oldH : oldW; const newH = swap ? oldW : oldH; const rad = (deg * Math.PI) / 180; const cos = Math.cos(rad), sin = Math.sin(rad); requestAnimationFrame(() => { try { for (const layer of state.layers) { const lw = layer.canvas.width, lh = layer.canvas.height; const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; // Layer centre in old image coords. const cx = off.x + lw / 2; const cy = off.y + lh / 2; // Rotate the centre around the old image centre and // translate so the new image centre lands at (newW/2, newH/2). const dx = cx - oldW / 2; const dy = cy - oldH / 2; const nx = dx * cos - dy * sin + newW / 2; const ny = dx * sin + dy * cos + newH / 2; // New per-layer dims: swap when 90/270. const newLw = swap ? lh : lw; const newLh = swap ? lw : lh; const tmp = document.createElement('canvas'); tmp.width = newLw; tmp.height = newLh; const tctx = tmp.getContext('2d'); tctx.translate(newLw / 2, newLh / 2); tctx.rotate(rad); tctx.drawImage(layer.canvas, -lw / 2, -lh / 2); layer.canvas.width = newLw; layer.canvas.height = newLh; layer.ctx.drawImage(tmp, 0, 0); // The adjustment-render caches are keyed only by the adjustment // signature, which rotation doesn't change — so composite would draw // the STALE pre-rotation cache (the "had to click twice" bug). Drop // them so the next composite re-renders from the rotated canvas. layer._adjCacheKey = null; layer._adjFinalKey = null; state.layerOffsets.set(layer.id, { x: Math.round(nx - newLw / 2), y: Math.round(ny - newLh / 2), }); } state.imgWidth = newW; state.imgHeight = newH; state.mainCanvas.width = newW; state.mainCanvas.height = newH; if (state.maskCanvas) { state.maskCanvas.width = newW; state.maskCanvas.height = newH; } const sizeLabel = document.getElementById('ge-canvas-size'); if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`; fitZoom(); composite(); } finally { hideCanvasLoading(); } }); }, /** * Mirror every layer horizontally ('h') or vertically ('v'). * Canvas dimensions don't change. Each layer offset is reflected * around the image centre. */ flipAll(axis) { if (!state.layers.length) return; saveState(axis === 'h' ? 'Flip horizontal' : 'Flip vertical'); for (const layer of state.layers) { const lw = layer.canvas.width, lh = layer.canvas.height; const tmp = document.createElement('canvas'); tmp.width = lw; tmp.height = lh; const tctx = tmp.getContext('2d'); tctx.save(); if (axis === 'h') { tctx.translate(lw, 0); tctx.scale(-1, 1); } else { tctx.translate(0, lh); tctx.scale(1, -1); } tctx.drawImage(layer.canvas, 0, 0); tctx.restore(); layer.ctx.clearRect(0, 0, lw, lh); layer.ctx.drawImage(tmp, 0, 0); // Invalidate the adjustment-render caches (keyed by adjustment sig only) // so composite redraws from the flipped canvas, not a stale cache. layer._adjCacheKey = null; layer._adjFinalKey = null; const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; if (axis === 'h') { state.layerOffsets.set(layer.id, { x: state.imgWidth - off.x - lw, y: off.y }); } else { state.layerOffsets.set(layer.id, { x: off.x, y: state.imgHeight - off.y - lh }); } } composite(); }, }; }