mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-27 18:42:20 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -23,31 +23,134 @@
|
||||
* hideCanvasLoading: () => void,
|
||||
* undo: () => void,
|
||||
* uiModule: object | null,
|
||||
* syncGeometryControls?: () => void,
|
||||
* getDocumentSelection?: () => HTMLCanvasElement | null,
|
||||
* syncSelectionUi?: () => void,
|
||||
* schedulePersist?: () => void,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{
|
||||
* startTransform, openTransformPopup, closeTransformPopup,
|
||||
* reapplyTransform, confirmTransform, cancelTransform,
|
||||
* 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();
|
||||
if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; }
|
||||
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.transformOrigW = layer.canvas.width;
|
||||
state.transformOrigH = layer.canvas.height;
|
||||
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;
|
||||
@@ -56,12 +159,16 @@ export function createTransformSession({
|
||||
// Snapshot the layer so live preview can re-derive from the
|
||||
// original pixels on every keystroke instead of stacking
|
||||
// destructive edits.
|
||||
state.transformOrigCanvas = document.createElement('canvas');
|
||||
state.transformOrigCanvas.width = state.transformOrigW;
|
||||
state.transformOrigCanvas.height = state.transformOrigH;
|
||||
state.transformOrigCanvas.getContext('2d').drawImage(layer.canvas, 0, 0);
|
||||
state.transformOrigOffset = { ...(state.layerOffsets.get(layer.id) || { x: 0, y: 0 }) };
|
||||
saveState();
|
||||
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.
|
||||
@@ -69,6 +176,48 @@ export function createTransformSession({
|
||||
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() {
|
||||
@@ -85,19 +234,39 @@ export function createTransformSession({
|
||||
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');
|
||||
wInput.value = String(state.transformOrigW);
|
||||
hInput.value = String(state.transformOrigH);
|
||||
rotInput.value = '0';
|
||||
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');
|
||||
|
||||
@@ -125,6 +294,8 @@ export function createTransformSession({
|
||||
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);
|
||||
@@ -132,8 +303,11 @@ export function createTransformSession({
|
||||
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';
|
||||
@@ -310,72 +484,265 @@ export function createTransformSession({
|
||||
document.addEventListener('touchcancel', endDrag);
|
||||
}
|
||||
|
||||
// Re-derive the active layer's pixels from the original snapshot
|
||||
// with the popup's current W/H/flip/rotation applied. Cheap —
|
||||
// paints into an off-screen canvas of the final size.
|
||||
function 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() {
|
||||
const layer = state.transformLayer;
|
||||
if (!layer || !state.transformOrigCanvas) return;
|
||||
const w = state.transformPendingW;
|
||||
const h = state.transformPendingH;
|
||||
const rotDeg = state.transformPendingRot;
|
||||
const rotRad = (rotDeg * Math.PI) / 180;
|
||||
const cos = Math.abs(Math.cos(rotRad));
|
||||
const sin = Math.abs(Math.sin(rotRad));
|
||||
// Bounding box of the rotated W×H — canvas grows so corners
|
||||
// don't clip.
|
||||
const finalW = Math.max(1, Math.round(w * cos + h * sin));
|
||||
const finalH = Math.max(1, Math.round(w * sin + h * cos));
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = finalW; tmp.height = finalH;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
tCtx.imageSmoothingEnabled = true;
|
||||
tCtx.imageSmoothingQuality = 'high';
|
||||
tCtx.save();
|
||||
tCtx.translate(finalW / 2, finalH / 2);
|
||||
if (rotDeg) tCtx.rotate(rotRad);
|
||||
tCtx.scale(state.transformPendingFlipH ? -1 : 1, state.transformPendingFlipV ? -1 : 1);
|
||||
tCtx.drawImage(state.transformOrigCanvas, -w / 2, -h / 2, w, h);
|
||||
tCtx.restore();
|
||||
layer.canvas.width = finalW;
|
||||
layer.canvas.height = finalH;
|
||||
layer.ctx.clearRect(0, 0, finalW, finalH);
|
||||
layer.ctx.drawImage(tmp, 0, 0);
|
||||
// Recenter the layer so the rotation pivot stays put visually.
|
||||
const origCenterX = state.transformOrigOffset.x + state.transformOrigW / 2;
|
||||
const origCenterY = state.transformOrigOffset.y + state.transformOrigH / 2;
|
||||
state.layerOffsets.set(layer.id, {
|
||||
x: Math.round(origCenterX - finalW / 2),
|
||||
y: Math.round(origCenterY - finalH / 2),
|
||||
});
|
||||
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;
|
||||
composite();
|
||||
uiModule.showToast('Transform applied');
|
||||
state.hoveredHandle = null;
|
||||
state.transformSessionStartW = 0;
|
||||
state.transformSessionStartH = 0;
|
||||
state.transformStartRotation = 0;
|
||||
state.transformStartFlipH = false;
|
||||
state.transformStartFlipV = false;
|
||||
lastValidLayerTarget = null;
|
||||
lastTransformLimitMessage = '';
|
||||
}
|
||||
|
||||
function cancelTransform() {
|
||||
closeTransformPopup();
|
||||
state.transformOrigCanvas = null;
|
||||
state.transformOrigOffset = null;
|
||||
if (state.transformLayer) undo(); // restore saved state
|
||||
state.transformActive = false;
|
||||
state.transformLayer = null;
|
||||
state.transformHandle = null;
|
||||
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, openTransformPopup, closeTransformPopup,
|
||||
reapplyTransform, confirmTransform, cancelTransform,
|
||||
startTransform, startSelectionTransform, openTransformPopup, closeTransformPopup,
|
||||
reapplyTransform, nudgeTransform, confirmTransform, cancelTransform,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user