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>
67 lines
2.1 KiB
TypeScript
67 lines
2.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { MediaField } from '../MediaField';
|
|
import { useAutosave } from '../useAutosave';
|
|
|
|
/**
|
|
* A photograph block's picture and its framing.
|
|
*
|
|
* ── Why the crop is held here ──
|
|
*
|
|
* `MediaField` is controlled: it reports every pointer move and draws whatever it is handed back. Wired
|
|
* straight to the block, that meant a PATCH and a full reload of the preview PER MOVE — the page went
|
|
* white while somebody dragged, which is what this exists to stop.
|
|
*
|
|
* So the drag runs against local state, which is instant, and the write is debounced by `useAutosave`
|
|
* — one request when the hand stops, not sixty on the way there. The same trade every other control in
|
|
* this admin makes, and the same hook.
|
|
*
|
|
* ── Reset by IDENTITY, not by value ──
|
|
*
|
|
* The effect keys on the url and the shape rather than on the crop. Keyed on the crop it would fight
|
|
* the drag: the parent's value is the last SAVED one, so every move would be pulled back to it until
|
|
* the save landed.
|
|
*/
|
|
export const BlockMedia = ({
|
|
url,
|
|
crop,
|
|
aspect,
|
|
onUrl,
|
|
onCrop,
|
|
onDraft,
|
|
}: {
|
|
url: string | null;
|
|
crop: string | null;
|
|
/** Tailwind's ratio, as the page spells it — `21/9`, `1/1`. */
|
|
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<string | null>(crop);
|
|
|
|
useEffect(() => setDraft(crop), [url, aspect]);
|
|
|
|
useAutosave(draft !== crop ? (draft ?? '') : null, false, () => onCrop(draft));
|
|
|
|
return (
|
|
<MediaField
|
|
value={url}
|
|
onChange={onUrl}
|
|
crop={draft}
|
|
onCropChange={(next) => {
|
|
setDraft(next);
|
|
onDraft?.(next);
|
|
}}
|
|
aspect={aspect}
|
|
label="Choose a photograph"
|
|
/>
|
|
);
|
|
};
|