Files
fofos-admin/web/crop.ts
T
pastilhasandClaude Opus 5 812371b64a 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>
2026-09-03 20:36:03 +01:00

72 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A crop rectangle, and the four sums everything does with one.
*
* ── Why this is its own module ──
*
* Three places now need the same arithmetic and must agree to the decimal: the panel's `MediaField`,
* the on-page framing in `framing.ts`, and the site's own `media/render.ts`. The first two are here.
* The third is in another repository and is the one that cannot be shared, which is exactly why the two
* that CAN be must not also drift — `parseCrop` was already copied once, and a crop that means one
* thing in the panel and another on the page is a bug nobody can see, only feel.
*
* The format is the site's: `x,y,w,h` as percentages of the ORIGINAL. Percentages rather than pixels
* because a slot is a different size on every screen and the framing has to mean the same thing on all
* of them.
*/
export type Rect = { x: number; y: number; w: number; h: number };
export const parseCrop = (value: string | null | undefined): Rect | 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;
};
export const formatCrop = (crop: Rect): 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.
*/
export const centredCrop = (imageAspect: number, slotAspect: number): Rect =>
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 };
/**
* Is this rectangle just the centred fit?
*
* A null crop and a crop that happens to be centred must not be two states — one of them would be
* stored, shown as "1.0×", and mean nothing. Compared with a tolerance because it arrives through a
* database and a `toFixed(1)`, so exact equality is never the question being asked.
*/
export const isCentred = (crop: Rect, imageAspect: number, slotAspect: number): boolean => {
const fit = centredCrop(imageAspect, slotAspect);
return (['x', 'y', 'w', 'h'] as const).every((key) => Math.abs(crop[key] - fit[key]) < 0.2);
};
/**
* The rectangle as four CSS lengths, in the picture's own box.
*
* `width` is how many times wider than the window the whole picture is; the offsets slide the chosen
* corner to the box's origin. It is the crop rectangle, drawn — and unlike `object-position` it carries
* the ZOOM, because zooming in is `w` and `h` getting smaller rather than the picture moving.
*/
export const rectToBox = (crop: Rect): { width: string; height: string; left: string; top: string } => ({
width: `${((100 / crop.w) * 100).toFixed(3)}%`,
height: `${((100 / crop.h) * 100).toFixed(3)}%`,
left: `${(-(crop.x / crop.w) * 100).toFixed(3)}%`,
top: `${(-(crop.y / crop.h) * 100).toFixed(3)}%`,
});
/** Keep the window inside the picture, so no edge shows empty space. */
export const clampRect = (crop: Rect): Rect => ({
...crop,
x: Math.min(100 - crop.w, Math.max(0, crop.x)),
y: Math.min(100 - crop.h, Math.max(0, crop.y)),
});