Files
fofos-admin/web/MediaField.tsx
T
pastilhasandClaude Opus 5 a4c3cb3644 zoom steps by a flat 0.1x
It multiplied by 1.25, which compounds: the same button was a 0.25x step at fit and a 1.75x step at 7x,
so the first press did almost nothing and the last threw the picture across the frame.

Added rather than multiplied, so every press is the same amount of change — which is what somebody
nudging a crop into place is asking for, and it makes the number beside the buttons predictable, which
a factor never is.

Rounded to a tenth, because floating point turns 1 + 0.1 + 0.1 into 1.2000000000000002 and the label
shows it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 00:08:33 +01:00

427 lines
19 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.
import { useRef, useState } from 'react';
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';
/**
* One picture or video on the page, shown at the shape the page will show it.
*
* ── Why not a path in a text box ──
*
* It was one, and a path is not what anybody is choosing. Somebody picking the header photograph is
* choosing a PHOTOGRAPH; `ABOUT US/HEADER.jpg` is how the machine refers to it, and reading a string to
* find out whether you picked the right image is doing the computer's job for it.
*
* So the field is the picture, at the aspect ratio the page uses. An empty one is the same box, empty —
* which also means the panel stops changing height as things are chosen, and a page with three slots
* looks like the page rather than like a form.
*
* ── The aspect ratio is a prop, and has to be ──
*
* The header is 21:9, the product video is 16:9, a gallery slot is square. This component cannot know
* that and must not guess: it is the SITE that decides what shape a slot is, and a component that
* guessed would be a second opinion to keep in step with the stylesheet.
*
* ── Dropping ──
*
* It accepts a drag from Officer's own file browser, which carries paths under
* `application/x-officer-files`. That is the same payload the file browser uses to move files between
* windows, so dragging a photograph out of Media and onto a slot is one gesture with no upload.
*
* It does NOT accept a file dragged from the desktop. That would be an upload into `originals/`, which
* is a different operation with its own failure modes, and the Media section already does it properly.
*
* ── Dragging the picture itself frames it ──
*
* 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`.
*
* 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
* whichever axis has room. A picture already the right shape does not move, correctly: there is nothing
* to choose between.
*
* Written in the picture's own coordinates rather than in pixels, because the slot is a different size
* on every screen and the crop has to mean the same thing on all of them.
*
* ── And a pair of buttons zoom ──
*
* Zooming in is the window getting smaller: `w` and `h` shrink together, keeping the slot's shape, so
* less of the picture fills the same box. Fit is the floor — you cannot show more of a photograph than
* there is.
*
* It was the wheel, which is the obvious gesture and cannot be made to behave. To zoom without also
* scrolling the panel it must call preventDefault, so the page stops scrolling whenever the pointer is
* over a picture — and a panel of slots is mostly picture. Not calling it means both happen at once.
* 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 = ({
value,
onChange,
crop,
onCropChange,
aspect,
kind = 'photo',
label,
}: {
/** A path under `originals/`, or null. */
value: string | null;
onChange: (path: string | null) => void;
/** `x,y,w,h` in percentages of the original, or null for centred. Omit to make the slot un-framable. */
crop?: string | null;
onCropChange?: (crop: string | null) => void;
/** Tailwind's ratio, as the page spells it — `21/9`, `16/9`, `1/1`. */
aspect: string;
/** A video is shown by its poster; there is no width rung to ask for. */
kind?: 'photo' | 'video';
/** What the picker's title says, so it names the thing rather than saying "media". */
label?: string;
}) => {
const [picking, setPicking] = useState(false);
const [over, setOver] = useState(false);
const [broken, setBroken] = useState(false);
/** The picture's own proportions, known only once it has loaded. Null until then. */
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
const box = useRef<HTMLDivElement | null>(null);
const dragging = useRef<{ x: number; y: number; crop: Crop } | null>(null);
const slotAspect = (() => {
const [w, h] = aspect.split('/').map(Number);
return w && h ? w / h : 1;
})();
const framable = Boolean(onCropChange && natural && !broken);
const current: Crop | null = natural
? (parseCrop(crop) ?? centredCrop(natural.w / natural.h, slotAspect))
: null;
/*
Pointer events, not mouse — one code path for a trackpad, a touchscreen and a pen. `setPointerCapture`
is what keeps the drag alive when the pointer leaves the box, which it will: the whole gesture is
about reaching the part of the picture that is currently outside.
*/
const onPointerDown = (event: React.PointerEvent) => {
if (!framable || !current) return;
event.preventDefault();
(event.target as Element).setPointerCapture?.(event.pointerId);
dragging.current = { x: event.clientX, y: event.clientY, crop: current };
};
const onPointerMove = (event: React.PointerEvent) => {
const start = dragging.current;
const rect = box.current?.getBoundingClientRect();
if (!start || !rect || !onCropChange) return;
/*
Pixels to picture-percent.
The picture is drawn `100 / w` times wider than the slot, so a pixel of travel is worth
`100 / (renderedWidth)` percent of it. Dragging RIGHT reveals what is to the LEFT, which is why
the delta is subtracted.
*/
const renderedW = rect.width * (100 / start.crop.w);
const renderedH = rect.height * (100 / start.crop.h);
const next: Crop = {
...start.crop,
x: start.crop.x - ((event.clientX - start.x) / renderedW) * 100,
y: start.crop.y - ((event.clientY - start.y) / renderedH) * 100,
};
// Clamped so the window cannot leave the picture and show empty space at an edge.
next.x = Math.min(100 - next.w, Math.max(0, next.x));
next.y = Math.min(100 - next.h, Math.max(0, next.y));
onCropChange(formatCrop(next));
};
const endDrag = () => {
dragging.current = null;
};
/*
Zoom is a pair of buttons, not the wheel.
The wheel was the obvious gesture and it cannot be made to behave. To zoom without also scrolling
the panel it has to call preventDefault, which means the page stops scrolling whenever the pointer
happens to be over a picture — and a panel of three slots is mostly picture. Not calling it means
both happen at once. There is no third option, so the wheel is left to the page.
Anchored to the CENTRE of the current window, because a button has no pointer position to anchor
to. That is also the behaviour somebody expects from a `+`: the middle of what I am looking at
stays where it is.
*/
const zoomBy = (step: number) => {
if (!current || !natural || !onCropChange) return;
const base = centredCrop(natural.w / natural.h, slotAspect);
/*
A tenth per press, ADDED rather than multiplied.
A factor compounds: the same button is a 0.25x step at fit and a 1.75x step at 7x, so the first
press does almost nothing and the last one throws the picture across the frame. A fixed step is
the same amount of change every time, which is what somebody nudging a crop into place is asking
for — and it makes the number beside the buttons predictable, which a factor never is.
Fit is the floor — there is no more picture — and 8x is where a rendition runs out of pixels.
Rounded, because floating point turns 1 + 0.1 + 0.1 into 1.2000000000000002 and the label shows it.
*/
const zoom = Math.round(Math.min(8, Math.max(1, (base.w / current.w) + step)) * 10) / 10;
const w = base.w / zoom;
const h = base.h / zoom;
const next: Crop = {
w,
h,
// The centre held: the window shrinks around the point it was already centred on.
x: current.x + current.w / 2 - w / 2,
y: current.y + current.h / 2 - h / 2,
};
next.x = Math.min(100 - w, Math.max(0, next.x));
next.y = Math.min(100 - h, Math.max(0, next.y));
// At fit there is nothing to remember, so the crop is cleared rather than stored as the centred
// value. A null crop and a crop that happens to be centred should not be two states.
onCropChange(zoom <= 1.001 ? null : formatCrop(next));
};
/** How far in this is, as a multiple of fit. 1 means the whole picture, and there is no zooming out. */
const zoomLevel = current && natural ? centredCrop(natural.w / natural.h, slotAspect).w / current.w : 1;
/*
1280, not a thumbnail.
This slot is drawn at most of the panel's width and can be zoomed into on top of that, so it is the
one place in the admin that needs real pixels. 640 was being stretched about three times over, which
reads as a poor photograph rather than as the wrong file.
Not `full.webp`: on this library that is 4.6 MB for a single header, and an admin panel that takes a
second to show a picture is its own problem. 1280 is the widest rung most originals have.
*/
/*
The ORIGINAL, rendered at roughly what this slot draws.
Not a rung from `derived/`. This box is most of the panel wide and can be zoomed on top of that, so
a rung picked in advance is picked wrong: 640 stretched over 1900 pixels reads as a poor photograph
rather than as the wrong file, and the widest rung available is whatever the ladder happens to hold.
av-club renders whatever the original is — including a HEIC no browser can decode — at the width
asked for.
1600 rather than measuring the box: the panel is a fixed fraction of a window, this is an admin
rather than a page, and a resize observer to save a few hundred kilobytes would be machinery nobody
benefits from.
*/
const preview = value ? (kind === 'video' ? posterUrl(value) : originalUrl(value, 1600)) : null;
const accept = (event: React.DragEvent): string | null => {
if (!event.dataTransfer.types.includes(DRAG_TYPE)) return null;
try {
const paths = JSON.parse(event.dataTransfer.getData(DRAG_TYPE));
return Array.isArray(paths) && typeof paths[0] === 'string' ? paths[0] : null;
} catch {
return null;
}
};
return (
<>
<div
// `group` so the controls can appear on hover of the whole slot rather than of themselves.
ref={box}
className={`group relative w-full overflow-hidden rounded-md border ${
over ? 'border-primary bg-primary/5' : 'border-dashed border-border'
} ${value ? 'border-solid' : ''}`}
style={{ aspectRatio: aspect.replace('/', ' / ') }}
onDragOver={(event) => {
if (!accept(event)) return;
// Without preventDefault the browser refuses the drop entirely — the one line whose absence
// makes a target look dead with no error anywhere.
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(event) => {
const path = accept(event);
setOver(false);
if (!path) return;
event.preventDefault();
setBroken(false);
onChange(path);
}}
>
{preview && !broken ? (
<img
src={preview}
alt=""
/*
Positioned and scaled to show exactly the crop rectangle, rather than object-cover with an
object-position.
object-cover always shows the LARGEST window of the slot's shape that fits — which is what
a crop with no zoom is, and nothing else. It can be panned and it cannot be zoomed, so a
preview built on it would silently ignore half of what this control produces.
Width `100/w` of the box means "the picture is this many times wider than the window"; the
negative offsets slide the chosen corner to the box's origin. It is the crop rectangle,
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' }
}
draggable={false}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLoad={(event) => {
const img = event.currentTarget;
setNatural({ w: img.naturalWidth, h: img.naturalHeight });
}}
// A ladder that has not been built yet, or a file removed since it was chosen. Falls back
// to the empty state rather than a browser's broken-image glyph, which says nothing.
onError={() => setBroken(true)}
/>
) : (
<button
type="button"
onClick={() => setPicking(true)}
className="flex h-full w-full flex-col items-center justify-center gap-1.5 text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
>
{broken ? <ImageOff className="size-5" /> : <Upload className="size-5" />}
<span className="text-xs">
{broken ? 'That file is not there any more' : 'Click to choose, or drag one here'}
</span>
</button>
)}
{/*
The controls, over the picture, on hover.
Only when there IS one — on an empty slot the whole box is already a button, and a Change
button floating over "click to choose" would be two ways to do one thing.
`focus-within` as well as `group-hover`, so they can be reached by keyboard rather than only
by a pointer.
*/}
{value && !broken && (
<div className="absolute top-2 right-2 flex items-center gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
{/* Zoom first, and grouped, because they are the pair somebody presses repeatedly — a
destructive Remove between them would be pressed by accident eventually. */}
{framable && (
<div className="flex items-center gap-0.5 rounded-md bg-background/90 p-0.5 shadow">
<Button
size="sm"
variant="ghost"
className="size-6 p-0"
onClick={() => zoomBy(-0.1)}
disabled={zoomLevel <= 1.001}
title="Zoom out"
>
<Minus className="size-3" />
</Button>
{/* The level, so a press that changes nothing at the limits is explained rather than
read as a dead button. */}
<span className="w-9 text-center text-[11px] tabular-nums text-muted-foreground">
{zoomLevel.toFixed(1)}×
</span>
<Button
size="sm"
variant="ghost"
className="size-6 p-0"
onClick={() => zoomBy(0.1)}
disabled={zoomLevel >= 7.99}
title="Zoom in"
>
<Plus className="size-3" />
</Button>
</div>
)}
<Button
size="sm"
variant="secondary"
className="h-7 gap-1 px-2 text-xs shadow"
onClick={() => setPicking(true)}
>
<Pencil className="size-3" />
Change
</Button>
<Button
size="sm"
variant="secondary"
className="h-7 px-2 text-xs shadow hover:text-destructive"
onClick={() => {
setBroken(false);
onChange(null);
}}
title="Remove"
>
<Trash2 className="size-3" />
</Button>
</div>
)}
</div>
{/* The path, small and underneath. Not editable — it is what was chosen, not a field — but
somebody comparing two similar photographs needs to be able to tell which is which. */}
{value && <p className="truncate font-mono text-[11px] text-muted-foreground/70">{value}</p>}
<MediaPicker
open={picking}
onClose={() => setPicking(false)}
title={label ? `Choose the ${label}` : 'Choose media'}
onPick={(path) => {
setPicking(false);
setBroken(false);
onChange(path);
}}
/>
</>
);
};