frame a photograph on the page, at the size it is printed

The panel frames a 21:9 header in a 200-pixel thumbnail while the real one is on screen six inches
away, in the layout it belongs to. Dragging the picture itself is the obvious control, and until now
it was also the expensive one: every new rectangle made the site cut a fresh ladder — nine widths, two
formats, 1.54s measured — with the preview waiting for it.

Both halves are gone. The site draws the rectangle in CSS in development (previous commit, other
repo), so a framing costs nothing to show; the drag here is inline styles on the picture, and the only
write is one PATCH when the hand stops.

Inline styles rather than the site's custom properties, because the site renders a FRAMED picture one
way and an untouched one another — and an untouched photograph is exactly the one somebody is about to
drag. Inline beats both, and beats the object-fit the unframed path leaves behind, which would
otherwise crop the picture a second time inside a box that is already the crop.

Attached to every shaped photograph rather than to the selected one. Selection would put itself in the
paint's dependencies, so choosing a block would repaint the whole page's chrome; this way onDocument
still depends on the sections alone. The cost is that a click could become a nudge, so nothing moves
until the pointer has travelled 3px — below that it is a click, it selects the block, and no crop is
written.

The panel's zoom buttons now reach the real picture too, through `onDraft`. Otherwise the thumbnail
and the page would disagree for the 600ms until the debounce lands, which reads as a broken control
rather than a delayed one.

crop.ts is new and is the reason this is safe: parseCrop had already been copied once, and a crop that
means one thing in the panel and another on the page is a bug you can only feel. MediaField now draws
its own preview through the same `rectToBox`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 20:36:03 +01:00
co-authored by Claude Opus 5
parent 6098ae2285
commit 812371b64a
5 changed files with 356 additions and 46 deletions
+8 -44
View File
@@ -3,6 +3,7 @@ import { ImageOff, Minus, Pencil, Plus, Trash2, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { MediaPicker } from './MediaPicker'; import { MediaPicker } from './MediaPicker';
import { originalUrl, posterUrl } from './thumbnail'; 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. * 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 * 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 * 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 * 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 * 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 `−`. * 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'; const DRAG_TYPE = 'application/x-officer-files';
export const MediaField = ({ export const MediaField = ({
@@ -303,16 +275,8 @@ export const MediaField = ({
drawn. drawn.
*/ */
className={`absolute max-w-none touch-none ${framable ? 'cursor-grab active:cursor-grabbing' : ''}`} className={`absolute max-w-none touch-none ${framable ? 'cursor-grab active:cursor-grabbing' : ''}`}
style={ // `rectToBox` — the same four sums the page uses. See crop.ts for why they are shared.
current style={current ? rectToBox(current) : { width: '100%', height: '100%', objectFit: 'cover' }}
? {
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} draggable={false}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
+71
View File
@@ -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)),
});
+221
View File
@@ -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<number, Attached>();
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<HTMLElement>(`[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<HTMLElement>('[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();
},
};
}
+13 -1
View File
@@ -27,6 +27,7 @@ export const BlockMedia = ({
aspect, aspect,
onUrl, onUrl,
onCrop, onCrop,
onDraft,
}: { }: {
url: string | null; url: string | null;
crop: string | null; crop: string | null;
@@ -34,6 +35,14 @@ export const BlockMedia = ({
aspect: string; aspect: string;
onUrl: (url: string | null) => void; onUrl: (url: string | null) => void;
onCrop: (crop: 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<string | null>(crop); const [draft, setDraft] = useState<string | null>(crop);
@@ -46,7 +55,10 @@ export const BlockMedia = ({
value={url} value={url}
onChange={onUrl} onChange={onUrl}
crop={draft} crop={draft}
onCropChange={setDraft} onCropChange={(next) => {
setDraft(next);
onDraft?.(next);
}}
aspect={aspect} aspect={aspect}
label="Choose a photograph" label="Choose a photograph"
/> />
+43 -1
View File
@@ -8,6 +8,7 @@ import { readableError } from '../errors';
import { SitePreview, useDevPort } from '../SitePreview'; import { SitePreview, useDevPort } from '../SitePreview';
import { usePreviewable, useViewMode } from '../ViewMode'; import { usePreviewable, useViewMode } from '../ViewMode';
import { paintMeasure, paintSelection, type CanvasComponent, type CanvasSection } from '../canvas'; 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. What the Spacing dialog calls a block, by kind.
@@ -269,6 +270,28 @@ export const PageBuilderPanel = () => {
band?.scrollIntoView({ behavior: 'smooth', block: 'start' }); band?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, [part]); }, [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<Framing | null>(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( const onDocument = useCallback(
(frameDocument: Document) => { (frameDocument: Document) => {
frameDoc.current = frameDocument; frameDoc.current = frameDocument;
@@ -313,14 +336,30 @@ export const PageBuilderPanel = () => {
onSpaceComponent: (component) => handlersRef.current.spaceComponent(component), 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 () => { return () => {
framingRef.current = null;
framing.cleanup();
selection(); selection();
measure(); measure();
}; };
}, },
// `mode` is in here so switching to View gives SitePreview a NEW function, which runs the previous // `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. // 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) => onCrop={(crop) =>
void handlersRef.current.setContent(block, { ...block.content, crop: crop || undefined }) 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)}
/> />
) : ( ) : (
<> <>