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 { 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 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(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]); /* 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 ( <>
{ 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 ? ( { 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)} /> ) : ( )} {/* 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 && (
)}
{/* 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 &&

{value}

} setPicking(false)} title={label ? `Choose the ${label}` : 'Choose media'} onPick={(path) => { setPicking(false); setBroken(false); onChange(path); }} /> ); };