mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 08:12:22 +01:00
Merge current dev and refresh PR #6741 from the 7011 integration
This commit is contained in:
@@ -127,9 +127,27 @@ export function createBucketTool({ activeLayer, saveState, composite, renderLaye
|
||||
return count;
|
||||
}
|
||||
|
||||
// The active layer can be an adjustment layer (no raster canvas) or a
|
||||
// hidden/locked one; in that case fall back to the topmost visible raster
|
||||
// layer so a click still fills something instead of silently doing nothing.
|
||||
function paintableLayer() {
|
||||
const direct = activeLayer();
|
||||
if (direct?.canvas && direct.ctx && direct.kind !== 'adjustment') return direct;
|
||||
for (let i = state.layers.length - 1; i >= 0; i--) {
|
||||
const l = state.layers[i];
|
||||
if (l?.canvas && l.ctx && l.kind !== 'adjustment' && l.visible !== false) return l;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fill(e) {
|
||||
const layer = activeLayer();
|
||||
if (!layer?.canvas || !layer.ctx) return 0;
|
||||
const layer = paintableLayer();
|
||||
if (!layer) { showToast?.('Select a pixel layer to fill'); return 0; }
|
||||
if (state.activeLayerId !== layer.id) {
|
||||
state.activeLayerId = layer.id;
|
||||
state.selectedLayerIds = [layer.id];
|
||||
renderLayerPanel?.();
|
||||
}
|
||||
const point = canvasCoords(e, state.mainCanvas);
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const count = fillAtPoint(layer, point.x - off.x, point.y - off.y);
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
* @param {{
|
||||
* composite: () => void,
|
||||
* showCropApply: () => void,
|
||||
* syncCropFrame?: () => void,
|
||||
* snapFrame?: (frame: object, center: object) => object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
@@ -19,7 +21,7 @@ import { canvasCoords } from '../canvas-coords.js';
|
||||
import { drawCheckerboard } from '../checkerboard.js';
|
||||
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
|
||||
|
||||
export function createCropTool({ composite, showCropApply, snapFrame }) {
|
||||
export function createCropTool({ composite, showCropApply, snapFrame, syncCropFrame }) {
|
||||
const gesture = createDirectManipulationSession({
|
||||
name: 'crop',
|
||||
getContext: () => ({ canvas: state.mainCanvas, tool: state.tool }),
|
||||
@@ -96,22 +98,26 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
|
||||
} else {
|
||||
state.activeSnapGuides = null;
|
||||
}
|
||||
// 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));
|
||||
// Allow the rect to be dragged beyond the image edge as well (that
|
||||
// expands the canvas on Apply). Only guard against absurd coords.
|
||||
const MAX = 20000;
|
||||
nx = Math.max(-MAX, Math.min(nx, MAX));
|
||||
ny = Math.max(-MAX, Math.min(ny, MAX));
|
||||
state.cropRect = { ...state.cropRect, x: nx, y: ny };
|
||||
composite();
|
||||
syncCropFrame?.();
|
||||
return;
|
||||
}
|
||||
if (!state.cropping) return;
|
||||
e.preventDefault();
|
||||
// Clamp the dragged corner to the document so a pointer that
|
||||
// overshoots onto a panel (or past the canvas edge) still yields a
|
||||
// rect inside the image rather than negative/off-canvas coords.
|
||||
// The dragged corner may go OUTSIDE the image: a rect that reaches
|
||||
// past an edge expands the canvas there (transparent fill) on Apply.
|
||||
// Only clamp against absurd coordinates (pointer flung off-screen).
|
||||
const end = canvasCoords(e, state.mainCanvas);
|
||||
const MAX = 20000;
|
||||
state.cropEnd = {
|
||||
x: Math.max(0, Math.min(state.mainCanvas.width, end.x)),
|
||||
y: Math.max(0, Math.min(state.mainCanvas.height, end.y)),
|
||||
x: Math.max(-MAX, Math.min(MAX, end.x)),
|
||||
y: Math.max(-MAX, Math.min(MAX, end.y)),
|
||||
};
|
||||
// Aspect lock. Shift-held snapshots the current aspect on first press
|
||||
// and stays locked until released. Otherwise a chosen crop preset
|
||||
@@ -187,6 +193,7 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
|
||||
state.mainCtx.strokeRect(x, y, w, h);
|
||||
state.mainCtx.setLineDash([]);
|
||||
state.cropRect = { x, y, w, h };
|
||||
syncCropFrame?.();
|
||||
},
|
||||
|
||||
end(e) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
// Holding Alt over these tools borrows the eyedropper (Photoshop parity).
|
||||
// Clone/Heal are excluded: Alt already sets their clone source.
|
||||
export const ALT_EYEDROPPER_TOOLS = new Set(['brush', 'pixel', 'eraser', 'smudge', 'dodge', 'burn', 'gradient', 'bucket']);
|
||||
|
||||
function hexByte(value) {
|
||||
return Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0');
|
||||
}
|
||||
@@ -60,11 +64,11 @@ export function createEyedropperTool({ activeLayer, composite, syncColor, syncPr
|
||||
return {
|
||||
preview(e) {
|
||||
const result = sample(e);
|
||||
syncPreview?.(result?.color || null, result);
|
||||
syncPreview?.(result?.color || null, result, e);
|
||||
return result?.color || null;
|
||||
},
|
||||
clearPreview() {
|
||||
syncPreview?.(null, null);
|
||||
syncPreview?.(null, null, null);
|
||||
},
|
||||
pick(e) {
|
||||
const result = sample(e, true);
|
||||
@@ -75,7 +79,7 @@ export function createEyedropperTool({ activeLayer, composite, syncColor, syncPr
|
||||
}
|
||||
state.color = color;
|
||||
syncColor?.(state.color);
|
||||
syncPreview?.(state.color, result);
|
||||
syncPreview?.(state.color, result, e);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
/**
|
||||
* Liquify — Photoshop-style destructive warp brush for the active layer.
|
||||
*
|
||||
* Dragging samples the layer's pixels through a displacement field: Push
|
||||
* drags pixels with the pointer, Twirl rotates them around the dab, Pucker
|
||||
* pulls inward, Bloat pushes outward and Smooth softens the area. Each
|
||||
* stroke is one undo step (the snapshot is taken on press) and the layer is
|
||||
* re-composited at most once per animation frame, so the canvas shows the
|
||||
* warp live without re-rendering the document on every mouse event.
|
||||
*/
|
||||
|
||||
const MODES = new Set(['push', 'twirl', 'pucker', 'bloat', 'smooth']);
|
||||
|
||||
export function createLiquifyTool({ activeLayer, saveState, composite }) {
|
||||
let layer = null, owner = null, last = null, live = false, raf = 0;
|
||||
|
||||
const radius = () => Math.max(4, Math.min(1000, Number(state.liquifySize) || 120));
|
||||
const strength = () => Math.max(1, Math.min(100, Number(state.liquifyStrength) || 50)) / 100;
|
||||
const mode = () => (MODES.has(state.liquifyMode) ? state.liquifyMode : 'push');
|
||||
|
||||
const requestComposite = () => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(() => { raf = 0; composite(); });
|
||||
};
|
||||
|
||||
// Pointer position in the active layer's own pixel space (layers can be
|
||||
// offset from the document origin).
|
||||
function layerPoint(event) {
|
||||
const p = canvasCoords(event, state.mainCanvas);
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
return { x: p.x - off.x, y: p.y - off.y };
|
||||
}
|
||||
|
||||
function sampleBilinear(src, w, h, fx, fy, dst, di) {
|
||||
const x = Math.max(0, Math.min(w - 1, fx - 0.5));
|
||||
const y = Math.max(0, Math.min(h - 1, fy - 0.5));
|
||||
const x1 = Math.floor(x), y1 = Math.floor(y);
|
||||
const x2 = Math.min(w - 1, x1 + 1), y2 = Math.min(h - 1, y1 + 1);
|
||||
const tx = x - x1, ty = y - y1;
|
||||
const i11 = (y1 * w + x1) * 4, i21 = (y1 * w + x2) * 4;
|
||||
const i12 = (y2 * w + x1) * 4, i22 = (y2 * w + x2) * 4;
|
||||
const wx1 = 1 - tx, wy1 = 1 - ty, wx2 = tx, wy2 = ty;
|
||||
for (let c = 0; c < 4; c++) {
|
||||
dst[di + c] = src[i11 + c] * wx1 * wy1 + src[i21 + c] * wx2 * wy1
|
||||
+ src[i12 + c] * wx1 * wy2 + src[i22 + c] * wx2 * wy2;
|
||||
}
|
||||
}
|
||||
|
||||
function average3x3(src, w, h, px, py) {
|
||||
let r = 0, g = 0, b = 0, a = 0, n = 0;
|
||||
for (let oy = -1; oy <= 1; oy++) {
|
||||
for (let ox = -1; ox <= 1; ox++) {
|
||||
const x = Math.max(0, Math.min(w - 1, Math.round(px) + ox));
|
||||
const y = Math.max(0, Math.min(h - 1, Math.round(py) + oy));
|
||||
const i = (y * w + x) * 4;
|
||||
r += src[i]; g += src[i + 1]; b += src[i + 2]; a += src[i + 3]; n++;
|
||||
}
|
||||
}
|
||||
return { r: r / n, g: g / n, b: b / n, a: a / n };
|
||||
}
|
||||
|
||||
function dab(cx, cy, dx, dy, spin) {
|
||||
const canvas = layer.canvas, ctx = layer.ctx;
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const r = radius();
|
||||
const x0 = Math.max(0, Math.floor(cx - r - 1));
|
||||
const y0 = Math.max(0, Math.floor(cy - r - 1));
|
||||
const x1 = Math.min(w, Math.ceil(cx + r + 1));
|
||||
const y1 = Math.min(h, Math.ceil(cy + r + 1));
|
||||
const bw = x1 - x0, bh = y1 - y0;
|
||||
if (bw <= 0 || bh <= 0) return;
|
||||
|
||||
const img = ctx.getImageData(x0, y0, bw, bh);
|
||||
const out = ctx.createImageData(bw, bh);
|
||||
const src = img.data, dst = out.data;
|
||||
const m = mode(), s = strength();
|
||||
|
||||
for (let y = 0; y < bh; y++) {
|
||||
for (let x = 0; x < bw; x++) {
|
||||
const di = (y * bw + x) * 4;
|
||||
const px = x0 + x + 0.5, py = y0 + y + 0.5;
|
||||
const ox = px - cx, oy = py - cy;
|
||||
const dist = Math.hypot(ox, oy);
|
||||
if (dist >= r) {
|
||||
dst[di] = src[di]; dst[di + 1] = src[di + 1]; dst[di + 2] = src[di + 2]; dst[di + 3] = src[di + 3];
|
||||
continue;
|
||||
}
|
||||
const t = 1 - dist / r;
|
||||
const fall = t * t * (3 - 2 * t);
|
||||
let sx = px, sy = py;
|
||||
if (m === 'push') {
|
||||
sx = px - dx * fall * s;
|
||||
sy = py - dy * fall * s;
|
||||
} else if (m === 'twirl') {
|
||||
const ang = spin * s * 2.6 * fall * fall;
|
||||
const ca = Math.cos(ang), sa = Math.sin(ang);
|
||||
sx = cx + ox * ca - oy * sa;
|
||||
sy = cy + ox * sa + oy * ca;
|
||||
} else if (m === 'pucker') {
|
||||
const k = 1 - 0.55 * s * fall;
|
||||
sx = cx + ox * k;
|
||||
sy = cy + oy * k;
|
||||
} else if (m === 'bloat') {
|
||||
const k = 1 + 0.55 * s * fall;
|
||||
sx = cx + ox * k;
|
||||
sy = cy + oy * k;
|
||||
} else if (m === 'smooth') {
|
||||
const avg = average3x3(src, bw, bh, px - x0, py - y0);
|
||||
const k = fall * s * 0.9;
|
||||
dst[di] = src[di] * (1 - k) + avg.r * k;
|
||||
dst[di + 1] = src[di + 1] * (1 - k) + avg.g * k;
|
||||
dst[di + 2] = src[di + 2] * (1 - k) + avg.b * k;
|
||||
dst[di + 3] = src[di + 3] * (1 - k) + avg.a * k;
|
||||
continue;
|
||||
}
|
||||
sampleBilinear(src, bw, bh, sx - x0, sy - y0, dst, di);
|
||||
}
|
||||
}
|
||||
ctx.putImageData(out, x0, y0);
|
||||
}
|
||||
|
||||
function strokeTo(point, previous) {
|
||||
const dx = point.x - previous.x, dy = point.y - previous.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
// Twirl direction: the pointer's motion around the dab decides clockwise
|
||||
// vs counter-clockwise, matching how Photoshop's twirl brush feels.
|
||||
const ox = previous.x - point.x, oy = previous.y - point.y;
|
||||
const spin = (ox * dy - oy * dx) >= 0 ? 1 : -1;
|
||||
const step = Math.max(1, radius() * 0.25);
|
||||
const steps = Math.max(1, Math.ceil(distance / step));
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
dab(previous.x + (dx * i) / steps, previous.y + (dy * i) / steps, dx / steps, dy / steps, spin);
|
||||
}
|
||||
requestComposite();
|
||||
}
|
||||
|
||||
return {
|
||||
begin(event) {
|
||||
const target = activeLayer();
|
||||
if (!target || !target.canvas || !target.ctx) return;
|
||||
if (event.button != null && event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
owner = target;
|
||||
layer = target;
|
||||
saveState('Liquify');
|
||||
last = layerPoint(event);
|
||||
live = true;
|
||||
// A single click still warps (twirl/pucker/bloat do something useful
|
||||
// on press); Push needs movement, so it is a no-op until the drag.
|
||||
if (mode() !== 'push') { dab(last.x, last.y, 0, 0, 1); requestComposite(); }
|
||||
state.drawing = true;
|
||||
},
|
||||
drag(event) {
|
||||
if (!live || !layer || owner !== activeLayer() || state.tool !== 'liquify') return;
|
||||
event.preventDefault();
|
||||
const point = layerPoint(event);
|
||||
strokeTo(point, last);
|
||||
last = point;
|
||||
},
|
||||
end() {
|
||||
if (!live) return;
|
||||
live = false;
|
||||
last = null;
|
||||
owner = null;
|
||||
state.drawing = false;
|
||||
if (raf) { cancelAnimationFrame(raf); raf = 0; }
|
||||
composite();
|
||||
},
|
||||
cancel() {
|
||||
live = false; last = null; owner = null; state.drawing = false;
|
||||
if (raf) { cancelAnimationFrame(raf); raf = 0; }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// The stroke is sampled into positive/negative points plus a bounding
|
||||
// box, both already supported by the backend endpoint.
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { selectionModeForEvent } from '../selection-modifiers.js';
|
||||
|
||||
export function createObjectSelectTool({
|
||||
@@ -47,7 +48,7 @@ export function createObjectSelectTool({
|
||||
active = true;
|
||||
points = [];
|
||||
box = null;
|
||||
mode = selectionModeForEvent(event, 'replace');
|
||||
mode = selectionModeForEvent(event, state.wandMode || 'replace');
|
||||
const coords = canvasCoords(event);
|
||||
if (coords) addPoint(coords, event);
|
||||
};
|
||||
|
||||
@@ -2,13 +2,17 @@ import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { selectionModeForEvent } from '../selection-modifiers.js';
|
||||
|
||||
export function createPenSelectionTool({ activeLayer, saveState, commitSelectionMask, composite, syncSelectionUi }) {
|
||||
export function createPenSelectionTool({ activeLayer, saveState, commitSelectionMask, composite, syncSelectionUi, redraw, refreshSelection }) {
|
||||
let points = [], dragging = false, owner = null, canvas = null, mode = 'replace';
|
||||
const current = () => canvas === state.mainCanvas && state.tool === 'pen' && owner === activeLayer();
|
||||
// The in-progress path lives on its own overlay canvas, so repainting it must
|
||||
// not re-composite the whole document (that is what made every pen click flash
|
||||
// the entire canvas). Fall back to a composite only if no overlay hook exists.
|
||||
const repaint = () => { if (typeof redraw === 'function') redraw(); else composite(); };
|
||||
function cancel() {
|
||||
const hadPath = points.length > 0;
|
||||
points = []; dragging = false; owner = canvas = null;
|
||||
if (hadPath) composite();
|
||||
if (hadPath) repaint();
|
||||
return hadPath;
|
||||
}
|
||||
function trace(ctx, closed) {
|
||||
@@ -27,12 +31,18 @@ export function createPenSelectionTool({ activeLayer, saveState, commitSelection
|
||||
const ctx = mask.getContext('2d');
|
||||
trace(ctx, true);
|
||||
ctx.fillStyle = '#fff'; ctx.fill();
|
||||
saveState('Pen selection');
|
||||
saveState('Pen selection', { selectionOnly: true });
|
||||
commitSelectionMask(mask, owner, mode, 'pen', { x: 0, y: 0 });
|
||||
state.wandMaskVisible = true;
|
||||
state.wandLastSeed = null;
|
||||
state.lassoPoints = []; state.lassoActive = false;
|
||||
cancel(); syncSelectionUi();
|
||||
// Only the selection changed: repainting the marching ants is enough, so
|
||||
// skip the full document composite (which is the most expensive part of
|
||||
// closing a path on a large canvas).
|
||||
cancel();
|
||||
if (typeof refreshSelection === 'function') refreshSelection();
|
||||
else composite();
|
||||
syncSelectionUi();
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
@@ -50,14 +60,14 @@ export function createPenSelectionTool({ activeLayer, saveState, commitSelection
|
||||
mode = selectionModeForEvent(event, state.wandMode || 'replace');
|
||||
}
|
||||
points.push({ ...p, dx: 0, dy: 0 }); dragging = true;
|
||||
composite();
|
||||
repaint();
|
||||
},
|
||||
drag(event) {
|
||||
if (!dragging || !current()) return;
|
||||
event.preventDefault();
|
||||
const p = canvasCoords(event, state.mainCanvas), anchor = points.at(-1);
|
||||
anchor.dx = p.x - anchor.x; anchor.dy = p.y - anchor.y;
|
||||
composite();
|
||||
repaint();
|
||||
},
|
||||
end() { dragging = false; },
|
||||
key(event) {
|
||||
@@ -65,19 +75,48 @@ export function createPenSelectionTool({ activeLayer, saveState, commitSelection
|
||||
if (event.key === 'Escape') cancel();
|
||||
else if (event.key === 'Enter') commit();
|
||||
else if (event.key === 'Backspace' || event.key === 'Delete' || ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z')) {
|
||||
points.pop(); dragging = false; composite();
|
||||
points.pop(); dragging = false; repaint();
|
||||
} else return false;
|
||||
event.preventDefault(); event.stopImmediatePropagation(); return true;
|
||||
},
|
||||
draw() {
|
||||
if (!points.length || !current()) return;
|
||||
const ctx = state.mainCtx, unit = 1 / state.zoom;
|
||||
const overlay = state.penOverlay, ctx = state.penOverlayCtx;
|
||||
if (!overlay || !ctx) return;
|
||||
if (!points.length || !current()) {
|
||||
if (overlay.style.display !== 'none') {
|
||||
overlay.style.display = 'none';
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const main = state.mainCanvas;
|
||||
const w = Math.max(1, Math.round(state.imgWidth * state.zoom));
|
||||
const h = Math.max(1, Math.round(state.imgHeight * state.zoom));
|
||||
if (overlay.width !== w || overlay.height !== h) { overlay.width = w; overlay.height = h; }
|
||||
overlay.style.width = w + 'px';
|
||||
overlay.style.height = h + 'px';
|
||||
overlay.style.left = main.offsetLeft + 'px';
|
||||
overlay.style.top = main.offsetTop + 'px';
|
||||
overlay.style.transform = main.style.transform || '';
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
// The stylesheet hides this overlay by default, so clearing the inline
|
||||
// value would leave it at display:none; show it explicitly.
|
||||
overlay.style.display = 'block';
|
||||
const unit = 1 / state.zoom;
|
||||
ctx.save();
|
||||
ctx.scale(state.zoom, state.zoom);
|
||||
trace(ctx, false);
|
||||
ctx.strokeStyle = '#000'; ctx.lineWidth = 3 * unit; ctx.stroke();
|
||||
ctx.strokeStyle = '#fff'; ctx.lineWidth = unit; ctx.stroke();
|
||||
for (const p of points) {
|
||||
if (p.dx || p.dy) {
|
||||
// Photoshop only shows the bezier handles of the anchor being shaped.
|
||||
// Drawing them for every point turned the preview into a mesh of long
|
||||
// straight lines across the shape.
|
||||
const shaped = dragging ? points.length - 1 : -1;
|
||||
for (let i = 0; i < points.length; i += 1) {
|
||||
const p = points[i];
|
||||
if (i === shaped && (p.dx || p.dy)) {
|
||||
ctx.beginPath(); ctx.moveTo(p.x - p.dx, p.y - p.dy); ctx.lineTo(p.x + p.dx, p.y + p.dy); ctx.stroke();
|
||||
for (const sign of [-1, 1]) {
|
||||
ctx.beginPath(); ctx.arc(p.x + sign * p.dx, p.y + sign * p.dy, 2.5 * unit, 0, Math.PI * 2);
|
||||
@@ -88,6 +127,15 @@ export function createPenSelectionTool({ activeLayer, saveState, commitSelection
|
||||
ctx.strokeStyle = '#000'; ctx.strokeRect(p.x - 3 * unit, p.y - 3 * unit, 6 * unit, 6 * unit);
|
||||
ctx.strokeStyle = '#fff';
|
||||
}
|
||||
// Ring the starting anchor once the path can be closed, so it reads as
|
||||
// "click here to close" instead of just another point.
|
||||
if (points.length >= 3) {
|
||||
const first = points[0];
|
||||
ctx.beginPath(); ctx.arc(first.x, first.y, 6 * unit, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = '#000'; ctx.lineWidth = 3 * unit; ctx.stroke();
|
||||
ctx.strokeStyle = '#fff'; ctx.lineWidth = unit; ctx.stroke();
|
||||
ctx.strokeStyle = '#fff';
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// stroke; intermediate samples skip the undo snapshot (retune mode) so a
|
||||
// whole drag collapses to one history entry (taken on begin).
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { selectionModeForEvent } from '../selection-modifiers.js';
|
||||
|
||||
export function createQuickSelectTool({
|
||||
@@ -16,13 +17,16 @@ export function createQuickSelectTool({
|
||||
}) {
|
||||
let painting = false;
|
||||
let last = null;
|
||||
let strokeMode = 'replace';
|
||||
|
||||
const modeFor = (event) => selectionModeForEvent(event, 'add');
|
||||
// Honour the New / Add / Subtract mode toggle (the wand's segmented
|
||||
// control); Shift/Alt still override for the duration of the dab.
|
||||
const modeFor = (event) => selectionModeForEvent(event, state.wandMode || 'replace');
|
||||
|
||||
const sample = (event) => {
|
||||
const sample = (event, mode) => {
|
||||
const coords = canvasCoords(event);
|
||||
if (!coords) return;
|
||||
runMagicWand(coords.x, coords.y, modeFor(event), { retune: true });
|
||||
runMagicWand(coords.x, coords.y, mode || modeFor(event), { retune: true });
|
||||
};
|
||||
|
||||
const begin = (event) => {
|
||||
@@ -31,7 +35,8 @@ export function createQuickSelectTool({
|
||||
saveState();
|
||||
painting = true;
|
||||
last = null;
|
||||
sample(event);
|
||||
strokeMode = modeFor(event);
|
||||
sample(event, strokeMode);
|
||||
const coords = canvasCoords(event);
|
||||
if (coords) last = coords;
|
||||
};
|
||||
@@ -42,7 +47,9 @@ export function createQuickSelectTool({
|
||||
if (!coords) return;
|
||||
const gap = Math.max(3, (brushSize?.() || 24) / 4);
|
||||
if (last && Math.hypot(coords.x - last.x, coords.y - last.y) < gap) return;
|
||||
sample(event);
|
||||
// Once the stroke has replaced the old selection, later dabs must
|
||||
// accumulate onto it instead of wiping the stroke's progress.
|
||||
sample(event, strokeMode === 'replace' ? 'add' : selectionModeForEvent(event, strokeMode));
|
||||
last = coords;
|
||||
};
|
||||
|
||||
|
||||
@@ -34,10 +34,11 @@ import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { isLayerPixelLocked, isLayerTransparencyLocked } from '../layer-groups.js';
|
||||
|
||||
const STROKE_TOOLS = new Set(['brush', 'eraser', 'smudge', 'dodge', 'burn', 'inpaint']);
|
||||
const STROKE_TOOLS = new Set(['brush', 'pixel', 'eraser', 'smudge', 'dodge', 'burn', 'inpaint']);
|
||||
|
||||
function strokeLabel(tool) {
|
||||
if (tool === 'brush') return 'Brush stroke';
|
||||
if (tool === 'pixel') return 'Pixel stroke';
|
||||
if (tool === 'eraser') return 'Eraser stroke';
|
||||
if (tool === 'inpaint') return state.inpaintEraseStroke ? 'Erase mask' : 'Paint mask';
|
||||
if (tool === 'smudge') return 'Smudge stroke';
|
||||
@@ -72,11 +73,11 @@ export function createStrokeTool({
|
||||
const activeMask = state.quickMaskActive && state.wandMask
|
||||
? { mode: 'quick-selection', canvas: state.wandMask, ctx: state.wandMask.getContext('2d') }
|
||||
: getActiveMaskLayer();
|
||||
if (['brush', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && parent?.kind === 'placed') {
|
||||
if (['brush', 'pixel', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && parent?.kind === 'placed') {
|
||||
showToast?.('Rasterize the placed layer before painting its pixels');
|
||||
return true;
|
||||
}
|
||||
if (['brush', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && isLayerPixelLocked(state, parent)) {
|
||||
if (['brush', 'pixel', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && isLayerPixelLocked(state, parent)) {
|
||||
showToast?.('Unlock image pixels before painting');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { resizeTransformFrame } from '../transform-frame-geometry.js';
|
||||
import { resizeTransformFrame, skewTransformFrame, distortTransformFrame } from '../transform-frame-geometry.js';
|
||||
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
|
||||
|
||||
export function createTransformDragTool({
|
||||
@@ -67,6 +67,9 @@ export function createTransformDragTool({
|
||||
state.transformStartRotation = state.transformPendingRot;
|
||||
state.transformStartFlipH = state.transformPendingFlipH;
|
||||
state.transformStartFlipV = state.transformPendingFlipV;
|
||||
state.transformStartSkewX = state.transformPendingSkewX || 0;
|
||||
state.transformStartSkewY = state.transformPendingSkewY || 0;
|
||||
state.transformStartDistort = state.transformPendingDistort || null;
|
||||
state.transformStartOffX = center.x - state.transformOrigW / 2;
|
||||
state.transformStartOffY = center.y - state.transformOrigH / 2;
|
||||
gesture.begin(e, { handle: state.transformHandle }, { captureTarget: e.currentTarget });
|
||||
@@ -156,6 +159,57 @@ export function createTransformDragTool({
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Ctrl/Cmd + drag a handle = FREE DISTORT (Photoshop). Only the
|
||||
// dragged point moves: a corner handle moves that corner alone
|
||||
// (the frame becomes a quad); an edge handle slides that edge's
|
||||
// two corners together. Size, centre, rotation and skew are left
|
||||
// untouched — distort is a separate slot on the transform target.
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const distorted = distortTransformFrame({
|
||||
centerX: state.transformStartCenter.x,
|
||||
centerY: state.transformStartCenter.y,
|
||||
width: state.transformOrigW,
|
||||
height: state.transformOrigH,
|
||||
rotation: state.transformStartRotation,
|
||||
}, state.transformHandle, {
|
||||
x: state.transformStartX,
|
||||
y: state.transformStartY,
|
||||
}, coords, {
|
||||
startDistort: state.transformStartDistort || null,
|
||||
});
|
||||
if (distorted) {
|
||||
state.transformPendingDistort = distorted;
|
||||
reapplyTransform();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Ctrl/Cmd with no distortable handle (rotation knob) keeps the old
|
||||
// edge-shear skew so the popup's Skew X / Skew Y fields stay live.
|
||||
if ((e.ctrlKey || e.metaKey) && state.transformHandle === 'rot') {
|
||||
const skewed = skewTransformFrame({
|
||||
centerX: state.transformStartCenter.x,
|
||||
centerY: state.transformStartCenter.y,
|
||||
width: state.transformOrigW,
|
||||
height: state.transformOrigH,
|
||||
rotation: state.transformStartRotation,
|
||||
}, state.transformHandle, {
|
||||
x: state.transformStartX,
|
||||
y: state.transformStartY,
|
||||
}, coords, {
|
||||
startSkewX: state.transformStartSkewX || 0,
|
||||
startSkewY: state.transformStartSkewY || 0,
|
||||
});
|
||||
state.transformPendingSkewX = skewed.skewX;
|
||||
state.transformPendingSkewY = skewed.skewY;
|
||||
reapplyTransform();
|
||||
if (state.transformPopup) {
|
||||
const skewXIn = state.transformPopup.querySelector('#ge-transform-skew-x');
|
||||
const skewYIn = state.transformPopup.querySelector('#ge-transform-skew-y');
|
||||
if (skewXIn) skewXIn.value = String(Math.round(skewed.skewX));
|
||||
if (skewYIn) skewYIn.value = String(Math.round(skewed.skewY));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Resize in the frame's LOCAL axes. This keeps rotated side handles
|
||||
// attached to the pointer and fixes the opposite edge in document space.
|
||||
const resized = resizeTransformFrame({
|
||||
|
||||
@@ -30,16 +30,43 @@ import {
|
||||
* Position the transform overlay canvas + size its backing bitmap.
|
||||
* Margin is screen-space slack on each side so controls remain visible
|
||||
* at every zoom. It is converted to image pixels for the backing canvas.
|
||||
*
|
||||
* `extents` (image px, optional) is an extra rectangle the overlay must
|
||||
* also cover — used so the skeleton frame + handles stay inside the
|
||||
* bitmap once the frame is scaled/moved OUTSIDE the image bounds. When
|
||||
* it is omitted the overlay only covers the image (+margin), matching
|
||||
* the original behaviour. Returns the overlay's top-left in image
|
||||
* coordinates (the draw translate origin), or null when hidden.
|
||||
*/
|
||||
export function syncOverlay(margin) {
|
||||
if (!state.transformOverlay || !state.mainCanvas) return;
|
||||
export function syncOverlay(margin, extents = null) {
|
||||
if (!state.transformOverlay || !state.mainCanvas) return null;
|
||||
if (!state.transformActive) {
|
||||
state.transformOverlay.style.display = 'none';
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const imageMargin = margin / Math.max(0.0001, state.zoom);
|
||||
const W = Math.ceil(state.mainCanvas.width + 2 * imageMargin);
|
||||
const H = Math.ceil(state.mainCanvas.height + 2 * imageMargin);
|
||||
const imgW = state.mainCanvas.width;
|
||||
const imgH = state.mainCanvas.height;
|
||||
// Union of the image and any extra extent the frame reaches, plus the
|
||||
// handle slack on every side. Cap the bitmap so an extreme pending
|
||||
// size can never allocate a runaway canvas.
|
||||
const MAX_BITMAP = 8192;
|
||||
let left = Math.min(0, extents?.left ?? 0) - imageMargin;
|
||||
let top = Math.min(0, extents?.top ?? 0) - imageMargin;
|
||||
let right = Math.max(imgW, extents?.right ?? imgW) + imageMargin;
|
||||
let bottom = Math.max(imgH, extents?.bottom ?? imgH) + imageMargin;
|
||||
const centerX = (left + right) / 2;
|
||||
const centerY = (top + bottom) / 2;
|
||||
if (right - left > MAX_BITMAP) {
|
||||
left = centerX - MAX_BITMAP / 2;
|
||||
right = centerX + MAX_BITMAP / 2;
|
||||
}
|
||||
if (bottom - top > MAX_BITMAP) {
|
||||
top = centerY - MAX_BITMAP / 2;
|
||||
bottom = centerY + MAX_BITMAP / 2;
|
||||
}
|
||||
const W = Math.ceil(right - left);
|
||||
const H = Math.ceil(bottom - top);
|
||||
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
|
||||
@@ -53,15 +80,35 @@ export function syncOverlay(margin) {
|
||||
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) + 'px';
|
||||
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop - margin) + 'px';
|
||||
// (offsetLeft/Top — unaffected by CSS transforms), shifted by the
|
||||
// covered extent in screen px. 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 + left * state.zoom) + 'px';
|
||||
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop + top * state.zoom) + 'px';
|
||||
state.transformOverlay.style.transform = state.mainCanvas.style.transform || 'none';
|
||||
return { left, top };
|
||||
}
|
||||
|
||||
/** Image-space AABB of the frame (incl. rotation knob + skew) or null. */
|
||||
function frameExtents(geometry) {
|
||||
if (!geometry) return null;
|
||||
const points = [
|
||||
geometry.corners?.tl, geometry.corners?.tr,
|
||||
geometry.corners?.br, geometry.corners?.bl,
|
||||
geometry.rotationHandle,
|
||||
].filter(Boolean);
|
||||
if (!points.length) return null;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const point of points) {
|
||||
minX = Math.min(minX, point.x);
|
||||
maxX = Math.max(maxX, point.x);
|
||||
minY = Math.min(minY, point.y);
|
||||
maxY = Math.max(maxY, point.y);
|
||||
}
|
||||
return { left: minX, top: minY, right: maxX, bottom: maxY };
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +150,9 @@ function transformFrame() {
|
||||
width: state.transformPendingW || state.transformSelectionBounds.width,
|
||||
height: state.transformPendingH || state.transformSelectionBounds.height,
|
||||
rotation: state.transformPendingRot || 0,
|
||||
skewX: state.transformPendingSkewX || 0,
|
||||
skewY: state.transformPendingSkewY || 0,
|
||||
distort: state.transformPendingDistort || null,
|
||||
};
|
||||
}
|
||||
const layer = state.transformLayer;
|
||||
@@ -118,6 +168,9 @@ function transformFrame() {
|
||||
width: state.transformPendingW || layer.canvas.width,
|
||||
height: state.transformPendingH || layer.canvas.height,
|
||||
rotation: state.transformPendingRot || 0,
|
||||
skewX: state.transformPendingSkewX || 0,
|
||||
skewY: state.transformPendingSkewY || 0,
|
||||
distort: state.transformPendingDistort || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,7 +182,6 @@ function transformFrame() {
|
||||
*/
|
||||
export function drawHandles(margin) {
|
||||
if (!state.transformActive || (state.transformTarget !== 'selection' && !state.transformLayer)) return;
|
||||
syncOverlay(margin);
|
||||
if (!state.transformOverlayCtx) return;
|
||||
const frame = transformFrame();
|
||||
if (!frame) return;
|
||||
@@ -138,13 +190,18 @@ export function drawHandles(margin) {
|
||||
zoom: state.zoom,
|
||||
rotationInside: rotInside,
|
||||
});
|
||||
// Grow the overlay so the whole frame + handles fit even when the
|
||||
// frame extends well past the image bounds (otherwise the skeleton
|
||||
// gets clipped at the bitmap edge during a resize).
|
||||
const cover = syncOverlay(margin, frameExtents(geometry));
|
||||
if (!cover) return;
|
||||
const ctx = state.transformOverlayCtx;
|
||||
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e06c75';
|
||||
const imageMargin = margin / Math.max(0.0001, state.zoom);
|
||||
// Clear + shift drawing by margin so image (0,0) maps to overlay (M,M).
|
||||
// Clear + shift drawing so image (cover.left, cover.top) maps to
|
||||
// overlay (0, 0).
|
||||
ctx.clearRect(0, 0, state.transformOverlay.width, state.transformOverlay.height);
|
||||
ctx.save();
|
||||
ctx.translate(imageMargin, imageMargin);
|
||||
ctx.translate(-cover.left, -cover.top);
|
||||
// Zoom-corrected handle size + stroke so they stay readable at any zoom.
|
||||
const sz = 10 / state.zoom;
|
||||
const stroke = 1.5 / state.zoom;
|
||||
|
||||
@@ -169,6 +169,7 @@ export function createTransformSession({
|
||||
state.transformPendingSkewY = 0;
|
||||
state.transformPendingPerspX = 0;
|
||||
state.transformPendingPerspY = 0;
|
||||
state.transformPendingDistort = null;
|
||||
// Snapshot the layer so live preview can re-derive from the
|
||||
// original pixels on every keystroke instead of stacking
|
||||
// destructive edits.
|
||||
@@ -239,7 +240,10 @@ export function createTransformSession({
|
||||
mobileBoundsHandler = null;
|
||||
}
|
||||
if (state.transformPopup) {
|
||||
try { state.transformPopup.remove(); } catch {}
|
||||
try {
|
||||
state.transformPopup.innerHTML = '';
|
||||
state.transformPopup.style.display = 'none';
|
||||
} catch {}
|
||||
state.transformPopup = null;
|
||||
}
|
||||
}
|
||||
@@ -277,15 +281,16 @@ export function createTransformSession({
|
||||
// signal than those transient messages, so clear them first.
|
||||
document.querySelector('.tour-hint .tour-hint-dismiss')?.click();
|
||||
document.querySelector('#toast.show .toast-close-btn')?.click();
|
||||
const pop = document.createElement('div');
|
||||
pop.className = 'ge-transform-popup';
|
||||
pop.innerHTML = transformPopupHTML();
|
||||
// The transform controls live inline in the editor options strip (header
|
||||
// bar) instead of a floating popup over the canvas.
|
||||
const pop = document.getElementById('ge-transform-section');
|
||||
if (!pop) return;
|
||||
pop.innerHTML = `<div class="ge-transform-popup">${transformPopupHTML()}</div>`;
|
||||
const title = pop.querySelector('.ge-adj-title');
|
||||
if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection';
|
||||
else if (title && state.transformLayers.length > 1) title.textContent = `Transform ${state.transformLayers.length} layers`;
|
||||
state.container.appendChild(pop);
|
||||
pop.style.display = '';
|
||||
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');
|
||||
@@ -438,9 +443,6 @@ export function createTransformSession({
|
||||
});
|
||||
});
|
||||
attachSpinRepeat(pop);
|
||||
applyMobileTransformBounds(pop);
|
||||
mobileBoundsHandler = () => applyMobileTransformBounds(pop);
|
||||
window.addEventListener('resize', mobileBoundsHandler);
|
||||
}
|
||||
|
||||
// Header-drag for the Transform popup. Default position: over the
|
||||
@@ -551,6 +553,19 @@ export function createTransformSession({
|
||||
ctx.save();
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
if (geometry.rotation) ctx.rotate(geometry.radians);
|
||||
if (geometry.distort && geometry.quad) {
|
||||
// Free distort: map the quad into the output bounds and warp.
|
||||
const { points, minX, minY, quadW, quadH } = geometry.quad;
|
||||
const normalized = points.map(point => ({
|
||||
x: (point.x - minX) / quadW,
|
||||
y: (point.y - minY) / quadH,
|
||||
}));
|
||||
const warped = perspectiveWarpCanvas(source, normalized, canvas.width, canvas.height);
|
||||
ctx.scale(Math.sign(geometry.signedScaleX) || 1, Math.sign(geometry.signedScaleY) || 1);
|
||||
ctx.drawImage(warped, -warped.width / 2, -warped.height / 2);
|
||||
ctx.restore();
|
||||
return canvas;
|
||||
}
|
||||
if (isPerspectiveActive(geometry.perspectiveX, geometry.perspectiveY)) {
|
||||
const warped = perspectiveWarpCanvas(source, keystoneCorners(geometry.perspectiveX, geometry.perspectiveY), canvas.width, canvas.height);
|
||||
ctx.scale(Math.sign(geometry.signedScaleX) || 1, Math.sign(geometry.signedScaleY) || 1);
|
||||
@@ -576,6 +591,7 @@ export function createTransformSession({
|
||||
skewY: state.transformPendingSkewY,
|
||||
perspectiveX: state.transformPendingPerspX,
|
||||
perspectiveY: state.transformPendingPerspY,
|
||||
distort: state.transformPendingDistort,
|
||||
centerX: state.transformCenter?.x,
|
||||
centerY: state.transformCenter?.y,
|
||||
};
|
||||
@@ -592,6 +608,7 @@ export function createTransformSession({
|
||||
state.transformPendingSkewY = target.skewY || 0;
|
||||
state.transformPendingPerspX = target.perspectiveX || 0;
|
||||
state.transformPendingPerspY = target.perspectiveY || 0;
|
||||
state.transformPendingDistort = target.distort || null;
|
||||
state.transformCenter = { x: target.centerX, y: target.centerY };
|
||||
}
|
||||
|
||||
@@ -654,7 +671,12 @@ export function createTransformSession({
|
||||
for (const { snapshot, geometry: plannedGeometry } of plan.items) {
|
||||
const layer = snapshot.layer;
|
||||
let geometry = plannedGeometry;
|
||||
if (snapshot.placed && (snapshot.wasPlaced || state.transformPreserveSource !== false)) {
|
||||
// A free-distort quad is a non-affine warp that placed-layer data cannot
|
||||
// express, so bake it into the raster instead of the placed source.
|
||||
const usePlaced = snapshot.placed
|
||||
&& (snapshot.wasPlaced || state.transformPreserveSource !== false)
|
||||
&& !plannedGeometry.distort;
|
||||
if (usePlaced) {
|
||||
layer.placed = transformPlacedData(snapshot.placed, documentTransform);
|
||||
const rendered = renderPlacedLayer(layer);
|
||||
geometry = {
|
||||
@@ -664,7 +686,7 @@ export function createTransformSession({
|
||||
offset: rendered.offset,
|
||||
};
|
||||
} else {
|
||||
if (snapshot.kind === 'raster') {
|
||||
if (snapshot.kind === 'raster' || plannedGeometry.distort) {
|
||||
layer.kind = 'raster';
|
||||
layer.placed = null;
|
||||
}
|
||||
@@ -791,6 +813,7 @@ export function createTransformSession({
|
||||
state.transformPendingSkewY = 0;
|
||||
state.transformPendingPerspX = 0;
|
||||
state.transformPendingPerspY = 0;
|
||||
state.transformPendingDistort = null;
|
||||
state.transformActive = false;
|
||||
state.transformTarget = null;
|
||||
state.transformLayer = null;
|
||||
|
||||
Reference in New Issue
Block a user