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>
This commit is contained in:
2026-09-16 10:28:40 -07:00
parent 3a1433b71e
commit d1615a9e01
2 changed files with 45 additions and 29 deletions
+39 -27
View File
@@ -8,9 +8,10 @@
* onApply: function (focalX, focalY, zoom) { ... }
* });
*
* The crop frame uses the exact same CSS recipe (object-position + transform
* scale/transform-origin) 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 + 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 —
@@ -105,11 +106,16 @@ 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).
var coverScale = Math.max(f.w / state.naturalW, f.h / state.naturalH);
var effW = state.naturalW * coverScale * state.zoom;
var effH = state.naturalH * coverScale * state.zoom;
var dxPct = -((e.clientX - drag.startX) / effW) * 100;
var dyPct = -((e.clientY - drag.startY) / effH) * 100;
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();
@@ -129,26 +135,25 @@ var PhotoCrop = (function () {
return { w: r.width, h: r.height };
}
/** Keep the crop window within the image's natural bounds. */
/**
* 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() {
var f = frameSize();
var coverScale = Math.max(f.w / state.naturalW, f.h / state.naturalH);
var cropW = f.w / (coverScale * state.zoom);
var cropH = f.h / (coverScale * state.zoom);
var minX = (cropW / 2) / state.naturalW * 100;
var maxX = 100 - minX;
var minY = (cropH / 2) / state.naturalH * 100;
var maxY = 100 - minY;
// If the crop window is bigger than the image on an axis (zoom≈min),
// min > max — just center it.
state.focalX = minX <= maxX ? Math.max(minX, Math.min(maxX, state.focalX)) : 50;
state.focalY = minY <= maxY ? Math.max(minY, Math.min(maxY, state.focalY)) : 50;
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);
var cropStyle = 'object-position:' + fx + '% ' + fy + '%;transform:scale(' + z + ');transform-origin:' + fx + '% ' + fy + '%;';
els.frameImg.style.cssText = cropStyle;
// 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
@@ -159,13 +164,20 @@ var PhotoCrop = (function () {
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);
// 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 centerXnat = (state.focalX / 100) * state.naturalW;
var centerYnat = (state.focalY / 100) * state.naturalH;
var cropLeftNat = centerXnat - cropNatW / 2;
var cropTopNat = centerYnat - cropNatH / 2;
var cropLeftNat = qx - cropNatW / 2;
var cropTopNat = qy - cropNatH / 2;
var rectLeft = offX + cropLeftNat * containScale;
var rectTop = offY + cropTopNat * containScale;