Files
odysseus/static/js/editor/tools/crop.js
T
pewdiepie-archdaemonandClaude Opus 5.5 5fac693c1c feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:57:35 +00:00

231 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Crop tool — drag-rect selection that lets the user cut down the
* canvas to a smaller region. Supports Shift-lock aspect ratio and
* click-inside-rect to reposition an existing crop without redrawing.
*
* Owns its own begin/drag/end handlers and reads/writes shared state.
* The factory takes a small dependency bag for things still living in
* galleryEditor.js — `composite` redraws the canvas, `showCropApply`
* mounts the floating W×H + Apply panel after the user finishes
* dragging.
*
* @param {{
* composite: () => void,
* showCropApply: () => void,
* }} deps
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { drawCheckerboard } from '../checkerboard.js';
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
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;
// Switching tools abandons the crop entirely — do not restore a
// half-drawn rect, and tear down any Apply panel a commit may have
// mounted mid-gesture.
if (reason === 'tool-switch') {
state.cropRect = null;
state.container?.querySelector('.ge-crop-apply')?.remove();
} else {
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 &&
coords.x >= state.cropRect.x && coords.x <= state.cropRect.x + state.cropRect.w &&
coords.y >= state.cropRect.y && coords.y <= state.cropRect.y + state.cropRect.h) {
state.cropMoving = true;
state.cropMoveStart = { x: coords.x, y: coords.y, rx: state.cropRect.x, ry: state.cropRect.y };
gesture.begin(e, { mode: 'move', previousRect }, { captureTarget: e.currentTarget });
return;
}
state.cropping = true;
state.cropStart = coords;
state.cropEnd = { ...state.cropStart };
state.cropRect = null;
state.cropAspectLock = null;
// Tear down the size panel while the user is drawing a new rect.
const old = state.container?.querySelector('.ge-crop-apply');
if (old) old.remove();
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();
const c = canvasCoords(e, state.mainCanvas);
const dx = c.x - state.cropMoveStart.x;
const dy = c.y - state.cropMoveStart.y;
let nx = state.cropMoveStart.rx + dx;
let ny = state.cropMoveStart.ry + dy;
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));
state.cropRect = { ...state.cropRect, x: nx, y: ny };
composite();
return;
}
if (!state.cropping) return;
e.preventDefault();
// Clamp the dragged corner to the document so a pointer that
// overshoots onto a panel (or past the canvas edge) still yields a
// rect inside the image rather than negative/off-canvas coords.
const end = canvasCoords(e, state.mainCanvas);
state.cropEnd = {
x: Math.max(0, Math.min(state.mainCanvas.width, end.x)),
y: Math.max(0, Math.min(state.mainCanvas.height, end.y)),
};
// Aspect lock. Shift-held snapshots the current aspect on first press
// and stays locked until released. Otherwise a chosen crop preset
// (state.cropAspectPreset) locks the ratio for new drags.
let presetRatio = null;
const preset = state.cropAspectPreset;
if (preset && preset !== 'free') {
if (preset === 'original') {
presetRatio = (state.imgWidth && state.imgHeight) ? state.imgWidth / state.imgHeight : null;
} else {
const m = /^(\d+):(\d+)$/.exec(preset);
presetRatio = m ? Number(m[1]) / Number(m[2]) : null;
}
}
let lockRatio = null;
if (e.shiftKey) {
const rawDx = state.cropEnd.x - state.cropStart.x;
const rawDy = state.cropEnd.y - state.cropStart.y;
if (state.cropAspectLock == null) {
const rawW = Math.abs(rawDx) || 1;
const rawH = Math.abs(rawDy) || 1;
state.cropAspectLock = rawW / rawH;
}
lockRatio = state.cropAspectLock;
} else {
state.cropAspectLock = null;
lockRatio = presetRatio;
}
if (lockRatio) {
const rawDx = state.cropEnd.x - state.cropStart.x;
const rawDy = state.cropEnd.y - state.cropStart.y;
const absDx = Math.abs(rawDx);
const absDy = Math.abs(rawDy);
// Whichever axis the user moved more (relative to the lock) is
// the driver; scale the other to preserve aspect.
let dx, dy;
if (absDx >= absDy * lockRatio) {
dx = rawDx;
dy = Math.sign(rawDy || 1) * (absDx / lockRatio);
} else {
dy = rawDy;
dx = Math.sign(rawDx || 1) * (absDy * lockRatio);
}
state.cropEnd = { x: state.cropStart.x + dx, y: state.cropStart.y + dy };
}
composite();
// Draw crop overlay.
const x = Math.min(state.cropStart.x, state.cropEnd.x);
const y = Math.min(state.cropStart.y, state.cropEnd.y);
const w = Math.abs(state.cropEnd.x - state.cropStart.x);
const h = Math.abs(state.cropEnd.y - state.cropStart.y);
state.mainCtx.fillStyle = 'rgba(0,0,0,0.4)';
state.mainCtx.fillRect(0, 0, state.mainCanvas.width, state.mainCanvas.height);
state.mainCtx.clearRect(x, y, w, h);
// Redraw layers inside the crop rect (dim everything outside).
state.mainCtx.save();
state.mainCtx.beginPath();
state.mainCtx.rect(x, y, w, h);
state.mainCtx.clip();
drawCheckerboard(state.mainCtx, state.mainCanvas.width, state.mainCanvas.height);
for (const layer of state.layers) {
if (!layer.visible) continue;
state.mainCtx.globalAlpha = layer.opacity;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
state.mainCtx.drawImage(layer.canvas, off.x, off.y);
}
state.mainCtx.globalAlpha = 1;
state.mainCtx.restore();
// Dashed border around the kept region.
state.mainCtx.strokeStyle = '#fff';
state.mainCtx.lineWidth = 1;
state.mainCtx.setLineDash([4, 4]);
state.mainCtx.strokeRect(x, y, w, h);
state.mainCtx.setLineDash([]);
state.cropRect = { x, y, w, h };
},
end(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) {
state.cropMoving = false;
state.cropMoveStart = null;
if (state.cropRect) showCropApply();
return;
}
state.cropping = false;
if (state.cropRect && state.cropRect.w > 5 && state.cropRect.h > 5) {
showCropApply();
}
},
cancel(reason) {
return gesture.cancel(reason);
},
// Unconditional teardown used when the user switches tools. Unlike
// cancel(), this clears a rect even if the gesture already committed
// (e.g. a stray pointerdown restarted it mid-drag) and removes any
// mounted Apply panel.
reset() {
gesture.cancel('tool-switch');
state.cropping = false;
state.cropMoving = false;
state.cropStart = null;
state.cropEnd = null;
state.cropMoveStart = null;
state.cropAspectLock = null;
state.activeSnapGuides = null;
state.cropRect = null;
state.container?.querySelector('.ge-crop-apply')?.remove();
composite();
},
};
}