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:
2026-09-16 11:17:10 -07:00
parent c20078890e
commit f79d56a82d
2 changed files with 43 additions and 38 deletions
+34 -32
View File
@@ -8,10 +8,20 @@
* 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.
* 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 —
@@ -106,16 +116,17 @@ var PhotoCrop = (function () {
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).
// 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 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;
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();
@@ -148,36 +159,27 @@ var PhotoCrop = (function () {
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 + ');';
// 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").
// 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;
// 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 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 = qx - cropNatW / 2;
var cropTopNat = qy - cropNatH / 2;
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;
+9 -6
View File
@@ -19,12 +19,15 @@ function photo_crop_style(array $row): string {
$y = max(0, min(100, $y));
$zoom = max(1, min(3, $zoom));
// transform-origin is deliberately left at its CSS default (50% 50% —
// the box's own center), not tied to $x/$y: object-position places the
// unzoomed crop, and the scale transform then magnifies around whatever
// is currently centered, matching the crop editor exactly.
// transform-origin matches object-position — see photo-crop.js's file
// header for why this (rather than a fixed 50% 50%) is required: a
// fixed center has zero pan room on whichever axis has no overflow at
// 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(
'object-position:%.2f%% %.2f%%;transform:scale(%.3f);',
$x, $y, $zoom
'object-position:%.2f%% %.2f%%;transform:scale(%.3f);transform-origin:%.2f%% %.2f%%;',
$x, $y, $zoom, $x, $y
);
}