import { useRef, useState } 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; /** * 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(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(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 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 (
{/* `@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. */}
setSelected(null)} > {children} {overlays.map((overlay, index) => (
{ 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 && (
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' }`} >
)}
))}
{/* 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) => (
setSelected(index)} className={`flex items-center gap-2 rounded-md border p-2 ${ selected === index ? 'border-primary' : 'border-border' }`} > 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" />
))}
); };