Add drag-to-reposition for aggressive crops
Docker / build-and-push (push) Successful in 4m34s

Non-original aspects (square, 4:5, 16:9) always centered the crop with
no way to choose what got cut off. Crop position is now a normalized
0-1 offset per axis instead of a fixed /2 center, and dragging directly
on the canvas -- only when the current aspect actually leaves slack to
pan within -- adjusts it. Pointer movement converts to rotated-image
pixels via the crop-width-to-display-width ratio, so it stays 1:1
regardless of the maxDim downscale or the canvas's own CSS scaling.
Resets to centered on aspect or rotation change, since old offsets
don't make sense against new dimensions. Verified with a real drag test:
a marker cropped out at the centered default becomes visible after
dragging, and the "original" aspect (no crop, no slack) correctly
leaves panning disabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-08-07 17:29:38 +02:00
co-authored by Claude Sonnet 5
parent bee81187fb
commit cb0e0acc11
3 changed files with 100 additions and 2 deletions
+88 -2
View File
@@ -5,6 +5,7 @@
const editorCard = document.getElementById('editor-card');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const panHint = document.getElementById('pan-hint');
const aspectRow = document.getElementById('aspect-row');
const rotateLeftBtn = document.getElementById('rotate-left-btn');
@@ -52,6 +53,11 @@
let originalImg = null;
let exposureImg = null;
// Slack the current rotation+aspect leaves to pan within, in rotated-
// image pixel space — recomputed every rebuildSource(), read by the
// canvas drag handler to convert pointer movement into cropOffset.
let cropBounds = { rotatedW: 0, rotatedH: 0, cropW: 0, cropH: 0 };
const PRESETS = {
none: { filter: 'none' },
// "Pick a vibe" presets rather than raw white-balance sliders, per
@@ -74,6 +80,12 @@
const params = {
rotation: 0,
cropAspect: 'original',
// Normalized 0-1 position of the crop window within whatever slack the
// chosen aspect leaves — 0.5/0.5 is centered (the old fixed behavior).
// Stored as a fraction rather than raw pixels so it survives rotation/
// aspect changes without needing to be re-derived.
cropOffsetX: 0.5,
cropOffsetY: 0.5,
preset: 'none',
grain: 0,
grainSize: 40,
@@ -122,6 +134,8 @@
function resetParams() {
params.rotation = 0;
params.cropAspect = 'original';
params.cropOffsetX = 0.5;
params.cropOffsetY = 0.5;
params.preset = 'none';
params.grain = 0;
params.grainSize = 40;
@@ -201,12 +215,14 @@
const currentAspect = rotatedW / rotatedH;
if (currentAspect > targetAspect) {
cropW = Math.round(rotatedH * targetAspect);
cropX = Math.round((rotatedW - cropW) / 2);
cropX = Math.round((rotatedW - cropW) * params.cropOffsetX);
} else {
cropH = Math.round(rotatedW / targetAspect);
cropY = Math.round((rotatedH - cropH) / 2);
cropY = Math.round((rotatedH - cropH) * params.cropOffsetY);
}
}
cropBounds = { rotatedW, rotatedH, cropW, cropH };
updatePanAffordance();
// Cap so a raw phone photo doesn't choke canvas ops.
const maxDim = 2400;
@@ -233,6 +249,70 @@
channelCanvas.height = outH;
}
function hasPanSlack() {
return cropBounds.rotatedW > cropBounds.cropW || cropBounds.rotatedH > cropBounds.cropH;
}
function updatePanAffordance() {
const pannable = hasPanSlack();
canvas.classList.toggle('pannable', pannable);
panHint.hidden = !pannable;
}
// Drag-to-reposition: only meaningful once a non-"original" aspect has
// actually cropped something away. Converts pointer movement in CSS
// pixels to rotated-image pixels via the crop width/display width
// ratio, so it stays 1:1 regardless of the maxDim downscale or the
// canvas's own CSS max-width/max-height scaling.
let dragState = null;
let panRaf = null;
canvas.addEventListener('pointerdown', (e) => {
if (!hasPanSlack()) return;
const { rotatedW, rotatedH, cropW, cropH } = cropBounds;
dragState = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
cropX0: Math.round((rotatedW - cropW) * params.cropOffsetX),
cropY0: Math.round((rotatedH - cropH) * params.cropOffsetY),
};
canvas.setPointerCapture(e.pointerId);
canvas.classList.add('panning');
});
canvas.addEventListener('pointermove', (e) => {
if (!dragState || e.pointerId !== dragState.pointerId) return;
const rect = canvas.getBoundingClientRect();
const { rotatedW, rotatedH, cropW, cropH } = cropBounds;
const rotatedPxPerCssPxX = cropW / rect.width;
const rotatedPxPerCssPxY = cropH / rect.height;
const deltaX = (e.clientX - dragState.startX) * rotatedPxPerCssPxX;
const deltaY = (e.clientY - dragState.startY) * rotatedPxPerCssPxY;
const slackX = rotatedW - cropW;
const slackY = rotatedH - cropH;
const newCropX = Math.min(Math.max(dragState.cropX0 - deltaX, 0), slackX);
const newCropY = Math.min(Math.max(dragState.cropY0 - deltaY, 0), slackY);
params.cropOffsetX = slackX > 0 ? newCropX / slackX : 0.5;
params.cropOffsetY = slackY > 0 ? newCropY / slackY : 0.5;
if (panRaf) return;
panRaf = requestAnimationFrame(() => {
panRaf = null;
rebuildSource();
render();
});
});
function endPan(e) {
if (!dragState || (e && e.pointerId !== dragState.pointerId)) return;
canvas.classList.remove('panning');
dragState = null;
}
canvas.addEventListener('pointerup', endPan);
canvas.addEventListener('pointercancel', endPan);
// Recomputes the full stack from `source` every time — never chains
// operations onto already-processed pixels — so dragging any slider
// back to 0 reproduces the source exactly.
@@ -531,6 +611,8 @@
[...aspectRow.children].forEach(btn => {
btn.addEventListener('click', () => {
params.cropAspect = btn.dataset.aspect;
params.cropOffsetX = 0.5;
params.cropOffsetY = 0.5;
[...aspectRow.children].forEach(b => b.classList.toggle('active', b === btn));
rebuildSource();
render();
@@ -539,11 +621,15 @@
rotateLeftBtn.addEventListener('click', () => {
params.rotation = (params.rotation + 270) % 360;
params.cropOffsetX = 0.5;
params.cropOffsetY = 0.5;
rebuildSource();
render();
});
rotateRightBtn.addEventListener('click', () => {
params.rotation = (params.rotation + 90) % 360;
params.cropOffsetX = 0.5;
params.cropOffsetY = 0.5;
rebuildSource();
render();
});