diff --git a/web/MediaField.tsx b/web/MediaField.tsx index cb559fa..5739ee3 100644 --- a/web/MediaField.tsx +++ b/web/MediaField.tsx @@ -3,7 +3,6 @@ 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. @@ -37,11 +36,7 @@ import { centredCrop, formatCrop, parseCrop, rectToBox, type Rect as Crop } from * * 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 - * 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. + * `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 @@ -63,6 +58,39 @@ import { centredCrop, formatCrop, parseCrop, rectToBox, type Rect as Crop } from * 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 = ({ @@ -275,8 +303,16 @@ export const MediaField = ({ drawn. */ className={`absolute max-w-none touch-none ${framable ? 'cursor-grab active:cursor-grabbing' : ''}`} - // `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' }} + 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' } + } draggable={false} onPointerDown={onPointerDown} onPointerMove={onPointerMove} diff --git a/web/crop.ts b/web/crop.ts deleted file mode 100644 index e26a5bf..0000000 --- a/web/crop.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * 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 deleted file mode 100644 index 027fd75..0000000 --- a/web/framing.ts +++ /dev/null @@ -1,221 +0,0 @@ -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 54aa0db..ccd1318 100644 --- a/web/sections/BlockMedia.tsx +++ b/web/sections/BlockMedia.tsx @@ -27,7 +27,6 @@ export const BlockMedia = ({ aspect, onUrl, onCrop, - onDraft, }: { url: string | null; crop: string | null; @@ -35,14 +34,6 @@ 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); @@ -55,10 +46,7 @@ export const BlockMedia = ({ value={url} onChange={onUrl} crop={draft} - onCropChange={(next) => { - setDraft(next); - onDraft?.(next); - }} + onCropChange={setDraft} aspect={aspect} label="Choose a photograph" /> diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index dd6626f..7f1718a 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -8,7 +8,6 @@ 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. @@ -270,28 +269,6 @@ 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; @@ -336,30 +313,14 @@ 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, frameKey], + [sections], ); /* @@ -889,9 +850,6 @@ 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)} /> ) : ( <>