Odysseus v1.0

This commit is contained in:
pewdiepie-archdaemon
2026-05-31 23:58:26 +09:00
commit e5c99a5eee
421 changed files with 271349 additions and 0 deletions

View 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);
},
};
}

View 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();
}
},
};
}

View 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;
}

View 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;
}

View 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();
},
};
}

View 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;
},
};
}

View 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;
},
};
}

View 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;
},
};
}

View 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;
}

View 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.52 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,
};
}

View 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);
},
};
}