feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)

Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pewdiepie-archdaemon
2026-10-15 11:57:35 +00:00
co-authored by Claude Opus 5.5
parent c74f475679
commit 5fac693c1c
290 changed files with 34358 additions and 766 deletions
@@ -63,6 +63,11 @@ import {
renderPlacedLayer,
transformPlacedData,
} from '../placed-layer.js';
import {
isPerspectiveActive,
keystoneCorners,
perspectiveWarpCanvas,
} from '../perspective-warp.js';
export function createTransformSession({
activeLayer, saveState, composite, fitZoom, drawTransformHandles,
@@ -86,6 +91,10 @@ export function createTransformSession({
'#ge-transform-w': state.transformPendingFlipH ? -state.transformPendingW : state.transformPendingW,
'#ge-transform-h': state.transformPendingFlipV ? -state.transformPendingH : state.transformPendingH,
'#ge-transform-rot': state.transformPendingRot,
'#ge-transform-skew-x': state.transformPendingSkewX,
'#ge-transform-skew-y': state.transformPendingSkewY,
'#ge-transform-persp-x': state.transformPendingPerspX,
'#ge-transform-persp-y': state.transformPendingPerspY,
};
for (const [selector, value] of Object.entries(values)) {
const input = pop.querySelector(selector);
@@ -156,6 +165,10 @@ export function createTransformSession({
state.transformPendingRot = 0;
state.transformPendingFlipH = false;
state.transformPendingFlipV = false;
state.transformPendingSkewX = 0;
state.transformPendingSkewY = 0;
state.transformPendingPerspX = 0;
state.transformPendingPerspY = 0;
// Snapshot the layer so live preview can re-derive from the
// original pixels on every keystroke instead of stacking
// destructive edits.
@@ -221,12 +234,37 @@ export function createTransformSession({
}
function closeTransformPopup() {
if (mobileBoundsHandler) {
window.removeEventListener('resize', mobileBoundsHandler);
mobileBoundsHandler = null;
}
if (state.transformPopup) {
try { state.transformPopup.remove(); } catch {}
state.transformPopup = null;
}
}
let mobileBoundsHandler = null;
// On phones the Transform popup docks at the TOP of the viewport
// (see the mobile stylesheet), where its full content height would
// otherwise reach down over the transform frame and swallow the
// touch targets for the top/left/right grab handles. Cap its height
// so its bottom edge stays clear of the canvas top; the body then
// scrolls. Desktop keeps the anchored layout untouched.
function applyMobileTransformBounds(pop) {
if (!pop || !window.matchMedia('(max-width: 820px)').matches) return;
const canvas = state.mainCanvas;
if (!canvas) return;
const POP_TOP = 32; // keep in sync with the mobile stylesheet
const GAP = 12;
const canvasTop = canvas.getBoundingClientRect().top;
const available = Math.round(canvasTop - POP_TOP - GAP);
const cap = available > 140 ? available : Math.round(window.innerHeight * 0.42);
pop.style.setProperty('min-height', '0', 'important');
pop.style.setProperty('max-height', cap + 'px', 'important');
}
// Floating Transform popup — horizontal layout, draggable via its
// header, anchored over the right panel (layers area) by default
// so it doesn't cover the canvas. Lets the user type exact W/H/Rot
@@ -253,6 +291,10 @@ export function createTransformSession({
const rotInput = pop.querySelector('#ge-transform-rot');
const xInput = pop.querySelector('#ge-transform-x');
const yInput = pop.querySelector('#ge-transform-y');
const skewXInput = pop.querySelector('#ge-transform-skew-x');
const skewYInput = pop.querySelector('#ge-transform-skew-y');
const perspXInput = pop.querySelector('#ge-transform-persp-x');
const perspYInput = pop.querySelector('#ge-transform-persp-y');
const aspectBtn = pop.querySelector('#ge-transform-aspect');
const preserveSource = pop.querySelector('#ge-transform-preserve-source');
const preserveField = pop.querySelector('.ge-transform-preserve-field');
@@ -296,6 +338,10 @@ export function createTransformSession({
const rot = parseInt(rotInput.value, 10) || 0;
const x = Number.parseFloat(xInput.value);
const y = Number.parseFloat(yInput.value);
const skewX = Number.parseFloat(skewXInput?.value) || 0;
const skewY = Number.parseFloat(skewYInput?.value) || 0;
const perspX = Number.parseFloat(perspXInput?.value) || 0;
const perspY = Number.parseFloat(perspYInput?.value) || 0;
state.transformPendingFlipH = w < 0;
state.transformPendingFlipV = h < 0;
w = Math.abs(w || state.transformOrigW);
@@ -303,6 +349,10 @@ export function createTransformSession({
state.transformPendingW = Math.max(1, w);
state.transformPendingH = Math.max(1, h);
state.transformPendingRot = rot;
state.transformPendingSkewX = skewX;
state.transformPendingSkewY = skewY;
state.transformPendingPerspX = perspX;
state.transformPendingPerspY = perspY;
if (Number.isFinite(x) && Number.isFinite(y)) state.transformCenter = { x, y };
reapplyTransform();
};
@@ -335,6 +385,10 @@ export function createTransformSession({
refresh();
});
rotInput.addEventListener('input', refresh);
skewXInput?.addEventListener('input', refresh);
skewYInput?.addEventListener('input', refresh);
perspXInput?.addEventListener('input', refresh);
perspYInput?.addEventListener('input', refresh);
aspectBtn.addEventListener('click', () => {
state.transformAspectLock = !state.transformAspectLock;
aspectBtn.classList.toggle('active', state.transformAspectLock);
@@ -384,6 +438,9 @@ export function createTransformSession({
});
});
attachSpinRepeat(pop);
applyMobileTransformBounds(pop);
mobileBoundsHandler = () => applyMobileTransformBounds(pop);
window.addEventListener('resize', mobileBoundsHandler);
}
// Header-drag for the Transform popup. Default position: over the
@@ -494,6 +551,14 @@ export function createTransformSession({
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
if (geometry.rotation) ctx.rotate(geometry.radians);
if (isPerspectiveActive(geometry.perspectiveX, geometry.perspectiveY)) {
const warped = perspectiveWarpCanvas(source, keystoneCorners(geometry.perspectiveX, geometry.perspectiveY), canvas.width, canvas.height);
ctx.scale(Math.sign(geometry.signedScaleX) || 1, Math.sign(geometry.signedScaleY) || 1);
ctx.drawImage(warped, -warped.width / 2, -warped.height / 2);
ctx.restore();
return canvas;
}
if (geometry.skewX || geometry.skewY) ctx.transform(1, Math.tan((geometry.skewY || 0) * Math.PI / 180), Math.tan((geometry.skewX || 0) * Math.PI / 180), 1, 0, 0);
ctx.scale(geometry.signedScaleX, geometry.signedScaleY);
ctx.drawImage(source, -snapshot.width / 2, -snapshot.height / 2, snapshot.width, snapshot.height);
ctx.restore();
@@ -507,6 +572,10 @@ export function createTransformSession({
rotation: state.transformPendingRot,
flipH: state.transformPendingFlipH,
flipV: state.transformPendingFlipV,
skewX: state.transformPendingSkewX,
skewY: state.transformPendingSkewY,
perspectiveX: state.transformPendingPerspX,
perspectiveY: state.transformPendingPerspY,
centerX: state.transformCenter?.x,
centerY: state.transformCenter?.y,
};
@@ -519,6 +588,10 @@ export function createTransformSession({
state.transformPendingRot = target.rotation;
state.transformPendingFlipH = target.flipH;
state.transformPendingFlipV = target.flipV;
state.transformPendingSkewX = target.skewX || 0;
state.transformPendingSkewY = target.skewY || 0;
state.transformPendingPerspX = target.perspectiveX || 0;
state.transformPendingPerspY = target.perspectiveY || 0;
state.transformCenter = { x: target.centerX, y: target.centerY };
}
@@ -714,6 +787,10 @@ export function createTransformSession({
state.transformPendingRot = 0;
state.transformPendingFlipH = false;
state.transformPendingFlipV = false;
state.transformPendingSkewX = 0;
state.transformPendingSkewY = 0;
state.transformPendingPerspX = 0;
state.transformPendingPerspY = 0;
state.transformActive = false;
state.transformTarget = null;
state.transformLayer = null;