Fix panning going dead when zoomed in on a portrait photo
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>
This commit is contained in:
+34
-32
@@ -8,10 +8,20 @@
|
|||||||
* onApply: function (focalX, focalY, zoom) { ... }
|
* onApply: function (focalX, focalY, zoom) { ... }
|
||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
* The crop frame uses the exact same CSS recipe (object-position + a
|
* The crop frame uses the exact same CSS recipe (object-position X% Y% +
|
||||||
* transform:scale magnifying around the box's own default center) that
|
* transform:scale magnifying around a *matching* transform-origin X% Y%)
|
||||||
* final card thumbnails use elsewhere in the app, so what you see here is
|
* that final card thumbnails use elsewhere in the app, so what you see
|
||||||
* what renders everywhere else.
|
* 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
|
* All math reads the frame/reference boxes' *actual rendered* size
|
||||||
* (getBoundingClientRect) rather than assuming fixed pixel dimensions —
|
* (getBoundingClientRect) rather than assuming fixed pixel dimensions —
|
||||||
@@ -106,16 +116,17 @@ var PhotoCrop = (function () {
|
|||||||
els.frame.addEventListener('pointermove', function (e) {
|
els.frame.addEventListener('pointermove', function (e) {
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
var f = frameSize();
|
var f = frameSize();
|
||||||
// Unzoomed cover scale — object-position is computed by the browser
|
// With transform-origin matching object-position, box position
|
||||||
// independent of the later `transform: scale(zoom)`, so the pixel
|
// (X%, Y%) always shows exactly natural-image point
|
||||||
// range a drag maps over is the *unzoomed* overflow, additionally
|
// (X/100*naturalW, Y/100*naturalH) at any zoom — dragging the
|
||||||
// divided by the current zoom (a screen pixel covers less of the
|
// full effective image width/height (coverScale * zoom *
|
||||||
// pannable range the more you've zoomed in).
|
// 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 coverScale = Math.max(f.w / state.naturalW, f.h / state.naturalH);
|
||||||
var denomX = state.naturalW * coverScale - f.w;
|
var effW = coverScale * state.zoom * state.naturalW;
|
||||||
var denomY = state.naturalH * coverScale - f.h;
|
var effH = coverScale * state.zoom * state.naturalH;
|
||||||
var dxPct = denomX > 0.01 ? -((e.clientX - drag.startX) / (denomX * state.zoom)) * 100 : 0;
|
var dxPct = -((e.clientX - drag.startX) / effW) * 100;
|
||||||
var dyPct = denomY > 0.01 ? -((e.clientY - drag.startY) / (denomY * state.zoom)) * 100 : 0;
|
var dyPct = -((e.clientY - drag.startY) / effH) * 100;
|
||||||
state.focalX = drag.startFocalX + dxPct;
|
state.focalX = drag.startFocalX + dxPct;
|
||||||
state.focalY = drag.startFocalY + dyPct;
|
state.focalY = drag.startFocalY + dyPct;
|
||||||
clampFocal();
|
clampFocal();
|
||||||
@@ -148,36 +159,27 @@ var PhotoCrop = (function () {
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
var fx = state.focalX.toFixed(2), fy = state.focalY.toFixed(2), z = state.zoom.toFixed(3);
|
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
|
// transform-origin matches object-position (see file header for why).
|
||||||
// magnifies around the box's own center (the CSS default transform-
|
els.frameImg.style.cssText =
|
||||||
// origin: 50% 50% — deliberately *not* tied to focalX/focalY, so
|
'object-position:' + fx + '% ' + fy + '%;transform:scale(' + z + ');transform-origin:' + fx + '% ' + fy + '%;';
|
||||||
// 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;
|
els.zoomSlider.value = state.zoom;
|
||||||
|
|
||||||
// Reference thumbnail: full image, contain-fit, with a rectangle
|
// Reference thumbnail: full image, contain-fit, with a rectangle
|
||||||
// overlay marking the current crop window (dimmed outside it via
|
// overlay marking the current crop window (dimmed outside it via
|
||||||
// a CSS box-shadow "spotlight").
|
// 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 f = frameSize(), rf = refSize();
|
||||||
var containScale = Math.min(rf.w / state.naturalW, rf.h / state.naturalH);
|
var containScale = Math.min(rf.w / state.naturalW, rf.h / state.naturalH);
|
||||||
var imgW = state.naturalW * containScale, imgH = state.naturalH * containScale;
|
var imgW = state.naturalW * containScale, imgH = state.naturalH * containScale;
|
||||||
var offX = (rf.w - imgW) / 2, offY = (rf.h - imgH) / 2;
|
var offX = (rf.w - imgW) / 2, offY = (rf.h - imgH) / 2;
|
||||||
|
|
||||||
// Q = the natural-image point that sits at the frame's own center —
|
var coverScale = Math.max(f.w / state.naturalW, f.h / state.naturalH);
|
||||||
// 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 cropNatW = f.w / (coverScale * state.zoom);
|
||||||
var cropNatH = f.h / (coverScale * state.zoom);
|
var cropNatH = f.h / (coverScale * state.zoom);
|
||||||
var cropLeftNat = qx - cropNatW / 2;
|
var cropLeftNat = (state.focalX / 100) * (state.naturalW - cropNatW);
|
||||||
var cropTopNat = qy - cropNatH / 2;
|
var cropTopNat = (state.focalY / 100) * (state.naturalH - cropNatH);
|
||||||
|
|
||||||
var rectLeft = offX + cropLeftNat * containScale;
|
var rectLeft = offX + cropLeftNat * containScale;
|
||||||
var rectTop = offY + cropTopNat * containScale;
|
var rectTop = offY + cropTopNat * containScale;
|
||||||
|
|||||||
+9
-6
@@ -19,12 +19,15 @@ function photo_crop_style(array $row): string {
|
|||||||
$y = max(0, min(100, $y));
|
$y = max(0, min(100, $y));
|
||||||
$zoom = max(1, min(3, $zoom));
|
$zoom = max(1, min(3, $zoom));
|
||||||
|
|
||||||
// transform-origin is deliberately left at its CSS default (50% 50% —
|
// transform-origin matches object-position — see photo-crop.js's file
|
||||||
// the box's own center), not tied to $x/$y: object-position places the
|
// header for why this (rather than a fixed 50% 50%) is required: a
|
||||||
// unzoomed crop, and the scale transform then magnifies around whatever
|
// fixed center has zero pan room on whichever axis has no overflow at
|
||||||
// is currently centered, matching the crop editor exactly.
|
// zoom=1 (common for a portrait photo in a landscape box), so panning
|
||||||
|
// that axis goes dead the moment you zoom in. Matching the origin to
|
||||||
|
// (X%, Y%) pins that natural-image point to the same box position at
|
||||||
|
// any zoom, with no such zero-slack case in either axis.
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'object-position:%.2f%% %.2f%%;transform:scale(%.3f);',
|
'object-position:%.2f%% %.2f%%;transform:scale(%.3f);transform-origin:%.2f%% %.2f%%;',
|
||||||
$x, $y, $zoom
|
$x, $y, $zoom, $x, $y
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user