Squash Odysseus development history

This commit is contained in:
pewdiepie-archdaemon
2026-09-11 06:04:19 +00:00
parent e5c99a5eee
commit 6ee6502010
2050 changed files with 538359 additions and 57745 deletions
+427 -60
View File
@@ -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,
};
}