Files
Rosary/assets/js/photo-crop.js
T
pguzman d1615a9e01 Fix crop editor's drag/zoom math not matching its own preview
Reported: dragging toward an image edge (e.g. to reveal a cropped-off
head) stopped short of what the reference preview showed was possible —
the two panels visibly disagreed.

Root cause: transform-origin was set to match the pan position (X%,Y%)
instead of staying at the CSS default (50% 50%, the box's own center).
CSS object-position places the *unzoomed* crop; transform:scale then
magnifies around transform-origin. Tying that origin to X/Y meant zoom
dragged its own anchor point toward whichever edge you'd panned to,
instead of always magnifying what's actually centered in the frame —
harmless near the middle (why initial testing looked fine) but
increasingly wrong the closer you drag to an edge, exactly where you'd
need to go to reach a cropped head. A second, smaller error was in how
the reference-rectangle preview converted focal position to natural-image
coordinates (didn't account for the zoom-independent anchor point).

Fixed both the crop editor's own math and includes/photo.php's
photo_crop_style() (used for every final render — cards, previews) to
drop the origin back to the CSS default and use the correct geometry.
clampFocal() also simplifies to a plain [0,100] clamp — under real
object-position semantics that's always a valid, fully-covered crop at
any zoom >= 1, no image-dimension-dependent math needed.

Verified in a standalone test harness: dragging now reaches all the way
to an image's edges, zoom stays synced between the editor's live frame
and its reference-rectangle preview, and the applied result matches the
editor's preview exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 10:28:40 -07:00

226 lines
10 KiB
JavaScript

/**
* 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 =
'<div class="photo-crop-box">' +
' <h3>Reposition Photo</h3>' +
' <div class="photo-crop-main">' +
' <div class="photo-crop-frame" style="width:' + FRAME_DEFAULT_W + 'px;height:' + FRAME_DEFAULT_H + 'px">' +
' <img class="photo-crop-frame-img" alt="">' +
' </div>' +
' <div class="photo-crop-ref" style="width:' + REF_DEFAULT_W + 'px;height:' + REF_DEFAULT_H + 'px">' +
' <img class="photo-crop-ref-img" alt="">' +
' <div class="photo-crop-ref-rect"></div>' +
' </div>' +
' </div>' +
' <div class="photo-crop-zoom-row">' +
' <label>Zoom</label>' +
' <input type="range" class="photo-crop-zoom-slider" min="' + ZOOM_MIN + '" max="' + ZOOM_MAX + '" step="0.05">' +
' </div>' +
' <p class="photo-crop-hint">Drag the photo to reposition it. Scroll or use the slider to zoom.</p>' +
' <div class="photo-crop-actions">' +
' <button type="button" class="btn btn-ghost photo-crop-reset">Reset</button>' +
' <div style="flex:1"></div>' +
' <button type="button" class="btn btn-ghost photo-crop-cancel">Cancel</button>' +
' <button type="button" class="btn btn-primary photo-crop-apply">Apply</button>' +
' </div>' +
'</div>';
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 };
})();