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(null); const dragging = useRef<{ index: number; dx: number; dy: number } | null>(null); const update = (index: number, patch: Partial) => 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 (
{/* `@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. */}
(dragging.current = null)}> {children} {overlays.map((overlay, index) => (
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'}
))}
{/* 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. */}
{overlays.map((overlay, index) => (
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. */}
{overlay.size}
{/* 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. */} update(index, { color: event.currentTarget.value })} className="size-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5" title="Colour" />
))}
); };