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>
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>
Every photo upload (session setup, novena group, Rosary Builder title
photo) gets shown two ways: full-size on the presentation cover slide
(unaffected, stays untouched), and cropped to a fixed box everywhere else
— home page cards, profile cards, the novena day-picker's circular hero
photo, and each admin form's own preview thumbnail. All of those crops
used to just take the image's dead center, with no way to control what
part of the photo that was — cropping out people's heads on portrait
photos.
- New sessions/novena_groups columns: photo_focal_x, photo_focal_y (0-100%),
photo_zoom (1-3x), defaulting to 50/50/1 — today's exact centered/
unzoomed behavior, so this is fully backward compatible until someone
actively repositions a photo.
- New assets/js/photo-crop.js: a reusable drag-to-pan + zoom modal editor.
The crop frame renders with the *exact* CSS recipe used at final render
time (object-position + transform:scale/transform-origin), so the editor
is a truthful live preview, not an approximation. A reference thumbnail
shows the full photo dimmed outside a rectangle marking the current crop.
All math reads actual rendered box dimensions rather than assuming fixed
pixel sizes, so it holds up responsively at any viewport width — caught
and fixed a real mismatch bug here by testing the widget standalone in a
browser before wiring it into any PHP form.
- New includes/photo.php: photo_crop_style() builds the inline style="..."
from a session/group row, used everywhere a crop is displayed.
- Wired into all three upload locations (admin/setup.php,
admin/novena_group.php, admin/builder.php) with a "Reposition" button,
and persisted through api/save_session.php, admin/novena_group.php's
save handler, and api/builder_session.php.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>