text over the header photograph, placed by dragging it
The wireframe puts two blocks of text on the header picture. The page had one `phrase`, drawn under it.
`header_overlays` is a jsonb list — `[{ text, x, y, size, color, align }]` — rather than a pair of
`header_text_1_*` columns. The design asks for two today; a third is then data rather than a migration,
and nothing has to decide what an empty second block means. `headerPhrase` is left alone: folding them
together would silently move somebody's existing copy on top of a photograph.
Everything is relative and has to be. x and y are percentages of the picture, so a position means the
same thing on a phone and a desktop. `size` is a percentage of the picture's WIDTH, because a headline
set at 40px is a headline on a laptop and a caption on a large screen. The preview renders that with
container query units against the picture's own box, which is exactly what the percentage means — the
page will do the same, so the two cannot drift.
The editor is the picture with the text on it, at the shape the page uses, because where a line sits on
a photograph is not a decision anybody can make from two number fields.
The API clamps every field rather than rejecting: a position into the picture, a size into something
legible, the colour to a hex triple. A caller sending nonsense gets a usable overlay instead of a 400,
and the page can never be handed a position that puts text off the edge of its own header.
Pushed to both databases and verified by querying information_schema rather than by reading db:push's
exit code, which is documented to lie.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text laid over a picture, positioned by dragging it.
|
||||||
|
*
|
||||||
|
* ── Why it is drawn on the picture rather than listed beside it ──
|
||||||
|
*
|
||||||
|
* The thing being decided is where a line of text sits on a photograph, and that is not a decision
|
||||||
|
* anybody can make from two number fields. The editor is the picture with the text on it, at the shape
|
||||||
|
* the page uses, so what is arranged is what is published.
|
||||||
|
*
|
||||||
|
* ── Everything is relative, and has to be ──
|
||||||
|
*
|
||||||
|
* `x` and `y` are percentages of the picture, so a position means the same thing on a phone and a
|
||||||
|
* desktop. `size` is a percentage of the picture's WIDTH rather than pixels, for the same reason: a
|
||||||
|
* headline set at 40px is a headline on a laptop and a caption on a large screen.
|
||||||
|
*
|
||||||
|
* The preview renders that with container query units — `cqw` — against the box, which is exactly what
|
||||||
|
* the percentage means. The page will do the same, so the two cannot drift.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Overlay = {
|
||||||
|
text: string;
|
||||||
|
/** Percent of the picture. The text's own anchor corner, per `align`. */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** Percent of the picture's width. */
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
align: 'left' | 'center' | 'right';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyOverlay = (): Overlay => ({ text: '', x: 10, y: 70, size: 4, color: '#ffffff', align: 'left' });
|
||||||
|
|
||||||
|
export const OverlayEditor = ({
|
||||||
|
overlays,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
overlays: Overlay[];
|
||||||
|
onChange: (overlays: Overlay[]) => void;
|
||||||
|
/** The picture. Rendered underneath, at whatever shape its own slot decides. */
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
const box = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dragging = useRef<{ index: number; dx: number; dy: number } | null>(null);
|
||||||
|
|
||||||
|
const update = (index: number, patch: Partial<Overlay>) =>
|
||||||
|
onChange(overlays.map((overlay, i) => (i === index ? { ...overlay, ...patch } : overlay)));
|
||||||
|
|
||||||
|
const onPointerDown = (event: React.PointerEvent, index: number) => {
|
||||||
|
const rect = box.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
(event.target as Element).setPointerCapture?.(event.pointerId);
|
||||||
|
// The grab offset, so the text does not jump its own corner under the cursor on the first move.
|
||||||
|
dragging.current = {
|
||||||
|
index,
|
||||||
|
dx: ((event.clientX - rect.left) / rect.width) * 100 - overlays[index]!.x,
|
||||||
|
dy: ((event.clientY - rect.top) / rect.height) * 100 - overlays[index]!.y,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (event: React.PointerEvent) => {
|
||||||
|
const drag = dragging.current;
|
||||||
|
const rect = box.current?.getBoundingClientRect();
|
||||||
|
if (!drag || !rect) return;
|
||||||
|
update(drag.index, {
|
||||||
|
x: Math.min(100, Math.max(0, ((event.clientX - rect.left) / rect.width) * 100 - drag.dx)),
|
||||||
|
y: Math.min(100, Math.max(0, ((event.clientY - rect.top) / rect.height) * 100 - drag.dy)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/*
|
||||||
|
`@container` so the overlays can be sized in `cqw` — a percentage of THIS box rather than of the
|
||||||
|
viewport. The picture is the thing text should scale with, and the panel is not the page width.
|
||||||
|
*/}
|
||||||
|
<div ref={box} className="@container relative" onPointerMove={onPointerMove} onPointerUp={() => (dragging.current = null)}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{overlays.map((overlay, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
onPointerDown={(event) => onPointerDown(event, index)}
|
||||||
|
className="absolute cursor-grab touch-none select-none whitespace-pre-wrap active:cursor-grabbing"
|
||||||
|
style={{
|
||||||
|
left: `${overlay.x}%`,
|
||||||
|
top: `${overlay.y}%`,
|
||||||
|
// The anchor follows the alignment, so a right-aligned block grows leftwards from where
|
||||||
|
// it was put rather than sliding out of the picture.
|
||||||
|
transform: overlay.align === 'right' ? 'translateX(-100%)' : overlay.align === 'center' ? 'translateX(-50%)' : undefined,
|
||||||
|
fontSize: `${overlay.size}cqw`,
|
||||||
|
color: overlay.color,
|
||||||
|
textAlign: overlay.align,
|
||||||
|
lineHeight: 1.15,
|
||||||
|
// Only so the text stays readable while it is being placed over a busy photograph. The
|
||||||
|
// page draws it without one — this is scaffolding, not styling.
|
||||||
|
textShadow: '0 1px 3px rgba(0,0,0,.45)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{overlay.text || 'Text'}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The controls, under the picture rather than floating over it. Two blocks means two rows, and a
|
||||||
|
popover per block would hide the thing being compared. */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{overlays.map((overlay, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 rounded-md border border-border p-2">
|
||||||
|
<Input
|
||||||
|
value={overlay.text}
|
||||||
|
placeholder="Text"
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
onChange={(event) => update(index, { text: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Size as a number the person can reason about — 4 means "a twenty-fifth of the picture
|
||||||
|
wide" is not a thought anybody has, so it is presented as a plain step. */}
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button size="sm" variant="outline" className="size-7 p-0" onClick={() => update(index, { size: Math.max(0.5, Math.round((overlay.size - 0.5) * 10) / 10) })}>
|
||||||
|
−
|
||||||
|
</Button>
|
||||||
|
<span className="w-8 text-center text-xs tabular-nums text-muted-foreground">{overlay.size}</span>
|
||||||
|
<Button size="sm" variant="outline" className="size-7 p-0" onClick={() => update(index, { size: Math.min(20, Math.round((overlay.size + 0.5) * 10) / 10) })}>
|
||||||
|
+
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={overlay.align}
|
||||||
|
onChange={(event) => update(index, { align: event.currentTarget.value as Overlay['align'] })}
|
||||||
|
className="h-8 shrink-0 rounded-md border border-input bg-transparent px-2 text-xs"
|
||||||
|
>
|
||||||
|
<option value="left">Left</option>
|
||||||
|
<option value="center">Centre</option>
|
||||||
|
<option value="right">Right</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* A native colour input: it is the one control every platform already knows how to draw,
|
||||||
|
and a hand-rolled picker here would be worse on every one of them. */}
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={overlay.color}
|
||||||
|
onChange={(event) => update(index, { color: event.currentTarget.value })}
|
||||||
|
className="size-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5"
|
||||||
|
title="Colour"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => onChange(overlays.filter((_, i) => i !== index))}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="self-start"
|
||||||
|
onClick={() => onChange([...overlays, emptyOverlay()])}
|
||||||
|
disabled={overlays.length >= 8}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add text
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+30
-14
@@ -7,6 +7,7 @@ import { readableError } from '../errors';
|
|||||||
import { useFlash } from '../useFlash';
|
import { useFlash } from '../useFlash';
|
||||||
import { RichText } from '../RichText';
|
import { RichText } from '../RichText';
|
||||||
import { MediaField } from '../MediaField';
|
import { MediaField } from '../MediaField';
|
||||||
|
import { OverlayEditor, type Overlay } from '../OverlayEditor';
|
||||||
import { useSaveTarget } from '../SaveTarget';
|
import { useSaveTarget } from '../SaveTarget';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +41,14 @@ import { useSaveTarget } from '../SaveTarget';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
type About = {
|
type About = {
|
||||||
header: { url: string | null; crop: string | null; fit: string | null; phrase: string | null };
|
header: {
|
||||||
|
url: string | null;
|
||||||
|
crop: string | null;
|
||||||
|
fit: string | null;
|
||||||
|
phrase: string | null;
|
||||||
|
/** Text laid over the picture — the wireframe's two blocks. */
|
||||||
|
overlays: Overlay[];
|
||||||
|
};
|
||||||
about_text: string | null;
|
about_text: string | null;
|
||||||
product_video_url: string | null;
|
product_video_url: string | null;
|
||||||
product_text: string | null;
|
product_text: string | null;
|
||||||
@@ -56,6 +64,7 @@ export const AboutPanel = () => {
|
|||||||
const [headerUrl, setHeaderUrl] = useState('');
|
const [headerUrl, setHeaderUrl] = useState('');
|
||||||
const [phrase, setPhrase] = useState('');
|
const [phrase, setPhrase] = useState('');
|
||||||
const [headerCrop, setHeaderCrop] = useState<string | null>(null);
|
const [headerCrop, setHeaderCrop] = useState<string | null>(null);
|
||||||
|
const [overlays, setOverlays] = useState<Overlay[]>([]);
|
||||||
const [aboutText, setAboutText] = useState('');
|
const [aboutText, setAboutText] = useState('');
|
||||||
const [productVideo, setProductVideo] = useState('');
|
const [productVideo, setProductVideo] = useState('');
|
||||||
const [productText, setProductText] = useState('');
|
const [productText, setProductText] = useState('');
|
||||||
@@ -69,6 +78,7 @@ export const AboutPanel = () => {
|
|||||||
setAbout(data);
|
setAbout(data);
|
||||||
setHeaderUrl(data.header.url ?? '');
|
setHeaderUrl(data.header.url ?? '');
|
||||||
setHeaderCrop(data.header.crop);
|
setHeaderCrop(data.header.crop);
|
||||||
|
setOverlays(data.header.overlays ?? []);
|
||||||
setPhrase(data.header.phrase ?? '');
|
setPhrase(data.header.phrase ?? '');
|
||||||
setAboutText(data.about_text ?? '');
|
setAboutText(data.about_text ?? '');
|
||||||
setProductVideo(data.product_video_url ?? '');
|
setProductVideo(data.product_video_url ?? '');
|
||||||
@@ -95,6 +105,7 @@ export const AboutPanel = () => {
|
|||||||
const edited = {
|
const edited = {
|
||||||
header_url: orNull(headerUrl),
|
header_url: orNull(headerUrl),
|
||||||
header_crop: headerCrop,
|
header_crop: headerCrop,
|
||||||
|
header_overlays: overlays,
|
||||||
header_phrase: orNull(phrase),
|
header_phrase: orNull(phrase),
|
||||||
about_text: orNull(aboutText),
|
about_text: orNull(aboutText),
|
||||||
product_video_url: orNull(productVideo),
|
product_video_url: orNull(productVideo),
|
||||||
@@ -105,6 +116,7 @@ export const AboutPanel = () => {
|
|||||||
const stored = about && {
|
const stored = about && {
|
||||||
header_url: about.header.url,
|
header_url: about.header.url,
|
||||||
header_crop: about.header.crop,
|
header_crop: about.header.crop,
|
||||||
|
header_overlays: about.header.overlays ?? [],
|
||||||
header_phrase: about.header.phrase,
|
header_phrase: about.header.phrase,
|
||||||
about_text: about.about_text,
|
about_text: about.about_text,
|
||||||
product_video_url: about.product_video_url,
|
product_video_url: about.product_video_url,
|
||||||
@@ -148,19 +160,23 @@ export const AboutPanel = () => {
|
|||||||
{/* 21/9 because that is what the page uses — `aspect-[21/9]` in about/partials.tsx. The two
|
{/* 21/9 because that is what the page uses — `aspect-[21/9]` in about/partials.tsx. The two
|
||||||
have to agree or the admin shows a shape the site does not have, which is worse than showing
|
have to agree or the admin shows a shape the site does not have, which is worse than showing
|
||||||
no shape at all: it looks decided. */}
|
no shape at all: it looks decided. */}
|
||||||
<MediaField
|
{/* The picture is the editor's canvas: the overlays are positioned against the same box the
|
||||||
value={headerUrl || null}
|
page draws, so what is arranged here is what is published. */}
|
||||||
onChange={(path) => {
|
<OverlayEditor overlays={overlays} onChange={setOverlays}>
|
||||||
setHeaderUrl(path ?? '');
|
<MediaField
|
||||||
// A framing belongs to the picture it was made for. Keeping it across a change would apply
|
value={headerUrl || null}
|
||||||
// one photograph's decision to another one's composition.
|
onChange={(path) => {
|
||||||
setHeaderCrop(null);
|
setHeaderUrl(path ?? '');
|
||||||
}}
|
// A framing belongs to the picture it was made for. Keeping it across a change would
|
||||||
crop={headerCrop}
|
// apply one photograph's decision to another one's composition.
|
||||||
onCropChange={setHeaderCrop}
|
setHeaderCrop(null);
|
||||||
aspect="21/9"
|
}}
|
||||||
label="header photograph"
|
crop={headerCrop}
|
||||||
/>
|
onCropChange={setHeaderCrop}
|
||||||
|
aspect="21/9"
|
||||||
|
label="header photograph"
|
||||||
|
/>
|
||||||
|
</OverlayEditor>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-muted-foreground">Phrase</label>
|
<label className="text-xs text-muted-foreground">Phrase</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user