mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 11:52:25 +02:00
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:
co-authored by
Claude Opus 5.5
parent
c74f475679
commit
5fac693c1c
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user