/**
* 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,
* syncGeometryControls?: () => void,
* getDocumentSelection?: () => HTMLCanvasElement | null,
* syncSelectionUi?: () => void,
* schedulePersist?: () => void,
* }} deps
*
* @returns {{
* startTransform, openTransformPopup, closeTransformPopup,
* reapplyTransform, nudgeTransform, confirmTransform, cancelTransform,
* }}
*/
import { state } from '../state.js';
import { normalizeTextData, renderTextLayer } from '../text-layer.js';
import { normalizeShapeData, renderShapeLayer } from '../shape-layer.js';
import { isLayerPositionLocked } from '../layer-groups.js';
import { selectedLayers, selectionLabel } from '../layer-selection.js';
import {
planLayerTransform,
selectionBounds,
transformedSelectionBounds,
} from '../multi-transform.js';
import {
EDITOR_MAX_DIMENSION,
EDITOR_MAX_SURFACE_PIXELS,
} from '../document-codec.js';
import {
transformPopupHTML,
attachSpinRepeat,
} from '../build/transform-popup.js';
import {
selectionMaskBounds,
transformSelectionMask,
} from '../selection-mask.js';
import {
clonePlacedData,
createPlacedData,
frameTransformMatrix,
renderPlacedLayer,
transformPlacedData,
} from '../placed-layer.js';
import {
isPerspectiveActive,
keystoneCorners,
perspectiveWarpCanvas,
} from '../perspective-warp.js';
export function createTransformSession({
activeLayer, saveState, composite, fitZoom, drawTransformHandles,
showCanvasLoading, hideCanvasLoading, undo, uiModule,
syncGeometryControls, getDocumentSelection, syncSelectionUi, schedulePersist,
}) {
let lastValidLayerTarget = null;
let lastTransformLimitMessage = '';
const formatTransformValue = value => {
const number = Number(value) || 0;
return Number.isInteger(number) ? String(number) : String(Number(number.toFixed(2)));
};
function syncTransformPopupValues(force = false) {
const pop = state.transformPopup;
if (!pop || !state.transformCenter) return;
const values = {
'#ge-transform-x': state.transformCenter.x,
'#ge-transform-y': state.transformCenter.y,
'#ge-transform-w': state.transformPendingFlipH ? -state.transformPendingW : state.transformPendingW,
'#ge-transform-h': state.transformPendingFlipV ? -state.transformPendingH : state.transformPendingH,
'#ge-transform-rot': state.transformPendingRot,
'#ge-transform-skew-x': state.transformPendingSkewX,
'#ge-transform-skew-y': state.transformPendingSkewY,
'#ge-transform-persp-x': state.transformPendingPerspX,
'#ge-transform-persp-y': state.transformPendingPerspY,
};
for (const [selector, value] of Object.entries(values)) {
const input = pop.querySelector(selector);
if (input && (force || document.activeElement !== input)) input.value = formatTransformValue(value);
}
}
function copyCanvas(source) {
const canvas = document.createElement('canvas');
canvas.width = source.width;
canvas.height = source.height;
canvas.getContext('2d').drawImage(source, 0, 0);
return canvas;
}
function startTransform() {
const layer = activeLayer();
const layers = selectedLayers(state);
if (!layer || !layers.length) { uiModule.showToast('Select an unlocked layer'); return; }
if (layers.some(item => isLayerPositionLocked(state, item))) {
uiModule.showToast(layers.length > 1 ? 'Unlock the position of every selected layer before transforming' : 'Unlock the layer position before transforming');
return;
}
if (state.transformActive) { cancelTransform(); return; } // toggle off
const bounds = selectionBounds(state, layers);
state.transformActive = true;
state.transformTarget = 'layers';
state.transformLayer = layer;
state.transformLayers = [...layers];
state.transformItems = layers.map(item => ({
layer: item,
kind: item.kind || 'raster',
canvas: copyCanvas(item.canvas),
width: item.canvas.width,
height: item.canvas.height,
offset: { ...(state.layerOffsets.get(item.id) || { x: 0, y: 0 }) },
wasPlaced: item.kind === 'placed',
placed: item.kind === 'placed'
? clonePlacedData(item.placed)
: (item.kind === 'raster' ? createPlacedData(item.canvas, [1, 0, 0, 1, 0, 0], item.name || 'Raster layer') : null),
masks: (item.masks || [])
.filter(mask => (mask.space || 'layer') === 'layer' && mask.canvas)
.map(mask => {
const maskOffset = {
x: Number(mask.offset?.x) || 0,
y: Number(mask.offset?.y) || 0,
};
const layerOffset = state.layerOffsets.get(item.id) || { x: 0, y: 0 };
return {
mask,
canvas: copyCanvas(mask.canvas),
linked: mask.linked !== false,
offset: maskOffset,
documentOffset: {
x: layerOffset.x + maskOffset.x,
y: layerOffset.y + maskOffset.y,
},
};
}),
}));
const activeSnapshot = state.transformItems.find(item => item.layer.id === layer.id);
state.transformOrigW = bounds.width;
state.transformOrigH = bounds.height;
state.transformSessionStartW = bounds.width;
state.transformSessionStartH = bounds.height;
state.transformPendingW = state.transformOrigW;
state.transformPendingH = state.transformOrigH;
state.transformPendingRot = 0;
state.transformPendingFlipH = false;
state.transformPendingFlipV = false;
state.transformPendingSkewX = 0;
state.transformPendingSkewY = 0;
state.transformPendingPerspX = 0;
state.transformPendingPerspY = 0;
state.transformPendingDistort = null;
// Snapshot the layer so live preview can re-derive from the
// original pixels on every keystroke instead of stacking
// destructive edits.
state.transformOrigCanvas = activeSnapshot?.canvas || null;
state.transformOrigMasks = activeSnapshot?.masks || [];
state.transformOrigOffset = { ...(activeSnapshot?.offset || { x: 0, y: 0 }) };
state.transformSelectionBounds = { ...bounds };
state.transformBounds = { ...bounds };
state.transformCenter = { x: bounds.centerX, y: bounds.centerY };
state.transformStartCenter = { ...state.transformCenter };
lastValidLayerTarget = currentTransformTarget();
lastTransformLimitMessage = '';
saveState(`Transform ${selectionLabel(state, `"${layer.name || 'layer'}"`)}`);
// 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();
syncGeometryControls?.();
}
function startSelectionTransform() {
if (state.transformActive) cancelTransform();
const selection = getDocumentSelection?.();
const bounds = selectionMaskBounds(selection);
if (!selection || !bounds) {
uiModule?.showToast('Make a selection before transforming it');
return false;
}
const frame = {
...bounds,
centerX: bounds.x + bounds.width / 2,
centerY: bounds.y + bounds.height / 2,
};
state.transformActive = true;
state.transformTarget = 'selection';
state.transformLayer = null;
state.transformLayers = [];
state.transformItems = [];
state.transformSelectionCanvas = copyCanvas(selection);
state.transformSelectionBounds = { ...frame };
state.transformBounds = { ...frame };
state.transformCenter = { x: frame.centerX, y: frame.centerY };
state.transformStartCenter = { ...state.transformCenter };
state.transformOrigW = frame.width;
state.transformOrigH = frame.height;
state.transformSessionStartW = frame.width;
state.transformSessionStartH = frame.height;
state.transformPendingW = frame.width;
state.transformPendingH = frame.height;
state.transformPendingRot = 0;
state.transformPendingFlipH = false;
state.transformPendingFlipV = false;
saveState('Transform selection');
try { fitZoom(); } catch {}
composite();
drawTransformHandles();
openTransformPopup();
syncSelectionUi?.();
return true;
}
function closeTransformPopup() {
if (mobileBoundsHandler) {
window.removeEventListener('resize', mobileBoundsHandler);
mobileBoundsHandler = null;
}
if (state.transformPopup) {
try {
state.transformPopup.innerHTML = '';
state.transformPopup.style.display = 'none';
} catch {}
state.transformPopup = null;
}
}
let mobileBoundsHandler = null;
// On phones the Transform popup docks at the TOP of the viewport
// (see the mobile stylesheet), where its full content height would
// otherwise reach down over the transform frame and swallow the
// touch targets for the top/left/right grab handles. Cap its height
// so its bottom edge stays clear of the canvas top; the body then
// scrolls. Desktop keeps the anchored layout untouched.
function applyMobileTransformBounds(pop) {
if (!pop || !window.matchMedia('(max-width: 820px)').matches) return;
const canvas = state.mainCanvas;
if (!canvas) return;
const POP_TOP = 32; // keep in sync with the mobile stylesheet
const GAP = 12;
const canvasTop = canvas.getBoundingClientRect().top;
const available = Math.round(canvasTop - POP_TOP - GAP);
const cap = available > 140 ? available : Math.round(window.innerHeight * 0.42);
pop.style.setProperty('min-height', '0', 'important');
pop.style.setProperty('max-height', cap + 'px', 'important');
}
// 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;
// Global tours and toasts sit above tool popups and can cover the compact
// mobile header or Apply/Cancel controls. Starting a new edit is a stronger
// signal than those transient messages, so clear them first.
document.querySelector('.tour-hint .tour-hint-dismiss')?.click();
document.querySelector('#toast.show .toast-close-btn')?.click();
// The transform controls live inline in the editor options strip (header
// bar) instead of a floating popup over the canvas.
const pop = document.getElementById('ge-transform-section');
if (!pop) return;
pop.innerHTML = `
`;
const title = pop.querySelector('.ge-adj-title');
if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection';
else if (title && state.transformLayers.length > 1) title.textContent = `Transform ${state.transformLayers.length} layers`;
pop.style.display = '';
state.transformPopup = pop;
const wInput = pop.querySelector('#ge-transform-w');
const hInput = pop.querySelector('#ge-transform-h');
const rotInput = pop.querySelector('#ge-transform-rot');
const xInput = pop.querySelector('#ge-transform-x');
const yInput = pop.querySelector('#ge-transform-y');
const skewXInput = pop.querySelector('#ge-transform-skew-x');
const skewYInput = pop.querySelector('#ge-transform-skew-y');
const perspXInput = pop.querySelector('#ge-transform-persp-x');
const perspYInput = pop.querySelector('#ge-transform-persp-y');
const aspectBtn = pop.querySelector('#ge-transform-aspect');
const preserveSource = pop.querySelector('#ge-transform-preserve-source');
const preserveField = pop.querySelector('.ge-transform-preserve-field');
const canPreserveSource = state.transformTarget !== 'selection'
&& state.transformItems.some(item => item.layer?.kind === 'raster' || item.wasPlaced);
if (preserveField) preserveField.hidden = !canPreserveSource;
if (preserveSource) {
preserveSource.checked = state.transformPreserveSource !== false;
preserveSource.addEventListener('change', () => {
state.transformPreserveSource = preserveSource.checked;
reapplyTransform();
});
}
syncTransformPopupValues();
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;
const x = Number.parseFloat(xInput.value);
const y = Number.parseFloat(yInput.value);
const skewX = Number.parseFloat(skewXInput?.value) || 0;
const skewY = Number.parseFloat(skewYInput?.value) || 0;
const perspX = Number.parseFloat(perspXInput?.value) || 0;
const perspY = Number.parseFloat(perspYInput?.value) || 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;
state.transformPendingSkewX = skewX;
state.transformPendingSkewY = skewY;
state.transformPendingPerspX = perspX;
state.transformPendingPerspY = perspY;
if (Number.isFinite(x) && Number.isFinite(y)) state.transformCenter = { x, y };
reapplyTransform();
};
xInput.addEventListener('input', refresh);
yInput.addEventListener('input', refresh);
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);
skewXInput?.addEventListener('input', refresh);
skewYInput?.addEventListener('input', refresh);
perspXInput?.addEventListener('input', refresh);
perspYInput?.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);
}
function renderSnapshot(source, snapshot, geometry) {
const canvas = document.createElement('canvas');
canvas.width = geometry.width;
canvas.height = geometry.height;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
if (geometry.rotation) ctx.rotate(geometry.radians);
if (geometry.distort && geometry.quad) {
// Free distort: map the quad into the output bounds and warp.
const { points, minX, minY, quadW, quadH } = geometry.quad;
const normalized = points.map(point => ({
x: (point.x - minX) / quadW,
y: (point.y - minY) / quadH,
}));
const warped = perspectiveWarpCanvas(source, normalized, canvas.width, canvas.height);
ctx.scale(Math.sign(geometry.signedScaleX) || 1, Math.sign(geometry.signedScaleY) || 1);
ctx.drawImage(warped, -warped.width / 2, -warped.height / 2);
ctx.restore();
return canvas;
}
if (isPerspectiveActive(geometry.perspectiveX, geometry.perspectiveY)) {
const warped = perspectiveWarpCanvas(source, keystoneCorners(geometry.perspectiveX, geometry.perspectiveY), canvas.width, canvas.height);
ctx.scale(Math.sign(geometry.signedScaleX) || 1, Math.sign(geometry.signedScaleY) || 1);
ctx.drawImage(warped, -warped.width / 2, -warped.height / 2);
ctx.restore();
return canvas;
}
if (geometry.skewX || geometry.skewY) ctx.transform(1, Math.tan((geometry.skewY || 0) * Math.PI / 180), Math.tan((geometry.skewX || 0) * Math.PI / 180), 1, 0, 0);
ctx.scale(geometry.signedScaleX, geometry.signedScaleY);
ctx.drawImage(source, -snapshot.width / 2, -snapshot.height / 2, snapshot.width, snapshot.height);
ctx.restore();
return canvas;
}
function currentTransformTarget() {
return {
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
flipH: state.transformPendingFlipH,
flipV: state.transformPendingFlipV,
skewX: state.transformPendingSkewX,
skewY: state.transformPendingSkewY,
perspectiveX: state.transformPendingPerspX,
perspectiveY: state.transformPendingPerspY,
distort: state.transformPendingDistort,
centerX: state.transformCenter?.x,
centerY: state.transformCenter?.y,
};
}
function restoreTransformTarget(target) {
if (!target) return;
state.transformPendingW = target.width;
state.transformPendingH = target.height;
state.transformPendingRot = target.rotation;
state.transformPendingFlipH = target.flipH;
state.transformPendingFlipV = target.flipV;
state.transformPendingSkewX = target.skewX || 0;
state.transformPendingSkewY = target.skewY || 0;
state.transformPendingPerspX = target.perspectiveX || 0;
state.transformPendingPerspY = target.perspectiveY || 0;
state.transformPendingDistort = target.distort || null;
state.transformCenter = { x: target.centerX, y: target.centerY };
}
// Re-derive every selected layer from immutable per-session snapshots.
function reapplyTransform() {
if (state.transformTarget === 'selection') {
if (!state.transformSelectionCanvas || !state.transformSelectionBounds || !state.transformCenter) return;
const target = {
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
flipH: state.transformPendingFlipH,
flipV: state.transformPendingFlipV,
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
};
state.wandMask = transformSelectionMask(
state.transformSelectionCanvas,
state.transformSelectionBounds,
target,
state.imgWidth,
state.imgHeight,
);
state.wandLayerId = state.activeLayerId;
state.wandMaskSpace = 'document';
state.selectionSource = 'transform';
state.wandLastSeed = null;
state.lassoPoints = [];
state.lassoActive = false;
state.transformBounds = transformedSelectionBounds(state.transformSelectionBounds, target);
composite();
syncTransformPopupValues();
syncSelectionUi?.();
drawTransformHandles();
return;
}
if (!state.transformItems.length || !state.transformSelectionBounds || !state.transformCenter) return;
const target = currentTransformTarget();
const plan = planLayerTransform(
state,
state.transformItems,
state.transformSelectionBounds,
target,
{ maxDimension: EDITOR_MAX_DIMENSION, maxSurfacePixels: EDITOR_MAX_SURFACE_PIXELS },
);
if (!plan.ok) {
restoreTransformTarget(lastValidLayerTarget);
syncTransformPopupValues(true);
if (lastTransformLimitMessage !== plan.reason) {
uiModule?.showToast(plan.reason, 5000);
lastTransformLimitMessage = plan.reason;
}
composite();
drawTransformHandles();
return false;
}
lastValidLayerTarget = { ...target };
lastTransformLimitMessage = '';
const documentTransform = frameTransformMatrix(state.transformSelectionBounds, target);
for (const { snapshot, geometry: plannedGeometry } of plan.items) {
const layer = snapshot.layer;
let geometry = plannedGeometry;
// A free-distort quad is a non-affine warp that placed-layer data cannot
// express, so bake it into the raster instead of the placed source.
const usePlaced = snapshot.placed
&& (snapshot.wasPlaced || state.transformPreserveSource !== false)
&& !plannedGeometry.distort;
if (usePlaced) {
layer.placed = transformPlacedData(snapshot.placed, documentTransform);
const rendered = renderPlacedLayer(layer);
geometry = {
...plannedGeometry,
width: rendered.canvas.width,
height: rendered.canvas.height,
offset: rendered.offset,
};
} else {
if (snapshot.kind === 'raster' || plannedGeometry.distort) {
layer.kind = 'raster';
layer.placed = null;
}
const output = renderSnapshot(snapshot.canvas, snapshot, geometry);
layer.canvas.width = geometry.width;
layer.canvas.height = geometry.height;
layer.ctx = layer.canvas.getContext('2d');
layer.ctx.drawImage(output, 0, 0);
}
layer._adjFinal = null;
layer._adjFinalKey = null;
layer._adjCache = null;
layer._adjCacheKey = null;
state.layerOffsets.set(layer.id, geometry.offset);
for (const maskSnapshot of snapshot.masks) {
if (maskSnapshot.linked) {
const positioned = document.createElement('canvas');
positioned.width = snapshot.width;
positioned.height = snapshot.height;
positioned.getContext('2d').drawImage(
maskSnapshot.canvas,
maskSnapshot.offset.x,
maskSnapshot.offset.y,
);
const maskOutput = renderSnapshot(positioned, snapshot, geometry);
maskSnapshot.mask.canvas.width = geometry.width;
maskSnapshot.mask.canvas.height = geometry.height;
maskSnapshot.mask.ctx = maskSnapshot.mask.canvas.getContext('2d');
maskSnapshot.mask.ctx.drawImage(maskOutput, 0, 0);
maskSnapshot.mask.offset = { x: 0, y: 0 };
} else {
// Rebuild from the immutable snapshot so every preview remains
// cancel-safe, then compensate for the layer's new document offset.
maskSnapshot.mask.canvas.width = maskSnapshot.canvas.width;
maskSnapshot.mask.canvas.height = maskSnapshot.canvas.height;
maskSnapshot.mask.ctx = maskSnapshot.mask.canvas.getContext('2d');
maskSnapshot.mask.ctx.drawImage(maskSnapshot.canvas, 0, 0);
maskSnapshot.mask.offset = {
x: maskSnapshot.documentOffset.x - geometry.offset.x,
y: maskSnapshot.documentOffset.y - geometry.offset.y,
};
}
}
}
state.transformBounds = transformedSelectionBounds(state.transformSelectionBounds, target);
composite();
syncTransformPopupValues();
syncGeometryControls?.();
drawTransformHandles();
return true;
}
function confirmTransform() {
closeTransformPopup();
const target = state.transformTarget;
if (target === 'selection') {
clearTransformState();
composite();
syncSelectionUi?.();
schedulePersist?.();
uiModule?.showToast('Selection transformed');
return;
}
for (const snapshot of state.transformItems) {
const layer = snapshot.layer;
if (!layer || !['text', 'shape'].includes(layer.kind)) continue;
const offset = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const center = { x: offset.x + layer.canvas.width / 2, y: offset.y + layer.canvas.height / 2 };
const retained = layer.kind === 'text' ? normalizeTextData(layer.text) : normalizeShapeData(layer.shape);
retained.transform.scaleX *= state.transformPendingW / Math.max(1, state.transformSessionStartW);
retained.transform.scaleY *= state.transformPendingH / Math.max(1, state.transformSessionStartH);
retained.transform.rotation += state.transformPendingRot;
if (state.transformPendingFlipH) retained.transform.flipH = !retained.transform.flipH;
if (state.transformPendingFlipV) retained.transform.flipV = !retained.transform.flipV;
if (layer.kind === 'text') {
layer.text = retained;
renderTextLayer(layer);
} else {
layer.shape = retained;
renderShapeLayer(layer);
}
state.layerOffsets.set(layer.id, {
x: Math.round(center.x - layer.canvas.width / 2),
y: Math.round(center.y - layer.canvas.height / 2),
});
}
const count = state.transformItems.length;
clearTransformState();
composite();
syncGeometryControls?.();
schedulePersist?.();
uiModule.showToast(count > 1 ? `${count} layers transformed` : 'Transform applied');
}
function nudgeTransform(dx, dy) {
if (!state.transformActive || !state.transformCenter) return false;
state.transformCenter = {
x: state.transformCenter.x + Number(dx || 0),
y: state.transformCenter.y + Number(dy || 0),
};
reapplyTransform();
return true;
}
function clearTransformState() {
state.transformOrigCanvas = null;
state.transformOrigMasks = [];
state.transformOrigOffset = null;
state.transformSelectionCanvas = null;
state.transformLayers = [];
state.transformItems = [];
state.transformSelectionBounds = null;
state.transformBounds = null;
state.transformCenter = null;
state.transformStartCenter = null;
state.transformOrigW = 0;
state.transformOrigH = 0;
state.transformPendingW = 0;
state.transformPendingH = 0;
state.transformPendingRot = 0;
state.transformPendingFlipH = false;
state.transformPendingFlipV = false;
state.transformPendingSkewX = 0;
state.transformPendingSkewY = 0;
state.transformPendingPerspX = 0;
state.transformPendingPerspY = 0;
state.transformPendingDistort = null;
state.transformActive = false;
state.transformTarget = null;
state.transformLayer = null;
state.transformHandle = null;
state.hoveredHandle = null;
state.transformSessionStartW = 0;
state.transformSessionStartH = 0;
state.transformStartRotation = 0;
state.transformStartFlipH = false;
state.transformStartFlipV = false;
lastValidLayerTarget = null;
lastTransformLimitMessage = '';
}
function cancelTransform() {
closeTransformPopup();
const hadTransform = state.transformActive;
if (hadTransform) {
undo(); // restore the one pre-session snapshot
state.redoStack = []; // Cancel must not expose the discarded preview via Redo.
}
clearTransformState();
composite();
syncGeometryControls?.();
syncSelectionUi?.();
}
return {
startTransform, startSelectionTransform, openTransformPopup, closeTransformPopup,
reapplyTransform, nudgeTransform, confirmTransform, cancelTransform,
};
}