mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-14 20:22:22 +02:00
749 lines
30 KiB
JavaScript
749 lines
30 KiB
JavaScript
/**
|
||
* 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.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);
|
||
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,
|
||
};
|
||
}
|