import { centredCrop, clampRect, formatCrop, isCentred, parseCrop, rectToBox, type Rect } from './crop'; /** * Framing a photograph ON the page, at the size it is actually printed. * * ── Why it moved out of the panel ── * * The panel's `MediaField` frames a picture in a thumbnail beside the page. It works, and it asks * somebody to judge a 21:9 header by looking at a 200-pixel copy of it — while the real one is on * screen, six inches away, in the layout it belongs to. * * The other half was cost. Every new rectangle used to make the site cut a fresh ladder — nine widths * in two formats, 1.54s measured — and the preview waited for it. That is fixed on the site side: in * development the whole picture is served and the framing is CSS, so a rectangle costs nothing to show * and the build cuts the pixels once, later. * * With both true, dragging the picture itself is the obvious control, and it is free. * * ── Inline styles, not the site's custom properties ── * * The site renders a framed picture through four custom properties and an unframed one through * `object-cover`. Driving the properties would work for the first and do nothing for the second — and * an untouched photograph is precisely the one somebody is about to drag. * * So this writes inline styles, which beat any selector and are identical for both cases. The site's * own rendering takes over on the next load, and it now draws the same rectangle — see * PageComponent.tsx. * * ── Every picture, not only the selected one ── * * Attaching to the selection would make the paint depend on it, so choosing a block would repaint the * whole page's chrome. Attaching to all of them keeps `onDocument` depending on the sections alone, * which is the rule the rest of this builder already follows — and dragging the thing you want is a * better gesture than selecting it first anyway. * * The cost is that a click could become a nudge, so nothing moves until the pointer has travelled * `THRESHOLD` pixels. Below that it is a click, it selects the block, and no crop is written. */ /** How far the pointer must travel before this is a drag rather than a click. */ const THRESHOLD = 3; export type FrameTarget = { id: number; crop: string | null }; type Attached = { box: HTMLElement; image: HTMLImageElement; slotAspect: number; /** The rectangle now on screen. Null means nothing has been chosen and the centred fit is showing. */ rect: Rect | null; }; export type Framing = { /** Show a rectangle for a block without waiting for a save — the panel's zoom buttons use this. */ apply: (id: number, crop: string | null) => void; cleanup: () => void; }; const NOOP: Framing = { apply: () => {}, cleanup: () => {} }; /** * @param onCommit Called once when the hand stops, never during the drag. A crop of null means the * picture is back at its centred fit and has nothing worth storing. */ export function paintFraming( document: Document, targets: FrameTarget[], onCommit: (id: number, crop: string | null) => void, ): Framing { if (!targets.length) return NOOP; const attached = new Map(); const undo: (() => void)[] = []; /** * The rectangle, written straight onto the elements. * * Nothing here is a class: the site renders a framed picture one way and an unframed one another, and * this has to work on both without knowing which. Inline styles beat either. */ const draw = (state: Attached) => { const aspect = state.image.naturalWidth / state.image.naturalHeight; if (!Number.isFinite(aspect) || aspect <= 0) return; const rect = state.rect ?? centredCrop(aspect, state.slotAspect); const placed = rectToBox(rect); state.box.style.position = 'relative'; state.box.style.overflow = 'hidden'; Object.assign(state.image.style, { position: 'absolute', maxWidth: 'none', // `object-fit` and `object-position` are what the site used to frame with. Left in place they go // on cropping the picture a second time, inside a box that is already exactly the crop. objectFit: 'fill', objectPosition: '50% 50%', ...placed, }); }; for (const target of targets) { const node = document.querySelector(`[data-fofos-component="${target.id}"]`); if (!node) continue; // The component's node is not always the framed box: a block with a heading wraps both. const box = node.matches('[data-fofos-frame]') ? node : node.querySelector('[data-fofos-frame]'); const image = box?.querySelector('img'); const slotAspect = Number(box?.dataset.fofosFrame); if (!box || !image || !Number.isFinite(slotAspect) || slotAspect <= 0) continue; const state: Attached = { box, image, slotAspect, rect: parseCrop(target.crop) }; attached.set(target.id, state); const before = { boxPosition: box.style.position, boxOverflow: box.style.overflow, image: image.style.cssText, }; image.style.cursor = 'grab'; // Otherwise the browser's own image drag starts and the pointer stream stops mid-gesture. image.draggable = false; image.style.touchAction = 'none'; let start: { x: number; y: number; rect: Rect } | null = null; let moved = false; /** The picture's own proportions. Zero until it has loaded, and a drag before that does nothing. */ const naturalAspect = () => image.naturalWidth && image.naturalHeight ? image.naturalWidth / image.naturalHeight : 0; const current = (): Rect | null => { const aspect = naturalAspect(); if (!aspect) return null; return state.rect ?? centredCrop(aspect, slotAspect); }; const onPointerDown = (event: PointerEvent) => { const rect = current(); if (!rect) return; // Not preventDefault: the click that follows is what SELECTS this block, and the panel opening // beside the picture you just framed is the right outcome. image.setPointerCapture?.(event.pointerId); start = { x: event.clientX, y: event.clientY, rect }; moved = false; }; const onPointerMove = (event: PointerEvent) => { if (!start) return; const dx = event.clientX - start.x; const dy = event.clientY - start.y; if (!moved && Math.hypot(dx, dy) < THRESHOLD) return; if (!moved) { moved = true; image.style.cursor = 'grabbing'; } /* Pixels to picture-percent. The picture is drawn `100 / w` times wider than the box, 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 measured = box.getBoundingClientRect(); const renderedW = measured.width * (100 / start.rect.w); const renderedH = measured.height * (100 / start.rect.h); state.rect = clampRect({ ...start.rect, x: start.rect.x - (dx / renderedW) * 100, y: start.rect.y - (dy / renderedH) * 100, }); draw(state); }; const endDrag = (event: PointerEvent) => { if (!start) return; image.releasePointerCapture?.(event.pointerId); start = null; image.style.cursor = 'grab'; if (!moved) return; moved = false; const rect = state.rect; const aspect = naturalAspect(); if (!rect || !aspect) return; // Back where it started is not a framing. Storing the centred rectangle would make "untouched" // and "deliberately centred" two states that look identical and read differently. onCommit(target.id, isCentred(rect, aspect, slotAspect) ? null : formatCrop(rect)); }; image.addEventListener('pointerdown', onPointerDown); image.addEventListener('pointermove', onPointerMove); image.addEventListener('pointerup', endDrag); image.addEventListener('pointercancel', endDrag); undo.push(() => { image.removeEventListener('pointerdown', onPointerDown); image.removeEventListener('pointermove', onPointerMove); image.removeEventListener('pointerup', endDrag); image.removeEventListener('pointercancel', endDrag); box.style.position = before.boxPosition; box.style.overflow = before.boxOverflow; image.style.cssText = before.image; image.draggable = true; }); } return { apply: (id, crop) => { const state = attached.get(id); if (!state) return; state.rect = parseCrop(crop); draw(state); }, cleanup: () => { for (const step of undo.reverse()) step(); }, }; }