mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-01 12:32:19 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user