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 { RichText } from '../RichText';
|
||||
import { MediaField } from '../MediaField';
|
||||
import { OverlayEditor, type Overlay } from '../OverlayEditor';
|
||||
import { useSaveTarget } from '../SaveTarget';
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,14 @@ import { useSaveTarget } from '../SaveTarget';
|
||||
*/
|
||||
|
||||
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;
|
||||
product_video_url: string | null;
|
||||
product_text: string | null;
|
||||
@@ -56,6 +64,7 @@ export const AboutPanel = () => {
|
||||
const [headerUrl, setHeaderUrl] = useState('');
|
||||
const [phrase, setPhrase] = useState('');
|
||||
const [headerCrop, setHeaderCrop] = useState<string | null>(null);
|
||||
const [overlays, setOverlays] = useState<Overlay[]>([]);
|
||||
const [aboutText, setAboutText] = useState('');
|
||||
const [productVideo, setProductVideo] = useState('');
|
||||
const [productText, setProductText] = useState('');
|
||||
@@ -69,6 +78,7 @@ export const AboutPanel = () => {
|
||||
setAbout(data);
|
||||
setHeaderUrl(data.header.url ?? '');
|
||||
setHeaderCrop(data.header.crop);
|
||||
setOverlays(data.header.overlays ?? []);
|
||||
setPhrase(data.header.phrase ?? '');
|
||||
setAboutText(data.about_text ?? '');
|
||||
setProductVideo(data.product_video_url ?? '');
|
||||
@@ -95,6 +105,7 @@ export const AboutPanel = () => {
|
||||
const edited = {
|
||||
header_url: orNull(headerUrl),
|
||||
header_crop: headerCrop,
|
||||
header_overlays: overlays,
|
||||
header_phrase: orNull(phrase),
|
||||
about_text: orNull(aboutText),
|
||||
product_video_url: orNull(productVideo),
|
||||
@@ -105,6 +116,7 @@ export const AboutPanel = () => {
|
||||
const stored = about && {
|
||||
header_url: about.header.url,
|
||||
header_crop: about.header.crop,
|
||||
header_overlays: about.header.overlays ?? [],
|
||||
header_phrase: about.header.phrase,
|
||||
about_text: about.about_text,
|
||||
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
|
||||
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. */}
|
||||
<MediaField
|
||||
value={headerUrl || null}
|
||||
onChange={(path) => {
|
||||
setHeaderUrl(path ?? '');
|
||||
// A framing belongs to the picture it was made for. Keeping it across a change would apply
|
||||
// one photograph's decision to another one's composition.
|
||||
setHeaderCrop(null);
|
||||
}}
|
||||
crop={headerCrop}
|
||||
onCropChange={setHeaderCrop}
|
||||
aspect="21/9"
|
||||
label="header photograph"
|
||||
/>
|
||||
{/* The picture is the editor's canvas: the overlays are positioned against the same box the
|
||||
page draws, so what is arranged here is what is published. */}
|
||||
<OverlayEditor overlays={overlays} onChange={setOverlays}>
|
||||
<MediaField
|
||||
value={headerUrl || null}
|
||||
onChange={(path) => {
|
||||
setHeaderUrl(path ?? '');
|
||||
// A framing belongs to the picture it was made for. Keeping it across a change would
|
||||
// apply one photograph's decision to another one's composition.
|
||||
setHeaderCrop(null);
|
||||
}}
|
||||
crop={headerCrop}
|
||||
onCropChange={setHeaderCrop}
|
||||
aspect="21/9"
|
||||
label="header photograph"
|
||||
/>
|
||||
</OverlayEditor>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-muted-foreground">Phrase</label>
|
||||
|
||||
Reference in New Issue
Block a user