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
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user