/** * photo-crop.js — reusable drag-to-pan + zoom photo crop editor. * * Usage: * PhotoCrop.open({ * imageUrl: '/uploads/xyz.jpg', * focalX: 50, focalY: 50, zoom: 1, // current stored values (0-100, 0-100, 1-2.5) * onApply: function (focalX, focalY, zoom) { ... } * }); * * The crop frame uses the exact same CSS recipe (object-position + a * transform:scale magnifying around the box's own default center) that * final card thumbnails use elsewhere in the app, so what you see here is * what renders everywhere else. * * All math reads the frame/reference boxes' *actual rendered* size * (getBoundingClientRect) rather than assuming fixed pixel dimensions — * the boxes are responsive (aspect-ratio + max-width:100%), so this must * hold at any viewport size, mobile included. */ var PhotoCrop = (function () { 'use strict'; var FRAME_DEFAULT_W = 320, FRAME_DEFAULT_H = 160; // initial size hint only var REF_DEFAULT_W = 160, REF_DEFAULT_H = 160; var ZOOM_MIN = 1, ZOOM_MAX = 2.5; var els = null; // DOM refs, built once var state = null; // { naturalW, naturalH, focalX, focalY, zoom, onApply } var drag = null; // { startX, startY, startFocalX, startFocalY } while dragging function ensureBuilt() { if (els) return; var overlay = document.createElement('div'); overlay.className = 'photo-crop-overlay'; overlay.innerHTML = '
' + '

Reposition Photo

' + '
' + '
' + ' ' + '
' + '
' + ' ' + '
' + '
' + '
' + '
' + ' ' + ' ' + '
' + '

Drag the photo to reposition it. Scroll or use the slider to zoom.

' + '
' + ' ' + '
' + ' ' + ' ' + '
' + '
'; document.body.appendChild(overlay); els = { overlay: overlay, frame: overlay.querySelector('.photo-crop-frame'), frameImg: overlay.querySelector('.photo-crop-frame-img'), ref: overlay.querySelector('.photo-crop-ref'), refImg: overlay.querySelector('.photo-crop-ref-img'), refRect: overlay.querySelector('.photo-crop-ref-rect'), zoomSlider: overlay.querySelector('.photo-crop-zoom-slider'), }; overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); }); overlay.querySelector('.photo-crop-cancel').addEventListener('click', close); overlay.querySelector('.photo-crop-reset').addEventListener('click', function () { state.focalX = 50; state.focalY = 50; state.zoom = 1; render(); }); overlay.querySelector('.photo-crop-apply').addEventListener('click', function () { var cb = state.onApply; var fx = state.focalX, fy = state.focalY, z = state.zoom; close(); if (cb) cb(fx, fy, z); }); els.zoomSlider.addEventListener('input', function () { state.zoom = parseFloat(els.zoomSlider.value); clampFocal(); render(); }); els.frame.addEventListener('wheel', function (e) { e.preventDefault(); var delta = e.deltaY < 0 ? 0.1 : -0.1; state.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, state.zoom + delta)); clampFocal(); render(); }, { passive: false }); els.frame.addEventListener('pointerdown', function (e) { drag = { startX: e.clientX, startY: e.clientY, startFocalX: state.focalX, startFocalY: state.focalY }; els.frame.setPointerCapture(e.pointerId); }); els.frame.addEventListener('pointermove', function (e) { if (!drag) return; var f = frameSize(); // Unzoomed cover scale — object-position is computed by the browser // independent of the later `transform: scale(zoom)`, so the pixel // range a drag maps over is the *unzoomed* overflow, additionally // divided by the current zoom (a screen pixel covers less of the // pannable range the more you've zoomed in). var coverScale = Math.max(f.w / state.naturalW, f.h / state.naturalH); var denomX = state.naturalW * coverScale - f.w; var denomY = state.naturalH * coverScale - f.h; var dxPct = denomX > 0.01 ? -((e.clientX - drag.startX) / (denomX * state.zoom)) * 100 : 0; var dyPct = denomY > 0.01 ? -((e.clientY - drag.startY) / (denomY * state.zoom)) * 100 : 0; state.focalX = drag.startFocalX + dxPct; state.focalY = drag.startFocalY + dyPct; clampFocal(); render(); }); var endDrag = function () { drag = null; }; els.frame.addEventListener('pointerup', endDrag); els.frame.addEventListener('pointercancel', endDrag); } function frameSize() { var r = els.frame.getBoundingClientRect(); return { w: r.width, h: r.height }; } function refSize() { var r = els.ref.getBoundingClientRect(); return { w: r.width, h: r.height }; } /** * Clamp focalX/focalY to their valid [0,100] range. Under CSS * object-position semantics, 0-100% is *always* a valid, fully-covered * crop at any zoom >= 1 (zooming in only ever gives *more* pan room, it * never restricts it) — no image-dimension-dependent math needed here. */ function clampFocal() { state.focalX = Math.max(0, Math.min(100, state.focalX)); state.focalY = Math.max(0, Math.min(100, state.focalY)); } function render() { var fx = state.focalX.toFixed(2), fy = state.focalY.toFixed(2), z = state.zoom.toFixed(3); // object-position places the (unzoomed) crop; transform:scale then // magnifies around the box's own center (the CSS default transform- // origin: 50% 50% — deliberately *not* tied to focalX/focalY, so // zoom always magnifies what's currently centered rather than // dragging the anchor toward whichever edge focalX/focalY is near). els.frameImg.style.cssText = 'object-position:' + fx + '% ' + fy + '%;transform:scale(' + z + ');'; els.zoomSlider.value = state.zoom; // Reference thumbnail: full image, contain-fit, with a rectangle // overlay marking the current crop window (dimmed outside it via // a CSS box-shadow "spotlight"). var f = frameSize(), rf = refSize(); var containScale = Math.min(rf.w / state.naturalW, rf.h / state.naturalH); var imgW = state.naturalW * containScale, imgH = state.naturalH * containScale; var offX = (rf.w - imgW) / 2, offY = (rf.h - imgH) / 2; // Q = the natural-image point that sits at the frame's own center — // fixed by the *unzoomed* object-position placement (matches what // the browser actually computes; zoom then just magnifies around it, // per the fixed transform-origin above). var coverScale = Math.max(f.w / state.naturalW, f.h / state.naturalH); var cropNatW_z1 = f.w / coverScale; var cropNatH_z1 = f.h / coverScale; var qx = (state.focalX / 100) * (state.naturalW - cropNatW_z1) + cropNatW_z1 / 2; var qy = (state.focalY / 100) * (state.naturalH - cropNatH_z1) + cropNatH_z1 / 2; var cropNatW = f.w / (coverScale * state.zoom); var cropNatH = f.h / (coverScale * state.zoom); var cropLeftNat = qx - cropNatW / 2; var cropTopNat = qy - cropNatH / 2; var rectLeft = offX + cropLeftNat * containScale; var rectTop = offY + cropTopNat * containScale; var rectW = cropNatW * containScale; var rectH = cropNatH * containScale; els.refRect.style.left = rectLeft + 'px'; els.refRect.style.top = rectTop + 'px'; els.refRect.style.width = rectW + 'px'; els.refRect.style.height = rectH + 'px'; } function open(opts) { ensureBuilt(); state = { naturalW: 0, naturalH: 0, focalX: opts.focalX != null ? opts.focalX : 50, focalY: opts.focalY != null ? opts.focalY : 50, zoom: opts.zoom != null ? opts.zoom : 1, onApply: opts.onApply, }; els.overlay.classList.add('open'); els.frameImg.style.cssText = ''; els.refRect.style.display = 'none'; var loader = new Image(); loader.onload = function () { state.naturalW = loader.naturalWidth; state.naturalH = loader.naturalHeight; els.frameImg.src = opts.imageUrl; els.refImg.src = opts.imageUrl; els.refRect.style.display = ''; clampFocal(); render(); }; loader.src = opts.imageUrl; } function close() { if (els) els.overlay.classList.remove('open'); drag = null; } return { open: open, close: close }; })();