diff --git a/assets/js/photo-crop.js b/assets/js/photo-crop.js index 926554c..e3aaba0 100644 --- a/assets/js/photo-crop.js +++ b/assets/js/photo-crop.js @@ -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; diff --git a/includes/photo.php b/includes/photo.php index d9db383..92247e0 100644 --- a/includes/photo.php +++ b/includes/photo.php @@ -19,8 +19,12 @@ 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. return sprintf( - 'object-position:%.2f%% %.2f%%;transform:scale(%.3f);transform-origin:%.2f%% %.2f%%;', - $x, $y, $zoom, $x, $y + 'object-position:%.2f%% %.2f%%;transform:scale(%.3f);', + $x, $y, $zoom ); }