From 05ad4f4d519a84e987e0b09599226afae92d24b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 5 Sep 2026 15:46:22 +0100 Subject: [PATCH] draft the crop instead of saving every pointer move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panning went berserk. Wired straight to MediaField, `onCropChange` fired on every move, each move saved, each save reloaded the row, and the reload handed the crop back as the last SAVED value — yanking the picture out from under the drag. BlockMedia exists for exactly this and I went around it. It keeps the crop in a local draft, saves once when the drag stops, and resets on the url and the shape rather than on the crop — its own note says why, and the failure it describes is the one I reproduced. Also: an empty picture slot looked the same whether the carousel was still being read or genuinely had nothing in it. `undefined` now means loading and `null` means empty, and the line underneath says which. Co-Authored-By: Claude Opus 5 (1M context) --- web/sections/SubjectFields.tsx | 25 ++++++++++++++++++------- web/sections/SubjectPanel.tsx | 4 +++- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/web/sections/SubjectFields.tsx b/web/sections/SubjectFields.tsx index 83b1be1..8f2f8c6 100644 --- a/web/sections/SubjectFields.tsx +++ b/web/sections/SubjectFields.tsx @@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { RichText } from '../RichText'; import { useAutosave } from '../useAutosave'; -import { MediaField } from '../MediaField'; +import { BlockMedia } from './BlockMedia'; import { SHAPES } from './shapes'; import type { SubjectRecord, SubjectSection } from './subjects'; @@ -106,18 +106,29 @@ export const SubjectFields = ({ */}
- save({ cover_url: path, ...(path ? {} : { cover_crop: null }) })} + {/* + `BlockMedia`, not `MediaField` directly. + + It holds the crop in a local draft and saves once, when the drag stops. Wired straight to + `MediaField` this saved on every pointer move — and each save reloaded the row, which handed + the crop back as the last SAVED value and yanked the picture out from under the drag. That is + the exact failure the note at the top of BlockMedia describes, and the reason it exists. + */} + save({ cover_crop: crop })} aspect={aspect} - label="Choose the main image" + onUrl={(path) => save({ cover_url: path, ...(path ? {} : { cover_crop: null }) })} + onCrop={(crop) => save({ cover_crop: crop })} />

{record.cover_url ? 'Chosen for this one. Remove it to go back to the first picture in its carousel.' - : 'From the first picture in its carousel. Choose one to override that.'} + : firstPhoto === undefined + ? 'Reading its carousel…' + : firstPhoto + ? 'From the first picture in its carousel. Choose one to override that.' + : 'Its carousel is empty. Choose a picture, or add some in Carousels.'} {record.cover_shape === null && ' No shape is set on the page, so it draws at its own.'}

diff --git a/web/sections/SubjectPanel.tsx b/web/sections/SubjectPanel.tsx index 65bdee2..a872b73 100644 --- a/web/sections/SubjectPanel.tsx +++ b/web/sections/SubjectPanel.tsx @@ -122,7 +122,9 @@ export const SubjectPanel = ({ section }: { section: Su record={selected} // What the cover falls back to when the row has not chosen one — the same first item the // grid below is already showing, so the two cannot disagree about which picture that is. - firstPhoto={photos?.[0]?.path ?? null} + // `undefined` while the carousel is still being read, `null` when it genuinely has none. + // The two look identical in an empty picture slot, and only one of them is a problem. + firstPhoto={photos === null ? undefined : (photos[0]?.path ?? null)} onSaved={() => void load()} /> ) : null,