f79d56a82d
Reported: fully zoomed in, couldn't pan left/right at all — stuck dead-center. My previous fix pinned the zoom's transform-origin at a fixed 50% 50% (the frame's own center) specifically to fix a *different* bug (the head of a portrait being unreachable). That traded one bug for another: with a fixed center, object-position's own pan formula only has room to move in whichever axis has overflow at zoom=1. For a portrait photo in this landscape frame, width is the *exact*-fit axis at zoom=1 — literally zero horizontal slack — so there was nothing for a fixed-center zoom to magnify away from, and horizontal dragging computed a division by (essentially) zero, guarded down to a hard no-op. The actual fix: transform-origin should match object-position (X% Y%), not stay fixed at center. Re-derived from there — with a matching origin, box position (X%, Y%) always shows exactly natural-image point (X/100*naturalW, Y/100*naturalH), at any zoom, in either axis, with no zero-slack case at all. Rewrote the drag-sensitivity and reference- rectangle math to match this (both simplify to plain linear formulas). Verified in the standalone test harness with the same portrait test image that reproduces the zero-slack case: dragging now works at any zoom level in both axes, and reaching an image edge (the original bug) still works too — tested both scenarios explicitly before deploying, since the first fix silently broke the second. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
228 lines
10 KiB
JavaScript
228 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 X% Y% +
|
|
* transform:scale magnifying around a *matching* transform-origin X% Y%)
|
|
* that final card thumbnails use elsewhere in the app, so what you see
|
|
* here is what renders everywhere else.
|
|
*
|
|
* Why transform-origin must match object-position, not stay fixed at the
|
|
* box's center: object-position's own placement formula only has "room to
|
|
* pan" in whichever axis has overflow at zoom=1. For a portrait photo in
|
|
* this landscape frame, width is often the *exact*-fit axis at zoom=1 —
|
|
* zero horizontal slack — so a fixed-center transform-origin has nothing
|
|
* to magnify away from, and horizontal panning goes dead the moment you
|
|
* zoom in. Matching transform-origin to object-position instead pins the
|
|
* natural-image point (focalX%, focalY%) to that same box position at any
|
|
* zoom — a relationship with no such zero-slack case in either axis.
|
|
*
|
|
* 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();
|
|
// With transform-origin matching object-position, box position
|
|
// (X%, Y%) always shows exactly natural-image point
|
|
// (X/100*naturalW, Y/100*naturalH) at any zoom — dragging the
|
|
// full effective image width/height (coverScale * zoom *
|
|
// naturalW/H) should move X/Y by exactly 100%. No zero-slack
|
|
// case: this denominator is never zero.
|
|
var coverScale = Math.max(f.w / state.naturalW, f.h / state.naturalH);
|
|
var effW = coverScale * state.zoom * state.naturalW;
|
|
var effH = coverScale * state.zoom * state.naturalH;
|
|
var dxPct = -((e.clientX - drag.startX) / effW) * 100;
|
|
var dyPct = -((e.clientY - drag.startY) / effH) * 100;
|
|
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);
|
|
// transform-origin matches object-position (see file header for why).
|
|
els.frameImg.style.cssText =
|
|
'object-position:' + fx + '% ' + fy + '%;transform:scale(' + z + ');transform-origin:' + fx + '% ' + fy + '%;';
|
|
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"). With transform-origin matching
|
|
// object-position, the visible crop's natural-image left/top edge
|
|
// is simply focalX/Y% of the *pannable range* at the current zoom —
|
|
// a plain linear relationship, valid at any zoom, either axis.
|
|
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;
|
|
|
|
var coverScale = Math.max(f.w / state.naturalW, f.h / state.naturalH);
|
|
var cropNatW = f.w / (coverScale * state.zoom);
|
|
var cropNatH = f.h / (coverScale * state.zoom);
|
|
var cropLeftNat = (state.focalX / 100) * (state.naturalW - cropNatW);
|
|
var cropTopNat = (state.focalY / 100) * (state.naturalH - cropNatH);
|
|
|
|
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 };
|
|
})();
|