a selected overlay gets a frame and a resize handle

Clicking a block selects it: an outline appears, its row below highlights with it, and a handle on the
edge it grows from resizes the box. Clicking the picture deselects, which is how somebody says "none of
them" without hunting for a button.

Only the selected one is outlined. Every block framed at once turns the picture into a wireframe and
hides the thing actually being judged, which is how the text looks ON the photograph.

The block now has a width, and that is what fixes the dragging. Without one it was as wide as its
longest line, so it changed shape as it was typed and there was nothing stable to take hold of. Text
wraps inside the box now, so a line break is a decision rather than an accident of the current text.

The handle follows the alignment — on the left for a right-aligned block — because it belongs on the
edge the box grows from, and dragging it inwards would otherwise shrink from the wrong side. Its grab
area is four times the visible bar: a 3px target is a target nobody hits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 00:26:17 +01:00
co-authored by Claude Opus 5
parent d1e79d95db
commit b3032093fe
+90 -8
View File
@@ -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<HTMLDivElement | null>(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<number | null>(null);
const update = (index: number, patch: Partial<Overlay>) =>
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 (
<div className="flex flex-col gap-3">
{/*
`@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.
*/}
<div ref={box} className="@container relative" onPointerMove={onPointerMove} onPointerUp={() => (dragging.current = null)}>
<div
ref={box}
className="@container relative"
onPointerMove={onPointerMove}
onPointerUp={endGesture}
onPointerCancel={endGesture}
// A press that reaches the picture is a press that missed every block, which is how somebody
// says "none of them" without hunting for a Deselect.
onPointerDown={() => setSelected(null)}
>
{children}
{overlays.map((overlay, index) => (
<div
key={index}
onPointerDown={(event) => 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 && (
<div
onPointerDown={(event) => 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'
}`}
>
<div className="h-8 w-1 rounded bg-primary" />
</div>
)}
</div>
))}
</div>
@@ -113,7 +189,13 @@ export const OverlayEditor = ({
popover per block would hide the thing being compared. */}
<div className="flex flex-col gap-2">
{overlays.map((overlay, index) => (
<div key={index} className="flex items-center gap-2 rounded-md border border-border p-2">
<div
key={index}
onPointerDown={() => setSelected(index)}
className={`flex items-center gap-2 rounded-md border p-2 ${
selected === index ? 'border-primary' : 'border-border'
}`}
>
<Input
value={overlay.text}
placeholder="Text"