diff --git a/web/OverlayEditor.tsx b/web/OverlayEditor.tsx new file mode 100644 index 0000000..3c34401 --- /dev/null +++ b/web/OverlayEditor.tsx @@ -0,0 +1,181 @@ +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" + /> + + +
+ ))} + + +
+
+ ); +}; diff --git a/web/sections/AboutPanel.tsx b/web/sections/AboutPanel.tsx index 6846c9f..5806bf6 100644 --- a/web/sections/AboutPanel.tsx +++ b/web/sections/AboutPanel.tsx @@ -7,6 +7,7 @@ import { readableError } from '../errors'; import { useFlash } from '../useFlash'; import { RichText } from '../RichText'; import { MediaField } from '../MediaField'; +import { OverlayEditor, type Overlay } from '../OverlayEditor'; import { useSaveTarget } from '../SaveTarget'; /** @@ -40,7 +41,14 @@ import { useSaveTarget } from '../SaveTarget'; */ type About = { - header: { url: string | null; crop: string | null; fit: string | null; phrase: string | null }; + header: { + url: string | null; + crop: string | null; + fit: string | null; + phrase: string | null; + /** Text laid over the picture — the wireframe's two blocks. */ + overlays: Overlay[]; + }; about_text: string | null; product_video_url: string | null; product_text: string | null; @@ -56,6 +64,7 @@ export const AboutPanel = () => { const [headerUrl, setHeaderUrl] = useState(''); const [phrase, setPhrase] = useState(''); const [headerCrop, setHeaderCrop] = useState(null); + const [overlays, setOverlays] = useState([]); const [aboutText, setAboutText] = useState(''); const [productVideo, setProductVideo] = useState(''); const [productText, setProductText] = useState(''); @@ -69,6 +78,7 @@ export const AboutPanel = () => { setAbout(data); setHeaderUrl(data.header.url ?? ''); setHeaderCrop(data.header.crop); + setOverlays(data.header.overlays ?? []); setPhrase(data.header.phrase ?? ''); setAboutText(data.about_text ?? ''); setProductVideo(data.product_video_url ?? ''); @@ -95,6 +105,7 @@ export const AboutPanel = () => { const edited = { header_url: orNull(headerUrl), header_crop: headerCrop, + header_overlays: overlays, header_phrase: orNull(phrase), about_text: orNull(aboutText), product_video_url: orNull(productVideo), @@ -105,6 +116,7 @@ export const AboutPanel = () => { const stored = about && { header_url: about.header.url, header_crop: about.header.crop, + header_overlays: about.header.overlays ?? [], header_phrase: about.header.phrase, about_text: about.about_text, product_video_url: about.product_video_url, @@ -148,19 +160,23 @@ export const AboutPanel = () => { {/* 21/9 because that is what the page uses — `aspect-[21/9]` in about/partials.tsx. The two have to agree or the admin shows a shape the site does not have, which is worse than showing no shape at all: it looks decided. */} - { - setHeaderUrl(path ?? ''); - // A framing belongs to the picture it was made for. Keeping it across a change would apply - // one photograph's decision to another one's composition. - setHeaderCrop(null); - }} - crop={headerCrop} - onCropChange={setHeaderCrop} - aspect="21/9" - label="header photograph" - /> + {/* The picture is the editor's canvas: the overlays are positioned against the same box the + page draws, so what is arranged here is what is published. */} + + { + setHeaderUrl(path ?? ''); + // A framing belongs to the picture it was made for. Keeping it across a change would + // apply one photograph's decision to another one's composition. + setHeaderCrop(null); + }} + crop={headerCrop} + onCropChange={setHeaderCrop} + aspect="21/9" + label="header photograph" + /> +