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
+141
View File
@@ -0,0 +1,141 @@
/**
* Paint Bucket (flood fill) tool.
*
* `floodFillImageData` and `hexToRgb` are pure helpers (exported for tests);
* `createBucketTool` fills the active layer's contiguous matching pixels —
* confined to the active selection mask when there is one — like Photoshop's
* contiguous Paint Bucket.
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
export function hexToRgb(color) {
const hex = String(color || '').replace('#', '');
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
if (full.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(full)) return [0, 0, 0];
const n = parseInt(full, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function matches(data, i, target, tolerance) {
return Math.abs(data[i] - target[0]) <= tolerance
&& Math.abs(data[i + 1] - target[1]) <= tolerance
&& Math.abs(data[i + 2] - target[2]) <= tolerance
&& Math.abs(data[i + 3] - target[3]) <= tolerance;
}
/**
* Scanline flood fill over an RGBA buffer, in place.
*
* @param {Uint8ClampedArray} data RGBA pixels (width*height*4).
* @param {number} width
* @param {number} height
* @param {number} startX
* @param {number} startY
* @param {[number,number,number]} rgb fill colour (alpha forced opaque).
* @param {{tolerance?: number, mask?: Uint8ClampedArray|null}} options
* `mask` is an optional RGBA alpha buffer; only pixels whose mask alpha
* is >= 128 are eligible (confines the fill to a selection).
* @returns {number} number of pixels filled.
*/
export function floodFillImageData(data, width, height, startX, startY, rgb, options = {}) {
const tolerance = Math.max(0, Math.min(255, options.tolerance == null ? 32 : options.tolerance));
const mask = options.mask || null;
const x0 = Math.floor(startX);
const y0 = Math.floor(startY);
if (x0 < 0 || y0 < 0 || x0 >= width || y0 >= height) return 0;
const startIdx = (y0 * width + x0) * 4;
if (mask && mask[startIdx + 3] < 128) return 0;
const target = [data[startIdx], data[startIdx + 1], data[startIdx + 2], data[startIdx + 3]];
const fill = [rgb[0], rgb[1], rgb[2], 255];
if (target[3] === 255
&& Math.abs(target[0] - fill[0]) <= tolerance
&& Math.abs(target[1] - fill[1]) <= tolerance
&& Math.abs(target[2] - fill[2]) <= tolerance) return 0;
const visited = new Uint8Array(width * height);
const eligible = (px, py) => {
const vi = py * width + px;
if (visited[vi]) return false;
const i = vi * 4;
if (mask && mask[i + 3] < 128) return false;
return matches(data, i, target, tolerance);
};
const stack = [[x0, y0]];
let filled = 0;
while (stack.length) {
const [sx, sy] = stack.pop();
let x = sx;
while (x >= 0 && eligible(x, sy)) x--;
x++;
let spanUp = false;
let spanDown = false;
while (x < width && eligible(x, sy)) {
visited[sy * width + x] = 1;
const i = (sy * width + x) * 4;
data[i] = fill[0];
data[i + 1] = fill[1];
data[i + 2] = fill[2];
data[i + 3] = 255;
filled++;
if (sy > 0) {
const up = eligible(x, sy - 1);
if (up && !spanUp) { stack.push([x, sy - 1]); spanUp = true; }
else if (!up) spanUp = false;
}
if (sy < height - 1) {
const down = eligible(x, sy + 1);
if (down && !spanDown) { stack.push([x, sy + 1]); spanDown = true; }
else if (!down) spanDown = false;
}
x++;
}
}
return filled;
}
export function createBucketTool({ activeLayer, saveState, composite, renderLayerPanel, invalidateLayerCaches, showToast }) {
function maskFor(layer) {
if (state.wandMask
&& state.wandMask.width === layer.canvas.width
&& state.wandMask.height === layer.canvas.height) {
return state.wandMask.getContext('2d')
.getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
}
return null;
}
// Flood-fill `layer` at layer-local pixel (x, y). Returns pixels filled.
function fillAtPoint(layer, x, y, { color, tolerance } = {}) {
if (!layer?.canvas || !layer.ctx) return 0;
const lx = Math.floor(x);
const ly = Math.floor(y);
if (lx < 0 || ly < 0 || lx >= layer.canvas.width || ly >= layer.canvas.height) return 0;
const tol = Math.max(0, Math.min(255, tolerance == null ? (Number(state.bucketTolerance) || 0) : Number(tolerance)));
const img = layer.ctx.getImageData(0, 0, layer.canvas.width, layer.canvas.height);
const count = floodFillImageData(
img.data, layer.canvas.width, layer.canvas.height, lx, ly,
hexToRgb(color || state.color), { tolerance: tol, mask: maskFor(layer) },
);
if (!count) return 0;
saveState?.();
layer.ctx.putImageData(img, 0, 0);
invalidateLayerCaches?.(layer);
composite?.();
renderLayerPanel?.();
return count;
}
function fill(e) {
const layer = activeLayer();
if (!layer?.canvas || !layer.ctx) return 0;
const point = canvasCoords(e, state.mainCanvas);
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const count = fillAtPoint(layer, point.x - off.x, point.y - off.y);
if (!count) showToast?.('Nothing to fill there');
return count;
}
return { fill, fillAtPoint };
}
+42
View File
@@ -0,0 +1,42 @@
// Color Range selection — pure helpers.
export function hexToRgb(hex) {
const value = String(hex || '').trim().replace(/^#/, '');
const full = value.length === 3
? value.split('').map(ch => ch + ch).join('')
: value.padEnd(6, '0').slice(0, 6);
const int = parseInt(full, 16);
if (Number.isNaN(int)) return { r: 0, g: 0, b: 0 };
return { r: (int >> 16) & 255, g: (int >> 8) & 255, b: int & 255 };
}
// Global (non-connected) color match. Returns a canvas whose alpha marks
// every pixel within `fuzziness` of `target` (RGBA squared-distance, the
// same scaling the magic wand uses for tolerance). Fully transparent
// source pixels are never selected.
export function colorRangeMask(srcData, width, height, target, fuzziness) {
const out = document.createElement('canvas');
out.width = width;
out.height = height;
const ctx = out.getContext('2d');
const image = ctx.createImageData(width, height);
const threshold = Math.max(0, Number(fuzziness) || 0) * 4.42;
const thresholdSq = threshold * threshold;
const tr = target?.r ?? 0;
const tg = target?.g ?? 0;
const tb = target?.b ?? 0;
for (let i = 0; i < srcData.length; i += 4) {
if (srcData[i + 3] === 0) continue;
const dr = srcData[i] - tr;
const dg = srcData[i + 1] - tg;
const db = srcData[i + 2] - tb;
if (dr * dr + dg * dg + db * db <= thresholdSq) {
image.data[i] = 255;
image.data[i + 1] = 255;
image.data[i + 2] = 255;
image.data[i + 3] = 255;
}
}
ctx.putImageData(image, 0, 0);
return out;
}
+60 -10
View File
@@ -32,7 +32,15 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
state.cropMoveStart = null;
state.cropAspectLock = null;
state.activeSnapGuides = null;
state.cropRect = data?.previousRect ? { ...data.previousRect } : null;
// Switching tools abandons the crop entirely — do not restore a
// half-drawn rect, and tear down any Apply panel a commit may have
// mounted mid-gesture.
if (reason === 'tool-switch') {
state.cropRect = null;
state.container?.querySelector('.ge-crop-apply')?.remove();
} else {
state.cropRect = data?.previousRect ? { ...data.previousRect } : null;
}
composite();
if (state.cropRect && reason !== 'tool-switch') showCropApply();
},
@@ -97,10 +105,28 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
}
if (!state.cropping) return;
e.preventDefault();
state.cropEnd = canvasCoords(e, state.mainCanvas);
// Shift-held = lock aspect ratio. First Shift press during the
// drag snapshots the current aspect; subsequent moves stay locked.
// Releasing Shift resets so the user can re-lock at a new ratio.
// Clamp the dragged corner to the document so a pointer that
// overshoots onto a panel (or past the canvas edge) still yields a
// rect inside the image rather than negative/off-canvas coords.
const end = canvasCoords(e, state.mainCanvas);
state.cropEnd = {
x: Math.max(0, Math.min(state.mainCanvas.width, end.x)),
y: Math.max(0, Math.min(state.mainCanvas.height, end.y)),
};
// Aspect lock. Shift-held snapshots the current aspect on first press
// and stays locked until released. Otherwise a chosen crop preset
// (state.cropAspectPreset) locks the ratio for new drags.
let presetRatio = null;
const preset = state.cropAspectPreset;
if (preset && preset !== 'free') {
if (preset === 'original') {
presetRatio = (state.imgWidth && state.imgHeight) ? state.imgWidth / state.imgHeight : null;
} else {
const m = /^(\d+):(\d+)$/.exec(preset);
presetRatio = m ? Number(m[1]) / Number(m[2]) : null;
}
}
let lockRatio = null;
if (e.shiftKey) {
const rawDx = state.cropEnd.x - state.cropStart.x;
const rawDy = state.cropEnd.y - state.cropStart.y;
@@ -109,21 +135,27 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
const rawH = Math.abs(rawDy) || 1;
state.cropAspectLock = rawW / rawH;
}
lockRatio = state.cropAspectLock;
} else {
state.cropAspectLock = null;
lockRatio = presetRatio;
}
if (lockRatio) {
const rawDx = state.cropEnd.x - state.cropStart.x;
const rawDy = state.cropEnd.y - state.cropStart.y;
const absDx = Math.abs(rawDx);
const absDy = Math.abs(rawDy);
// Whichever axis the user moved more (relative to the lock) is
// the driver; scale the other to preserve aspect.
let dx, dy;
if (absDx >= absDy * state.cropAspectLock) {
if (absDx >= absDy * lockRatio) {
dx = rawDx;
dy = Math.sign(rawDy || 1) * (absDx / state.cropAspectLock);
dy = Math.sign(rawDy || 1) * (absDx / lockRatio);
} else {
dy = rawDy;
dx = Math.sign(rawDx || 1) * (absDy * state.cropAspectLock);
dx = Math.sign(rawDx || 1) * (absDy * lockRatio);
}
state.cropEnd = { x: state.cropStart.x + dx, y: state.cropStart.y + dy };
} else {
state.cropAspectLock = null;
}
composite();
// Draw crop overlay.
@@ -176,5 +208,23 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
cancel(reason) {
return gesture.cancel(reason);
},
// Unconditional teardown used when the user switches tools. Unlike
// cancel(), this clears a rect even if the gesture already committed
// (e.g. a stray pointerdown restarted it mid-drag) and removes any
// mounted Apply panel.
reset() {
gesture.cancel('tool-switch');
state.cropping = false;
state.cropMoving = false;
state.cropStart = null;
state.cropEnd = null;
state.cropMoveStart = null;
state.cropAspectLock = null;
state.activeSnapGuides = null;
state.cropRect = null;
state.container?.querySelector('.ge-crop-apply')?.remove();
composite();
},
};
}
+35 -5
View File
@@ -5,6 +5,24 @@ function hexByte(value) {
return Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0');
}
// Average an N-by-N block centred on (cx, cy), clamped to the canvas edges.
// Photoshop's "Sample Size" option: 1 = Point Sample, 3/5/11/31/51 = N-by-N.
function averageBlock(ctx, cx, cy, size, width, height) {
const half = Math.floor(size / 2);
const x0 = Math.max(0, cx - half);
const y0 = Math.max(0, cy - half);
const x1 = Math.min(width - 1, cx + half);
const y1 = Math.min(height - 1, cy + half);
const w = x1 - x0 + 1;
const h = y1 - y0 + 1;
if (w <= 0 || h <= 0) return null;
const data = ctx.getImageData(x0, y0, w, h).data;
let r = 0, g = 0, b = 0, a = 0;
const n = data.length / 4;
for (let i = 0; i < data.length; i += 4) { r += data[i]; g += data[i + 1]; b += data[i + 2]; a += data[i + 3]; }
return { r: Math.round(r / n), g: Math.round(g / n), b: Math.round(b / n), a: a / n };
}
export function createEyedropperTool({ activeLayer, composite, syncColor, syncPreview, showToast }) {
function sample(e, refreshComposite = false) {
const point = canvasCoords(e, state.mainCanvas);
@@ -21,11 +39,23 @@ export function createEyedropperTool({ activeLayer, composite, syncColor, syncPr
} else if (refreshComposite) {
composite();
}
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return null;
const pixel = canvas.getContext('2d').getImageData(Math.floor(x), Math.floor(y), 1, 1).data;
const rgb = [pixel[0], pixel[1], pixel[2]];
if (!pixel[3]) return { color: null, rgb, canvas, x, y };
return { color: `#${hexByte(pixel[0])}${hexByte(pixel[1])}${hexByte(pixel[2])}`, rgb, canvas, x, y };
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= canvas.width || iy >= canvas.height) return null;
const ctx = canvas.getContext('2d');
const size = Math.max(1, Math.round(Number(state.eyedropperSampleSize) || 1));
let r, g, b, a;
if (size <= 1) {
const pixel = ctx.getImageData(ix, iy, 1, 1).data;
r = pixel[0]; g = pixel[1]; b = pixel[2]; a = pixel[3];
} else {
const avg = averageBlock(ctx, ix, iy, size, canvas.width, canvas.height);
if (!avg) return null;
r = avg.r; g = avg.g; b = avg.b; a = avg.a;
}
const rgb = [r, g, b];
if (!a) return { color: null, rgb, canvas, x, y };
return { color: `#${hexByte(r)}${hexByte(g)}${hexByte(b)}`, rgb, canvas, x, y };
}
return {
preview(e) {
+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();
+88
View File
@@ -0,0 +1,88 @@
// Object Selection brush — drag a rough box over the subject and let the
// server-side segmentation model (SAM, via /api/image/mask) pick it out.
// The stroke is sampled into positive/negative points plus a bounding
// box, both already supported by the backend endpoint.
import { selectionModeForEvent } from '../selection-modifiers.js';
export function createObjectSelectTool({
activeLayer,
saveState,
requestSam,
canvasCoords,
layerOffset,
showToast,
syncToolClearIndicators,
}) {
let active = false;
let layer = null;
let points = [];
let box = null;
let mode = 'replace';
const addPoint = (coords, event) => {
if (!layer) return;
const off = layerOffset(layer) || { x: 0, y: 0 };
const lx = Math.floor(coords.x - off.x);
const ly = Math.floor(coords.y - off.y);
if (lx < 0 || ly < 0 || lx >= layer.canvas.width || ly >= layer.canvas.height) return;
const last = points[points.length - 1];
if (last && Math.hypot(lx - last.x, ly - last.y) < 8) return;
points.push({ x: lx, y: ly, label: event.altKey ? 0 : 1 });
if (!box) box = [lx, ly, lx, ly];
else {
box[0] = Math.min(box[0], lx);
box[1] = Math.min(box[1], ly);
box[2] = Math.max(box[2], lx);
box[3] = Math.max(box[3], ly);
}
};
const begin = (event) => {
layer = activeLayer();
if (!layer) {
showToast?.('Select a layer');
return;
}
active = true;
points = [];
box = null;
mode = selectionModeForEvent(event, 'replace');
const coords = canvasCoords(event);
if (coords) addPoint(coords, event);
};
const drag = (event) => {
if (!active) return;
const coords = canvasCoords(event);
if (coords) addPoint(coords, event);
};
const end = async () => {
if (!active) return;
active = false;
const target = layer;
const pts = points;
const bx = points.length ? box : null;
points = [];
box = null;
layer = null;
if (!target || !pts.length) return;
saveState();
try {
await requestSam(target, { points: pts, box: bx }, mode);
} catch (err) {
if (err?.name !== 'AbortError') showToast?.('Object selection failed');
}
syncToolClearIndicators?.();
};
const cancel = () => {
active = false;
points = [];
box = null;
layer = null;
};
return { begin, drag, end, cancel };
}
+69
View File
@@ -0,0 +1,69 @@
// Patch tool — with a selection active, drag from inside it to a clean
// source region; on release the selected content is replaced by that
// source region (feathered at the seam). The pixel work lives in
// ../patch-ops.js; this module only tracks the drag offset.
//
// begin()/drag()/end() are dispatched from the canvas event handler the
// same way as the object-selection brush.
import { state } from '../state.js';
export function createPatchTool({
activeLayer,
canvasCoords,
saveState,
showToast,
onApply,
}) {
let active = false;
let start = null;
const begin = (event) => {
const layer = activeLayer();
if (!layer) {
showToast?.('Select a layer to patch');
return;
}
if (!state.wandMask) {
showToast?.('Make a selection, then drag the Patch tool to a source area');
return;
}
const coords = canvasCoords(event);
if (!coords) return;
start = coords;
state.patchDx = 0;
state.patchDy = 0;
active = true;
if (state.mainCanvas) state.mainCanvas.style.cursor = 'copy';
};
const drag = (event) => {
if (!active || !start) return;
const coords = canvasCoords(event);
if (!coords) return;
state.patchDx = Math.round(coords.x - start.x);
state.patchDy = Math.round(coords.y - start.y);
};
const end = () => {
if (!active) return;
active = false;
start = null;
if (state.mainCanvas) state.mainCanvas.style.cursor = '';
const dx = Number(state.patchDx) || 0;
const dy = Number(state.patchDy) || 0;
state.patchDx = 0;
state.patchDy = 0;
if (dx === 0 && dy === 0) return;
saveState?.('Patch');
onApply?.(dx, dy);
};
const cancel = () => {
active = false;
start = null;
if (state.mainCanvas) state.mainCanvas.style.cursor = '';
};
return { begin, drag, end, cancel };
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Vector pen / path tool — click to drop anchors, double-click (or the
* Finish button / Enter) to close the path. The draft is rendered live as
* a `shape` layer of type `path`; the caller owns layer creation, geometry
* updates and cleanup so this module stays free of editor state.
*/
export function createPenPathTool({
saveState = () => {},
canvasCoords = () => ({ x: 0, y: 0 }),
mainCanvas = null,
ensurePathLayer = () => null,
drawPath = () => {},
commitPath = () => {},
cancelPath = () => {},
composite = () => {},
syncToolClearIndicators = () => {},
} = {}) {
let draft = null;
function addPoint(e) {
const point = canvasCoords(e, mainCanvas);
if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) return false;
if (!draft) {
saveState('Add path');
const layer = ensurePathLayer();
if (!layer) return false;
draft = { layer, points: [point] };
} else {
draft.points.push(point);
}
drawPath(draft.layer, draft.points);
composite();
return true;
}
function begin(e) {
addPoint(e);
if (e && e.detail >= 2) finish();
return true;
}
function drag() {}
function end() {}
function finish() {
if (!draft) return false;
if (draft.points.length >= 2) {
commitPath(draft.layer, draft.points);
} else {
cancelPath(draft.layer);
}
draft = null;
composite();
syncToolClearIndicators();
return true;
}
function cancel() {
if (!draft) return false;
cancelPath(draft.layer);
draft = null;
composite();
return true;
}
return { begin, drag, end, finish, cancel, hasDraft: () => !!draft };
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Perspective Crop tool — overlay four draggable corner handles over the
* canvas; on Apply the quadrilateral is straightened (and resized) by the pure
* engine in ../perspective-crop.js. The overlay is plain DOM (fixed-position
* handles + an SVG outline) so it does not touch the canvas draw pipeline.
*/
import { state } from '../state.js';
const HANDLE = 14;
function clamp(value, min, max) {
return value < min ? min : value > max ? max : value;
}
export function createPerspectiveCropTool({ getCanvas, onApply, showToast = () => {} } = {}) {
let overlay = null;
let corners = null; // document-space {x,y} x4 (TL,TR,BR,BL)
let dragging = -1;
function canvasRect() {
const canvas = getCanvas?.();
const r = canvas?.getBoundingClientRect?.();
return r && r.width > 0 ? r : null;
}
function toDisplay(point, rect) {
return { x: rect.left + (point.x / state.imgWidth) * rect.width, y: rect.top + (point.y / state.imgHeight) * rect.height };
}
function toDoc(clientX, clientY, rect) {
return {
x: clamp(((clientX - rect.left) / rect.width) * state.imgWidth, 0, state.imgWidth),
y: clamp(((clientY - rect.top) / rect.height) * state.imgHeight, 0, state.imgHeight),
};
}
function sizeHint() {
const width = Math.round((Math.hypot(corners[1].x - corners[0].x, corners[1].y - corners[0].y) + Math.hypot(corners[2].x - corners[3].x, corners[2].y - corners[3].y)) / 2);
const height = Math.round((Math.hypot(corners[3].x - corners[0].x, corners[3].y - corners[0].y) + Math.hypot(corners[2].x - corners[1].x, corners[2].y - corners[1].y)) / 2);
return `${Math.max(1, width)} × ${Math.max(1, height)} px`;
}
function render() {
if (!overlay) return;
const rect = canvasRect();
if (!rect) return;
overlay.style.left = rect.left + 'px';
overlay.style.top = rect.top + 'px';
overlay.style.width = rect.width + 'px';
overlay.style.height = rect.height + 'px';
const pts = corners.map(c => toDisplay(c, rect));
overlay.querySelector('polygon')?.setAttribute('points', pts.map(p => `${p.x - rect.left},${p.y - rect.top}`).join(' '));
overlay.querySelectorAll('.ge-pcrop-handle').forEach((h, i) => {
const p = pts[i];
h.style.left = (p.x - rect.left) + 'px';
h.style.top = (p.y - rect.top) + 'px';
});
const hint = overlay.querySelector('.ge-pcrop-size');
if (hint) hint.textContent = sizeHint();
}
function onPointerDown(i, event) {
event.preventDefault();
event.stopPropagation();
dragging = i;
event.currentTarget.setPointerCapture?.(event.pointerId);
document.addEventListener('pointermove', onPointerMove, true);
document.addEventListener('pointerup', onPointerUp, true);
}
function onPointerMove(event) {
if (dragging < 0) return;
const rect = canvasRect();
if (!rect) return;
corners[dragging] = toDoc(event.clientX, event.clientY, rect);
render();
}
function onPointerUp() {
dragging = -1;
document.removeEventListener('pointermove', onPointerMove, true);
document.removeEventListener('pointerup', onPointerUp, true);
}
function onKey(event) {
if (event.key === 'Escape') { event.preventDefault(); close(); }
else if (event.key === 'Enter') { event.preventDefault(); apply(); }
}
function apply() {
const next = corners.map(c => ({ ...c }));
close();
onApply?.(next);
}
function open() {
close();
const inset = 0.06;
corners = [
{ x: state.imgWidth * inset, y: state.imgHeight * inset },
{ x: state.imgWidth * (1 - inset), y: state.imgHeight * inset },
{ x: state.imgWidth * (1 - inset), y: state.imgHeight * (1 - inset) },
{ x: state.imgWidth * inset, y: state.imgHeight * (1 - inset) },
];
overlay = document.createElement('div');
overlay.className = 'ge-pcrop-overlay';
overlay.innerHTML = `
<svg class="ge-pcrop-svg" preserveAspectRatio="none"><polygon points=""></polygon></svg>
<div class="ge-pcrop-bar">
<span class="ge-pcrop-title">Perspective Crop</span>
<span class="ge-pcrop-size"></span>
<span class="ge-pcrop-spacer"></span>
<button type="button" class="ge-btn ge-btn-sm" data-pcrop="cancel">Cancel</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-primary" data-pcrop="apply">Apply</button>
</div>
${corners.map((_, i) => `<div class="ge-pcrop-handle" data-corner="${i}"></div>`).join('')}
`;
document.body.appendChild(overlay);
overlay.querySelectorAll('.ge-pcrop-handle').forEach((handle, i) => {
handle.addEventListener('pointerdown', (event) => onPointerDown(i, event));
});
overlay.querySelector('[data-pcrop="apply"]').addEventListener('click', apply);
overlay.querySelector('[data-pcrop="cancel"]').addEventListener('click', close);
document.addEventListener('keydown', onKey, true);
window.addEventListener('resize', render);
render();
showToast?.('Drag the four corners, then Apply');
}
function close() {
if (!overlay) return;
document.removeEventListener('keydown', onKey, true);
window.removeEventListener('resize', render);
document.removeEventListener('pointermove', onPointerMove, true);
document.removeEventListener('pointerup', onPointerUp, true);
overlay.remove();
overlay = null;
corners = null;
dragging = -1;
}
return { open, close, isOpen: () => !!overlay, render };
}
export default { createPerspectiveCropTool };
+63
View File
@@ -0,0 +1,63 @@
// Quick Selection brush — drag to grow a wand-style selection.
// Reuses the existing magic-wand flood fill by re-sampling along the
// stroke; intermediate samples skip the undo snapshot (retune mode) so a
// whole drag collapses to one history entry (taken on begin).
import { selectionModeForEvent } from '../selection-modifiers.js';
export function createQuickSelectTool({
activeLayer,
saveState,
composite,
runMagicWand,
canvasCoords,
brushSize,
syncToolClearIndicators,
}) {
let painting = false;
let last = null;
const modeFor = (event) => selectionModeForEvent(event, 'add');
const sample = (event) => {
const coords = canvasCoords(event);
if (!coords) return;
runMagicWand(coords.x, coords.y, modeFor(event), { retune: true });
};
const begin = (event) => {
const layer = activeLayer();
if (!layer) return;
saveState();
painting = true;
last = null;
sample(event);
const coords = canvasCoords(event);
if (coords) last = coords;
};
const drag = (event) => {
if (!painting) return;
const coords = canvasCoords(event);
if (!coords) return;
const gap = Math.max(3, (brushSize?.() || 24) / 4);
if (last && Math.hypot(coords.x - last.x, coords.y - last.y) < gap) return;
sample(event);
last = coords;
};
const end = () => {
if (!painting) return;
painting = false;
last = null;
composite();
syncToolClearIndicators?.();
};
const cancel = () => {
painting = false;
last = null;
};
return { begin, drag, end, cancel };
}
+1 -1
View File
@@ -53,7 +53,7 @@ function pointerPressure(e) {
function eventSample(e) {
const coords = canvasCoords(e, state.mainCanvas);
return { x: coords.x, y: coords.y, pressure: pointerPressure(e) };
return { x: coords.x, y: coords.y, pressure: pointerPressure(e), shift: !!e?.shiftKey };
}
export function createStrokeTool({
@@ -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;