From fe51c70f708c7b27df22e3b5237598f85d3bc7ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 30 Aug 2026 23:57:33 +0100 Subject: [PATCH] a media slot can be framed by dragging and zooming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- api/static-site.ts | 20 ++++ web/MediaField.tsx | 199 +++++++++++++++++++++++++++++++++++- web/sections/AboutPanel.tsx | 18 +++- 3 files changed, 234 insertions(+), 3 deletions(-) diff --git a/api/static-site.ts b/api/static-site.ts index a82df2a..faf67c8 100644 --- a/api/static-site.ts +++ b/api/static-site.ts @@ -2,6 +2,8 @@ import { stat, readdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { db } from 'officerdb/db'; import { fofosAdminSettings } from '../db/schema'; +import { sidecarPort } from '@@/sidecar-registry'; +import { getOwnerUser } from 'officerdb'; import { readDatabases } from './databases'; /** @@ -139,6 +141,12 @@ export async function buildSite(secrets: { if (!row?.rootPath) throw new Error('No root location is set.'); const websiteDir = join(row.rootPath, 'website'); + // Absent when av-club is not installed. The build is told, rather than left to fail on a fetch to a + // port that is not there — and it can then say so plainly instead of timing out. + const avClubPort = sidecarPort('av-club'); + // av-club resolves every path against a user's own media directory, so the build has to say who it + // is working for. The owner, because that is whose root location this plugin administers. + const owner = await getOwnerUser(); const databases = await readDatabases(); if (!databases.production.name || !databases.production.exists) { throw new Error('The production database does not exist yet.'); @@ -160,6 +168,18 @@ export async function buildSite(secrets: { FOFOS_API_TOKEN: secrets.apiToken, ADMIN_SESSION_SECRET: secrets.sessionSecret, FOFOS_MEDIA_DIR: join(row.rootPath, 'media'), + /* + How the build reaches av-club. + + The build crops each placement to the exact shape and size its slot renders at, rather than + shipping a whole photograph and asking CSS to hide most of it. That needs an encoder, and the + rule is that every encoder is av-club — so the plugin, which knows the sidecar registry, hands + the site a URL and the site knows nothing else about the platform. + + `x-officer-user` is av-club's whole authentication, and the build has to send it: the sidecar + resolves paths against that user's own media directory. + */ + ...(avClubPort && owner?.email ? { AV_CLUB_URL: `http://127.0.0.1:${avClubPort}`, AV_CLUB_USER: owner?.email ?? '' } : {}), // Only used to build absolute URLs in the output — og:image, canonical links. The real domain // belongs in the checkout's own env, where it is a deployment fact rather than one this invented. PUBLIC_URL: process.env.FOFOS_PUBLIC_URL ?? 'http://127.0.0.1', diff --git a/web/MediaField.tsx b/web/MediaField.tsx index fa9e50f..92f1bc6 100644 --- a/web/MediaField.tsx +++ b/web/MediaField.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { ImageOff, Pencil, Trash2, Upload } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { MediaPicker } from './MediaPicker'; @@ -31,13 +31,69 @@ import { posterUrl, thumbUrl } from './thumbnail'; * * 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, @@ -45,6 +101,9 @@ export const MediaField = ({ /** 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. */ @@ -55,6 +114,110 @@ export const MediaField = ({ 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; @@ -72,6 +235,7 @@ export const MediaField = ({ <>
{ + 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)} diff --git a/web/sections/AboutPanel.tsx b/web/sections/AboutPanel.tsx index 6aa48a1..1b1167b 100644 --- a/web/sections/AboutPanel.tsx +++ b/web/sections/AboutPanel.tsx @@ -55,6 +55,7 @@ export const AboutPanel = () => { const [about, setAbout] = useState(null); const [headerUrl, setHeaderUrl] = useState(''); const [phrase, setPhrase] = useState(''); + const [headerCrop, setHeaderCrop] = useState(null); const [aboutText, setAboutText] = useState(''); const [productVideo, setProductVideo] = useState(''); const [productText, setProductText] = useState(''); @@ -67,6 +68,7 @@ export const AboutPanel = () => { const apply = useCallback((data: About) => { setAbout(data); setHeaderUrl(data.header.url ?? ''); + setHeaderCrop(data.header.crop); setPhrase(data.header.phrase ?? ''); setAboutText(data.about_text ?? ''); setProductVideo(data.product_video_url ?? ''); @@ -92,6 +94,7 @@ export const AboutPanel = () => { /** Every field this page owns, as the API spells them. One place, so save and dirty cannot disagree. */ const edited = { header_url: orNull(headerUrl), + header_crop: headerCrop, header_phrase: orNull(phrase), about_text: orNull(aboutText), product_video_url: orNull(productVideo), @@ -101,6 +104,7 @@ export const AboutPanel = () => { const stored = about && { header_url: about.header.url, + header_crop: about.header.crop, header_phrase: about.header.phrase, about_text: about.about_text, product_video_url: about.product_video_url, @@ -153,7 +157,19 @@ export const AboutPanel = () => { {/* 21/9 because that is what the page uses — `aspect-[21/9]` in about/partials.tsx. The two have to agree or the admin shows a shape the site does not have, which is worse than showing no shape at all: it looks decided. */} - setHeaderUrl(path ?? '')} aspect="21/9" label="header photograph" /> + { + setHeaderUrl(path ?? ''); + // A framing belongs to the picture it was made for. Keeping it across a change would apply + // one photograph's decision to another one's composition. + setHeaderCrop(null); + }} + crop={headerCrop} + onCropChange={setHeaderCrop} + aspect="21/9" + label="header photograph" + />