From cb0e0acc1119ff52a996db500d37383c14dcd778 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Fri, 7 Aug 2026 17:29:38 +0200 Subject: [PATCH] Add drag-to-reposition for aggressive crops 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 --- app.js | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- index.html | 1 + style.css | 11 +++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index c200728..9d4bb6c 100644 --- a/app.js +++ b/app.js @@ -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(); }); diff --git a/index.html b/index.html index 3059f27..045a25d 100644 --- a/index.html +++ b/index.html @@ -26,6 +26,7 @@
+
diff --git a/style.css b/style.css index b008b26..890fbe9 100644 --- a/style.css +++ b/style.css @@ -128,6 +128,17 @@ p { margin: 0; } max-width: 100%; max-height: 60vh; display: block; + touch-action: none; +} + +#canvas.pannable { cursor: grab; } +#canvas.pannable.panning { cursor: grabbing; } + +.pan-hint { + text-align: center; + font-size: 12px; + color: var(--text-dim); + margin: -12px 0 20px; } /* ── Controls ───────────────────────────────────────────────────────── */