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 @@
Drag the photo to reposition the crop