Squash Odysseus development history

This commit is contained in:
pewdiepie-archdaemon
2026-09-11 06:04:19 +00:00
parent e5c99a5eee
commit 6ee6502010
2050 changed files with 538359 additions and 57745 deletions
+44 -7
View File
@@ -13,12 +13,14 @@
* saveState: (label?: string) => void,
* strokeTo: (x: number, y: number) => void,
* showToast: (msg: string) => void,
* onSourceChanged?: () => void,
* }} deps
*/
import { state } from '../state.js';
import { isLayerPixelLocked } from '../layer-groups.js';
import { canvasCoords } from '../canvas-coords.js';
export function createCloneTool({ activeLayer, saveState, strokeTo, showToast }) {
export function createCloneTool({ activeLayer, saveState, beginStroke, showToast, onSourceChanged, getSampleCanvas }) {
return {
begin(e) {
const layer = activeLayer();
@@ -49,33 +51,68 @@ export function createCloneTool({ activeLayer, saveState, strokeTo, showToast })
state.cloneSourceX = coords.x;
state.cloneSourceY = coords.y;
state.cloneSourceLayerId = (layer && layer.id) || state.activeLayerId;
const sampledCanvas = getSampleCanvas?.({ layer, mode: state.cloneSampleMode });
const sourceLayer = layer && (
state.cloneSampleMode === 'active-layer' || !sampledCanvas
) ? layer : null;
const sourceOffset = sourceLayer ? (state.layerOffsets.get(sourceLayer.id) || { x: 0, y: 0 }) : { x: 0, y: 0 };
state.cloneSourceOffsetX = sourceOffset.x;
state.cloneSourceOffsetY = sourceOffset.y;
state.cloneSourceSnapshot = null; // captured at first stroke
onSourceChanged?.();
showToast('Clone source set');
return;
}
// Healing defaults to source-free spot correction. Once a source is
// chosen, the same tool switches to sampled healing in the pipeline.
if (state.tool === 'heal' && (state.cloneSourceX === null || state.cloneSourceY === null)) {
if (!layer || isLayerPixelLocked(state, layer)) {
showToast('Unlock image pixels before healing');
return;
}
if (layer.kind === 'placed') {
showToast('Rasterize the placed layer before healing its pixels');
return;
}
saveState('Healing stroke');
state.drawing = true;
state.lastX = coords.x;
state.lastY = coords.y;
beginStroke({ x: coords.x, y: coords.y, pressure: Number(e.pressure) > 0 ? e.pressure : 1 }, state.tool);
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');
if (!layer || isLayerPixelLocked(state, layer)) {
showToast('Unlock image pixels before cloning');
return;
}
if (layer.kind === 'placed') {
showToast('Rasterize the placed layer before cloning into it');
return;
}
const toolName = state.tool === 'heal' ? 'Healing stroke' : 'Clone stroke';
saveState(toolName);
// 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 sourceCanvas = getSampleCanvas?.({ layer: srcLayer, mode: state.cloneSampleMode }) || srcLayer.canvas;
const snap = document.createElement('canvas');
snap.width = srcLayer.canvas.width;
snap.height = srcLayer.canvas.height;
snap.getContext('2d').drawImage(srcLayer.canvas, 0, 0);
snap.width = sourceCanvas.width;
snap.height = sourceCanvas.height;
snap.getContext('2d').drawImage(sourceCanvas, 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);
beginStroke({ x: coords.x, y: coords.y, pressure: Number(e.pressure) > 0 ? e.pressure : 1 }, state.tool);
},
};
}
+45 -2
View File
@@ -17,11 +17,30 @@
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { drawCheckerboard } from '../checkerboard.js';
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
export function createCropTool({ composite, showCropApply }) {
export function createCropTool({ composite, showCropApply, snapFrame }) {
const gesture = createDirectManipulationSession({
name: 'crop',
getContext: () => ({ canvas: state.mainCanvas, tool: state.tool }),
isContextCurrent: context => context.canvas === state.mainCanvas && state.tool === 'crop',
onCancel: (data, reason) => {
state.cropping = false;
state.cropMoving = false;
state.cropStart = null;
state.cropEnd = null;
state.cropMoveStart = null;
state.cropAspectLock = null;
state.activeSnapGuides = null;
state.cropRect = data?.previousRect ? { ...data.previousRect } : null;
composite();
if (state.cropRect && reason !== 'tool-switch') showCropApply();
},
});
return {
begin(e) {
const coords = canvasCoords(e, state.mainCanvas);
const previousRect = state.cropRect ? { ...state.cropRect } : null;
// Click inside an existing crop rect → switch to move-mode so
// the user can reposition without redrawing.
if (state.cropRect &&
@@ -29,6 +48,7 @@ export function createCropTool({ composite, showCropApply }) {
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 };
gesture.begin(e, { mode: 'move', previousRect }, { captureTarget: e.currentTarget });
return;
}
state.cropping = true;
@@ -39,9 +59,11 @@ export function createCropTool({ composite, showCropApply }) {
// 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();
gesture.begin(e, { mode: 'draw', previousRect }, { captureTarget: e.currentTarget });
},
drag(e) {
if (!gesture.update(e)) return;
// Move-mode: drag the existing rect around the canvas.
if (state.cropMoving && state.cropRect && state.cropMoveStart) {
e.preventDefault();
@@ -50,6 +72,22 @@ export function createCropTool({ composite, showCropApply }) {
const dy = c.y - state.cropMoveStart.y;
let nx = state.cropMoveStart.rx + dx;
let ny = state.cropMoveStart.ry + dy;
const modifier = e.ctrlKey || e.metaKey;
const snapping = state.snapEnabled ? !modifier : modifier;
if (snapping && snapFrame) {
const snapped = snapFrame({
centerX: state.cropMoveStart.rx + state.cropRect.w / 2,
centerY: state.cropMoveStart.ry + state.cropRect.h / 2,
width: state.cropRect.w,
height: state.cropRect.h,
rotation: 0,
}, { x: nx + state.cropRect.w / 2, y: ny + state.cropRect.h / 2 });
nx = snapped.centerX - state.cropRect.w / 2;
ny = snapped.centerY - state.cropRect.h / 2;
state.activeSnapGuides = snapped.guides || null;
} 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));
@@ -119,7 +157,9 @@ export function createCropTool({ composite, showCropApply }) {
state.cropRect = { x, y, w, h };
},
end() {
end(e) {
if (!gesture.commit(e)) return;
state.activeSnapGuides = null;
// Move-mode wrap-up: refresh the floating panel so Apply follows
// the rect to its new spot.
if (state.cropMoving) {
@@ -133,5 +173,8 @@ export function createCropTool({ composite, showCropApply }) {
showCropApply();
}
},
cancel(reason) {
return gesture.cancel(reason);
},
};
}
+51
View File
@@ -0,0 +1,51 @@
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
function hexByte(value) {
return Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0');
}
export function createEyedropperTool({ activeLayer, composite, syncColor, syncPreview, showToast }) {
function sample(e, refreshComposite = false) {
const point = canvasCoords(e, state.mainCanvas);
let canvas = state.mainCanvas;
let x = point.x;
let y = point.y;
if (state.eyedropperSample === 'layer') {
const layer = activeLayer();
if (!layer?.canvas) return null;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
canvas = layer.canvas;
x -= off.x;
y -= off.y;
} else if (refreshComposite) {
composite();
}
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return null;
const pixel = canvas.getContext('2d').getImageData(Math.floor(x), Math.floor(y), 1, 1).data;
const rgb = [pixel[0], pixel[1], pixel[2]];
if (!pixel[3]) return { color: null, rgb, canvas, x, y };
return { color: `#${hexByte(pixel[0])}${hexByte(pixel[1])}${hexByte(pixel[2])}`, rgb, canvas, x, y };
}
return {
preview(e) {
const result = sample(e);
syncPreview?.(result?.color || null, result);
return result?.color || null;
},
clearPreview() {
syncPreview?.(null, null);
},
pick(e) {
const result = sample(e, true);
const color = result?.color;
if (!color) {
showToast?.('That pixel is transparent');
return;
}
state.color = color;
syncColor?.(state.color);
syncPreview?.(state.color, result);
},
};
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Local two-color gradient tool.
*
* The drag becomes a retained effect on completion, so previews never mutate
* source pixels or compound across pointer-move events.
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { isLayerPixelLocked } from '../layer-groups.js';
export function createGradientTool({ activeLayer, saveState, composite, schedulePersist, renderLayerPanel, getSettings }) {
let start = null;
let lastPoint = null;
let activeLayerId = null;
let historySaved = false;
function settings() {
const values = getSettings?.() || {};
return {
start: values.start || state.gradientStart || state.color,
mid: values.mid || state.gradientMid || '#808080',
midPosition: values.midPosition ?? state.gradientMidPosition ?? 50,
midEnabled: values.midEnabled ?? state.gradientMidEnabled ?? false,
extraStops: Array.isArray(values.extraStops) ? values.extraStops : (state.gradientStops || []),
end: values.end || state.gradientEnd || '#ffffff',
endAlpha: values.endAlpha ?? state.gradientEndAlpha ?? 100,
opacity: values.opacity ?? state.gradientOpacity ?? 100,
type: values.type || state.gradientType || 'linear-gradient',
};
}
function preview(point) {
const layer = activeLayer();
if (!start || !layer || layer.id !== activeLayerId) return false;
const dx = point.x - start.x;
const dy = point.y - start.y;
if (Math.hypot(dx, dy) < 0.5) return false;
const values = settings();
const stops = [
{ position: 0, color: values.start, alpha: 1 },
...(values.midEnabled ? [{ position: Number(values.midPosition), color: values.mid, alpha: 1 }] : []),
...values.extraStops.slice(0, 10).map(stop => ({
position: Math.max(1, Math.min(99, Number(stop.position) || 50)),
color: stop.color || '#808080',
alpha: 1,
})),
{ position: 100, color: values.end, alpha: Math.max(0, Math.min(100, Number(values.endAlpha))) / 100 },
].sort((a, b) => a.position - b.position);
layer._effectPreview = {
id: 'gradient-preview', type: values.type, name: values.type === 'radial-gradient' ? 'Radial Gradient' : 'Gradient', visible: true, opacity: 1,
params: { x1: start.x, y1: start.y, x2: point.x, y2: point.y, stops, opacity: Math.max(0, Math.min(100, Number(values.opacity))) / 100 },
};
composite();
return true;
}
return {
begin(e) {
const layer = activeLayer();
if (!layer) return false;
if (isLayerPixelLocked(state, layer)) return false;
if (layer.kind === 'placed') return false;
activeLayerId = layer.id;
start = canvasCoords(e, state.mainCanvas);
lastPoint = { ...start };
state.gradientActive = true;
historySaved = false;
return true;
},
drag(e) {
if (!state.gradientActive) return false;
lastPoint = canvasCoords(e, state.mainCanvas);
preview(lastPoint);
return true;
},
end(e) {
if (!state.gradientActive) return false;
const point = e && (Number.isFinite(e.clientX) || (e.touches && e.touches.length))
? canvasCoords(e, state.mainCanvas)
: lastPoint;
const changed = point ? preview(point) : false;
const layer = activeLayer();
state.gradientActive = false;
if (layer?._effectPreview && changed) {
if (!historySaved) {
saveState('Gradient');
historySaved = true;
}
layer.effects = Array.isArray(layer.effects) ? layer.effects : [];
layer.effects.push(layer._effectPreview);
}
if (layer) layer._effectPreview = null;
start = null;
lastPoint = null;
activeLayerId = null;
historySaved = false;
if (changed) {
renderLayerPanel?.();
schedulePersist?.();
}
return true;
},
cancel() {
if (!state.gradientActive) return false;
const layer = activeLayer();
if (layer) layer._effectPreview = null;
state.gradientActive = false;
start = null;
lastPoint = null;
activeLayerId = null;
historySaved = false;
composite();
return true;
},
};
}
+34 -3
View File
@@ -14,10 +14,18 @@
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { buildLassoMask } from './lasso-mask.js';
export function createLassoTool({ composite, drawLassoOverlay, syncToolClearIndicators }) {
export function createLassoTool({
activeLayer, saveState, commitSelectionMask,
composite, drawLassoOverlay, syncToolClearIndicators,
}) {
let pendingMode = 'replace';
return {
begin(e) {
pendingMode = state.wandMode || 'replace';
if (e.shiftKey) pendingMode = 'add';
else if (e.altKey) pendingMode = 'subtract';
state.lassoPoints = [];
state.lassoActive = true;
const coords = canvasCoords(e, state.mainCanvas);
@@ -56,10 +64,33 @@ export function createLassoTool({ composite, drawLassoOverlay, syncToolClearIndi
syncToolClearIndicators();
return;
}
// Keep the selection drawn — the panel's action buttons use it.
const layer = activeLayer?.();
if (!layer) return;
saveState?.('Lasso selection');
const mask = buildLassoMask(
state.lassoPoints,
state.imgWidth,
state.imgHeight,
0,
0,
0,
0,
);
commitSelectionMask?.(mask, layer, pendingMode, 'lasso', { x: 0, y: 0 });
state.wandMaskVisible = true;
state.wandLastSeed = null;
state.lassoPoints = [];
composite();
drawLassoOverlay();
syncToolClearIndicators();
},
cancel() {
if (!state.lassoActive && !state.lassoPoints.length) return false;
state.lassoActive = false;
state.lassoPoints = [];
composite();
syncToolClearIndicators();
return true;
},
};
}
+209
View File
@@ -0,0 +1,209 @@
/** Rectangle and ellipse marquee interaction producing a document-space mask. */
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import {
createMarqueeMask,
mergeSelectionMasks,
normalizeConstrainedSelectionRect,
selectionMaskBounds,
selectionMaskContains,
selectionMaskToDocument,
translateSelectionMask,
} from '../selection-mask.js';
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
export function createMarqueeTool({
activeLayer, saveState, composite, drawOverlay, syncSelectionUi,
ensureDocumentSelection, snapFrame,
}) {
let pendingMode = 'replace';
let moveHistorySaved = false;
const gesture = createDirectManipulationSession({
name: 'selection-boundary',
getContext: () => ({ canvas: state.mainCanvas, tool: state.tool }),
isContextCurrent: context => context.canvas === state.mainCanvas && state.tool === 'marquee',
onCancel: data => {
if (data?.mode === 'move' && state.selectionMoveOrigin) {
state.wandMask = translateSelectionMask(
state.selectionMoveOrigin, 0, 0, state.imgWidth, state.imgHeight,
);
state.wandMaskSpace = 'document';
if (moveHistorySaved && state.undoStack?.at(-1)?._label === 'Move selection boundary') {
state.undoStack.pop();
}
}
state.selectionMoving = false;
state.selectionMoveStart = null;
state.selectionMoveOrigin = null;
state.marqueeActive = false;
state.marqueeStart = null;
state.marqueeRect = null;
state.activeSnapGuides = null;
moveHistorySaved = false;
composite();
syncSelectionUi();
},
});
return {
begin(e) {
const layer = activeLayer();
if (!layer) return;
const coords = canvasCoords(e, state.mainCanvas);
const selection = ensureDocumentSelection?.() || null;
if (selection && !e.shiftKey && !e.altKey && selectionMaskContains(selection, coords.x, coords.y)) {
state.selectionMoving = true;
state.selectionMoveStart = coords;
state.selectionMoveOrigin = translateSelectionMask(selection, 0, 0, state.imgWidth, state.imgHeight);
moveHistorySaved = false;
gesture.begin(e, { mode: 'move' }, { captureTarget: e.currentTarget });
return;
}
pendingMode = state.wandMode || 'replace';
if (e.shiftKey) pendingMode = 'add';
else if (e.altKey) pendingMode = 'subtract';
state.marqueeStart = coords;
state.marqueeRect = normalizeConstrainedSelectionRect(
coords,
coords,
state.imgWidth,
state.imgHeight,
{
mode: state.marqueeConstraint,
ratioWidth: state.marqueeRatioWidth,
ratioHeight: state.marqueeRatioHeight,
fixedWidth: state.marqueeFixedWidth,
fixedHeight: state.marqueeFixedHeight,
},
);
state.marqueeActive = true;
gesture.begin(e, { mode: 'draw' }, { captureTarget: e.currentTarget });
},
drag(e) {
if (!gesture.update(e)) return;
if (state.selectionMoving && state.selectionMoveStart && state.selectionMoveOrigin) {
e.preventDefault();
const coords = canvasCoords(e, state.mainCanvas);
let dx = Math.round(coords.x - state.selectionMoveStart.x);
let dy = Math.round(coords.y - state.selectionMoveStart.y);
if (!dx && !dy) return;
const bounds = selectionMaskBounds(state.selectionMoveOrigin);
const modifier = e.ctrlKey || e.metaKey;
const snapping = state.snapEnabled ? !modifier : modifier;
if (bounds && snapping && snapFrame) {
const center = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
const snapped = snapFrame({
centerX: center.x,
centerY: center.y,
width: bounds.width,
height: bounds.height,
rotation: 0,
}, { x: center.x + dx, y: center.y + dy });
dx = Math.round(snapped.centerX - center.x);
dy = Math.round(snapped.centerY - center.y);
state.activeSnapGuides = snapped.guides || null;
} else {
state.activeSnapGuides = null;
}
if (!moveHistorySaved) {
saveState('Move selection boundary');
moveHistorySaved = true;
}
state.wandMask = translateSelectionMask(
state.selectionMoveOrigin,
dx,
dy,
state.imgWidth,
state.imgHeight,
);
state.wandMaskSpace = 'document';
state.wandLastSeed = null;
composite();
return;
}
if (!state.marqueeActive || !state.marqueeStart) return;
e.preventDefault();
state.marqueeRect = normalizeConstrainedSelectionRect(
state.marqueeStart,
canvasCoords(e, state.mainCanvas),
state.imgWidth,
state.imgHeight,
{
mode: state.marqueeConstraint,
ratioWidth: state.marqueeRatioWidth,
ratioHeight: state.marqueeRatioHeight,
fixedWidth: state.marqueeFixedWidth,
fixedHeight: state.marqueeFixedHeight,
square: !!e.shiftKey,
},
);
composite();
drawOverlay();
},
end(e) {
if (!gesture.commit(e)) return;
state.activeSnapGuides = null;
if (state.selectionMoving) {
state.selectionMoving = false;
state.selectionMoveStart = null;
state.selectionMoveOrigin = null;
moveHistorySaved = false;
composite();
syncSelectionUi();
return;
}
if (!state.marqueeActive) return;
state.marqueeActive = false;
const rect = state.marqueeRect;
state.marqueeStart = null;
state.marqueeRect = null;
if (!rect || rect.w < 1 || rect.h < 1) {
if (pendingMode === 'replace' && state.wandMask) {
saveState('Clear selection');
state.wandMask = null;
state.wandLayerId = null;
state.wandMaskSpace = 'layer';
state.selectionSource = null;
state.wandLastSeed = null;
}
composite();
syncSelectionUi();
return;
}
saveState(`${state.marqueeShape === 'ellipse' ? 'Ellipse' : 'Rectangle'} selection`);
const active = activeLayer();
const selectedLayer = state.layers.find(layer => layer.id === state.wandLayerId);
const selectedOffset = selectedLayer ? (state.layerOffsets.get(selectedLayer.id) || { x: 0, y: 0 }) : { x: 0, y: 0 };
const current = selectionMaskToDocument(
state.wandMask,
state.wandMaskSpace || 'layer',
selectedOffset,
state.imgWidth,
state.imgHeight,
);
const incoming = createMarqueeMask(
state.imgWidth,
state.imgHeight,
rect,
state.marqueeShape,
);
state.wandMask = mergeSelectionMasks(current, incoming, pendingMode);
state.wandLayerId = active?.id || state.activeLayerId;
state.wandMaskSpace = 'document';
state.selectionSource = 'marquee';
state.wandLastSeed = null;
state.wandMaskVisible = true;
state.lassoPoints = [];
state.lassoActive = false;
composite();
syncSelectionUi();
},
cancel(reason) {
return gesture.cancel(reason);
},
};
}
+88 -10
View File
@@ -1,5 +1,5 @@
/**
* Move tool — drag a layer around the canvas, with optional snap-on-Ctrl
* Move tool — drag a layer around the canvas, with configurable snapping
* to other layers' edges/centers and to canvas edges/center.
*
* Owns its own input handlers (begin/drag/end) and reads/writes the
@@ -12,40 +12,74 @@
* activeLayer: () => {id: string, canvas: HTMLCanvasElement, locked?: boolean} | null,
* saveState: (label?: string) => void,
* composite: () => void,
* onPositionChange?: () => 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';
import { selectedLayers, selectionLabel } from '../layer-selection.js';
import { isLayerPositionLocked } from '../layer-groups.js';
import { clonePlacedData, translatePlacedData } from '../placed-layer.js';
export function createMoveTool({ activeLayer, saveState, composite }) {
export function createMoveTool({ activeLayer, saveState, composite, onPositionChange }) {
function computeSnap(layer, nx, ny) {
const selectedIds = new Set(state.selectedLayerIds || []);
return computeSnapImpl(layer, nx, ny, {
zoom: state.zoom,
canvasW: state.imgWidth,
canvasH: state.imgHeight,
otherLayers: state.layers.map(l => ({
otherLayers: state.layers.filter(l => !selectedIds.has(l.id)).map(l => ({
visible: l.visible,
id: l.id,
canvas: l.canvas,
offset: state.layerOffsets.get(l.id) || { x: 0, y: 0 },
})),
verticalGuides: state.guides?.vertical || [],
horizontalGuides: state.guides?.horizontal || [],
snapToGrid: !!state.snapToGrid,
gridSize: state.gridSize || 16,
});
}
return {
begin(e) {
const layer = activeLayer();
if (!layer || layer.locked) return;
saveState();
if (!layer || isLayerPositionLocked(state, layer)) return;
const activeMask = (layer.masks || []).find(mask =>
mask.id === layer.activeMaskId && mask.mode === 'layer' && mask.linked === false);
state.moving = true;
state.moveHistorySaved = false;
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;
state.moveMaskTarget = activeMask || null;
state.moveMaskOffsetX = Number(activeMask?.offset?.x) || 0;
state.moveMaskOffsetY = Number(activeMask?.offset?.y) || 0;
state.moveLayerOffsets = new Map(selectedLayers(state)
.filter(item => !isLayerPositionLocked(state, item))
.map(item => [
item.id,
{ ...(state.layerOffsets.get(item.id) || { x: 0, y: 0 }) },
]));
state.movePlacedSources = new Map(selectedLayers(state)
.filter(item => item.kind === 'placed' && item.placed)
.map(item => [item.id, clonePlacedData(item.placed)]));
state.moveUnlinkedMaskOffsets = new Map();
for (const item of selectedLayers(state)) {
for (const mask of item.masks || []) {
if (mask.mode !== 'layer' || mask.linked !== false) continue;
state.moveUnlinkedMaskOffsets.set(mask.id, {
mask,
x: Number(mask.offset?.x) || 0,
y: Number(mask.offset?.y) || 0,
});
}
}
},
drag(e) {
if (!state.moving) return;
@@ -55,12 +89,27 @@ export function createMoveTool({ activeLayer, saveState, composite }) {
const coords = canvasCoords(e, state.mainCanvas);
const dx = coords.x - state.moveStartX;
const dy = coords.y - state.moveStartY;
if (state.moveMaskTarget) {
const mask = state.moveMaskTarget;
const nx = Math.round(state.moveMaskOffsetX + dx);
const ny = Math.round(state.moveMaskOffsetY + dy);
const before = mask.offset || { x: 0, y: 0 };
if (nx === before.x && ny === before.y) return;
if (!state.moveHistorySaved) {
saveState(`Move mask "${mask.name || 'Layer Mask'}"`);
state.moveHistorySaved = true;
}
mask.offset = { x: nx, y: ny };
state.activeSnapGuides = null;
composite();
return;
}
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) {
// Ctrl/Cmd temporarily inverts the persistent Snap preference.
const modifier = e.ctrlKey || e.metaKey;
const snapping = state.snapEnabled ? !modifier : modifier;
if (snapping) {
const snapped = computeSnap(layer, nx, ny);
nx = snapped.x;
ny = snapped.y;
@@ -68,11 +117,40 @@ export function createMoveTool({ activeLayer, saveState, composite }) {
} else {
state.activeSnapGuides = null;
}
state.layerOffsets.set(layer.id, { x: nx, y: ny });
const before = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
if (nx === before.x && ny === before.y) return;
if (!state.moveHistorySaved) {
// Transform already owns one pre-session snapshot. A second Move
// snapshot would make Cancel restore the staged transform instead of
// the original layer.
if (!state.transformActive) saveState(`Move ${selectionLabel(state, `"${layer.name || 'layer'}"`)}`);
state.moveHistorySaved = true;
}
const appliedDx = nx - state.moveLayerOffsetX;
const appliedDy = ny - state.moveLayerOffsetY;
for (const [id, start] of state.moveLayerOffsets) {
state.layerOffsets.set(id, { x: start.x + appliedDx, y: start.y + appliedDy });
const placedStart = state.movePlacedSources?.get(id);
const movedLayer = state.layers.find(item => item.id === id);
if (placedStart && movedLayer) movedLayer.placed = translatePlacedData(placedStart, appliedDx, appliedDy);
}
// An unlinked mask stays in the same document position while its layer
// moves. Its layer-relative offset therefore changes by the inverse
// layer delta. Linked masks retain their relative offset and move along.
for (const entry of state.moveUnlinkedMaskOffsets.values()) {
entry.mask.offset = { x: entry.x - appliedDx, y: entry.y - appliedDy };
}
const next = state.layerOffsets.get(layer.id) || { x: nx, y: ny };
composite();
onPositionChange?.(layer, before, next);
},
end() {
state.moving = false;
state.moveHistorySaved = false;
state.moveLayerOffsets = new Map();
state.movePlacedSources = new Map();
state.moveMaskTarget = null;
state.moveUnlinkedMaskOffsets = new Map();
state.activeSnapGuides = null;
},
};
+56 -10
View File
@@ -19,6 +19,7 @@
*
* @param {{
* saveState: (label: string) => void,
* undo: () => void,
* strokeTo: (x: number, y: number) => void,
* composite: () => void,
* getActiveMaskLayer: () => object | null,
@@ -31,20 +32,34 @@
*/
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', 'inpaint']);
const STROKE_TOOLS = new Set(['brush', 'eraser', 'smudge', 'dodge', 'burn', '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';
if (tool === 'smudge') return 'Smudge stroke';
if (tool === 'dodge') return 'Dodge stroke';
if (tool === 'burn') return 'Burn stroke';
return 'Stroke';
}
function pointerPressure(e) {
if (e?.touches?.[0] && Number.isFinite(e.touches[0].force) && e.touches[0].force > 0) return e.touches[0].force;
return Number.isFinite(e?.pressure) && e.pressure > 0 ? e.pressure : 1;
}
function eventSample(e) {
const coords = canvasCoords(e, state.mainCanvas);
return { x: coords.x, y: coords.y, pressure: pointerPressure(e) };
}
export function createStrokeTool({
saveState, strokeTo, composite,
saveState, undo, beginStroke, strokeTo, endStroke, composite,
getActiveMaskLayer, activeParentLayer, ensureActiveMaskLayer, createLayer,
renderLayerPanel, syncToolClearIndicators,
renderLayerPanel, syncToolClearIndicators, showToast,
}) {
return {
/**
@@ -53,6 +68,22 @@ export function createStrokeTool({
*/
tryBegin(e) {
if (!STROKE_TOOLS.has(state.tool)) return false;
const parent = activeParentLayer();
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') {
showToast?.('Rasterize the placed layer before painting its pixels');
return true;
}
if (['brush', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && isLayerPixelLocked(state, parent)) {
showToast?.('Unlock image pixels before painting');
return true;
}
if (state.tool === 'eraser' && !activeMask && isLayerTransparencyLocked(state, parent)) {
showToast?.('Unlock transparent pixels before erasing');
return true;
}
// Capture the inpaint-erase flag for this stroke. Ctrl+Alt
// pressed at pointerdown flips the persistent toggle for one
// stroke only.
@@ -89,10 +120,7 @@ export function createStrokeTool({
}
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);
beginStroke(eventSample(e), state.tool);
return true;
},
@@ -103,21 +131,39 @@ export function createStrokeTool({
tryContinue(e) {
if (!state.drawing) return false;
e.preventDefault();
const coords = canvasCoords(e, state.mainCanvas);
strokeTo(coords.x, coords.y);
const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [];
const samples = events.length ? events : [e];
for (const sampleEvent of samples) strokeTo(eventSample(sampleEvent));
return true;
},
/**
* Wrap up an in-progress stroke. Returns true if there was one.
*/
tryEnd() {
tryEnd(e) {
if (!state.drawing) return false;
const wasDrawingInpaint = state.tool === 'inpaint';
state.drawing = false;
endStroke(e ? eventSample(e) : null);
composite();
// Strokes composite every frame while drawing, but the layer panel is
// intentionally not rebuilt at that frequency. Refresh once at the end
// so inline layer/group thumbnails reflect the completed edit.
renderLayerPanel();
if (wasDrawingInpaint) syncToolClearIndicators();
return true;
},
cancel() {
if (!state.drawing) return false;
state.drawing = false;
state.cloneSourceSnapshot = null;
undo?.();
state.redoStack = [];
composite();
renderLayerPanel();
syncToolClearIndicators();
return true;
},
};
}
+106 -70
View File
@@ -13,21 +13,37 @@
* 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,
* pointInTransformFrame: (x: number, y: number) => boolean,
* snapTransformFrame?: (frame: object, center: {x:number,y:number}) => object,
* }} deps
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { resizeTransformFrame } from '../transform-frame-geometry.js';
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
export function createTransformDragTool({
beginMove, composite, drawTransformHandles, reapplyTransform,
getTransformHandle, cursorForHandle,
composite, drawTransformHandles, reapplyTransform,
getTransformHandle, cursorForHandle, pointInTransformFrame,
snapTransformFrame,
}) {
const gesture = createDirectManipulationSession({
name: 'transform-frame',
getContext: () => ({ canvas: state.mainCanvas, items: state.transformItems }),
isContextCurrent: context => state.transformActive &&
context.canvas === state.mainCanvas && context.items === state.transformItems,
onCancel: () => {
state.transformHandle = null;
state.activeSnapGuides = null;
composite();
drawTransformHandles();
},
});
return {
/**
* Called on pointerdown. Returns true if the transform tool handled
@@ -36,33 +52,36 @@ export function createTransformDragTool({
tryBegin(e) {
if (!state.transformActive) return false;
const coords = canvasCoords(e, state.mainCanvas);
state.transformHandle = getTransformHandle(coords.x, coords.y);
const pointerType = e.pointerType || (e.touches ? 'touch' : 'mouse');
state.transformHandle = getTransformHandle(coords.x, coords.y, { pointerType });
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;
const center = state.transformCenter || { x: 0, y: 0 };
state.transformStartCenter = { ...center };
state.transformOrigW = state.transformPendingW;
state.transformOrigH = state.transformPendingH;
state.transformStartRotation = state.transformPendingRot;
state.transformStartFlipH = state.transformPendingFlipH;
state.transformStartFlipV = state.transformPendingFlipV;
state.transformStartOffX = center.x - state.transformOrigW / 2;
state.transformStartOffY = center.y - state.transformOrigH / 2;
gesture.begin(e, { handle: state.transformHandle }, { captureTarget: e.currentTarget });
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;
}
// No handle hit: dragging inside the staged frame moves the transform
// itself. Keeping this in the transform session is essential for
// selection transforms, which must never fall through to layer Move.
if (pointInTransformFrame?.(coords.x, coords.y)) {
state.transformHandle = 'move';
state.transformStartX = coords.x;
state.transformStartY = coords.y;
state.transformStartCenter = { ...(state.transformCenter || { x: 0, y: 0 }) };
gesture.begin(e, { handle: 'move' }, { captureTarget: e.currentTarget });
return true;
}
return false;
},
@@ -79,8 +98,12 @@ export function createTransformDragTool({
// 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';
const pointerType = e.pointerType || (e.touches ? 'touch' : 'mouse');
const hovered = getTransformHandle(coords.x, coords.y, { pointerType });
const bodyHovered = !hovered && pointInTransformFrame?.(coords.x, coords.y);
state.mainCanvas.style.cursor = hovered
? cursorForHandle(hovered, state.transformPendingRot)
: bodyHovered ? 'move' : 'default';
if (hovered !== state.hoveredHandle) {
state.hoveredHandle = hovered;
composite();
@@ -88,15 +111,38 @@ export function createTransformDragTool({
return false; // didn't fully consume the event
}
if (!state.transformHandle) return false;
if (!gesture.update(e)) return false;
e.preventDefault();
const coords = canvasCoords(e, state.mainCanvas);
if (state.transformHandle === 'move') {
let center = {
x: state.transformStartCenter.x + coords.x - state.transformStartX,
y: state.transformStartCenter.y + coords.y - state.transformStartY,
};
const modifier = e.ctrlKey || e.metaKey;
const snapping = state.snapEnabled ? !modifier : modifier;
if (snapping && snapTransformFrame) {
const snapped = snapTransformFrame({
centerX: state.transformStartCenter.x,
centerY: state.transformStartCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, center);
center = { x: snapped.centerX, y: snapped.centerY };
state.activeSnapGuides = snapped.guides || null;
} else {
state.activeSnapGuides = null;
}
state.transformCenter = center;
reapplyTransform();
return true;
}
// 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 cx = state.transformCenter?.x || 0;
const cy = state.transformCenter?.y || 0;
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
@@ -110,50 +156,35 @@ export function createTransformDragTool({
}
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,
};
// 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({
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, {
centered: !!e.altKey,
lockAspect: !!e.shiftKey,
flipH: state.transformStartFlipH,
flipV: state.transformStartFlipV,
});
state.transformPendingW = resized.width;
state.transformPendingH = resized.height;
state.transformPendingFlipH = resized.flipH;
state.transformPendingFlipV = resized.flipV;
state.transformCenter = { x: resized.centerX, y: resized.centerY };
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);
if (wIn) wIn.value = String(state.transformPendingFlipH ? -resized.width : resized.width);
if (hIn) hIn.value = String(state.transformPendingFlipV ? -resized.height : resized.height);
}
return true;
},
@@ -161,14 +192,19 @@ export function createTransformDragTool({
/**
* Called on pointerup. Returns true if handled.
*/
tryEnd() {
tryEnd(e) {
if (!(state.transformActive && state.transformHandle)) return false;
if (!gesture.commit(e)) return false;
state.transformHandle = null;
state.transformOrigW = state.transformLayer?.canvas.width || 0;
state.transformOrigH = state.transformLayer?.canvas.height || 0;
state.transformOrigW = state.transformPendingW;
state.transformOrigH = state.transformPendingH;
state.activeSnapGuides = null;
composite();
drawTransformHandles();
return true;
},
cancel(reason) {
return gesture.cancel(reason);
},
};
}
+129 -138
View File
@@ -8,8 +8,8 @@
*
* - `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
* - `drawHandles(margin)` draws the rotated bounding outline + 8
* resize 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`
@@ -20,13 +20,16 @@
* pointer events.
*/
import { state } from '../state.js';
import {
hitTestTransformHandle,
pointInTransformFrame,
transformFrameGeometry,
} from '../transform-frame-geometry.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).
* 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.
*/
export function syncOverlay(margin) {
if (!state.transformOverlay || !state.mainCanvas) return;
@@ -34,8 +37,9 @@ export function syncOverlay(margin) {
state.transformOverlay.style.display = 'none';
return;
}
const W = state.mainCanvas.width + 2 * margin;
const H = state.mainCanvas.height + 2 * margin;
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);
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
@@ -55,103 +59,100 @@ export function syncOverlay(margin) {
// 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.left = Math.round(state.mainCanvas.offsetLeft - margin) + 'px';
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop - margin) + '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.
function rotationHandleInside(frame) {
let inside = false;
const outside = transformFrameGeometry(frame, { zoom: state.zoom }).rotationHandle;
// The overlay deliberately extends beyond the image, so only fold the
// rotation control inward when the surrounding viewport would clip it.
try {
const area = state.container && state.container.querySelector('.ge-canvas-area');
if (area && !rotInside) {
if (area && !inside) {
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;
const knobClientX = mRect.left + outside.x * scaleX;
const knobClientY = mRect.top + outside.y * scaleY;
if (knobClientY < aRect.top + 6) inside = true;
if (knobClientX < aRect.left + 6 || knobClientX > aRect.right - 6) inside = true;
const popupRect = state.transformPopup?.getBoundingClientRect?.();
if (popupRect
&& knobClientX >= popupRect.left
&& knobClientX <= popupRect.right
&& knobClientY >= popupRect.top
&& knobClientY <= popupRect.bottom) {
inside = true;
}
}
} catch {}
const innerR = rotInside ? Math.max(4, baseInnerR - rotOffset) : baseInnerR;
const rotR = rotInside ? innerR : baseInnerR + rotOffset;
return inside;
}
function transformFrame() {
if (state.transformTarget === 'selection') {
if (!state.transformSelectionBounds) return null;
return {
centerX: state.transformCenter?.x ?? state.transformSelectionBounds.centerX,
centerY: state.transformCenter?.y ?? state.transformSelectionBounds.centerY,
width: state.transformPendingW || state.transformSelectionBounds.width,
height: state.transformPendingH || state.transformSelectionBounds.height,
rotation: state.transformPendingRot || 0,
};
}
const layer = state.transformLayer;
if (!layer) return null;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const fallbackCenter = {
x: off.x + layer.canvas.width / 2,
y: off.y + layer.canvas.height / 2,
};
return {
rotInside,
innerR,
rotX: cxh + Math.sin(rotRad) * rotR,
rotY: cyh - Math.cos(rotRad) * rotR,
centerX: state.transformCenter?.x ?? fallbackCenter.x,
centerY: state.transformCenter?.y ?? fallbackCenter.y,
width: state.transformPendingW || layer.canvas.width,
height: state.transformPendingH || layer.canvas.height,
rotation: state.transformPendingRot || 0,
};
}
/**
* Draw the rotated bounding outline + 4 corner handles + the rotation
* Draw the rotated bounding outline + 8 resize 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;
if (!state.transformActive || (state.transformTarget !== 'selection' && !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 frame = transformFrame();
if (!frame) return;
const rotInside = rotationHandleInside(frame);
const geometry = transformFrameGeometry(frame, {
zoom: state.zoom,
rotationInside: rotInside,
});
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).
ctx.clearRect(0, 0, state.transformOverlay.width, state.transformOverlay.height);
ctx.save();
ctx.translate(margin, margin);
ctx.translate(imageMargin, imageMargin);
// 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);
const { tl, tr, br, bl } = geometry.corners;
// Outline of the rotated rectangle — solid white inner line with a
// thin black halo for contrast on light AND dark backgrounds.
// Use the editor accent instead of white so the frame remains distinct
// from both the canvas and the ruler/UI chrome.
const drawRectOutline = () => {
ctx.beginPath();
ctx.moveTo(tl.x, tl.y);
@@ -161,54 +162,41 @@ export function drawHandles(margin) {
ctx.closePath();
ctx.stroke();
};
ctx.lineWidth = 1 / state.zoom;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.45)';
ctx.lineWidth = 3 / state.zoom;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.82)';
ctx.setLineDash([6 / state.zoom, 4 / state.zoom]);
ctx.lineDashOffset = 1 / state.zoom;
drawRectOutline();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5 / state.zoom;
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e06c75';
ctx.lineDashOffset = 0;
drawRectOutline();
ctx.setLineDash([]);
// Corner handles + rotation knob anchored to the rotated layer's
// Resize 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) {
const topHandle = geometry.resizeHandles.find(handle => handle.id === 't');
if (!rotInside) {
ctx.beginPath();
ctx.moveTo(innerX, innerY);
ctx.lineTo(knob.rotX, knob.rotY);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
ctx.moveTo(topHandle.x, topHandle.y);
ctx.lineTo(geometry.rotationHandle.x, geometry.rotationHandle.y);
ctx.strokeStyle = accent;
ctx.lineWidth = 1 / state.zoom;
ctx.stroke();
}
for (const c of corners) {
for (const c of geometry.handles) {
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);
const edge = c.id.length === 1 && c.id !== 'rot';
const baseRadius = edge ? sz * 0.42 : sz / 2;
const radius = active ? sz * 0.75 : hovered ? sz * 0.6 : baseRadius;
ctx.beginPath();
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
ctx.fillStyle = active ? '#e06c75' : hovered ? '#ffd' : '#fff';
ctx.fillStyle = active ? '#fff' : hovered ? '#ffd' : accent;
ctx.fill();
ctx.lineWidth = stroke;
ctx.strokeStyle = active ? '#fff' : 'rgba(0, 0, 0, 0.5)';
ctx.strokeStyle = active ? accent : 'rgba(0, 0, 0, 0.72)';
ctx.stroke();
if (hovered) {
// Subtle red ring around the hovered handle for visual feedback.
@@ -219,53 +207,56 @@ export function drawHandles(margin) {
ctx.stroke();
}
}
// The center marker communicates the rotation pivot without becoming an
// interactive handle until pivot dragging lands in the transform workflow.
const pivotRadius = 3 / state.zoom;
ctx.beginPath();
ctx.arc(geometry.pivot.x, geometry.pivot.y, pivotRadius, 0, Math.PI * 2);
ctx.strokeStyle = accent;
ctx.lineWidth = 1 / state.zoom;
ctx.stroke();
ctx.restore();
}
/**
* Hit-test (x, y) against the transform handles. Returns the handle
* id ('tl' | 'tr' | 'br' | 'bl' | 'rot') or null.
* id ('tl' | 't' | 'tr' | 'r' | 'br' | 'b' | 'bl' | 'l' | '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);
export function getHandleAt(x, y, options = {}) {
if (state.transformTarget !== 'selection' && !state.transformLayer) return null;
const frame = transformFrame();
if (!frame) return null;
const rotationInside = rotationHandleInside(frame);
const geometry = transformFrameGeometry(frame, {
zoom: state.zoom,
rotationInside,
});
const hit = hitTestTransformHandle(geometry, x, y, {
zoom: state.zoom,
pointerType: options.pointerType,
});
if (hit || !rotationInside) return hit;
// 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,
// Keep the clipped grip reachable during viewport-edge transitions. The
// frame is drawn with the inward position, but a touch can begin from the
// previously rendered outward position for one frame after a resize/pan.
const unclippedGeometry = transformFrameGeometry(frame, {
zoom: state.zoom,
rotationInside: false,
});
return hitTestTransformHandle(unclippedGeometry, x, y, {
zoom: state.zoom,
pointerType: options.pointerType,
});
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;
}
/** True only for the visible rotated frame, not its empty AABB corners. */
export function containsFramePoint(x, y) {
const frame = transformFrame();
if (!frame) return false;
return pointInTransformFrame(transformFrameGeometry(frame, { zoom: state.zoom }), x, y);
}
+427 -60
View File
@@ -23,31 +23,134 @@
* hideCanvasLoading: () => void,
* undo: () => void,
* uiModule: object | null,
* syncGeometryControls?: () => void,
* getDocumentSelection?: () => HTMLCanvasElement | null,
* syncSelectionUi?: () => void,
* schedulePersist?: () => void,
* }} deps
*
* @returns {{
* startTransform, openTransformPopup, closeTransformPopup,
* reapplyTransform, confirmTransform, cancelTransform,
* reapplyTransform, nudgeTransform, confirmTransform, cancelTransform,
* }}
*/
import { state } from '../state.js';
import { normalizeTextData, renderTextLayer } from '../text-layer.js';
import { normalizeShapeData, renderShapeLayer } from '../shape-layer.js';
import { isLayerPositionLocked } from '../layer-groups.js';
import { selectedLayers, selectionLabel } from '../layer-selection.js';
import {
planLayerTransform,
selectionBounds,
transformedSelectionBounds,
} from '../multi-transform.js';
import {
EDITOR_MAX_DIMENSION,
EDITOR_MAX_SURFACE_PIXELS,
} from '../document-codec.js';
import {
transformPopupHTML,
attachSpinRepeat,
} from '../build/transform-popup.js';
import {
selectionMaskBounds,
transformSelectionMask,
} from '../selection-mask.js';
import {
clonePlacedData,
createPlacedData,
frameTransformMatrix,
renderPlacedLayer,
transformPlacedData,
} from '../placed-layer.js';
export function createTransformSession({
activeLayer, saveState, composite, fitZoom, drawTransformHandles,
showCanvasLoading, hideCanvasLoading, undo, uiModule,
syncGeometryControls, getDocumentSelection, syncSelectionUi, schedulePersist,
}) {
let lastValidLayerTarget = null;
let lastTransformLimitMessage = '';
const formatTransformValue = value => {
const number = Number(value) || 0;
return Number.isInteger(number) ? String(number) : String(Number(number.toFixed(2)));
};
function syncTransformPopupValues(force = false) {
const pop = state.transformPopup;
if (!pop || !state.transformCenter) return;
const values = {
'#ge-transform-x': state.transformCenter.x,
'#ge-transform-y': state.transformCenter.y,
'#ge-transform-w': state.transformPendingFlipH ? -state.transformPendingW : state.transformPendingW,
'#ge-transform-h': state.transformPendingFlipV ? -state.transformPendingH : state.transformPendingH,
'#ge-transform-rot': state.transformPendingRot,
};
for (const [selector, value] of Object.entries(values)) {
const input = pop.querySelector(selector);
if (input && (force || document.activeElement !== input)) input.value = formatTransformValue(value);
}
}
function copyCanvas(source) {
const canvas = document.createElement('canvas');
canvas.width = source.width;
canvas.height = source.height;
canvas.getContext('2d').drawImage(source, 0, 0);
return canvas;
}
function startTransform() {
const layer = activeLayer();
if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; }
const layers = selectedLayers(state);
if (!layer || !layers.length) { uiModule.showToast('Select an unlocked layer'); return; }
if (layers.some(item => isLayerPositionLocked(state, item))) {
uiModule.showToast(layers.length > 1 ? 'Unlock the position of every selected layer before transforming' : 'Unlock the layer position before transforming');
return;
}
if (state.transformActive) { cancelTransform(); return; } // toggle off
const bounds = selectionBounds(state, layers);
state.transformActive = true;
state.transformTarget = 'layers';
state.transformLayer = layer;
state.transformOrigW = layer.canvas.width;
state.transformOrigH = layer.canvas.height;
state.transformLayers = [...layers];
state.transformItems = layers.map(item => ({
layer: item,
kind: item.kind || 'raster',
canvas: copyCanvas(item.canvas),
width: item.canvas.width,
height: item.canvas.height,
offset: { ...(state.layerOffsets.get(item.id) || { x: 0, y: 0 }) },
wasPlaced: item.kind === 'placed',
placed: item.kind === 'placed'
? clonePlacedData(item.placed)
: (item.kind === 'raster' ? createPlacedData(item.canvas, [1, 0, 0, 1, 0, 0], item.name || 'Raster layer') : null),
masks: (item.masks || [])
.filter(mask => (mask.space || 'layer') === 'layer' && mask.canvas)
.map(mask => {
const maskOffset = {
x: Number(mask.offset?.x) || 0,
y: Number(mask.offset?.y) || 0,
};
const layerOffset = state.layerOffsets.get(item.id) || { x: 0, y: 0 };
return {
mask,
canvas: copyCanvas(mask.canvas),
linked: mask.linked !== false,
offset: maskOffset,
documentOffset: {
x: layerOffset.x + maskOffset.x,
y: layerOffset.y + maskOffset.y,
},
};
}),
}));
const activeSnapshot = state.transformItems.find(item => item.layer.id === layer.id);
state.transformOrigW = bounds.width;
state.transformOrigH = bounds.height;
state.transformSessionStartW = bounds.width;
state.transformSessionStartH = bounds.height;
state.transformPendingW = state.transformOrigW;
state.transformPendingH = state.transformOrigH;
state.transformPendingRot = 0;
@@ -56,12 +159,16 @@ export function createTransformSession({
// 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();
state.transformOrigCanvas = activeSnapshot?.canvas || null;
state.transformOrigMasks = activeSnapshot?.masks || [];
state.transformOrigOffset = { ...(activeSnapshot?.offset || { x: 0, y: 0 }) };
state.transformSelectionBounds = { ...bounds };
state.transformBounds = { ...bounds };
state.transformCenter = { x: bounds.centerX, y: bounds.centerY };
state.transformStartCenter = { ...state.transformCenter };
lastValidLayerTarget = currentTransformTarget();
lastTransformLimitMessage = '';
saveState(`Transform ${selectionLabel(state, `"${layer.name || 'layer'}"`)}`);
// Fit canvas to viewport so the corner handles are visible —
// without this, a layer larger than the viewport leaves the grab
// markers off-screen.
@@ -69,6 +176,48 @@ export function createTransformSession({
composite();
drawTransformHandles();
openTransformPopup();
syncGeometryControls?.();
}
function startSelectionTransform() {
if (state.transformActive) cancelTransform();
const selection = getDocumentSelection?.();
const bounds = selectionMaskBounds(selection);
if (!selection || !bounds) {
uiModule?.showToast('Make a selection before transforming it');
return false;
}
const frame = {
...bounds,
centerX: bounds.x + bounds.width / 2,
centerY: bounds.y + bounds.height / 2,
};
state.transformActive = true;
state.transformTarget = 'selection';
state.transformLayer = null;
state.transformLayers = [];
state.transformItems = [];
state.transformSelectionCanvas = copyCanvas(selection);
state.transformSelectionBounds = { ...frame };
state.transformBounds = { ...frame };
state.transformCenter = { x: frame.centerX, y: frame.centerY };
state.transformStartCenter = { ...state.transformCenter };
state.transformOrigW = frame.width;
state.transformOrigH = frame.height;
state.transformSessionStartW = frame.width;
state.transformSessionStartH = frame.height;
state.transformPendingW = frame.width;
state.transformPendingH = frame.height;
state.transformPendingRot = 0;
state.transformPendingFlipH = false;
state.transformPendingFlipV = false;
saveState('Transform selection');
try { fitZoom(); } catch {}
composite();
drawTransformHandles();
openTransformPopup();
syncSelectionUi?.();
return true;
}
function closeTransformPopup() {
@@ -85,19 +234,39 @@ export function createTransformSession({
function openTransformPopup() {
closeTransformPopup();
if (!state.container) return;
// Global tours and toasts sit above tool popups and can cover the compact
// mobile header or Apply/Cancel controls. Starting a new edit is a stronger
// 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();
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);
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 xInput = pop.querySelector('#ge-transform-x');
const yInput = pop.querySelector('#ge-transform-y');
const aspectBtn = pop.querySelector('#ge-transform-aspect');
wInput.value = String(state.transformOrigW);
hInput.value = String(state.transformOrigH);
rotInput.value = '0';
const preserveSource = pop.querySelector('#ge-transform-preserve-source');
const preserveField = pop.querySelector('.ge-transform-preserve-field');
const canPreserveSource = state.transformTarget !== 'selection'
&& state.transformItems.some(item => item.layer?.kind === 'raster' || item.wasPlaced);
if (preserveField) preserveField.hidden = !canPreserveSource;
if (preserveSource) {
preserveSource.checked = state.transformPreserveSource !== false;
preserveSource.addEventListener('change', () => {
state.transformPreserveSource = preserveSource.checked;
reapplyTransform();
});
}
syncTransformPopupValues();
aspectBtn.classList.toggle('active', state.transformAspectLock);
aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false');
@@ -125,6 +294,8 @@ export function createTransformSession({
let w = parseInt(wInput.value, 10);
let h = parseInt(hInput.value, 10);
const rot = parseInt(rotInput.value, 10) || 0;
const x = Number.parseFloat(xInput.value);
const y = Number.parseFloat(yInput.value);
state.transformPendingFlipH = w < 0;
state.transformPendingFlipV = h < 0;
w = Math.abs(w || state.transformOrigW);
@@ -132,8 +303,11 @@ export function createTransformSession({
state.transformPendingW = Math.max(1, w);
state.transformPendingH = Math.max(1, h);
state.transformPendingRot = rot;
if (Number.isFinite(x) && Number.isFinite(y)) state.transformCenter = { x, y };
reapplyTransform();
};
xInput.addEventListener('input', refresh);
yInput.addEventListener('input', refresh);
wInput.addEventListener('input', () => {
if (state.transformAspectLock) {
driver = 'w';
@@ -310,72 +484,265 @@ export function createTransformSession({
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 renderSnapshot(source, snapshot, geometry) {
const canvas = document.createElement('canvas');
canvas.width = geometry.width;
canvas.height = geometry.height;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
if (geometry.rotation) ctx.rotate(geometry.radians);
ctx.scale(geometry.signedScaleX, geometry.signedScaleY);
ctx.drawImage(source, -snapshot.width / 2, -snapshot.height / 2, snapshot.width, snapshot.height);
ctx.restore();
return canvas;
}
function currentTransformTarget() {
return {
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
flipH: state.transformPendingFlipH,
flipV: state.transformPendingFlipV,
centerX: state.transformCenter?.x,
centerY: state.transformCenter?.y,
};
}
function restoreTransformTarget(target) {
if (!target) return;
state.transformPendingW = target.width;
state.transformPendingH = target.height;
state.transformPendingRot = target.rotation;
state.transformPendingFlipH = target.flipH;
state.transformPendingFlipV = target.flipV;
state.transformCenter = { x: target.centerX, y: target.centerY };
}
// Re-derive every selected layer from immutable per-session snapshots.
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),
});
if (state.transformTarget === 'selection') {
if (!state.transformSelectionCanvas || !state.transformSelectionBounds || !state.transformCenter) return;
const target = {
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
flipH: state.transformPendingFlipH,
flipV: state.transformPendingFlipV,
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
};
state.wandMask = transformSelectionMask(
state.transformSelectionCanvas,
state.transformSelectionBounds,
target,
state.imgWidth,
state.imgHeight,
);
state.wandLayerId = state.activeLayerId;
state.wandMaskSpace = 'document';
state.selectionSource = 'transform';
state.wandLastSeed = null;
state.lassoPoints = [];
state.lassoActive = false;
state.transformBounds = transformedSelectionBounds(state.transformSelectionBounds, target);
composite();
syncTransformPopupValues();
syncSelectionUi?.();
drawTransformHandles();
return;
}
if (!state.transformItems.length || !state.transformSelectionBounds || !state.transformCenter) return;
const target = currentTransformTarget();
const plan = planLayerTransform(
state,
state.transformItems,
state.transformSelectionBounds,
target,
{ maxDimension: EDITOR_MAX_DIMENSION, maxSurfacePixels: EDITOR_MAX_SURFACE_PIXELS },
);
if (!plan.ok) {
restoreTransformTarget(lastValidLayerTarget);
syncTransformPopupValues(true);
if (lastTransformLimitMessage !== plan.reason) {
uiModule?.showToast(plan.reason, 5000);
lastTransformLimitMessage = plan.reason;
}
composite();
drawTransformHandles();
return false;
}
lastValidLayerTarget = { ...target };
lastTransformLimitMessage = '';
const documentTransform = frameTransformMatrix(state.transformSelectionBounds, target);
for (const { snapshot, geometry: plannedGeometry } of plan.items) {
const layer = snapshot.layer;
let geometry = plannedGeometry;
if (snapshot.placed && (snapshot.wasPlaced || state.transformPreserveSource !== false)) {
layer.placed = transformPlacedData(snapshot.placed, documentTransform);
const rendered = renderPlacedLayer(layer);
geometry = {
...plannedGeometry,
width: rendered.canvas.width,
height: rendered.canvas.height,
offset: rendered.offset,
};
} else {
if (snapshot.kind === 'raster') {
layer.kind = 'raster';
layer.placed = null;
}
const output = renderSnapshot(snapshot.canvas, snapshot, geometry);
layer.canvas.width = geometry.width;
layer.canvas.height = geometry.height;
layer.ctx = layer.canvas.getContext('2d');
layer.ctx.drawImage(output, 0, 0);
}
layer._adjFinal = null;
layer._adjFinalKey = null;
layer._adjCache = null;
layer._adjCacheKey = null;
state.layerOffsets.set(layer.id, geometry.offset);
for (const maskSnapshot of snapshot.masks) {
if (maskSnapshot.linked) {
const positioned = document.createElement('canvas');
positioned.width = snapshot.width;
positioned.height = snapshot.height;
positioned.getContext('2d').drawImage(
maskSnapshot.canvas,
maskSnapshot.offset.x,
maskSnapshot.offset.y,
);
const maskOutput = renderSnapshot(positioned, snapshot, geometry);
maskSnapshot.mask.canvas.width = geometry.width;
maskSnapshot.mask.canvas.height = geometry.height;
maskSnapshot.mask.ctx = maskSnapshot.mask.canvas.getContext('2d');
maskSnapshot.mask.ctx.drawImage(maskOutput, 0, 0);
maskSnapshot.mask.offset = { x: 0, y: 0 };
} else {
// Rebuild from the immutable snapshot so every preview remains
// cancel-safe, then compensate for the layer's new document offset.
maskSnapshot.mask.canvas.width = maskSnapshot.canvas.width;
maskSnapshot.mask.canvas.height = maskSnapshot.canvas.height;
maskSnapshot.mask.ctx = maskSnapshot.mask.canvas.getContext('2d');
maskSnapshot.mask.ctx.drawImage(maskSnapshot.canvas, 0, 0);
maskSnapshot.mask.offset = {
x: maskSnapshot.documentOffset.x - geometry.offset.x,
y: maskSnapshot.documentOffset.y - geometry.offset.y,
};
}
}
}
state.transformBounds = transformedSelectionBounds(state.transformSelectionBounds, target);
composite();
syncTransformPopupValues();
syncGeometryControls?.();
drawTransformHandles();
return true;
}
function confirmTransform() {
closeTransformPopup();
const target = state.transformTarget;
if (target === 'selection') {
clearTransformState();
composite();
syncSelectionUi?.();
schedulePersist?.();
uiModule?.showToast('Selection transformed');
return;
}
for (const snapshot of state.transformItems) {
const layer = snapshot.layer;
if (!layer || !['text', 'shape'].includes(layer.kind)) continue;
const offset = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const center = { x: offset.x + layer.canvas.width / 2, y: offset.y + layer.canvas.height / 2 };
const retained = layer.kind === 'text' ? normalizeTextData(layer.text) : normalizeShapeData(layer.shape);
retained.transform.scaleX *= state.transformPendingW / Math.max(1, state.transformSessionStartW);
retained.transform.scaleY *= state.transformPendingH / Math.max(1, state.transformSessionStartH);
retained.transform.rotation += state.transformPendingRot;
if (state.transformPendingFlipH) retained.transform.flipH = !retained.transform.flipH;
if (state.transformPendingFlipV) retained.transform.flipV = !retained.transform.flipV;
if (layer.kind === 'text') {
layer.text = retained;
renderTextLayer(layer);
} else {
layer.shape = retained;
renderShapeLayer(layer);
}
state.layerOffsets.set(layer.id, {
x: Math.round(center.x - layer.canvas.width / 2),
y: Math.round(center.y - layer.canvas.height / 2),
});
}
const count = state.transformItems.length;
clearTransformState();
composite();
syncGeometryControls?.();
schedulePersist?.();
uiModule.showToast(count > 1 ? `${count} layers transformed` : 'Transform applied');
}
function nudgeTransform(dx, dy) {
if (!state.transformActive || !state.transformCenter) return false;
state.transformCenter = {
x: state.transformCenter.x + Number(dx || 0),
y: state.transformCenter.y + Number(dy || 0),
};
reapplyTransform();
return true;
}
function clearTransformState() {
state.transformOrigCanvas = null;
state.transformOrigMasks = [];
state.transformOrigOffset = null;
state.transformSelectionCanvas = null;
state.transformLayers = [];
state.transformItems = [];
state.transformSelectionBounds = null;
state.transformBounds = null;
state.transformCenter = null;
state.transformStartCenter = null;
state.transformOrigW = 0;
state.transformOrigH = 0;
state.transformPendingW = 0;
state.transformPendingH = 0;
state.transformPendingRot = 0;
state.transformPendingFlipH = false;
state.transformPendingFlipV = false;
state.transformActive = false;
state.transformTarget = null;
state.transformLayer = null;
state.transformHandle = null;
composite();
uiModule.showToast('Transform applied');
state.hoveredHandle = null;
state.transformSessionStartW = 0;
state.transformSessionStartH = 0;
state.transformStartRotation = 0;
state.transformStartFlipH = false;
state.transformStartFlipV = false;
lastValidLayerTarget = null;
lastTransformLimitMessage = '';
}
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;
const hadTransform = state.transformActive;
if (hadTransform) {
undo(); // restore the one pre-session snapshot
state.redoStack = []; // Cancel must not expose the discarded preview via Redo.
}
clearTransformState();
composite();
syncGeometryControls?.();
syncSelectionUi?.();
}
return {
startTransform, openTransformPopup, closeTransformPopup,
reapplyTransform, confirmTransform, cancelTransform,
startTransform, startSelectionTransform, openTransformPopup, closeTransformPopup,
reapplyTransform, nudgeTransform, confirmTransform, cancelTransform,
};
}
+7 -1
View File
@@ -20,7 +20,7 @@
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
export function createWandTool({ activeLayer, saveState, composite, wandHits, runMagicWand }) {
export function createWandTool({ activeLayer, saveState, composite, wandHits, runMagicWand, deselectSelection }) {
return {
click(e) {
const layer = activeLayer();
@@ -33,9 +33,15 @@ export function createWandTool({ activeLayer, saveState, composite, wandHits, ru
else if (e.altKey) mode = 'subtract';
// Click INSIDE the existing selection with no modifier → deselect.
if (mode === 'replace' && wandHits(coords.x, coords.y)) {
if (deselectSelection) {
deselectSelection();
return;
}
saveState();
state.wandMask = null;
state.wandLayerId = null;
state.wandMaskSpace = 'layer';
state.selectionSource = null;
state.wandLastSeed = null;
composite();
return;