/** * 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)), });