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
+272 -6
View File
@@ -9,18 +9,22 @@
import { flipTextLayer, rotateTextLayer, scaleTextLayer } from './text-layer.js';
import { flipShapeLayer, rotateShapeLayer, scaleShapeLayer } from './shape-layer.js';
import { renderPlacedLayer, transformPlacedData } from './placed-layer.js';
import { affineBounds, renderPlacedLayer, transformPlacedData } from './placed-layer.js';
function makeCanvas(width, height) {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
canvas.width = Math.max(1, Math.round(Number.isFinite(width) ? width : 1));
canvas.height = Math.max(1, Math.round(Number.isFinite(height) ? height : 1));
return canvas;
}
function renderWindow(source, x, y, width, height) {
const out = makeCanvas(width, height);
out.getContext('2d').drawImage(source, -x, -y);
// A freshly-created / empty layer can legitimately have a 0-sized canvas;
// drawImage() throws on those, which would abort cropping the whole document.
if (source && source.width > 0 && source.height > 0) {
out.getContext('2d').drawImage(source, -x, -y);
}
return out;
}
@@ -50,6 +54,25 @@ function renderRotated(source, degrees) {
return out;
}
function renderRotatedArbitrary(source, degrees) {
const radians = (degrees * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const width = Math.abs(source.width * cos) + Math.abs(source.height * sin);
const height = Math.abs(source.width * sin) + Math.abs(source.height * cos);
const out = makeCanvas(Math.ceil(width), Math.ceil(height));
// Empty layers can have a 0-sized canvas; drawImage() throws on those.
if (source && source.width > 0 && source.height > 0) {
const ctx = out.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.translate(out.width / 2, out.height / 2);
ctx.rotate(radians);
ctx.drawImage(source, -source.width / 2, -source.height / 2);
}
return out;
}
function renderFlipped(source, axis) {
const out = makeCanvas(source.width, source.height);
const ctx = out.getContext('2d');
@@ -172,10 +195,13 @@ export function clipPolygonToRect(points, width, height) {
}
function normalizeCropRect(editorState, rect) {
// Accept either {w,h} (internal) or {width,height} (control-op params).
const rawW = Number.isFinite(rect.w) ? rect.w : rect.width;
const rawH = Number.isFinite(rect.h) ? rect.h : rect.height;
const x = Math.max(0, Math.min(editorState.imgWidth - 1, Math.round(rect.x)));
const y = Math.max(0, Math.min(editorState.imgHeight - 1, Math.round(rect.y)));
const width = Math.max(1, Math.min(editorState.imgWidth - x, Math.round(rect.w)));
const height = Math.max(1, Math.min(editorState.imgHeight - y, Math.round(rect.h)));
const width = Math.max(1, Math.min(editorState.imgWidth - x, Math.round(Number.isFinite(rawW) ? rawW : editorState.imgWidth)));
const height = Math.max(1, Math.min(editorState.imgHeight - y, Math.round(Number.isFinite(rawH) ? rawH : editorState.imgHeight)));
return { x, y, width, height };
}
@@ -501,6 +527,121 @@ export function rotateDocument(editorState, degrees) {
return { width: newWidth, height: newHeight, degrees: normalized };
}
/**
* Rotate the whole document by an arbitrary angle (degrees, clockwise),
* expanding the canvas so nothing is clipped. Right angles delegate to the
* exact integer path; other angles resample every layer, mask, and selection
* about the old image centre and re-place each layer's centre in the new
* frame. Guides are dropped because arbitrary rotation leaves no
* axis-aligned reference.
*/
export function rotateDocumentArbitrary(editorState, degrees) {
const raw = Number(degrees);
if (!Number.isFinite(raw)) return null;
const normalized = ((raw % 360) + 360) % 360;
if (normalized === 0) {
return { width: editorState.imgWidth, height: editorState.imgHeight, degrees: 0 };
}
if ([90, 180, 270].includes(normalized)) return rotateDocument(editorState, normalized);
const radians = (normalized * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const oldWidth = editorState.imgWidth;
const oldHeight = editorState.imgHeight;
const newWidth = Math.max(1, Math.ceil(Math.abs(oldWidth * cos) + Math.abs(oldHeight * sin)));
const newHeight = Math.max(1, Math.ceil(Math.abs(oldWidth * sin) + Math.abs(oldHeight * cos)));
const mapPoint = (x, y) => ({
x: cos * (x - oldWidth / 2) - sin * (y - oldHeight / 2) + newWidth / 2,
y: sin * (x - oldWidth / 2) + cos * (y - oldHeight / 2) + newHeight / 2,
});
for (const layer of editorState.layers) {
const before = layerBounds(editorState, layer);
const mapped = mapPoint(before.centerX, before.centerY);
let placedOffset = null;
if (layer.kind === 'text' && layer.text) {
rotateTextLayer(layer, normalized);
} else if (layer.kind === 'shape' && layer.shape) {
rotateShapeLayer(layer, normalized);
} else if (layer.kind === 'placed' && layer.placed) {
// Rotate the placed matrix about its centre, then translate that centre
// to where the document rotation maps it.
const matrix = [
cos, sin, -sin, cos,
before.centerX - cos * before.centerX + sin * before.centerY + (mapped.x - before.centerX),
before.centerY - sin * before.centerX - cos * before.centerY + (mapped.y - before.centerY),
];
layer.placed = transformPlacedData(layer.placed, matrix);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else {
replaceCanvas(layer, renderRotatedArbitrary(layer.canvas, normalized));
}
const layerWidth = layer.canvas.width;
const layerHeight = layer.canvas.height;
for (const mask of layer.masks || []) {
const oldMaskWidth = mask.canvas.width;
const oldMaskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderRotatedArbitrary(mask.canvas, normalized));
const cx = (Number(maskOffset.x) || 0) + oldMaskWidth / 2;
const cy = (Number(maskOffset.y) || 0) + oldMaskHeight / 2;
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
// Layer-space masks rotate with their layer's canvas.
const nx = cos * (cx - before.width / 2) - sin * (cy - before.height / 2) + layerWidth / 2;
const ny = sin * (cx - before.width / 2) + cos * (cy - before.height / 2) + layerHeight / 2;
mask.offset = {
x: Math.round(nx - mask.canvas.width / 2),
y: Math.round(ny - mask.canvas.height / 2),
};
} else {
const mappedCenter = mapPoint(cx, cy);
mask.offset = {
x: Math.round(mappedCenter.x - mask.canvas.width / 2),
y: Math.round(mappedCenter.y - mask.canvas.height / 2),
};
}
}
editorState.layerOffsets.set(layer.id, placedOffset || {
x: Math.round(mapped.x - layerWidth / 2),
y: Math.round(mapped.y - layerHeight / 2),
});
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) {
const oldMaskWidth = mask.canvas.width;
const oldMaskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderRotatedArbitrary(mask.canvas, normalized));
const mappedCenter = mapPoint(
(Number(maskOffset.x) || 0) + oldMaskWidth / 2,
(Number(maskOffset.y) || 0) + oldMaskHeight / 2,
);
mask.offset = {
x: Math.round(mappedCenter.x - mask.canvas.width / 2),
y: Math.round(mappedCenter.y - mask.canvas.height / 2),
};
}
}
if (editorState.wandMask) {
replaceStandaloneCanvas(editorState.wandMask, renderRotatedArbitrary(editorState.wandMask, normalized));
}
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderRotatedArbitrary(canvas, normalized));
});
if (editorState.wandLastSeed) {
const mappedSeed = mapPoint(editorState.wandLastSeed.x, editorState.wandLastSeed.y);
editorState.wandLastSeed = { ...editorState.wandLastSeed, x: mappedSeed.x, y: mappedSeed.y };
}
editorState.lassoPoints = (editorState.lassoPoints || []).map(point => mapPoint(point.x, point.y));
// Arbitrary rotation leaves no axis-aligned reference, so guides are dropped.
editorState.guides = { vertical: [], horizontal: [] };
syncDocumentCanvas(editorState, newWidth, newHeight);
return { width: newWidth, height: newHeight, degrees: normalized };
}
/** Flip every layer, mask, offset, and active selection across the document. */
export function flipDocument(editorState, axis) {
if (axis !== 'h' && axis !== 'v') return null;
@@ -573,3 +714,128 @@ export function flipDocument(editorState, axis) {
syncDocumentCanvas(editorState, width, height);
return { width, height, axis };
}
// ── Single-layer transforms (Edit ▸ Transform semantics) ──
// Unlike the document-wide helpers above, these mutate ONE layer in place:
// the document bounds, other layers, and the active selection are untouched.
// The layer's visual centre is preserved so a rotation does not fling it
// across the canvas.
function layerBounds(editorState, layer) {
if (layer.kind === 'placed' && layer.placed?.sourceCanvas) {
const sw = layer.placed.sourceWidth || layer.placed.sourceCanvas.width;
const sh = layer.placed.sourceHeight || layer.placed.sourceCanvas.height;
return affineBounds(layer.placed.matrix, sw, sh);
}
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const width = layer.canvas.width;
const height = layer.canvas.height;
return {
x: offset.x,
y: offset.y,
width,
height,
centerX: offset.x + width / 2,
centerY: offset.y + height / 2,
};
}
function recenterLayer(editorState, layer, before) {
const width = layer.canvas.width;
const height = layer.canvas.height;
editorState.layerOffsets.set(layer.id, {
x: Math.round(before.centerX - width / 2),
y: Math.round(before.centerY - height / 2),
});
}
function flipMatrixAbout(centerX, centerY, axis) {
return axis === 'h'
? [-1, 0, 0, 1, 2 * centerX, 0]
: [1, 0, 0, -1, 0, 2 * centerY];
}
function rotateMatrixAbout(centerX, centerY, degrees) {
const radians = (degrees * Math.PI) / 180;
const cos = Math.round(Math.cos(radians));
const sin = Math.round(Math.sin(radians));
// T(center) · R · T(-center)
return [
cos, sin, -sin, cos,
centerX - cos * centerX + sin * centerY,
centerY - sin * centerX - cos * centerY,
];
}
function transformLayerMasks(layer, transform) {
for (const mask of layer.masks || []) {
transform(mask);
}
}
/** Flip a single layer horizontally ('h') or vertically ('v'), in place. */
export function flipLayer(editorState, layer, axis) {
if (!layer || (axis !== 'h' && axis !== 'v')) return null;
const before = layerBounds(editorState, layer);
if (layer.kind === 'text' && layer.text) flipTextLayer(layer, axis);
else if (layer.kind === 'shape' && layer.shape) flipShapeLayer(layer, axis);
else if (layer.kind === 'placed' && layer.placed) {
layer.placed = transformPlacedData(layer.placed, flipMatrixAbout(before.centerX, before.centerY, axis));
const rendered = renderPlacedLayer(layer);
if (rendered) editorState.layerOffsets.set(layer.id, rendered.offset);
} else {
replaceCanvas(layer, renderFlipped(layer.canvas, axis));
recenterLayer(editorState, layer, before);
}
transformLayerMasks(layer, (mask) => {
const maskWidth = mask.canvas.width;
const maskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderFlipped(mask.canvas, axis));
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
const ox = Number(maskOffset.x) || 0;
const oy = Number(maskOffset.y) || 0;
mask.offset = axis === 'h'
? { x: before.width - ox - maskWidth, y: oy }
: { x: ox, y: before.height - oy - maskHeight };
}
});
invalidateLayerCaches(layer);
return { axis };
}
/** Rotate a single layer by 90/180/270°, keeping its centre fixed. */
export function rotateLayer(editorState, layer, degrees) {
const normalized = ((degrees % 360) + 360) % 360;
if (!layer || ![90, 180, 270].includes(normalized)) return null;
const before = layerBounds(editorState, layer);
if (layer.kind === 'text' && layer.text) rotateTextLayer(layer, normalized);
else if (layer.kind === 'shape' && layer.shape) rotateShapeLayer(layer, normalized);
else if (layer.kind === 'placed' && layer.placed) {
layer.placed = transformPlacedData(layer.placed, rotateMatrixAbout(before.centerX, before.centerY, normalized));
const rendered = renderPlacedLayer(layer);
if (rendered) editorState.layerOffsets.set(layer.id, rendered.offset);
} else {
replaceCanvas(layer, renderRotated(layer.canvas, normalized));
recenterLayer(editorState, layer, before);
}
transformLayerMasks(layer, (mask) => {
const oldMaskWidth = mask.canvas.width;
const oldMaskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderRotated(mask.canvas, normalized));
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
const ox = Number(maskOffset.x) || 0;
const oy = Number(maskOffset.y) || 0;
if (normalized === 90) {
mask.offset = { x: before.height - oy - oldMaskHeight, y: ox };
} else if (normalized === 180) {
mask.offset = { x: before.width - ox - oldMaskWidth, y: before.height - oy - oldMaskHeight };
} else {
mask.offset = { x: oy, y: before.width - ox - oldMaskWidth };
}
}
});
invalidateLayerCaches(layer);
return { degrees: normalized };
}