Files
odysseus/static/js/editor/tools/transform-session.js
T

749 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
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,
};
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;
// 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 (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;
// 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();
const pop = document.createElement('div');
pop.className = 'ge-transform-popup';
pop.innerHTML = transformPopupHTML();
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`;
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 xInput = pop.querySelector('#ge-transform-x');
const yInput = pop.querySelector('#ge-transform-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);
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;
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);
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.52 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);
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,
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.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;
if (snapshot.placed && (snapshot.wasPlaced || state.transformPreserveSource !== false)) {
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') {
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.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,
};
}