diff --git a/web/OverlayEditor.tsx b/web/OverlayEditor.tsx index 3c34401..488e8f4 100644 --- a/web/OverlayEditor.tsx +++ b/web/OverlayEditor.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import { Plus, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -27,13 +27,24 @@ export type Overlay = { /** 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, size: 4, color: '#ffffff', align: 'left' }); +export const emptyOverlay = (): Overlay => ({ text: '', x: 10, y: 70, w: 40, size: 4, color: '#ffffff', align: 'left' }); export const OverlayEditor = ({ overlays, @@ -47,6 +58,9 @@ export const OverlayEditor = ({ }) => { 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))); @@ -65,9 +79,29 @@ export const OverlayEditor = ({ }; }; - const onPointerMove = (event: React.PointerEvent) => { - const drag = dragging.current; + 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)), @@ -75,23 +109,52 @@ export const OverlayEditor = ({ }); }; + 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. */} -
(dragging.current = null)}> +
setSelected(null)} + > {children} {overlays.map((overlay, index) => (
onPointerDown(event, index)} - className="absolute cursor-grab touch-none select-none whitespace-pre-wrap active:cursor-grabbing" + 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, @@ -105,6 +168,19 @@ export const OverlayEditor = ({ }} > {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' + }`} + > +
+
+ )}
))}
@@ -113,7 +189,13 @@ export const OverlayEditor = ({ 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' + }`} + >