frame the photograph in the shape you chose
With a shape set, MediaField replaces the plain thumbnail: it draws the picture in that shape and lets somebody drag and zoom to say which part shows. It already did all of that — it needed the block's crop and the aspect. Without a shape there is nothing to frame into, so the thumbnail stays and there is no framing control that would do nothing. Changing the shape clears the crop. A crop is a rectangle OF a shape, and keeping it would frame the new one by numbers chosen for the old. One SHAPES table, because three things have to agree: the options, the float stored as content.ratio, and the Tailwind aspect MediaField frames into. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,34 @@ 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/** The Tailwind aspect for a block's stored ratio, or null when it keeps its own shape. */
|
||||||
|
const shapeOf = (block: { content?: { url?: string; ratio?: number } }): string | null => {
|
||||||
|
if (!block.content?.url || /\.(mp4|mov|webm|m4v)$/i.test(block.content.url)) return null;
|
||||||
|
return SHAPES.find((one) => one.ratio && closeTo(one.ratio, block.content?.ratio))?.value ?? null;
|
||||||
|
};
|
||||||
import { useEditorSlot } from '../useEditorSlot';
|
import { useEditorSlot } from '../useEditorSlot';
|
||||||
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
||||||
import { MediaPicker } from '../MediaPicker';
|
import { MediaPicker } from '../MediaPicker';
|
||||||
@@ -737,79 +765,87 @@ export const PageBuilderPanel = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(block.kind === 'media' || block.kind === 'video') && (
|
{(block.kind === 'media' || block.kind === 'video') && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
onClick={() => handlersRef.current.editComponent(block)}
|
|
||||||
>
|
|
||||||
Choose media…
|
|
||||||
</Button>
|
|
||||||
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">
|
|
||||||
{block.content?.url ? block.content.url.split('/').pop() : 'Nothing chosen'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
The shape of the slot, for a PHOTOGRAPH.
|
The shape of the slot, and the framing inside it.
|
||||||
|
|
||||||
It is how you make a band shorter than the picture in it: the ratio becomes a slot
|
The ratio becomes a slot shape and the site serves a rendition cut to it, so a band
|
||||||
shape, and the site asks av-club for a rendition cut to that — so the bytes served
|
shorter than its photograph costs FEWER bytes rather than more.
|
||||||
match the space rather than being scaled down by the browser.
|
|
||||||
|
|
||||||
Not offered for a video. There is nothing to crop and a ratio would letterbox it.
|
With a shape chosen, `MediaField` replaces the plain thumbnail: it draws the picture in
|
||||||
|
that shape and lets somebody drag and zoom to say which part shows. Without one there
|
||||||
|
is nothing to frame into — the whole picture is used — so framing would be a control
|
||||||
|
that does nothing.
|
||||||
|
|
||||||
`content` is replaced wholesale by the API, so this sends what is already there
|
`content` is replaced wholesale by the API, so every write sends what is already there
|
||||||
alongside the new value — a PATCH with `{ ratio }` alone would drop the url.
|
alongside the new value.
|
||||||
*/}
|
*/}
|
||||||
{block.content?.url && !/\.(mp4|mov|webm|m4v)$/i.test(block.content.url) && (
|
{!/\.(mp4|mov|webm|m4v)$/i.test(block.content?.url ?? '') && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs">Shape</label>
|
<label className="text-xs">Shape</label>
|
||||||
<select
|
<select
|
||||||
value={block.content.ratio ? String(block.content.ratio) : ''}
|
value={shapeOf(block) ?? ''}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const value = event.currentTarget.value;
|
const chosen = SHAPES.find((one) => one.value === event.currentTarget.value);
|
||||||
void handlersRef.current.setContent(block, {
|
void handlersRef.current.setContent(block, {
|
||||||
...block.content,
|
...block.content,
|
||||||
ratio: value ? Number(value) : undefined,
|
ratio: chosen?.ratio ?? undefined,
|
||||||
|
// A crop is a rectangle of a SHAPE. Changing the shape makes it meaningless,
|
||||||
|
// and keeping it would frame the new one by numbers chosen for the old.
|
||||||
|
crop: undefined,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
|
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
|
||||||
>
|
>
|
||||||
<option value="">The picture's own shape</option>
|
{SHAPES.map((one) => (
|
||||||
<option value={String(21 / 9)}>21:9 — a letterbox band</option>
|
<option key={one.value || 'own'} value={one.value}>
|
||||||
<option value={String(16 / 9)}>16:9</option>
|
{one.label}
|
||||||
<option value={String(3 / 2)}>3:2</option>
|
</option>
|
||||||
<option value={String(4 / 3)}>4:3</option>
|
))}
|
||||||
<option value="1">1:1 — square</option>
|
|
||||||
<option value={String(3 / 4)}>3:4 — upright</option>
|
|
||||||
<option value={String(2 / 3)}>2:3 — upright</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/*
|
{shapeOf(block) ? (
|
||||||
What is actually in the block, at the width of the column.
|
<MediaField
|
||||||
|
value={block.content?.url ?? null}
|
||||||
A button that says "Choose media…" and nothing else asks somebody to open a dialog to
|
onChange={(url) => void handlersRef.current.setContent(block, { ...block.content, url: url ?? '' })}
|
||||||
find out what they already chose. Full width and its own aspect, because a strip of a
|
crop={block.content?.crop ?? null}
|
||||||
photograph tells you it is a photograph and not which one.
|
onCropChange={(crop) =>
|
||||||
|
void handlersRef.current.setContent(block, { ...block.content, crop: crop ?? undefined })
|
||||||
A video shows its POSTER rather than playing: the question here is which file this is,
|
|
||||||
and a frame answers it.
|
|
||||||
*/}
|
|
||||||
{block.content?.url && (
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
/\.(mp4|mov|webm|m4v)$/i.test(block.content.url)
|
|
||||||
? posterUrl(block.content.url)
|
|
||||||
: thumbUrl(block.content.url, 640)
|
|
||||||
}
|
}
|
||||||
alt=""
|
aspect={shapeOf(block)!}
|
||||||
className="w-full rounded border border-border"
|
label="Choose a photograph"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
onClick={() => handlersRef.current.editComponent(block)}
|
||||||
|
>
|
||||||
|
Choose media…
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">
|
||||||
|
{block.content?.url ? block.content.url.split('/').pop() : 'Nothing chosen'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* A video shows its POSTER rather than playing: the question is which file this is. */}
|
||||||
|
{block.content?.url && (
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
/\.(mp4|mov|webm|m4v)$/i.test(block.content.url)
|
||||||
|
? posterUrl(block.content.url)
|
||||||
|
: thumbUrl(block.content.url, 640)
|
||||||
|
}
|
||||||
|
alt=""
|
||||||
|
className="w-full rounded border border-border"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user