Files
fofos-admin/web/MediaField.tsx
T
pastilhasandClaude Opus 5 fe51c70f70 a media slot can be framed by dragging and zooming
A photograph is rarely the shape of the slot it goes in, so something decides which part survives. It
was a centred crop and no way to change it.

Dragging the picture slides the window; the wheel zooms, anchored to the pointer so the thing under the
cursor stays under it. Both write the site's own format — `x,y,w,h` in percentages of the ORIGINAL —
which is resolution-independent and survives the file being re-encoded.

Zooming in is the window getting smaller: w and h shrink together, keeping the slot's shape. Fit is the
floor, because you cannot show more of a photograph than there is, and at exactly fit the crop is
cleared rather than written as the centred value — a null crop and a crop that happens to be centred
should not be two states.

── The preview is not object-cover ──

It draws the rectangle: the picture scaled to `100/w` of the box and offset so the chosen corner lands
at the origin. object-cover shows the largest window of the slot's shape that fits 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.

── Also here ──

buildSite now hands the site an av-club URL and user. That is the first piece of publishing images
cropped rather than cropping them in the browser with CSS: the plugin knows the sidecar registry, the
site knows a URL. Inert until the site uses it.

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

367 lines
16 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { ImageOff, Pencil, Trash2, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { MediaPicker } from './MediaPicker';
import { posterUrl, thumbUrl } 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 the wheel zooms ──
*
* 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 — and it is anchored to the pointer, so the thing under the cursor stays under the cursor.
* Anchoring to the centre instead is fewer lines and feels wrong the moment you use it.
*/
/** `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;
};
/*
Wheel to zoom, on a NON-PASSIVE listener.
React attaches `onWheel` passively, and a passive listener may not call preventDefault — so the
zoom would work and scroll the panel at the same time. The only way to get a cancellable wheel
event is to add it by hand with `{ passive: false }`.
The handler is rebuilt whenever the crop changes, which is what keeps it reading the current window
rather than the one that existed when the picture loaded.
*/
useEffect(() => {
const element = box.current;
if (!element || !framable || !current || !onCropChange || !natural) return;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
const rect = element.getBoundingClientRect();
const base = centredCrop(natural.w / natural.h, slotAspect);
// Where the pointer is in the picture, before anything moves. This is the point that must not.
const fx = (event.clientX - rect.left) / rect.width;
const fy = (event.clientY - rect.top) / rect.height;
const px = current.x + fx * current.w;
const py = current.y + fy * current.h;
// A tenth per notch, and the sign follows the platform's own convention for "away is in".
const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
// Fit is the floor and 8x is a sane ceiling: past it a rendition has no pixels left to show.
const zoom = Math.min(8, Math.max(1, (base.w / current.w) * factor));
const next: Crop = {
w: base.w / zoom,
h: base.h / zoom,
x: px - fx * (base.w / zoom),
y: py - fy * (base.h / zoom),
};
next.x = Math.min(100 - next.w, Math.max(0, next.x));
next.y = Math.min(100 - next.h, Math.max(0, next.y));
// At fit exactly there is nothing to remember, so the crop is cleared rather than written 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));
};
element.addEventListener('wheel', onWheel, { passive: false });
return () => element.removeEventListener('wheel', onWheel);
}, [framable, current, onCropChange, natural, slotAspect]);
const preview = value ? (kind === 'video' ? posterUrl(value) : thumbUrl(value, 640)) : 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 gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<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);
}}
/>
</>
);
};