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
+76 -1
View File
@@ -4,9 +4,17 @@
* mouse-up keeps the selection visible (the panel's action buttons
* read `state.lassoPoints` to act on it).
*
* Magnetic sub-mode: when `state.lassoMagnetic` is on, each sampled
* point is snapped to the strongest local image gradient within
* `state.lassoMagneticWidth` px, so the outline hugs edges. Edge
* strength below `state.lassoMagneticContrast` (0-100) is ignored.
*
* Owns its own begin/drag/end handlers and reads/writes shared state.
*
* @param {{
* activeLayer: () => object | null,
* saveState: (label?: string) => void,
* commitSelectionMask: (mask, layer, mode, source, offset) => void,
* composite: () => void,
* drawLassoOverlay: () => void,
* syncToolClearIndicators: () => void,
@@ -17,16 +25,69 @@ import { selectionModeForEvent } from '../selection-modifiers.js';
import { canvasCoords } from '../canvas-coords.js';
import { buildLassoMask } from './lasso-mask.js';
// Luminance-gradient magnitude field for the active layer, used by the
// magnetic sub-mode. Built once per stroke; null when unavailable.
function buildEdgeField(layer, width, height) {
if (!layer || !layer.ctx || !width || !height) return null;
try {
const data = layer.ctx.getImageData(0, 0, width, height).data;
const lum = new Float32Array(width * height);
for (let i = 0; i < width * height; i++) {
lum[i] = 0.299 * data[i * 4] + 0.587 * data[i * 4 + 1] + 0.114 * data[i * 4 + 2];
}
const mag = new Float32Array(width * height);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const i = y * width + x;
const gx = lum[i + 1] - lum[i - 1];
const gy = lum[i + width] - lum[i - width];
mag[i] = Math.abs(gx) + Math.abs(gy);
}
}
return { mag, width, height };
} catch {
return null;
}
}
function snapToEdge(field, x, y, radius, contrast) {
const r = Math.max(1, Math.round(radius));
const threshold = contrast * 2.55;
let best = null;
let bestScore = -Infinity;
const cx = Math.round(x);
const cy = Math.round(y);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const px = cx + dx;
const py = cy + dy;
if (px < 1 || py < 1 || px >= field.width - 1 || py >= field.height - 1) continue;
const m = field.mag[py * field.width + px];
if (m < threshold) continue;
const score = m - Math.hypot(dx, dy) * 2;
if (score > bestScore) {
bestScore = score;
best = { x: px, y: py };
}
}
}
return best;
}
export function createLassoTool({
activeLayer, saveState, commitSelectionMask,
composite, drawLassoOverlay, syncToolClearIndicators,
}) {
let pendingMode = 'replace';
let edgeField = null;
return {
begin(e) {
pendingMode = selectionModeForEvent(e, state.wandMode || 'replace');
state.lassoPoints = [];
state.lassoActive = true;
edgeField = state.lassoMagnetic
? buildEdgeField(activeLayer?.(), state.imgWidth, state.imgHeight)
: null;
const coords = canvasCoords(e, state.mainCanvas);
state.lassoPoints.push(coords);
},
@@ -34,7 +95,19 @@ export function createLassoTool({
drag(e) {
if (!state.lassoActive) return;
e.preventDefault();
const coords = canvasCoords(e, state.mainCanvas);
let coords = canvasCoords(e, state.mainCanvas);
if (state.lassoMagnetic && edgeField) {
const snapped = snapToEdge(
edgeField,
coords.x,
coords.y,
state.lassoMagneticWidth || 12,
state.lassoMagneticContrast ?? 20,
);
if (snapped) coords = snapped;
}
const last = state.lassoPoints[state.lassoPoints.length - 1];
if (last && Math.hypot(coords.x - last.x, coords.y - last.y) < 1) return;
state.lassoPoints.push(coords);
// Live overlay: dashed white outline + translucent red fill.
composite();
@@ -57,6 +130,7 @@ export function createLassoTool({
end() {
state.lassoActive = false;
edgeField = null;
if (state.lassoPoints.length < 3) {
state.lassoPoints = [];
composite();
@@ -86,6 +160,7 @@ export function createLassoTool({
cancel() {
if (!state.lassoActive && !state.lassoPoints.length) return false;
state.lassoActive = false;
edgeField = null;
state.lassoPoints = [];
composite();
syncToolClearIndicators();