Merge current dev and refresh PR #6741 from the 7011 integration

This commit is contained in:
pewdiepie-archdaemon
2026-10-26 16:21:28 +00:00
295 changed files with 24233 additions and 1587 deletions
+20 -2
View File
@@ -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);
+16 -9
View File
@@ -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) {
+7 -3
View File
@@ -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);
},
};
}
+177
View File
@@ -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; }
},
};
}
+2 -1
View File
@@ -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);
};
+59 -11
View File
@@ -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();
},
};
+12 -5
View File
@@ -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;
};
+4 -3
View File
@@ -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;
}
+55 -1
View File
@@ -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({
+74 -17
View File
@@ -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;
+34 -11
View File
@@ -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;