draft the crop instead of saving every pointer move

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 15:46:22 +01:00
co-authored by Claude Opus 5
parent 5968cb5d83
commit 05ad4f4d51
2 changed files with 21 additions and 8 deletions
+18 -7
View File
@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { RichText } from '../RichText'; import { RichText } from '../RichText';
import { useAutosave } from '../useAutosave'; import { useAutosave } from '../useAutosave';
import { MediaField } from '../MediaField'; import { BlockMedia } from './BlockMedia';
import { SHAPES } from './shapes'; import { SHAPES } from './shapes';
import type { SubjectRecord, SubjectSection } from './subjects'; import type { SubjectRecord, SubjectSection } from './subjects';
@@ -106,18 +106,29 @@ export const SubjectFields = <T extends SubjectRecord>({
*/} */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Main image</label> <label className="text-xs text-muted-foreground">Main image</label>
<MediaField {/*
value={cover} `BlockMedia`, not `MediaField` directly.
onChange={(path) => save({ cover_url: path, ...(path ? {} : { cover_crop: null }) })}
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.
*/}
<BlockMedia
url={cover}
crop={record.cover_crop} crop={record.cover_crop}
onCropChange={(crop) => save({ cover_crop: crop })}
aspect={aspect} aspect={aspect}
label="Choose the main image" onUrl={(path) => save({ cover_url: path, ...(path ? {} : { cover_crop: null }) })}
onCrop={(crop) => save({ cover_crop: crop })}
/> />
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
{record.cover_url {record.cover_url
? 'Chosen for this one. Remove it to go back to the first picture in its carousel.' ? '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.'} {record.cover_shape === null && ' No shape is set on the page, so it draws at its own.'}
</p> </p>
</div> </div>
+3 -1
View File
@@ -122,7 +122,9 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
record={selected} record={selected}
// What the cover falls back to when the row has not chosen one — the same first item the // 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. // 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()} onSaved={() => void load()}
/> />
) : null, ) : null,