each row picks its own main image, and frames it

The shape is one decision for the whole section and stays on the template — every
collab is a 21:9 band or none of them is. Which part of THIS photograph survives
that shape is a decision about this photograph, and there is one per row. A single
rectangle chosen against one lead and applied to all of them is how a portrait
ends up cropped through somebody's head, which is what the block-level crop did
and why it is gone.

So a row's settings now carry Main image: a picture and a cropping frame in the
proportions the page asked for. Empty means the first item of its carousel, which
is the ordinary state and is not stored — reorder the carousel and the lead
follows. Choosing one is the deliberate act of saying "not that one", and it may
be a picture the carousel does not contain at all.

`cover_shape` rides along with each row so this panel can draw the right frame
without knowing how a template is assembled. SHAPES moved out of PageBuilderPanel
because two screens offer them now, and two lists is two places to add the next
ratio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 15:41:37 +01:00
co-authored by Claude Opus 5
parent c6cebc5000
commit 5968cb5d83
5 changed files with 105 additions and 19 deletions
+2 -17
View File
@@ -55,6 +55,7 @@ const PLACES_ONLY: string[] = [
'subject-specs',
'subject-carousel',
];
import { SHAPES } from './shapes';
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
import { FreeTextEditor } from './FreeTextEditor';
import { SpacingEditor, type Padding } from './SpacingEditor';
@@ -67,23 +68,7 @@ import { BlockMedia } from './BlockMedia';
import { PageStructure } from './PageStructure';
import { BandBlocks } from './BandBlocks';
/**
* The shapes a photograph block may take.
*
* One list, because three things need it and must agree: the select's options, the number stored as
* `content.ratio`, and the Tailwind aspect `MediaField` frames into. Two of those are strings and one
* is a float, which is exactly the drift a single table prevents.
*/
const SHAPES = [
{ value: '', label: "The picture's own shape", ratio: null as number | null },
{ value: '21/9', label: '21:9 — a letterbox band', ratio: 21 / 9 },
{ value: '16/9', label: '16:9', ratio: 16 / 9 },
{ value: '3/2', label: '3:2', ratio: 3 / 2 },
{ value: '4/3', label: '4:3', ratio: 4 / 3 },
{ value: '1/1', label: '1:1 — square', ratio: 1 },
{ value: '3/4', label: '3:4 — upright', ratio: 3 / 4 },
{ value: '2/3', label: '2:3 — upright', ratio: 2 / 3 },
];
/** Stored as a float, matched with a tolerance: 21/9 does not survive a round trip exactly. */
const closeTo = (a: number, b: unknown): boolean => typeof b === 'number' && Math.abs(a - b) < 0.01;