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(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 ( <>
{ 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); }} /> ); };