diff --git a/web/MediaField.tsx b/web/MediaField.tsx index 5739ee3..cb559fa 100644 --- a/web/MediaField.tsx +++ b/web/MediaField.tsx @@ -3,6 +3,7 @@ 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'; +import { centredCrop, formatCrop, parseCrop, rectToBox, type Rect as Crop } from './crop'; /** * One picture or video on the page, shown at the shape the page will show it. @@ -36,7 +37,11 @@ import { originalUrl, posterUrl } from './thumbnail'; * * 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`. + * the page draws as a rectangle and the build cuts into real pixels. + * + * The same picture can also be framed ON the page, at the size it is printed — see framing.ts. This + * field stays because it is where the file is CHOSEN, because it carries the zoom, and because a + * gallery slot has no page to drag on. * * 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 @@ -58,39 +63,6 @@ import { originalUrl, posterUrl } from './thumbnail'; * 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 = ({ @@ -303,16 +275,8 @@ export const MediaField = ({ 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' } - } + // `rectToBox` — the same four sums the page uses. See crop.ts for why they are shared. + style={current ? rectToBox(current) : { width: '100%', height: '100%', objectFit: 'cover' }} draggable={false} onPointerDown={onPointerDown} onPointerMove={onPointerMove} diff --git a/web/crop.ts b/web/crop.ts new file mode 100644 index 0000000..e26a5bf --- /dev/null +++ b/web/crop.ts @@ -0,0 +1,71 @@ +/** + * A crop rectangle, and the four sums everything does with one. + * + * ── Why this is its own module ── + * + * Three places now need the same arithmetic and must agree to the decimal: the panel's `MediaField`, + * the on-page framing in `framing.ts`, and the site's own `media/render.ts`. The first two are here. + * The third is in another repository and is the one that cannot be shared, which is exactly why the two + * that CAN be must not also drift — `parseCrop` was already copied once, and a crop that means one + * thing in the panel and another on the page is a bug nobody can see, only feel. + * + * The format is the site's: `x,y,w,h` as percentages of the ORIGINAL. Percentages rather than pixels + * because a slot is a different size on every screen and the framing has to mean the same thing on all + * of them. + */ + +export type Rect = { x: number; y: number; w: number; h: number }; + +export const parseCrop = (value: string | null | undefined): Rect | 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; +}; + +export const formatCrop = (crop: Rect): 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. + */ +export const centredCrop = (imageAspect: number, slotAspect: number): Rect => + 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 }; + +/** + * Is this rectangle just the centred fit? + * + * A null crop and a crop that happens to be centred must not be two states — one of them would be + * stored, shown as "1.0×", and mean nothing. Compared with a tolerance because it arrives through a + * database and a `toFixed(1)`, so exact equality is never the question being asked. + */ +export const isCentred = (crop: Rect, imageAspect: number, slotAspect: number): boolean => { + const fit = centredCrop(imageAspect, slotAspect); + return (['x', 'y', 'w', 'h'] as const).every((key) => Math.abs(crop[key] - fit[key]) < 0.2); +}; + +/** + * The rectangle as four CSS lengths, in the picture's own box. + * + * `width` is how many times wider than the window the whole picture is; the offsets slide the chosen + * corner to the box's origin. It is the crop rectangle, drawn — and unlike `object-position` it carries + * the ZOOM, because zooming in is `w` and `h` getting smaller rather than the picture moving. + */ +export const rectToBox = (crop: Rect): { width: string; height: string; left: string; top: string } => ({ + width: `${((100 / crop.w) * 100).toFixed(3)}%`, + height: `${((100 / crop.h) * 100).toFixed(3)}%`, + left: `${(-(crop.x / crop.w) * 100).toFixed(3)}%`, + top: `${(-(crop.y / crop.h) * 100).toFixed(3)}%`, +}); + +/** Keep the window inside the picture, so no edge shows empty space. */ +export const clampRect = (crop: Rect): Rect => ({ + ...crop, + x: Math.min(100 - crop.w, Math.max(0, crop.x)), + y: Math.min(100 - crop.h, Math.max(0, crop.y)), +}); diff --git a/web/framing.ts b/web/framing.ts new file mode 100644 index 0000000..027fd75 --- /dev/null +++ b/web/framing.ts @@ -0,0 +1,221 @@ +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(); + }, + }; +} diff --git a/web/sections/BlockMedia.tsx b/web/sections/BlockMedia.tsx index ccd1318..54aa0db 100644 --- a/web/sections/BlockMedia.tsx +++ b/web/sections/BlockMedia.tsx @@ -27,6 +27,7 @@ export const BlockMedia = ({ aspect, onUrl, onCrop, + onDraft, }: { url: string | null; crop: string | null; @@ -34,6 +35,14 @@ export const BlockMedia = ({ aspect: string; onUrl: (url: string | null) => void; onCrop: (crop: string | null) => void; + /** + * Every change to the draft, not only the saved one. + * + * The page shows the same photograph at full size, and it has to move when these buttons do — + * otherwise the thumbnail and the page disagree for the 600ms until the save lands, which reads as + * the control being broken rather than as it being debounced. + */ + onDraft?: (crop: string | null) => void; }) => { const [draft, setDraft] = useState(crop); @@ -46,7 +55,10 @@ export const BlockMedia = ({ value={url} onChange={onUrl} crop={draft} - onCropChange={setDraft} + onCropChange={(next) => { + setDraft(next); + onDraft?.(next); + }} aspect={aspect} label="Choose a photograph" /> diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 7f1718a..dd6626f 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -8,6 +8,7 @@ import { readableError } from '../errors'; import { SitePreview, useDevPort } from '../SitePreview'; import { usePreviewable, useViewMode } from '../ViewMode'; import { paintMeasure, paintSelection, type CanvasComponent, type CanvasSection } from '../canvas'; +import { paintFraming, type Framing, type FrameTarget } from '../framing'; /* What the Spacing dialog calls a block, by kind. @@ -269,6 +270,28 @@ export const PageBuilderPanel = () => { band?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, [part]); + /* + Framing, driven from outside the paint. + + The handle is kept in a ref rather than in state because the panel's zoom buttons have to push a + rectangle into the page on every press, and re-running `onDocument` for that would repaint every + control on the page sixty times a gesture. A ref lets one number reach one element. + */ + const framingRef = useRef(null); + + /* + Which pictures can be dragged: every photograph block that has been given a shape. + + Serialised into the dependency rather than passed as an array, because a new array every render is a + new identity every render, and `onDocument` would repaint on each one. This is the same trap the + theme in SitePreview hit. + */ + const frameTargets: FrameTarget[] = sections + .flatMap((section) => section.components) + .filter((component) => component.kind === 'media' && typeof component.content?.ratio === 'number') + .map((component) => ({ id: component.id, crop: component.content?.crop ?? null })); + const frameKey = JSON.stringify(frameTargets); + const onDocument = useCallback( (frameDocument: Document) => { frameDoc.current = frameDocument; @@ -313,14 +336,30 @@ export const PageBuilderPanel = () => { onSpaceComponent: (component) => handlersRef.current.spaceComponent(component), }); + /* + Dragging a photograph reframes it, at the size it is printed. + + The write is one PATCH when the hand stops — the drag itself is inline styles on the picture, + which costs nothing. See framing.ts, and the site's PageComponent for the half that makes the + page draw a rectangle rather than a pan. + */ + const framing = paintFraming(frameDocument, JSON.parse(frameKey) as FrameTarget[], (id, crop) => { + const component = sections.flatMap((section) => section.components).find((one) => one.id === id); + if (!component) return; + void handlersRef.current.setContent(component, { ...component.content, crop: crop || undefined }); + }); + framingRef.current = framing; + return () => { + framingRef.current = null; + framing.cleanup(); selection(); measure(); }; }, // `mode` is in here so switching to View gives SitePreview a NEW function, which runs the previous // paint's cleanup and then paints nothing. That is what removes the chrome without a reload. - [sections], + [sections, frameKey], ); /* @@ -850,6 +889,9 @@ export const PageBuilderPanel = () => { onCrop={(crop) => void handlersRef.current.setContent(block, { ...block.content, crop: crop || undefined }) } + /* Every press of a zoom button reaches the real picture too, so the thumbnail and the + page are never showing two different framings of the same photograph. */ + onDraft={(crop) => framingRef.current?.apply(block.id, crop)} /> ) : ( <>