import { useEffect, useRef, useState } from 'react'; import { AlignCenter, AlignLeft, AlignRight, Pencil, Plus, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; /** * 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. * * ── The controls belong to the block, not to a list under the picture ── * * They were a row per overlay below the canvas. Two problems, and the second one is the real one. * * A row is a second place to look: the block is up here and the thing that changes it is down there, * so every adjustment is a glance each way. And the row sat at the right-hand edge of a narrow panel, * which is where `` opened its picker off the side of the window — unreachable, and * unreachable in a way no CSS can fix, because the browser's picker is not in the document. * * Pinned to the frame instead, the native picker opens in the middle of the panel where there is room * for it. That is worth more than any palette we could draw: it is the full colour space, with an * eyedropper, already known to whoever is using it. */ 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' }); /** * The text itself, typed where it will appear. * * UNCONTROLLED, deliberately. A `contentEditable` whose children React re-renders from state loses the * caret to the end of the text on every keystroke, because React replaces the text node the caret is * standing in. So the DOM owns the text while it is being typed, and the effect below only writes back * when the two have genuinely diverged — a load, or an undo from somewhere else. Typing never trips it, * because by then the DOM already says what the state says. */ const EditableText = ({ text, editing, onText, onDone, }: { text: string; editing: boolean; onText: (text: string) => void; onDone: () => void; }) => { const el = useRef(null); useEffect(() => { if (el.current && el.current.textContent !== text) el.current.textContent = text; }, [text]); // Focus on the way in, and put the caret at the end rather than at the start — the common intent is // to carry on writing. useEffect(() => { if (!editing || !el.current) return; el.current.focus(); const range = document.createRange(); range.selectNodeContents(el.current); range.collapse(false); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); }, [editing]); return ( onText(event.currentTarget.textContent ?? '')} onBlur={onDone} onKeyDown={(event) => { // Enter would insert a
or a
, neither of which survives `textContent`, so a line // break typed here would vanish on save. Breaks come from the box's width instead — see `w`. if (event.key === 'Enter') event.preventDefault(); if (event.key === 'Escape') { event.preventDefault(); el.current?.blur(); } }} // Paste as text. The clipboard usually carries markup, and a contentEditable takes it whole — // fonts, colours and all — none of which this can store or the page can draw. onPaste={(event) => { event.preventDefault(); const plain = event.clipboardData.getData('text/plain').replace(/\s+/g, ' '); document.execCommand('insertText', false, plain); }} /> ); }; 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); /** Which block is taking keystrokes. At most one, and never the same one that is being dragged. */ const [editing, setEditing] = useState(null); const update = (index: number, patch: Partial) => onChange(overlays.map((overlay, i) => (i === index ? { ...overlay, ...patch } : overlay))); const remove = (index: number) => { setSelected(null); setEditing(null); onChange(overlays.filter((_, i) => i !== index)); }; const add = () => { onChange([...overlays, emptyOverlay()]); // Selected AND editing: a block added and left alone reads as "Text" on the photograph, which is // indistinguishable from one somebody forgot to fill in. setSelected(overlays.length); setEditing(overlays.length); }; const onPointerDown = (event: React.PointerEvent, index: number) => { const rect = box.current?.getBoundingClientRect(); if (!rect) return; event.stopPropagation(); // While editing, a press is the caret being placed. Dragging the block out from under the cursor // mid-sentence is the opposite of what was asked for, so the gesture is simply not started. if (editing === index) return; event.preventDefault(); (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; }; const ALIGNMENTS = [ { value: 'left' as const, Icon: AlignLeft, label: 'Left' }, { value: 'center' as const, Icon: AlignCenter, label: 'Centre' }, { value: 'right' as const, Icon: AlignRight, label: 'Right' }, ]; 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); setEditing(null); }} > {children} {overlays.map((overlay, index) => (
{ setSelected(index); if (editing !== index) setEditing(null); onPointerDown(event, index); }} onDoubleClick={() => setEditing(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 what the toolbar is attached to. */ className={`absolute touch-none whitespace-pre-wrap ${ editing === index ? 'cursor-text' : 'cursor-grab 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)', }} > {/* An empty block still has to be findable. Shown only when it is not being typed into, so the placeholder is never something to delete before writing. */} {overlay.text || editing === index ? ( update(index, { text })} onDone={() => setEditing(null)} /> ) : ( 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' }`} >
)} {selected === index && (
event.stopPropagation()} >
{/* Size as a plain step. "4" means a twenty-fifth of the picture's width, which is not a thought anybody has — so it is shown as a number that goes up and down. */} {overlay.size}
{ALIGNMENTS.map(({ value, Icon, label }) => ( ))}
{/* The browser's own picker, and the reason the toolbar is here at all. It is the whole colour space with an eyedropper in it, which no palette we drew came close to. It only ever failed because of WHERE it was: at the right-hand edge of the panel its popup opened off the side of the window. In the middle of the picture there is room for it. */} update(index, { color: event.currentTarget.value })} className="size-7 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5" title="Colour" />
)}
))}
); };