427 lines
19 KiB
TypeScript
427 lines
19 KiB
TypeScript
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);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
};
|