frame a photograph on the page, at the size it is printed

The panel frames a 21:9 header in a 200-pixel thumbnail while the real one is on screen six inches
away, in the layout it belongs to. Dragging the picture itself is the obvious control, and until now
it was also the expensive one: every new rectangle made the site cut a fresh ladder — nine widths, two
formats, 1.54s measured — with the preview waiting for it.

Both halves are gone. The site draws the rectangle in CSS in development (previous commit, other
repo), so a framing costs nothing to show; the drag here is inline styles on the picture, and the only
write is one PATCH when the hand stops.

Inline styles rather than the site's custom properties, because the site renders a FRAMED picture one
way and an untouched one another — and an untouched photograph is exactly the one somebody is about to
drag. Inline beats both, and beats the object-fit the unframed path leaves behind, which would
otherwise crop the picture a second time inside a box that is already the crop.

Attached to every shaped photograph rather than to the selected one. Selection would put itself in the
paint's dependencies, so choosing a block would repaint the whole page's chrome; this way onDocument
still depends on the sections alone. The cost is that a click could become a nudge, so nothing moves
until the pointer has travelled 3px — below that it is a click, it selects the block, and no crop is
written.

The panel's zoom buttons now reach the real picture too, through `onDraft`. Otherwise the thumbnail
and the page would disagree for the 600ms until the debounce lands, which reads as a broken control
rather than a delayed one.

crop.ts is new and is the reason this is safe: parseCrop had already been copied once, and a crop that
means one thing in the panel and another on the page is a bug you can only feel. MediaField now draws
its own preview through the same `rectToBox`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 20:36:03 +01:00
co-authored by Claude Opus 5
parent 6098ae2285
commit 812371b64a
5 changed files with 356 additions and 46 deletions
+8 -44
View File
@@ -3,6 +3,7 @@ import { ImageOff, Minus, Pencil, Plus, Trash2, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { MediaPicker } from './MediaPicker';
import { originalUrl, posterUrl } from './thumbnail';
import { centredCrop, formatCrop, parseCrop, rectToBox, type Rect as Crop } from './crop';
/**
* One picture or video on the page, shown at the shape the page will show it.
@@ -36,7 +37,11 @@ import { originalUrl, posterUrl } from './thumbnail';
*
* A photograph is rarely the shape of the slot it goes in, so something has to decide which part
* survives. The site's answer is a `crop`, stored as `x,y,w,h` in percentages of the ORIGINAL, which
* `render` turns into an `object-position`.
* the page draws as a rectangle and the build cuts into real pixels.
*
* The same picture can also be framed ON the page, at the size it is printed — see framing.ts. This
* field stays because it is where the file is CHOSEN, because it carries the zoom, and because a
* gallery slot has no page to drag on.
*
* Here that is a drag. The visible window is the largest rectangle of the slot's shape that fits inside
* the picture — so one axis has slack and the other has none — and dragging slides the window along
@@ -58,39 +63,6 @@ import { originalUrl, posterUrl } from './thumbnail';
* There is no third option, so the wheel belongs to the page and the zoom is `+` and `−`.
*/
/** `x,y,w,h` in percentages of the original. The site's format — see parseCrop in media/render.ts. */
type Crop = { x: number; y: number; w: number; h: number };
const parseCrop = (value: string | null | undefined): Crop | null => {
const parts = (value ?? '').split(',').map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return null;
const [x, y, w, h] = parts as [number, number, number, number];
return w > 0 && h > 0 ? { x, y, w, h } : null;
};
const formatCrop = (crop: Crop): string =>
[crop.x, crop.y, crop.w, crop.h].map((n) => Math.round(n * 10) / 10).join(',');
/**
* The window the slot shows of a picture, centred, when nothing has been chosen.
*
* The largest rectangle of the SLOT's ratio that fits inside the picture. One of the two dimensions is
* always 100 — you cannot show more of a picture than there is.
*/
const centredCrop = (imageAspect: number, slotAspect: number): Crop =>
imageAspect > slotAspect
? { x: (100 - (slotAspect / imageAspect) * 100) / 2, y: 0, w: (slotAspect / imageAspect) * 100, h: 100 }
: { x: 0, y: (100 - (imageAspect / slotAspect) * 100) / 2, w: 100, h: (imageAspect / slotAspect) * 100 };
/** Crop to CSS. The same arithmetic as cropToPosition in the site's media/render.ts, and it must stay so. */
const toObjectPosition = (crop: Crop): string => {
const along = (start: number, size: number) => {
const remaining = 100 - size;
return remaining <= 0 ? 50 : Math.min(100, Math.max(0, (start / remaining) * 100));
};
return `${along(crop.x, crop.w).toFixed(1)}% ${along(crop.y, crop.h).toFixed(1)}%`;
};
const DRAG_TYPE = 'application/x-officer-files';
export const MediaField = ({
@@ -303,16 +275,8 @@ export const MediaField = ({
drawn.
*/
className={`absolute max-w-none touch-none ${framable ? 'cursor-grab active:cursor-grabbing' : ''}`}
style={
current
? {
width: `${(100 / current.w) * 100}%`,
height: `${(100 / current.h) * 100}%`,
left: `${-(current.x / current.w) * 100}%`,
top: `${-(current.y / current.h) * 100}%`,
}
: { width: '100%', height: '100%', objectFit: 'cover' }
}
// `rectToBox` — the same four sums the page uses. See crop.ts for why they are shared.
style={current ? rectToBox(current) : { width: '100%', height: '100%', objectFit: 'cover' }}
draggable={false}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}