Odysseus v1.0
This commit is contained in:
81
static/js/editor/tools/clone.js
Normal file
81
static/js/editor/tools/clone.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Clone tool — Alt-click (desktop) or double-tap (mobile) sets the
|
||||
* sample source; a regular click+drag stamps from that source onto the
|
||||
* active layer. The source point moves WITH the brush so the offset
|
||||
* stays constant across the stroke.
|
||||
*
|
||||
* begin() handles the source-pick and stroke-start branches; the
|
||||
* actual per-sample stamping continues through the shared stroke
|
||||
* pipeline (`_strokeTo`) which knows about clone-mode internally.
|
||||
*
|
||||
* @param {{
|
||||
* activeLayer: () => object | null,
|
||||
* saveState: (label?: string) => void,
|
||||
* strokeTo: (x: number, y: number) => void,
|
||||
* showToast: (msg: string) => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
export function createCloneTool({ activeLayer, saveState, strokeTo, showToast }) {
|
||||
return {
|
||||
begin(e) {
|
||||
const layer = activeLayer();
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
// Mobile equivalent of Alt-click: double-tap in screen pixels.
|
||||
// Wider tolerances (500 ms, 40 px) than desktop because finger
|
||||
// taps drift more than mouse clicks.
|
||||
const isTouchEvt = e.type && e.type.startsWith('touch');
|
||||
let isDoubleTap = false;
|
||||
if (isTouchEvt) {
|
||||
const t = e.touches ? e.touches[0] : null;
|
||||
const cx = t ? t.clientX : 0;
|
||||
const cy = t ? t.clientY : 0;
|
||||
const now = Date.now();
|
||||
const dt = now - state.cloneLastTapTime;
|
||||
const dx = cx - state.cloneLastTapX;
|
||||
const dy = cy - state.cloneLastTapY;
|
||||
if (dt < 500 && Math.hypot(dx, dy) < 40) {
|
||||
isDoubleTap = true;
|
||||
state.cloneLastTapTime = 0; // consume the pair
|
||||
} else {
|
||||
state.cloneLastTapTime = now;
|
||||
state.cloneLastTapX = cx;
|
||||
state.cloneLastTapY = cy;
|
||||
}
|
||||
}
|
||||
if (e.altKey || isDoubleTap) {
|
||||
state.cloneSourceX = coords.x;
|
||||
state.cloneSourceY = coords.y;
|
||||
state.cloneSourceLayerId = (layer && layer.id) || state.activeLayerId;
|
||||
state.cloneSourceSnapshot = null; // captured at first stroke
|
||||
showToast('Clone source set');
|
||||
return;
|
||||
}
|
||||
if (state.cloneSourceX === null || state.cloneSourceY === null) {
|
||||
showToast(isTouchEvt
|
||||
? 'Double-tap first to set a clone source'
|
||||
: 'Alt-click first to set a clone source');
|
||||
return;
|
||||
}
|
||||
if (!layer || layer.locked) return;
|
||||
saveState('Clone stroke');
|
||||
// Snapshot the source layer's pixels at stroke-start so the
|
||||
// brush samples clean source pixels even after it has painted
|
||||
// over them. Otherwise we'd cascade-clone the same ring.
|
||||
const srcLayer = state.layers.find(l => l.id === state.cloneSourceLayerId) || layer;
|
||||
const snap = document.createElement('canvas');
|
||||
snap.width = srcLayer.canvas.width;
|
||||
snap.height = srcLayer.canvas.height;
|
||||
snap.getContext('2d').drawImage(srcLayer.canvas, 0, 0);
|
||||
state.cloneSourceSnapshot = snap;
|
||||
state.cloneStrokeStartX = coords.x;
|
||||
state.cloneStrokeStartY = coords.y;
|
||||
state.drawing = true;
|
||||
state.lastX = coords.x;
|
||||
state.lastY = coords.y;
|
||||
strokeTo(coords.x, coords.y);
|
||||
},
|
||||
};
|
||||
}
|
||||
137
static/js/editor/tools/crop.js
Normal file
137
static/js/editor/tools/crop.js
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Crop tool — drag-rect selection that lets the user cut down the
|
||||
* canvas to a smaller region. Supports Shift-lock aspect ratio and
|
||||
* click-inside-rect to reposition an existing crop without redrawing.
|
||||
*
|
||||
* Owns its own begin/drag/end handlers and reads/writes shared state.
|
||||
* The factory takes a small dependency bag for things still living in
|
||||
* galleryEditor.js — `composite` redraws the canvas, `showCropApply`
|
||||
* mounts the floating W×H + Apply panel after the user finishes
|
||||
* dragging.
|
||||
*
|
||||
* @param {{
|
||||
* composite: () => void,
|
||||
* showCropApply: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { drawCheckerboard } from '../checkerboard.js';
|
||||
|
||||
export function createCropTool({ composite, showCropApply }) {
|
||||
return {
|
||||
begin(e) {
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
// Click inside an existing crop rect → switch to move-mode so
|
||||
// the user can reposition without redrawing.
|
||||
if (state.cropRect &&
|
||||
coords.x >= state.cropRect.x && coords.x <= state.cropRect.x + state.cropRect.w &&
|
||||
coords.y >= state.cropRect.y && coords.y <= state.cropRect.y + state.cropRect.h) {
|
||||
state.cropMoving = true;
|
||||
state.cropMoveStart = { x: coords.x, y: coords.y, rx: state.cropRect.x, ry: state.cropRect.y };
|
||||
return;
|
||||
}
|
||||
state.cropping = true;
|
||||
state.cropStart = coords;
|
||||
state.cropEnd = { ...state.cropStart };
|
||||
state.cropRect = null;
|
||||
state.cropAspectLock = null;
|
||||
// Tear down the size panel while the user is drawing a new rect.
|
||||
const old = state.container?.querySelector('.ge-crop-apply');
|
||||
if (old) old.remove();
|
||||
},
|
||||
|
||||
drag(e) {
|
||||
// Move-mode: drag the existing rect around the canvas.
|
||||
if (state.cropMoving && state.cropRect && state.cropMoveStart) {
|
||||
e.preventDefault();
|
||||
const c = canvasCoords(e, state.mainCanvas);
|
||||
const dx = c.x - state.cropMoveStart.x;
|
||||
const dy = c.y - state.cropMoveStart.y;
|
||||
let nx = state.cropMoveStart.rx + dx;
|
||||
let ny = state.cropMoveStart.ry + dy;
|
||||
// Clamp to canvas bounds so the rect stays fully visible.
|
||||
nx = Math.max(0, Math.min(nx, state.mainCanvas.width - state.cropRect.w));
|
||||
ny = Math.max(0, Math.min(ny, state.mainCanvas.height - state.cropRect.h));
|
||||
state.cropRect = { ...state.cropRect, x: nx, y: ny };
|
||||
composite();
|
||||
return;
|
||||
}
|
||||
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.
|
||||
if (e.shiftKey) {
|
||||
const rawDx = state.cropEnd.x - state.cropStart.x;
|
||||
const rawDy = state.cropEnd.y - state.cropStart.y;
|
||||
if (state.cropAspectLock == null) {
|
||||
const rawW = Math.abs(rawDx) || 1;
|
||||
const rawH = Math.abs(rawDy) || 1;
|
||||
state.cropAspectLock = rawW / rawH;
|
||||
}
|
||||
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) {
|
||||
dx = rawDx;
|
||||
dy = Math.sign(rawDy || 1) * (absDx / state.cropAspectLock);
|
||||
} else {
|
||||
dy = rawDy;
|
||||
dx = Math.sign(rawDx || 1) * (absDy * state.cropAspectLock);
|
||||
}
|
||||
state.cropEnd = { x: state.cropStart.x + dx, y: state.cropStart.y + dy };
|
||||
} else {
|
||||
state.cropAspectLock = null;
|
||||
}
|
||||
composite();
|
||||
// Draw crop overlay.
|
||||
const x = Math.min(state.cropStart.x, state.cropEnd.x);
|
||||
const y = Math.min(state.cropStart.y, state.cropEnd.y);
|
||||
const w = Math.abs(state.cropEnd.x - state.cropStart.x);
|
||||
const h = Math.abs(state.cropEnd.y - state.cropStart.y);
|
||||
state.mainCtx.fillStyle = 'rgba(0,0,0,0.4)';
|
||||
state.mainCtx.fillRect(0, 0, state.mainCanvas.width, state.mainCanvas.height);
|
||||
state.mainCtx.clearRect(x, y, w, h);
|
||||
// Redraw layers inside the crop rect (dim everything outside).
|
||||
state.mainCtx.save();
|
||||
state.mainCtx.beginPath();
|
||||
state.mainCtx.rect(x, y, w, h);
|
||||
state.mainCtx.clip();
|
||||
drawCheckerboard(state.mainCtx, state.mainCanvas.width, state.mainCanvas.height);
|
||||
for (const layer of state.layers) {
|
||||
if (!layer.visible) continue;
|
||||
state.mainCtx.globalAlpha = layer.opacity;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
state.mainCtx.drawImage(layer.canvas, off.x, off.y);
|
||||
}
|
||||
state.mainCtx.globalAlpha = 1;
|
||||
state.mainCtx.restore();
|
||||
// Dashed border around the kept region.
|
||||
state.mainCtx.strokeStyle = '#fff';
|
||||
state.mainCtx.lineWidth = 1;
|
||||
state.mainCtx.setLineDash([4, 4]);
|
||||
state.mainCtx.strokeRect(x, y, w, h);
|
||||
state.mainCtx.setLineDash([]);
|
||||
state.cropRect = { x, y, w, h };
|
||||
},
|
||||
|
||||
end() {
|
||||
// Move-mode wrap-up: refresh the floating panel so Apply follows
|
||||
// the rect to its new spot.
|
||||
if (state.cropMoving) {
|
||||
state.cropMoving = false;
|
||||
state.cropMoveStart = null;
|
||||
if (state.cropRect) showCropApply();
|
||||
return;
|
||||
}
|
||||
state.cropping = false;
|
||||
if (state.cropRect && state.cropRect.w > 5 && state.cropRect.h > 5) {
|
||||
showCropApply();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
72
static/js/editor/tools/flood-fill.js
Normal file
72
static/js/editor/tools/flood-fill.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Iterative 4-connected flood fill on RGBA pixel data.
|
||||
*
|
||||
* Pure function — takes the source pixel array + seed + tolerance and
|
||||
* returns a mask canvas with white where the fill landed. The legacy
|
||||
* gallery editor's magic-wand tool delegates to this.
|
||||
*
|
||||
* @param {Uint8ClampedArray|Uint8Array} src RGBA bytes (length = w*h*4).
|
||||
* @param {number} w Pixel width.
|
||||
* @param {number} h Pixel height.
|
||||
* @param {number} seedX Floored seed X.
|
||||
* @param {number} seedY Floored seed Y.
|
||||
* @param {number} tolerance Tolerance 0..100. Internally
|
||||
* squared and scaled to RGB+A
|
||||
* space (max ≈ 195k at 100).
|
||||
* @returns {HTMLCanvasElement|null} A `w × h` mask canvas with
|
||||
* white-opaque pixels for
|
||||
* visited cells, or null if
|
||||
* the seed is out of bounds.
|
||||
*/
|
||||
export function floodFillMask(src, w, h, seedX, seedY, tolerance) {
|
||||
if (seedX < 0 || seedY < 0 || seedX >= w || seedY >= h) return null;
|
||||
|
||||
const seedIdx = (seedY * w + seedX) * 4;
|
||||
const sr = src[seedIdx], sg = src[seedIdx + 1];
|
||||
const sb = src[seedIdx + 2], sa = src[seedIdx + 3];
|
||||
|
||||
// 0..100 → squared RGB+A distance threshold. Max single-channel diff
|
||||
// is 255, so sqrt(4 * 255²) ≈ 510; squared cap ≈ 195k at tol = 100.
|
||||
const tol = Math.pow(tolerance * 4.42, 2);
|
||||
|
||||
const visited = new Uint8Array(w * h);
|
||||
const stack = [seedX, seedY];
|
||||
visited[seedY * w + seedX] = 1;
|
||||
while (stack.length) {
|
||||
const y = stack.pop();
|
||||
const x = stack.pop();
|
||||
const nbrs = [
|
||||
[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1],
|
||||
];
|
||||
for (const [nx, ny] of nbrs) {
|
||||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||||
const idx = ny * w + nx;
|
||||
if (visited[idx]) continue;
|
||||
const o = idx * 4;
|
||||
const dr = src[o] - sr, dg = src[o + 1] - sg;
|
||||
const db = src[o + 2] - sb, da = src[o + 3] - sa;
|
||||
// RGB + alpha-aware so a click on a transparent pixel selects
|
||||
// the transparent region cleanly.
|
||||
if (dr * dr + dg * dg + db * db + da * da <= tol) {
|
||||
visited[idx] = 1;
|
||||
stack.push(nx, ny);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mask = document.createElement('canvas');
|
||||
mask.width = w;
|
||||
mask.height = h;
|
||||
const mCtx = mask.getContext('2d');
|
||||
const mData = mCtx.createImageData(w, h);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
if (visited[i]) {
|
||||
mData.data[i * 4] = 255;
|
||||
mData.data[i * 4 + 1] = 255;
|
||||
mData.data[i * 4 + 2] = 255;
|
||||
mData.data[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
mCtx.putImageData(mData, 0, 0);
|
||||
return mask;
|
||||
}
|
||||
171
static/js/editor/tools/lasso-mask.js
Normal file
171
static/js/editor/tools/lasso-mask.js
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Lasso-tool pixel & path helpers.
|
||||
*
|
||||
* All functions take the lasso polygon `points` as an explicit
|
||||
* argument so they can be tested in isolation. The legacy gallery
|
||||
* editor calls them with its module-level `_lassoPoints` array.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shift each polygon vertex along the outward normal by `grow` pixels.
|
||||
* Used by the lasso overlay (to draw the "feather" halo) and by
|
||||
* `buildLassoMask` (to bake the grown polygon into the mask).
|
||||
*
|
||||
* @param {{x: number, y: number}[]} points Polygon vertices in draw order.
|
||||
* @param {number} grow Positive = expand outward, negative = contract.
|
||||
* @returns {{x: number, y: number}[]} New array (same length, original is not mutated).
|
||||
*/
|
||||
export function lassoOffsetPoints(points, grow) {
|
||||
const n = points.length;
|
||||
if (n < 3 || !grow) return points;
|
||||
// Polygon winding (positive = CCW) — flip the normal so it points
|
||||
// away from the interior regardless of draw direction.
|
||||
let area = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = points[i], q = points[(i + 1) % n];
|
||||
area += (q.x - p.x) * (q.y + p.y);
|
||||
}
|
||||
const sign = area > 0 ? 1 : -1;
|
||||
const out = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = points[(i - 1 + n) % n], b = points[i], c = points[(i + 1) % n];
|
||||
const e1x = b.x - a.x, e1y = b.y - a.y;
|
||||
const e2x = c.x - b.x, e2y = c.y - b.y;
|
||||
const l1 = Math.hypot(e1x, e1y) || 1;
|
||||
const l2 = Math.hypot(e2x, e2y) || 1;
|
||||
// Perpendicular (dy, -dx); flip via `sign` for outward direction.
|
||||
const n1x = (e1y / l1) * sign, n1y = (-e1x / l1) * sign;
|
||||
const n2x = (e2y / l2) * sign, n2y = (-e2x / l2) * sign;
|
||||
const nx = (n1x + n2x) / 2;
|
||||
const ny = (n1y + n2y) / 2;
|
||||
const nl = Math.hypot(nx, ny) || 1;
|
||||
out[i] = { x: b.x + (nx / nl) * grow, y: b.y + (ny / nl) * grow };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Trace the lasso polygon on the given context (move-to + line-to,
|
||||
* closed). Caller is responsible for `stroke()` / `fill()` choice.
|
||||
*/
|
||||
export function getLassoPath(ctx, points) {
|
||||
if (!points || points.length < 1) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
ctx.lineTo(points[i].x, points[i].y);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build a (optionally feathered, optionally grown) selection mask
|
||||
* from a lasso polygon.
|
||||
*
|
||||
* @param {{x: number, y: number}[]} points Polygon vertices.
|
||||
* @param {number} w / h Output canvas dimensions.
|
||||
* @param {number} offX / offY Translate the polygon by (offX, offY) before rasterising.
|
||||
* @param {number} feather Feather width in pixels. 0 = hard edge.
|
||||
* @param {number} grow Positive = dilate the polygon, negative = erode.
|
||||
* @returns {HTMLCanvasElement} A `w × h` canvas with alpha = selection strength.
|
||||
*/
|
||||
export function buildLassoMask(points, w, h, offX, offY, feather, grow) {
|
||||
// Step 1: draw hard mask
|
||||
const hard = document.createElement('canvas');
|
||||
hard.width = w; hard.height = h;
|
||||
const hCtx = hard.getContext('2d');
|
||||
hCtx.beginPath();
|
||||
hCtx.moveTo(points[0].x - offX, points[0].y - offY);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
hCtx.lineTo(points[i].x - offX, points[i].y - offY);
|
||||
}
|
||||
hCtx.closePath();
|
||||
hCtx.fillStyle = '#fff';
|
||||
hCtx.fill();
|
||||
|
||||
// Step 1b: grow / shrink — blur the hard mask, threshold low for
|
||||
// grow and high for shrink. Same technique as the bg-remove edge
|
||||
// tuner. RGB is left alone, alpha is replaced.
|
||||
if (grow && grow !== 0) {
|
||||
const blurC = document.createElement('canvas');
|
||||
blurC.width = w; blurC.height = h;
|
||||
const bctx = blurC.getContext('2d');
|
||||
bctx.filter = `blur(${Math.abs(grow)}px)`;
|
||||
bctx.drawImage(hard, 0, 0);
|
||||
bctx.filter = 'none';
|
||||
const blurred = bctx.getImageData(0, 0, w, h).data;
|
||||
const hd = hCtx.getImageData(0, 0, w, h);
|
||||
const out = hd.data;
|
||||
const thr = grow > 0 ? 32 : 200;
|
||||
for (let i = 0; i < out.length; i += 4) {
|
||||
const a = blurred[i + 3] >= thr ? 255 : 0;
|
||||
out[i] = a; out[i + 1] = a; out[i + 2] = a; out[i + 3] = a;
|
||||
}
|
||||
hCtx.putImageData(hd, 0, 0);
|
||||
}
|
||||
|
||||
if (feather <= 0) return hard;
|
||||
|
||||
// Step 2: pixel data and distance-based feather.
|
||||
const hardData = hCtx.getImageData(0, 0, w, h);
|
||||
const d = hardData.data;
|
||||
|
||||
// Build inside/outside map.
|
||||
const inside = new Uint8Array(w * h);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
inside[i] = d[i * 4] > 128 ? 1 : 0;
|
||||
}
|
||||
|
||||
// Distance from edge (for pixels inside the selection, distance to nearest outside pixel).
|
||||
const dist = new Float32Array(w * h);
|
||||
dist.fill(feather + 1);
|
||||
|
||||
// Seed: edge pixels (inside pixels adjacent to outside pixels).
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = y * w + x;
|
||||
if (!inside[i]) { dist[i] = 0; continue; }
|
||||
const hasOutside = (x > 0 && !inside[i-1]) || (x < w-1 && !inside[i+1]) ||
|
||||
(y > 0 && !inside[(y-1)*w+x]) || (y < h-1 && !inside[(y+1)*w+x]);
|
||||
if (hasOutside) dist[i] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Two-pass chamfer distance transform.
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = y * w + x;
|
||||
if (dist[i] === 0) continue;
|
||||
if (x > 0) dist[i] = Math.min(dist[i], dist[i-1] + 1);
|
||||
if (y > 0) dist[i] = Math.min(dist[i], dist[(y-1)*w+x] + 1);
|
||||
}
|
||||
}
|
||||
for (let y = h-1; y >= 0; y--) {
|
||||
for (let x = w-1; x >= 0; x--) {
|
||||
const i = y * w + x;
|
||||
if (dist[i] === 0) continue;
|
||||
if (x < w-1) dist[i] = Math.min(dist[i], dist[i+1] + 1);
|
||||
if (y < h-1) dist[i] = Math.min(dist[i], dist[(y+1)*w+x] + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Pixels near the edge get reduced alpha.
|
||||
const result = document.createElement('canvas');
|
||||
result.width = w; result.height = h;
|
||||
const rCtx = result.getContext('2d');
|
||||
const rData = rCtx.createImageData(w, h);
|
||||
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
if (!inside[i]) continue;
|
||||
const edgeDist = dist[i];
|
||||
const alpha = edgeDist >= feather ? 255 : Math.round((edgeDist / feather) * 255);
|
||||
rData.data[i*4] = alpha;
|
||||
rData.data[i*4+1] = alpha;
|
||||
rData.data[i*4+2] = alpha;
|
||||
rData.data[i*4+3] = 255;
|
||||
}
|
||||
rCtx.putImageData(rData, 0, 0);
|
||||
return result;
|
||||
}
|
||||
65
static/js/editor/tools/lasso.js
Normal file
65
static/js/editor/tools/lasso.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Lasso tool — freehand polygon selection. Mouse-down starts a fresh
|
||||
* polygon; every move appends a point and redraws the dashed outline;
|
||||
* mouse-up keeps the selection visible (the panel's action buttons
|
||||
* read `state.lassoPoints` to act on it).
|
||||
*
|
||||
* Owns its own begin/drag/end handlers and reads/writes shared state.
|
||||
*
|
||||
* @param {{
|
||||
* composite: () => void,
|
||||
* drawLassoOverlay: () => void,
|
||||
* syncToolClearIndicators: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
export function createLassoTool({ composite, drawLassoOverlay, syncToolClearIndicators }) {
|
||||
return {
|
||||
begin(e) {
|
||||
state.lassoPoints = [];
|
||||
state.lassoActive = true;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
state.lassoPoints.push(coords);
|
||||
},
|
||||
|
||||
drag(e) {
|
||||
if (!state.lassoActive) return;
|
||||
e.preventDefault();
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
state.lassoPoints.push(coords);
|
||||
// Live overlay: dashed white outline + translucent red fill.
|
||||
composite();
|
||||
if (state.lassoPoints.length > 1) {
|
||||
state.mainCtx.beginPath();
|
||||
state.mainCtx.moveTo(state.lassoPoints[0].x, state.lassoPoints[0].y);
|
||||
for (let i = 1; i < state.lassoPoints.length; i++) {
|
||||
state.mainCtx.lineTo(state.lassoPoints[i].x, state.lassoPoints[i].y);
|
||||
}
|
||||
state.mainCtx.closePath();
|
||||
state.mainCtx.strokeStyle = '#fff';
|
||||
state.mainCtx.lineWidth = 1 / state.zoom;
|
||||
state.mainCtx.setLineDash([4 / state.zoom, 4 / state.zoom]);
|
||||
state.mainCtx.stroke();
|
||||
state.mainCtx.setLineDash([]);
|
||||
state.mainCtx.fillStyle = 'rgba(255, 80, 80, 0.15)';
|
||||
state.mainCtx.fill();
|
||||
}
|
||||
},
|
||||
|
||||
end() {
|
||||
state.lassoActive = false;
|
||||
if (state.lassoPoints.length < 3) {
|
||||
state.lassoPoints = [];
|
||||
composite();
|
||||
syncToolClearIndicators();
|
||||
return;
|
||||
}
|
||||
// Keep the selection drawn — the panel's action buttons use it.
|
||||
composite();
|
||||
drawLassoOverlay();
|
||||
syncToolClearIndicators();
|
||||
},
|
||||
};
|
||||
}
|
||||
79
static/js/editor/tools/move.js
Normal file
79
static/js/editor/tools/move.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Move tool — drag a layer around the canvas, with optional snap-on-Ctrl
|
||||
* to other layers' edges/centers and to canvas edges/center.
|
||||
*
|
||||
* Owns its own input handlers (begin/drag/end) and reads/writes the
|
||||
* shared `state` store directly. The factory takes a small dependency
|
||||
* bag for things that still live in galleryEditor.js — `activeLayer`,
|
||||
* `saveState`, `composite` — so this module doesn't have to know about
|
||||
* the orchestrator.
|
||||
*
|
||||
* @param {{
|
||||
* activeLayer: () => {id: string, canvas: HTMLCanvasElement, locked?: boolean} | null,
|
||||
* saveState: (label?: string) => void,
|
||||
* composite: () => void,
|
||||
* }} deps
|
||||
* @returns {{ begin: (e: Event) => void, drag: (e: Event) => void, end: () => void }}
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { computeSnap as computeSnapImpl } from '../snap.js';
|
||||
|
||||
export function createMoveTool({ activeLayer, saveState, composite }) {
|
||||
function computeSnap(layer, nx, ny) {
|
||||
return computeSnapImpl(layer, nx, ny, {
|
||||
zoom: state.zoom,
|
||||
canvasW: state.imgWidth,
|
||||
canvasH: state.imgHeight,
|
||||
otherLayers: state.layers.map(l => ({
|
||||
visible: l.visible,
|
||||
id: l.id,
|
||||
canvas: l.canvas,
|
||||
offset: state.layerOffsets.get(l.id) || { x: 0, y: 0 },
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
begin(e) {
|
||||
const layer = activeLayer();
|
||||
if (!layer || layer.locked) return;
|
||||
saveState();
|
||||
state.moving = true;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
state.moveStartX = coords.x;
|
||||
state.moveStartY = coords.y;
|
||||
state.moveLayerOffsetX = off.x;
|
||||
state.moveLayerOffsetY = off.y;
|
||||
},
|
||||
drag(e) {
|
||||
if (!state.moving) return;
|
||||
e.preventDefault();
|
||||
const layer = activeLayer();
|
||||
if (!layer) return;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
const dx = coords.x - state.moveStartX;
|
||||
const dy = coords.y - state.moveStartY;
|
||||
let nx = state.moveLayerOffsetX + dx;
|
||||
let ny = state.moveLayerOffsetY + dy;
|
||||
// Ctrl held = snap to canvas edges/center and to every other
|
||||
// visible layer's edges/center. Opt-in to avoid a "sticky" feel
|
||||
// during normal drags.
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const snapped = computeSnap(layer, nx, ny);
|
||||
nx = snapped.x;
|
||||
ny = snapped.y;
|
||||
state.activeSnapGuides = snapped.guides;
|
||||
} else {
|
||||
state.activeSnapGuides = null;
|
||||
}
|
||||
state.layerOffsets.set(layer.id, { x: nx, y: ny });
|
||||
composite();
|
||||
},
|
||||
end() {
|
||||
state.moving = false;
|
||||
state.activeSnapGuides = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
123
static/js/editor/tools/stroke.js
Normal file
123
static/js/editor/tools/stroke.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Shared stroke pipeline for brush / eraser / inpaint.
|
||||
*
|
||||
* Per-sample stamping happens in `_strokeTo` (still in galleryEditor.js
|
||||
* because it touches a lot of pixel-pass internals). This module owns
|
||||
* the begin / continue / end orchestration around it:
|
||||
*
|
||||
* - begin: capture the inpaint-erase flag for the stroke, ensure a
|
||||
* mask sub-layer exists when inpaint runs against an empty
|
||||
* layer, push an undo entry with a tool-specific label, then
|
||||
* kick off the first stamp.
|
||||
* - continue: forward the new cursor position to `_strokeTo`.
|
||||
* - end: clear the drawing flag, composite, sync any tool indicators
|
||||
* that reflect mask state.
|
||||
*
|
||||
* Clone has its own begin (see tools/clone.js) but reuses `continue`
|
||||
* and `end` because once a clone stroke is in progress, the pipeline
|
||||
* is identical.
|
||||
*
|
||||
* @param {{
|
||||
* saveState: (label: string) => void,
|
||||
* strokeTo: (x: number, y: number) => void,
|
||||
* composite: () => void,
|
||||
* getActiveMaskLayer: () => object | null,
|
||||
* activeParentLayer: () => object | null,
|
||||
* ensureActiveMaskLayer: () => object | null,
|
||||
* createLayer: (name: string, w: number, h: number) => object,
|
||||
* renderLayerPanel: () => void,
|
||||
* syncToolClearIndicators: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
const STROKE_TOOLS = new Set(['brush', 'eraser', 'inpaint']);
|
||||
|
||||
function strokeLabel(tool) {
|
||||
if (tool === 'brush') return 'Brush stroke';
|
||||
if (tool === 'eraser') return 'Eraser stroke';
|
||||
if (tool === 'inpaint') return state.inpaintEraseStroke ? 'Erase mask' : 'Paint mask';
|
||||
return 'Stroke';
|
||||
}
|
||||
|
||||
export function createStrokeTool({
|
||||
saveState, strokeTo, composite,
|
||||
getActiveMaskLayer, activeParentLayer, ensureActiveMaskLayer, createLayer,
|
||||
renderLayerPanel, syncToolClearIndicators,
|
||||
}) {
|
||||
return {
|
||||
/**
|
||||
* Begin a stroke. Returns true if the dispatcher should consider
|
||||
* the event handled (i.e. tool is one of brush/eraser/inpaint).
|
||||
*/
|
||||
tryBegin(e) {
|
||||
if (!STROKE_TOOLS.has(state.tool)) return false;
|
||||
// Capture the inpaint-erase flag for this stroke. Ctrl+Alt
|
||||
// pressed at pointerdown flips the persistent toggle for one
|
||||
// stroke only.
|
||||
if (state.tool === 'inpaint') {
|
||||
const flip = e && e.ctrlKey && e.altKey;
|
||||
state.inpaintEraseStroke = flip ? !state.inpaintEraseMode : state.inpaintEraseMode;
|
||||
// Make sure we're painting onto an existing mask sub-layer. If
|
||||
// there's no parent layer at all, create one first so a totally
|
||||
// empty canvas can accept an inpaint stroke.
|
||||
if (!getActiveMaskLayer()) {
|
||||
let parent = activeParentLayer();
|
||||
if (!parent) {
|
||||
parent = createLayer('Layer 1', state.imgWidth, state.imgHeight);
|
||||
state.layers.push(parent);
|
||||
state.activeLayerId = parent.id;
|
||||
}
|
||||
if (parent.masks && parent.masks.length) {
|
||||
parent.activeMaskId = parent.masks[parent.masks.length - 1].id;
|
||||
const m = getActiveMaskLayer();
|
||||
if (m) {
|
||||
state.maskCanvas = m.canvas;
|
||||
state.maskCtx = m.ctx;
|
||||
renderLayerPanel();
|
||||
}
|
||||
} else {
|
||||
const mk = ensureActiveMaskLayer();
|
||||
if (mk) {
|
||||
state.maskCanvas = mk.canvas;
|
||||
state.maskCtx = mk.ctx;
|
||||
renderLayerPanel();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
saveState(strokeLabel(state.tool));
|
||||
state.drawing = true;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
state.lastX = coords.x;
|
||||
state.lastY = coords.y;
|
||||
strokeTo(coords.x, coords.y);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Forward an in-progress stroke. Returns true if a stroke is
|
||||
* actually in progress (dispatcher should short-circuit).
|
||||
*/
|
||||
tryContinue(e) {
|
||||
if (!state.drawing) return false;
|
||||
e.preventDefault();
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
strokeTo(coords.x, coords.y);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Wrap up an in-progress stroke. Returns true if there was one.
|
||||
*/
|
||||
tryEnd() {
|
||||
if (!state.drawing) return false;
|
||||
const wasDrawingInpaint = state.tool === 'inpaint';
|
||||
state.drawing = false;
|
||||
composite();
|
||||
if (wasDrawingInpaint) syncToolClearIndicators();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
174
static/js/editor/tools/transform-drag.js
Normal file
174
static/js/editor/tools/transform-drag.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Transform-drag tool — handle drag interactions for the Transform
|
||||
* tool (resize via corner/edge handles, rotation via the rot grip).
|
||||
*
|
||||
* The transform UI runs in TWO modes: the floating popup (W/H/rot
|
||||
* numeric inputs, lives elsewhere) AND direct drag on the canvas
|
||||
* handles. Both ultimately mutate `state.transformPendingW/H/Rot` and
|
||||
* call `reapplyTransform()` to redraw. This module owns the drag
|
||||
* branch.
|
||||
*
|
||||
* The dispatcher in galleryEditor.js calls `tryBegin/tryContinue/
|
||||
* tryEnd` which return `true` when the event was for the transform
|
||||
* tool and was handled (so the dispatcher can short-circuit).
|
||||
*
|
||||
* @param {{
|
||||
* beginMove: (e: Event) => void,
|
||||
* composite: () => void,
|
||||
* drawTransformHandles: () => void,
|
||||
* reapplyTransform: () => void,
|
||||
* getTransformHandle: (x: number, y: number) => string | null,
|
||||
* cursorForHandle: (id: string | null) => string,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
export function createTransformDragTool({
|
||||
beginMove, composite, drawTransformHandles, reapplyTransform,
|
||||
getTransformHandle, cursorForHandle,
|
||||
}) {
|
||||
return {
|
||||
/**
|
||||
* Called on pointerdown. Returns true if the transform tool handled
|
||||
* the event (the dispatcher should NOT fall through to other tools).
|
||||
*/
|
||||
tryBegin(e) {
|
||||
if (!state.transformActive) return false;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
state.transformHandle = getTransformHandle(coords.x, coords.y);
|
||||
if (state.transformHandle) {
|
||||
state.transformStartX = coords.x;
|
||||
state.transformStartY = coords.y;
|
||||
// Snapshot offset + size at drag-start so each frame computes
|
||||
// "start + dx" (correct delta) rather than accumulating off the
|
||||
// running offset, which was making top/left grabs drift.
|
||||
const layer = state.transformLayer;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
state.transformStartOffX = off.x;
|
||||
state.transformStartOffY = off.y;
|
||||
state.transformOrigW = layer.canvas.width;
|
||||
state.transformOrigH = layer.canvas.height;
|
||||
return true;
|
||||
}
|
||||
// No corner hit — if click inside the layer's bounding box, act
|
||||
// like Move so the user can drag the layer around without
|
||||
// switching tools.
|
||||
if (state.transformLayer) {
|
||||
const off = state.layerOffsets.get(state.transformLayer.id) || { x: 0, y: 0 };
|
||||
const w = state.transformLayer.canvas.width;
|
||||
const h = state.transformLayer.canvas.height;
|
||||
if (coords.x >= off.x && coords.x <= off.x + w &&
|
||||
coords.y >= off.y && coords.y <= off.y + h) {
|
||||
beginMove(e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Called on pointermove. Returns true if handled.
|
||||
*
|
||||
* When transformActive but no handle is grabbed, updates the
|
||||
* hover cursor + pulse. When a handle is grabbed, drives the
|
||||
* resize / rotation pipeline.
|
||||
*/
|
||||
tryContinue(e) {
|
||||
if (!state.transformActive) return false;
|
||||
// No drag in progress — just hover-cursor + pulse.
|
||||
if (!state.transformHandle && state.mainCanvas) {
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
const hovered = getTransformHandle(coords.x, coords.y);
|
||||
state.mainCanvas.style.cursor = hovered ? cursorForHandle(hovered) : 'default';
|
||||
if (hovered !== state.hoveredHandle) {
|
||||
state.hoveredHandle = hovered;
|
||||
composite();
|
||||
}
|
||||
return false; // didn't fully consume the event
|
||||
}
|
||||
if (!state.transformHandle) return false;
|
||||
e.preventDefault();
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
// Rotation grip — angle measured from the layer's geometric
|
||||
// centre to the cursor. Mirror into the popup if it's open.
|
||||
if (state.transformHandle === 'rot') {
|
||||
const layer = state.transformLayer;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const cx = off.x + layer.canvas.width / 2;
|
||||
const cy = off.y + layer.canvas.height / 2;
|
||||
const rad = Math.atan2(coords.y - cy, coords.x - cx) + Math.PI / 2;
|
||||
let deg = Math.round((rad * 180) / Math.PI);
|
||||
if (e.shiftKey) deg = Math.round(deg / 15) * 15; // 15° snap
|
||||
while (deg > 180) deg -= 360;
|
||||
while (deg <= -180) deg += 360;
|
||||
state.transformPendingRot = deg;
|
||||
reapplyTransform();
|
||||
if (state.transformPopup) {
|
||||
const rotIn = state.transformPopup.querySelector('#ge-transform-rot');
|
||||
if (rotIn) rotIn.value = String(deg);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Resize via corner / edge handle.
|
||||
const dx = coords.x - state.transformStartX;
|
||||
const dy = coords.y - state.transformStartY;
|
||||
const layer = state.transformLayer;
|
||||
let newW = layer.canvas.width;
|
||||
let newH = layer.canvas.height;
|
||||
if (state.transformHandle.includes('r')) newW = state.transformOrigW + dx;
|
||||
if (state.transformHandle.includes('l')) newW = state.transformOrigW - dx;
|
||||
if (state.transformHandle.includes('b')) newH = state.transformOrigH + dy;
|
||||
if (state.transformHandle.includes('t')) newH = state.transformOrigH - dy;
|
||||
// Shift = lock aspect ratio. Use whichever axis moved more
|
||||
// (relative to the original) as the driver.
|
||||
if (e.shiftKey && state.transformOrigW > 0 && state.transformOrigH > 0) {
|
||||
const aspect = state.transformOrigW / state.transformOrigH;
|
||||
const wDelta = Math.abs(newW - state.transformOrigW);
|
||||
const hDelta = Math.abs(newH - state.transformOrigH);
|
||||
if (wDelta >= hDelta) {
|
||||
newH = Math.max(1, Math.round(newW / aspect));
|
||||
} else {
|
||||
newW = Math.max(1, Math.round(newH * aspect));
|
||||
}
|
||||
}
|
||||
newW = Math.max(1, Math.round(newW));
|
||||
newH = Math.max(1, Math.round(newH));
|
||||
// Route through the popup-driven pipeline so popup + drag stay
|
||||
// in sync. Anchor the opposite corner via transformOrigOffset so
|
||||
// handles don't slide while the user drags.
|
||||
state.transformPendingW = newW;
|
||||
state.transformPendingH = newH;
|
||||
const anchorOffX = state.transformStartOffX +
|
||||
(state.transformHandle.includes('l') ? (state.transformOrigW - newW) : 0);
|
||||
const anchorOffY = state.transformStartOffY +
|
||||
(state.transformHandle.includes('t') ? (state.transformOrigH - newH) : 0);
|
||||
state.transformOrigOffset = {
|
||||
x: anchorOffX + newW / 2 - state.transformOrigW / 2,
|
||||
y: anchorOffY + newH / 2 - state.transformOrigH / 2,
|
||||
};
|
||||
reapplyTransform();
|
||||
// Mirror the new W/H into the popup if it's open.
|
||||
if (state.transformPopup) {
|
||||
const wIn = state.transformPopup.querySelector('#ge-transform-w');
|
||||
const hIn = state.transformPopup.querySelector('#ge-transform-h');
|
||||
if (wIn) wIn.value = String(state.transformPendingFlipH ? -newW : newW);
|
||||
if (hIn) hIn.value = String(state.transformPendingFlipV ? -newH : newH);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Called on pointerup. Returns true if handled.
|
||||
*/
|
||||
tryEnd() {
|
||||
if (!(state.transformActive && state.transformHandle)) return false;
|
||||
state.transformHandle = null;
|
||||
state.transformOrigW = state.transformLayer?.canvas.width || 0;
|
||||
state.transformOrigH = state.transformLayer?.canvas.height || 0;
|
||||
composite();
|
||||
drawTransformHandles();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
271
static/js/editor/tools/transform-handles.js
Normal file
271
static/js/editor/tools/transform-handles.js
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Transform-tool handle rendering + hit-testing + overlay sync.
|
||||
*
|
||||
* Lives separately from `transform-drag.js` (which owns the drag
|
||||
* STATE MACHINE) because these three helpers are pure geometry that
|
||||
* happens to read shared state — they don't track in-progress drags,
|
||||
* they just paint and hit-test.
|
||||
*
|
||||
* - `syncOverlay(margin)` positions the overlay canvas + sizes its
|
||||
* bitmap based on the main canvas + zoom.
|
||||
* - `drawHandles(margin)` draws the rotated bounding outline + 4
|
||||
* corner handles + the rotation knob (with
|
||||
* hover / active visual states).
|
||||
* - `getHandleAt(x, y)` returns the handle id under (x, y), or
|
||||
* null. Geometry MUST mirror `drawHandles`
|
||||
* exactly or the user grabs phantom points.
|
||||
*
|
||||
* No event listeners attached here — the dispatcher in
|
||||
* editor/tools/transform-drag.js calls `getHandleAt` and routes
|
||||
* pointer events.
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
|
||||
/**
|
||||
* Position the transform overlay canvas + size its backing bitmap.
|
||||
* Margin is the image-space slack each side so handles can render
|
||||
* outside the main canvas (matches _TRANSFORM_OVERLAY_MARGIN in
|
||||
* galleryEditor.js — kept as a parameter so this module has no
|
||||
* dependency on a magic number defined elsewhere).
|
||||
*/
|
||||
export function syncOverlay(margin) {
|
||||
if (!state.transformOverlay || !state.mainCanvas) return;
|
||||
if (!state.transformActive) {
|
||||
state.transformOverlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const W = state.mainCanvas.width + 2 * margin;
|
||||
const H = state.mainCanvas.height + 2 * margin;
|
||||
if (state.transformOverlay.width !== W) state.transformOverlay.width = W;
|
||||
if (state.transformOverlay.height !== H) state.transformOverlay.height = H;
|
||||
// Overlay must scale with state.zoom so its handles render at the
|
||||
// SAME on-screen size as the main canvas content. Without this, the
|
||||
// overlay renders at full bitmap size while main canvas shrinks
|
||||
// (zoomed-out), making handles look massive.
|
||||
state.transformOverlay.style.display = '';
|
||||
state.transformOverlay.style.position = 'absolute';
|
||||
state.transformOverlay.style.width = (W * state.zoom) + 'px';
|
||||
state.transformOverlay.style.height = (H * state.zoom) + 'px';
|
||||
state.transformOverlay.style.pointerEvents = 'none';
|
||||
state.transformOverlay.style.zIndex = '5';
|
||||
// Position the overlay at the main canvas's LAYOUT position
|
||||
// (offsetLeft/Top — unaffected by CSS transforms), shifted up-left by
|
||||
// the overlay's `margin` image-px of handle slack. Then SHARE the
|
||||
// canvas's transform (the pan handler writes the same translate3d to
|
||||
// both canvas + overlay), so pan moves them together. Reading the
|
||||
// layout offset (not getBoundingClientRect, which includes the pan
|
||||
// transform) is what avoids the double-pan "bounce".
|
||||
state.transformOverlay.style.left = Math.round(state.mainCanvas.offsetLeft - margin * state.zoom) + 'px';
|
||||
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop - margin * state.zoom) + 'px';
|
||||
state.transformOverlay.style.transform = state.mainCanvas.style.transform || 'none';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Compute the on-screen position of the rotation knob given the
|
||||
* layer's bbox center + rotation. The knob normally sits OUTSIDE the
|
||||
* top edge of the rotated layer; if that would land beyond the canvas
|
||||
* viewport, flip it INSIDE.
|
||||
*
|
||||
* Returned by `_knobPosition` and shared by drawHandles + getHandleAt
|
||||
* so both compute the same point.
|
||||
*/
|
||||
function knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset) {
|
||||
let rotInside = false;
|
||||
const outsideR = baseInnerR + rotOffset;
|
||||
const knobLocalX = cxh + Math.sin(rotRad) * outsideR;
|
||||
const knobLocalY = cyh - Math.cos(rotRad) * outsideR;
|
||||
// Primary check: anything drawn outside the main canvas's pixel
|
||||
// buffer is invisible (canvas operations clip silently).
|
||||
if (
|
||||
knobLocalX < 0 || knobLocalY < 0 ||
|
||||
knobLocalX > state.mainCanvas.width || knobLocalY > state.mainCanvas.height
|
||||
) {
|
||||
rotInside = true;
|
||||
}
|
||||
// Secondary check: even if the knob is inside the canvas bitmap, the
|
||||
// viewport may have scrolled the canvas such that the knob falls
|
||||
// outside the visible canvas-area window.
|
||||
try {
|
||||
const area = state.container && state.container.querySelector('.ge-canvas-area');
|
||||
if (area && !rotInside) {
|
||||
const aRect = area.getBoundingClientRect();
|
||||
const mRect = state.mainCanvas.getBoundingClientRect();
|
||||
const scaleX = mRect.width / state.mainCanvas.width;
|
||||
const scaleY = mRect.height / state.mainCanvas.height;
|
||||
const knobClientX = mRect.left + knobLocalX * scaleX;
|
||||
const knobClientY = mRect.top + knobLocalY * scaleY;
|
||||
if (knobClientY < aRect.top + 6) rotInside = true;
|
||||
if (knobClientX < aRect.left + 6 || knobClientX > aRect.right - 6) rotInside = true;
|
||||
}
|
||||
} catch {}
|
||||
const innerR = rotInside ? Math.max(4, baseInnerR - rotOffset) : baseInnerR;
|
||||
const rotR = rotInside ? innerR : baseInnerR + rotOffset;
|
||||
return {
|
||||
rotInside,
|
||||
innerR,
|
||||
rotX: cxh + Math.sin(rotRad) * rotR,
|
||||
rotY: cyh - Math.cos(rotRad) * rotR,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Draw the rotated bounding outline + 4 corner handles + the rotation
|
||||
* knob into the overlay canvas. The overlay is translated by `margin`
|
||||
* so image (0,0) maps to overlay (margin, margin).
|
||||
*/
|
||||
export function drawHandles(margin) {
|
||||
if (!state.transformActive || !state.transformLayer) return;
|
||||
syncOverlay(margin);
|
||||
if (!state.transformOverlayCtx) return;
|
||||
const layer = state.transformLayer;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const w = layer.canvas.width;
|
||||
const h = layer.canvas.height;
|
||||
const ctx = state.transformOverlayCtx;
|
||||
// Clear + shift drawing by margin so image (0,0) maps to overlay (M,M).
|
||||
ctx.clearRect(0, 0, state.transformOverlay.width, state.transformOverlay.height);
|
||||
ctx.save();
|
||||
ctx.translate(margin, margin);
|
||||
// Zoom-corrected handle size + stroke so they stay readable at any zoom.
|
||||
const sz = 10 / state.zoom;
|
||||
const stroke = 1.5 / state.zoom;
|
||||
|
||||
// Pre-rotation rectangle dims (what the user sees the layer as).
|
||||
// Falls back to layer bbox before any popup values exist.
|
||||
const preW = state.transformPendingW || w;
|
||||
const preH = state.transformPendingH || h;
|
||||
const cxBox = off.x + w / 2;
|
||||
const cyBox = off.y + h / 2;
|
||||
const rotRadBox = ((state.transformPendingRot || 0) * Math.PI) / 180;
|
||||
const cosBox = Math.cos(rotRadBox);
|
||||
const sinBox = Math.sin(rotRadBox);
|
||||
const rotPt = (dx, dy) => ({
|
||||
x: cxBox + dx * cosBox - dy * sinBox,
|
||||
y: cyBox + dx * sinBox + dy * cosBox,
|
||||
});
|
||||
const tl = rotPt(-preW / 2, -preH / 2);
|
||||
const tr = rotPt( preW / 2, -preH / 2);
|
||||
const br = rotPt( preW / 2, preH / 2);
|
||||
const bl = rotPt(-preW / 2, preH / 2);
|
||||
|
||||
// Outline of the rotated rectangle — solid white inner line with a
|
||||
// thin black halo for contrast on light AND dark backgrounds.
|
||||
const drawRectOutline = () => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tl.x, tl.y);
|
||||
ctx.lineTo(tr.x, tr.y);
|
||||
ctx.lineTo(br.x, br.y);
|
||||
ctx.lineTo(bl.x, bl.y);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
};
|
||||
ctx.lineWidth = 1 / state.zoom;
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.45)';
|
||||
ctx.setLineDash([6 / state.zoom, 4 / state.zoom]);
|
||||
ctx.lineDashOffset = 1 / state.zoom;
|
||||
drawRectOutline();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineDashOffset = 0;
|
||||
drawRectOutline();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Corner handles + rotation knob anchored to the rotated layer's
|
||||
// top-center (not bbox top), so the knob stays attached to the
|
||||
// visible content as it spins.
|
||||
const rotOffset = 24 / state.zoom;
|
||||
const cxh = off.x + w / 2;
|
||||
const cyh = off.y + h / 2;
|
||||
const rotRad = ((state.transformPendingRot || 0) * Math.PI) / 180;
|
||||
const baseInnerR = (state.transformPendingH || h) / 2;
|
||||
const knob = knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset);
|
||||
// Tether line collapses to a point when knob is inside the layer.
|
||||
const drawTether = !knob.rotInside;
|
||||
const innerX = cxh + Math.sin(rotRad) * baseInnerR;
|
||||
const innerY = cyh - Math.cos(rotRad) * baseInnerR;
|
||||
const corners = [
|
||||
{ x: tl.x, y: tl.y, id: 'tl' },
|
||||
{ x: tr.x, y: tr.y, id: 'tr' },
|
||||
{ x: br.x, y: br.y, id: 'br' },
|
||||
{ x: bl.x, y: bl.y, id: 'bl' },
|
||||
{ x: knob.rotX, y: knob.rotY, id: 'rot' },
|
||||
];
|
||||
if (drawTether) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(innerX, innerY);
|
||||
ctx.lineTo(knob.rotX, knob.rotY);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.lineWidth = 1 / state.zoom;
|
||||
ctx.stroke();
|
||||
}
|
||||
for (const c of corners) {
|
||||
const active = c.id === state.transformHandle;
|
||||
const hovered = !active && c.id === state.hoveredHandle;
|
||||
const radius = (active ? sz * 0.75 : hovered ? sz * 0.6 : sz / 2);
|
||||
ctx.beginPath();
|
||||
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = active ? '#e06c75' : hovered ? '#ffd' : '#fff';
|
||||
ctx.fill();
|
||||
ctx.lineWidth = stroke;
|
||||
ctx.strokeStyle = active ? '#fff' : 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.stroke();
|
||||
if (hovered) {
|
||||
// Subtle red ring around the hovered handle for visual feedback.
|
||||
ctx.beginPath();
|
||||
ctx.arc(c.x, c.y, radius + 2 / state.zoom, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = 'rgba(224, 108, 117, 0.7)';
|
||||
ctx.lineWidth = stroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Hit-test (x, y) against the transform handles. Returns the handle
|
||||
* id ('tl' | 'tr' | 'br' | 'bl' | 'rot') or null.
|
||||
*
|
||||
* Geometry MUST mirror `drawHandles` exactly, otherwise the user
|
||||
* grabs phantom points.
|
||||
*/
|
||||
export function getHandleAt(x, y) {
|
||||
if (!state.transformLayer) return null;
|
||||
const layer = state.transformLayer;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const w = layer.canvas.width;
|
||||
const h = layer.canvas.height;
|
||||
const threshold = 8 / state.zoom;
|
||||
const rotOffset = 24 / state.zoom;
|
||||
const cxh = off.x + w / 2;
|
||||
const cyh = off.y + h / 2;
|
||||
const rotRad = ((state.transformPendingRot || 0) * Math.PI) / 180;
|
||||
const baseInnerR = (state.transformPendingH || h) / 2;
|
||||
const knob = knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset);
|
||||
|
||||
// Rotate corners around centre — must match drawHandles.
|
||||
const preW = state.transformPendingW || w;
|
||||
const preH = state.transformPendingH || h;
|
||||
const cosA = Math.cos(rotRad);
|
||||
const sinA = Math.sin(rotRad);
|
||||
const rotCorner = (dx, dy) => ({
|
||||
x: cxh + dx * cosA - dy * sinA,
|
||||
y: cyh + dx * sinA + dy * cosA,
|
||||
});
|
||||
const tlH = rotCorner(-preW / 2, -preH / 2);
|
||||
const trH = rotCorner( preW / 2, -preH / 2);
|
||||
const brH = rotCorner( preW / 2, preH / 2);
|
||||
const blH = rotCorner(-preW / 2, preH / 2);
|
||||
const handles = [
|
||||
{ x: tlH.x, y: tlH.y, id: 'tl' },
|
||||
{ x: trH.x, y: trH.y, id: 'tr' },
|
||||
{ x: brH.x, y: brH.y, id: 'br' },
|
||||
{ x: blH.x, y: blH.y, id: 'bl' },
|
||||
{ x: knob.rotX, y: knob.rotY, id: 'rot' },
|
||||
];
|
||||
for (const c of handles) {
|
||||
if (Math.abs(x - c.x) < threshold && Math.abs(y - c.y) < threshold) return c.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
381
static/js/editor/tools/transform-session.js
Normal file
381
static/js/editor/tools/transform-session.js
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Transform-tool session lifecycle + floating popup wiring.
|
||||
*
|
||||
* _startTransform snapshot the active layer + open popup
|
||||
* _openTransformPopup build the W/H/rotation popup, wire inputs
|
||||
* _wireTransformDrag header drag, mobile + desktop position handling
|
||||
* _reapplyTransform live preview re-render from the snapshot
|
||||
* _confirmTransform commit + clear session state
|
||||
* _cancelTransform restore via undo() + clear session state
|
||||
*
|
||||
* Handle-drag interactions on the CANVAS (corner / rotation grip) live
|
||||
* in `editor/tools/transform-drag.js` — those mutate the same staged
|
||||
* `state.transformPending*` fields that the popup inputs do, so both
|
||||
* surfaces stay in sync via `_reapplyTransform()`.
|
||||
*
|
||||
* @param {{
|
||||
* activeLayer: () => object | null,
|
||||
* saveState: (label?: string) => void,
|
||||
* composite: () => void,
|
||||
* fitZoom: () => void,
|
||||
* drawTransformHandles: () => void,
|
||||
* showCanvasLoading: (label: string) => void,
|
||||
* hideCanvasLoading: () => void,
|
||||
* undo: () => void,
|
||||
* uiModule: object | null,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{
|
||||
* startTransform, openTransformPopup, closeTransformPopup,
|
||||
* reapplyTransform, confirmTransform, cancelTransform,
|
||||
* }}
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import {
|
||||
transformPopupHTML,
|
||||
attachSpinRepeat,
|
||||
} from '../build/transform-popup.js';
|
||||
|
||||
export function createTransformSession({
|
||||
activeLayer, saveState, composite, fitZoom, drawTransformHandles,
|
||||
showCanvasLoading, hideCanvasLoading, undo, uiModule,
|
||||
}) {
|
||||
function startTransform() {
|
||||
const layer = activeLayer();
|
||||
if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; }
|
||||
if (state.transformActive) { cancelTransform(); return; } // toggle off
|
||||
state.transformActive = true;
|
||||
state.transformLayer = layer;
|
||||
state.transformOrigW = layer.canvas.width;
|
||||
state.transformOrigH = layer.canvas.height;
|
||||
state.transformPendingW = state.transformOrigW;
|
||||
state.transformPendingH = state.transformOrigH;
|
||||
state.transformPendingRot = 0;
|
||||
state.transformPendingFlipH = false;
|
||||
state.transformPendingFlipV = false;
|
||||
// Snapshot the layer so live preview can re-derive from the
|
||||
// original pixels on every keystroke instead of stacking
|
||||
// destructive edits.
|
||||
state.transformOrigCanvas = document.createElement('canvas');
|
||||
state.transformOrigCanvas.width = state.transformOrigW;
|
||||
state.transformOrigCanvas.height = state.transformOrigH;
|
||||
state.transformOrigCanvas.getContext('2d').drawImage(layer.canvas, 0, 0);
|
||||
state.transformOrigOffset = { ...(state.layerOffsets.get(layer.id) || { x: 0, y: 0 }) };
|
||||
saveState();
|
||||
// Fit canvas to viewport so the corner handles are visible —
|
||||
// without this, a layer larger than the viewport leaves the grab
|
||||
// markers off-screen.
|
||||
try { fitZoom(); } catch {}
|
||||
composite();
|
||||
drawTransformHandles();
|
||||
openTransformPopup();
|
||||
}
|
||||
|
||||
function closeTransformPopup() {
|
||||
if (state.transformPopup) {
|
||||
try { state.transformPopup.remove(); } catch {}
|
||||
state.transformPopup = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// and flip via negative values.
|
||||
function openTransformPopup() {
|
||||
closeTransformPopup();
|
||||
if (!state.container) return;
|
||||
const pop = document.createElement('div');
|
||||
pop.className = 'ge-transform-popup';
|
||||
pop.innerHTML = transformPopupHTML();
|
||||
state.container.appendChild(pop);
|
||||
state.transformPopup = pop;
|
||||
wireTransformDrag(pop);
|
||||
const wInput = pop.querySelector('#ge-transform-w');
|
||||
const hInput = pop.querySelector('#ge-transform-h');
|
||||
const rotInput = pop.querySelector('#ge-transform-rot');
|
||||
const aspectBtn = pop.querySelector('#ge-transform-aspect');
|
||||
wInput.value = String(state.transformOrigW);
|
||||
hInput.value = String(state.transformOrigH);
|
||||
rotInput.value = '0';
|
||||
aspectBtn.classList.toggle('active', state.transformAspectLock);
|
||||
aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false');
|
||||
|
||||
// Aspect-lock follower model: while the lock is engaged, ONE
|
||||
// field is the "driver" and the other is read-only + dimmed.
|
||||
// Driver = whichever field the user last typed in. Toggling the
|
||||
// chain releases the follower.
|
||||
let driver = null;
|
||||
const applyAspectVisuals = () => {
|
||||
if (!state.transformAspectLock || !driver) {
|
||||
wInput.readOnly = false;
|
||||
hInput.readOnly = false;
|
||||
wInput.classList.remove('ge-transform-input-locked');
|
||||
hInput.classList.remove('ge-transform-input-locked');
|
||||
return;
|
||||
}
|
||||
const followerW = driver === 'h';
|
||||
const followerH = driver === 'w';
|
||||
wInput.readOnly = followerW;
|
||||
hInput.readOnly = followerH;
|
||||
wInput.classList.toggle('ge-transform-input-locked', followerW);
|
||||
hInput.classList.toggle('ge-transform-input-locked', followerH);
|
||||
};
|
||||
const refresh = () => {
|
||||
let w = parseInt(wInput.value, 10);
|
||||
let h = parseInt(hInput.value, 10);
|
||||
const rot = parseInt(rotInput.value, 10) || 0;
|
||||
state.transformPendingFlipH = w < 0;
|
||||
state.transformPendingFlipV = h < 0;
|
||||
w = Math.abs(w || state.transformOrigW);
|
||||
h = Math.abs(h || state.transformOrigH);
|
||||
state.transformPendingW = Math.max(1, w);
|
||||
state.transformPendingH = Math.max(1, h);
|
||||
state.transformPendingRot = rot;
|
||||
reapplyTransform();
|
||||
};
|
||||
wInput.addEventListener('input', () => {
|
||||
if (state.transformAspectLock) {
|
||||
driver = 'w';
|
||||
const w = parseInt(wInput.value, 10);
|
||||
if (!Number.isNaN(w) && state.transformOrigW > 0) {
|
||||
const sign = (parseInt(hInput.value, 10) || 1) < 0 ? -1 : 1;
|
||||
const newH = Math.round((Math.abs(w) / state.transformOrigW) * state.transformOrigH) * sign;
|
||||
hInput.value = String(newH);
|
||||
}
|
||||
applyAspectVisuals();
|
||||
}
|
||||
refresh();
|
||||
});
|
||||
hInput.addEventListener('input', () => {
|
||||
if (state.transformAspectLock) {
|
||||
driver = 'h';
|
||||
const h = parseInt(hInput.value, 10);
|
||||
if (!Number.isNaN(h) && state.transformOrigH > 0) {
|
||||
const sign = (parseInt(wInput.value, 10) || 1) < 0 ? -1 : 1;
|
||||
const newW = Math.round((Math.abs(h) / state.transformOrigH) * state.transformOrigW) * sign;
|
||||
wInput.value = String(newW);
|
||||
}
|
||||
applyAspectVisuals();
|
||||
}
|
||||
refresh();
|
||||
});
|
||||
rotInput.addEventListener('input', refresh);
|
||||
aspectBtn.addEventListener('click', () => {
|
||||
state.transformAspectLock = !state.transformAspectLock;
|
||||
aspectBtn.classList.toggle('active', state.transformAspectLock);
|
||||
aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false');
|
||||
// Reset follower the moment the user breaks the lock so both
|
||||
// fields go editable; re-engaging means "next type sets the driver".
|
||||
driver = null;
|
||||
applyAspectVisuals();
|
||||
});
|
||||
pop.querySelector('#ge-transform-apply').addEventListener('click', () => confirmTransform());
|
||||
pop.querySelector('#ge-transform-cancel').addEventListener('click', () => cancelTransform());
|
||||
pop.querySelector('#ge-transform-cancel-btn')?.addEventListener('click', () => cancelTransform());
|
||||
// Minimise — collapses the body so only the header is visible.
|
||||
pop.querySelector('#ge-transform-min')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
pop.classList.toggle('ge-transform-popup-minimised');
|
||||
});
|
||||
// Quick actions: flip W/H via sign so the reapply pipeline picks
|
||||
// up the new orientation. Rotate-90 nudges rotation ±90°.
|
||||
pop.querySelector('#ge-transform-flip-h')?.addEventListener('click', () => {
|
||||
const wIn = pop.querySelector('#ge-transform-w');
|
||||
const cur = parseInt(wIn.value, 10) || state.transformOrigW;
|
||||
wIn.value = String(-cur);
|
||||
wIn.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
pop.querySelector('#ge-transform-flip-v')?.addEventListener('click', () => {
|
||||
const hIn = pop.querySelector('#ge-transform-h');
|
||||
const cur = parseInt(hIn.value, 10) || state.transformOrigH;
|
||||
hIn.value = String(-cur);
|
||||
hIn.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
pop.querySelector('#ge-transform-rot-90')?.addEventListener('click', (e) => {
|
||||
const rIn = pop.querySelector('#ge-transform-rot');
|
||||
const cur = parseInt(rIn.value, 10) || 0;
|
||||
const delta = e.shiftKey ? -90 : 90;
|
||||
let next = cur + delta;
|
||||
while (next > 180) next -= 360;
|
||||
while (next <= -180) next += 360;
|
||||
rIn.value = String(next);
|
||||
// Big images: rotation pass blocks UI ~0.5–2 s. Show a spinner
|
||||
// so the user sees something happen. rAF defers the heavy work
|
||||
// past the current frame so the overlay paints first.
|
||||
showCanvasLoading('Rotating…');
|
||||
requestAnimationFrame(() => {
|
||||
try { rIn.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
finally { hideCanvasLoading(); }
|
||||
});
|
||||
});
|
||||
attachSpinRepeat(pop);
|
||||
}
|
||||
|
||||
// Header-drag for the Transform popup. Default position: over the
|
||||
// right panel (layers area). Mobile pins via stylesheet so we use
|
||||
// setProperty 'important' to override during drag.
|
||||
function wireTransformDrag(pop) {
|
||||
const isMobile = window.matchMedia('(max-width: 820px)').matches;
|
||||
const defaultRight = 20;
|
||||
const defaultTop = 60;
|
||||
if (isMobile) {
|
||||
pop.style.setProperty('position', 'fixed', 'important');
|
||||
} else {
|
||||
pop.style.position = 'absolute';
|
||||
pop.style.right = defaultRight + 'px';
|
||||
pop.style.top = defaultTop + 'px';
|
||||
pop.style.left = 'auto';
|
||||
}
|
||||
const dragSource = pop.querySelector('[data-transform-drag]') || pop;
|
||||
let dragging = false;
|
||||
let startX = 0, startY = 0, originLeft = 0, originTop = 0;
|
||||
const NON_DRAG = 'input,button,select,textarea,a,[contenteditable]';
|
||||
|
||||
const setPos = (x, y) => {
|
||||
if (isMobile) {
|
||||
pop.style.setProperty('left', x + 'px', 'important');
|
||||
pop.style.setProperty('top', y + 'px', 'important');
|
||||
pop.style.setProperty('right', 'auto', 'important');
|
||||
pop.style.setProperty('bottom', 'auto', 'important');
|
||||
pop.style.setProperty('width', 'auto', 'important');
|
||||
pop.style.setProperty('max-width', 'calc(100vw - 16px)', 'important');
|
||||
} else {
|
||||
pop.style.left = x + 'px';
|
||||
pop.style.top = y + 'px';
|
||||
pop.style.right = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const beginDrag = (clientX, clientY) => {
|
||||
dragging = true;
|
||||
const rect = pop.getBoundingClientRect();
|
||||
if (isMobile) {
|
||||
originLeft = rect.left;
|
||||
originTop = rect.top;
|
||||
} else {
|
||||
const parentRect = state.container.getBoundingClientRect();
|
||||
originLeft = rect.left - parentRect.left;
|
||||
originTop = rect.top - parentRect.top;
|
||||
}
|
||||
startX = clientX;
|
||||
startY = clientY;
|
||||
setPos(originLeft, originTop);
|
||||
pop.classList.add('ge-transform-popup-dragging');
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const moveDrag = (clientX, clientY) => {
|
||||
if (!dragging) return;
|
||||
const dx = clientX - startX;
|
||||
const dy = clientY - startY;
|
||||
let nx = originLeft + dx;
|
||||
let ny = originTop + dy;
|
||||
if (isMobile) {
|
||||
const rect = pop.getBoundingClientRect();
|
||||
nx = Math.max(0, Math.min(window.innerWidth - rect.width, nx));
|
||||
ny = Math.max(0, Math.min(window.innerHeight - rect.height, ny));
|
||||
}
|
||||
setPos(nx, ny);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
document.body.style.userSelect = '';
|
||||
pop.classList.remove('ge-transform-popup-dragging');
|
||||
};
|
||||
|
||||
dragSource.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest(NON_DRAG)) return;
|
||||
e.preventDefault();
|
||||
beginDrag(e.clientX, e.clientY);
|
||||
});
|
||||
document.addEventListener('mousemove', (e) => moveDrag(e.clientX, e.clientY));
|
||||
document.addEventListener('mouseup', endDrag);
|
||||
|
||||
dragSource.addEventListener('touchstart', (e) => {
|
||||
if (e.target.closest(NON_DRAG)) return;
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
e.preventDefault();
|
||||
beginDrag(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
if (!dragging) return;
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
e.preventDefault();
|
||||
moveDrag(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchend', endDrag);
|
||||
document.addEventListener('touchcancel', endDrag);
|
||||
}
|
||||
|
||||
// Re-derive the active layer's pixels from the original snapshot
|
||||
// with the popup's current W/H/flip/rotation applied. Cheap —
|
||||
// paints into an off-screen canvas of the final size.
|
||||
function reapplyTransform() {
|
||||
const layer = state.transformLayer;
|
||||
if (!layer || !state.transformOrigCanvas) return;
|
||||
const w = state.transformPendingW;
|
||||
const h = state.transformPendingH;
|
||||
const rotDeg = state.transformPendingRot;
|
||||
const rotRad = (rotDeg * Math.PI) / 180;
|
||||
const cos = Math.abs(Math.cos(rotRad));
|
||||
const sin = Math.abs(Math.sin(rotRad));
|
||||
// Bounding box of the rotated W×H — canvas grows so corners
|
||||
// don't clip.
|
||||
const finalW = Math.max(1, Math.round(w * cos + h * sin));
|
||||
const finalH = Math.max(1, Math.round(w * sin + h * cos));
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = finalW; tmp.height = finalH;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
tCtx.imageSmoothingEnabled = true;
|
||||
tCtx.imageSmoothingQuality = 'high';
|
||||
tCtx.save();
|
||||
tCtx.translate(finalW / 2, finalH / 2);
|
||||
if (rotDeg) tCtx.rotate(rotRad);
|
||||
tCtx.scale(state.transformPendingFlipH ? -1 : 1, state.transformPendingFlipV ? -1 : 1);
|
||||
tCtx.drawImage(state.transformOrigCanvas, -w / 2, -h / 2, w, h);
|
||||
tCtx.restore();
|
||||
layer.canvas.width = finalW;
|
||||
layer.canvas.height = finalH;
|
||||
layer.ctx.clearRect(0, 0, finalW, finalH);
|
||||
layer.ctx.drawImage(tmp, 0, 0);
|
||||
// Recenter the layer so the rotation pivot stays put visually.
|
||||
const origCenterX = state.transformOrigOffset.x + state.transformOrigW / 2;
|
||||
const origCenterY = state.transformOrigOffset.y + state.transformOrigH / 2;
|
||||
state.layerOffsets.set(layer.id, {
|
||||
x: Math.round(origCenterX - finalW / 2),
|
||||
y: Math.round(origCenterY - finalH / 2),
|
||||
});
|
||||
composite();
|
||||
drawTransformHandles();
|
||||
}
|
||||
|
||||
function confirmTransform() {
|
||||
closeTransformPopup();
|
||||
state.transformOrigCanvas = null;
|
||||
state.transformOrigOffset = null;
|
||||
state.transformActive = false;
|
||||
state.transformLayer = null;
|
||||
state.transformHandle = null;
|
||||
composite();
|
||||
uiModule.showToast('Transform applied');
|
||||
}
|
||||
|
||||
function cancelTransform() {
|
||||
closeTransformPopup();
|
||||
state.transformOrigCanvas = null;
|
||||
state.transformOrigOffset = null;
|
||||
if (state.transformLayer) undo(); // restore saved state
|
||||
state.transformActive = false;
|
||||
state.transformLayer = null;
|
||||
state.transformHandle = null;
|
||||
composite();
|
||||
}
|
||||
|
||||
return {
|
||||
startTransform, openTransformPopup, closeTransformPopup,
|
||||
reapplyTransform, confirmTransform, cancelTransform,
|
||||
};
|
||||
}
|
||||
46
static/js/editor/tools/wand.js
Normal file
46
static/js/editor/tools/wand.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Magic-wand tool — single-click flood-fill selection on the active
|
||||
* layer's pixels. Shift/Alt modifiers override the persistent mode
|
||||
* toggle for the duration of the click (add / subtract).
|
||||
*
|
||||
* Clicking inside an existing selection with no modifier deselects.
|
||||
*
|
||||
* Wand is selection-only — it doesn't mutate the layer until the user
|
||||
* invokes an action (Erase / Copy / etc.) from the panel. That's why
|
||||
* it has just a `click` handler instead of begin/drag/end.
|
||||
*
|
||||
* @param {{
|
||||
* activeLayer: () => object | null,
|
||||
* saveState: () => void,
|
||||
* composite: () => void,
|
||||
* wandHits: (cx: number, cy: number) => boolean,
|
||||
* runMagicWand: (cx: number, cy: number, mode: 'replace'|'add'|'subtract') => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
export function createWandTool({ activeLayer, saveState, composite, wandHits, runMagicWand }) {
|
||||
return {
|
||||
click(e) {
|
||||
const layer = activeLayer();
|
||||
if (!layer) return;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
// Persistent toggle sets the default mode; Shift forces add, Alt
|
||||
// forces subtract regardless of the toggle (modifiers always win).
|
||||
let mode = state.wandMode || 'replace';
|
||||
if (e.shiftKey) mode = 'add';
|
||||
else if (e.altKey) mode = 'subtract';
|
||||
// Click INSIDE the existing selection with no modifier → deselect.
|
||||
if (mode === 'replace' && wandHits(coords.x, coords.y)) {
|
||||
saveState();
|
||||
state.wandMask = null;
|
||||
state.wandLayerId = null;
|
||||
state.wandLastSeed = null;
|
||||
composite();
|
||||
return;
|
||||
}
|
||||
runMagicWand(coords.x, coords.y, mode);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user