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:
+38
-26
@@ -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;
|
||||
|
||||
// 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;
|
||||
|
||||
+6
-2
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user