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>
222 lines
8.5 KiB
TypeScript
222 lines
8.5 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|