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

This reverts commit 812371b64a.
This commit is contained in:
2026-09-03 20:47:23 +01:00
parent 812371b64a
commit 4587ce2d39
5 changed files with 46 additions and 356 deletions
+44 -8
View File
@@ -3,7 +3,6 @@ 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.
@@ -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 * 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
* the page draws as a rectangle and the build cuts into real pixels. * `render` turns into an `object-position`.
*
* 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
@@ -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 `−`. * 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 = ({
@@ -275,8 +303,16 @@ 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' : ''}`}
// `rectToBox` — the same four sums the page uses. See crop.ts for why they are shared. style={
style={current ? rectToBox(current) : { width: '100%', height: '100%', objectFit: 'cover' }} 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} draggable={false}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
-71
View File
@@ -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)),
});
-221
View File
@@ -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<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();
},
};
}
+1 -13
View File
@@ -27,7 +27,6 @@ export const BlockMedia = ({
aspect, aspect,
onUrl, onUrl,
onCrop, onCrop,
onDraft,
}: { }: {
url: string | null; url: string | null;
crop: string | null; crop: string | null;
@@ -35,14 +34,6 @@ 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);
@@ -55,10 +46,7 @@ export const BlockMedia = ({
value={url} value={url}
onChange={onUrl} onChange={onUrl}
crop={draft} crop={draft}
onCropChange={(next) => { onCropChange={setDraft}
setDraft(next);
onDraft?.(next);
}}
aspect={aspect} aspect={aspect}
label="Choose a photograph" label="Choose a photograph"
/> />
+1 -43
View File
@@ -8,7 +8,6 @@ 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.
@@ -270,28 +269,6 @@ 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;
@@ -336,30 +313,14 @@ 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, frameKey], [sections],
); );
/* /*
@@ -889,9 +850,6 @@ 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)}
/> />
) : ( ) : (
<> <>