From 3053765494f37d0c4b192fe858c54813ee6f14fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 1 Sep 2026 21:40:35 +0100 Subject: [PATCH] debounce the framing, or the preview reloads sixty times a drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/sections/BlockMedia.tsx | 54 +++++++++++++++++++++++++++++++ web/sections/PageBuilderPanel.tsx | 15 ++++----- 2 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 web/sections/BlockMedia.tsx diff --git a/web/sections/BlockMedia.tsx b/web/sections/BlockMedia.tsx new file mode 100644 index 0000000..ccd1318 --- /dev/null +++ b/web/sections/BlockMedia.tsx @@ -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(crop); + + useEffect(() => setDraft(crop), [url, aspect]); + + useAutosave(draft !== crop ? (draft ?? '') : null, false, () => onCrop(draft)); + + return ( + + ); +}; diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 950df3c..41bfe22 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -38,7 +38,7 @@ import { BandSettings } from './BandSettings'; import { BlockSettings } from './BlockSettings'; import { GalleryItems } from './GalleryItems'; import { PartnersEditor } from './PartnersEditor'; -import { MediaField } from '../MediaField'; +import { BlockMedia } from './BlockMedia'; /** * The shapes a photograph block may take. @@ -807,15 +807,14 @@ export const PageBuilderPanel = () => { )} {shapeOf(block) ? ( - void handlersRef.current.setContent(block, { ...block.content, url: url ?? '' })} + - void handlersRef.current.setContent(block, { ...block.content, crop: crop ?? undefined }) - } 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 }) + } /> ) : ( <>