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>
182 lines
7.5 KiB
TypeScript
182 lines
7.5 KiB
TypeScript
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>
|
||
);
|
||
};
|