From f79d56a82d8bf036f64d27c4f36a036b8441e88d Mon Sep 17 00:00:00 2001 From: Philip Guzman III Date: Wed, 16 Sep 2026 11:17:10 -0700 Subject: [PATCH] Fix panning going dead when zoomed in on a portrait photo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/js/photo-crop.js | 66 +++++++++++++++++++++-------------------- includes/photo.php | 15 ++++++---- 2 files changed, 43 insertions(+), 38 deletions(-) diff --git a/assets/js/photo-crop.js b/assets/js/photo-crop.js index e3aaba0..06f7cc3 100644 --- a/assets/js/photo-crop.js +++ b/assets/js/photo-crop.js @@ -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; diff --git a/includes/photo.php b/includes/photo.php index 92247e0..c21a2e5 100644 --- a/includes/photo.php +++ b/includes/photo.php @@ -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 ); }