Files
fofos-admin/web/OverlayEditor.tsx
T
pastilhasandClaude Opus 5 e0a5f1b653 the colour picker stays inside the panel
It was `<input type="color">`, and the browser's own picker is a window-level popup positioned by the
browser. In a panel near the right edge it opened off the side of the window and could not be reached —
and nothing in CSS can move it, because it is not in the document.

Swatches and a hex field instead, in a panel anchored to the RIGHT of its trigger so it grows inward
and cannot leave the screen the same way.

Deliberately not a gradient area and a hue strip. That is what the native control does well, and
rebuilding it badly to fix a positioning problem would be a worse control than the one being replaced.
Text over a photograph is white or black almost every time; the hex field is there for the times it is
not.

Closes on `pointerdown` anywhere else rather than `click`, so it closes on the way down and a press
meant for something behind it does not land twice. Captured, because a control below may stop
propagation for its own reasons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 00:39:12 +01:00

259 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ColorField } from './ColorField';
/**
* 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;
/**
* The BOX's width, as a percent of the picture. Text wraps inside it.
*
* Without one a block is as wide as its longest line, so it changes shape as it is typed and there
* is nothing stable to drag — which is what made moving one feel like it was fighting back. A width
* also means a line break is a decision rather than an accident of the current text.
*
* Height is not stored. It is whatever the wrapped text needs, and a box that could be shorter than
* its own words would only ever clip them.
*/
w: number;
/** Percent of the picture's width. */
size: number;
color: string;
align: 'left' | 'center' | 'right';
};
export const emptyOverlay = (): Overlay => ({ text: '', x: 10, y: 70, w: 40, 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);
/** Resizing the selected block's width. Separate from a move, because the two start the same way. */
const resizing = useRef<{ index: number; startX: number; startW: number } | null>(null);
const [selected, setSelected] = useState<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 onResizeDown = (event: React.PointerEvent, index: number) => {
const rect = box.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
event.stopPropagation();
(event.target as Element).setPointerCapture?.(event.pointerId);
resizing.current = { index, startX: event.clientX, startW: overlays[index]!.w };
};
const onPointerMove = (event: React.PointerEvent) => {
const rect = box.current?.getBoundingClientRect();
const resize = resizing.current;
if (resize && rect) {
// The handle is on the trailing edge, so for a right-aligned block dragging left widens it.
const direction = overlays[resize.index]!.align === 'right' ? -1 : 1;
const delta = ((event.clientX - resize.startX) / rect.width) * 100 * direction;
// 5% is about a word at any sensible size; 100% is the whole picture and there is no more.
update(resize.index, { w: Math.min(100, Math.max(5, Math.round((resize.startW + delta) * 10) / 10)) });
return;
}
const drag = dragging.current;
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)),
});
};
const endGesture = () => {
dragging.current = null;
resizing.current = null;
};
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={endGesture}
onPointerCancel={endGesture}
// A press that reaches the picture is a press that missed every block, which is how somebody
// says "none of them" without hunting for a Deselect.
onPointerDown={() => setSelected(null)}
>
{children}
{overlays.map((overlay, index) => (
<div
key={index}
onPointerDown={(event) => {
setSelected(index);
onPointerDown(event, index);
}}
/*
A frame, but only on the selected one.
Every block outlined at once turns the picture into a wireframe and hides the thing being
judged — which is how the text looks ON the photograph. Selection is a click, and the
frame is how you know which block the controls below belong to.
*/
className={`absolute cursor-grab touch-none whitespace-pre-wrap active:cursor-grabbing ${
selected === index ? 'outline outline-1 outline-offset-4 outline-primary' : ''
}`}
style={{
left: `${overlay.x}%`,
top: `${overlay.y}%`,
// The box, not the text. Wrapping inside a known width is what makes a block a stable
// thing to drag — see the note on `w`.
width: `${overlay.w}%`,
// 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'}
{/* The handle, on the edge the block grows from. Bigger than it looks — a 3px target is a
target nobody hits, so the visible bar sits inside a wider grab area. */}
{selected === index && (
<div
onPointerDown={(event) => onResizeDown(event, index)}
className={`absolute inset-y-0 flex w-4 cursor-ew-resize items-center ${
overlay.align === 'right' ? '-left-4 justify-start' : '-right-4 justify-end'
}`}
>
<div className="h-8 w-1 rounded bg-primary" />
</div>
)}
</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}
onPointerDown={() => setSelected(index)}
className={`flex items-center gap-2 rounded-md border p-2 ${
selected === index ? 'border-primary' : 'border-border'
}`}
>
<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>
{/* Not `<input type="color">`. The browser's picker is a window-level popup and opened off
the side of the window in a panel near the right edge — see ColorField. */}
<ColorField value={overlay.color} onChange={(color) => update(index, { color })} />
<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>
);
};