debounce the framing, or the preview reloads sixty times a drag

MediaField is controlled: it reports every pointer move and draws what it is
handed back. Wired straight to the block, that was a PATCH and a full reload of
the preview per move, and the page went white while somebody dragged.

The drag runs against local state now — instant — and the write is debounced by
useAutosave, the same hook every other control here uses.

The reset effect keys on the url and the shape, not 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 snap back until the save landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 21:40:35 +01:00
co-authored by Claude Opus 5
parent 53d1937430
commit 3053765494
2 changed files with 61 additions and 8 deletions
+54
View File
@@ -0,0 +1,54 @@
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,
}: {
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;
}) => {
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={setDraft}
aspect={aspect}
label="Choose a photograph"
/>
);
};
+7 -8
View File
@@ -38,7 +38,7 @@ import { BandSettings } from './BandSettings';
import { BlockSettings } from './BlockSettings'; import { BlockSettings } from './BlockSettings';
import { GalleryItems } from './GalleryItems'; import { GalleryItems } from './GalleryItems';
import { PartnersEditor } from './PartnersEditor'; import { PartnersEditor } from './PartnersEditor';
import { MediaField } from '../MediaField'; import { BlockMedia } from './BlockMedia';
/** /**
* The shapes a photograph block may take. * The shapes a photograph block may take.
@@ -807,15 +807,14 @@ export const PageBuilderPanel = () => {
)} )}
{shapeOf(block) ? ( {shapeOf(block) ? (
<MediaField <BlockMedia
value={block.content?.url ?? null} url={block.content?.url ?? null}
onChange={(url) => void handlersRef.current.setContent(block, { ...block.content, url: url ?? '' })}
crop={block.content?.crop ?? null} crop={block.content?.crop ?? null}
onCropChange={(crop) =>
void handlersRef.current.setContent(block, { ...block.content, crop: crop ?? undefined })
}
aspect={shapeOf(block)!} aspect={shapeOf(block)!}
label="Choose a photograph" onUrl={(url) => void handlersRef.current.setContent(block, { ...block.content, url: url ?? '' })}
onCrop={(crop) =>
void handlersRef.current.setContent(block, { ...block.content, crop: crop || undefined })
}
/> />
) : ( ) : (
<> <>