the controls live on the block, and the picker is native again

The palette was not enough colour, and it was never going to be. What it replaced — the browser's own
picker — is the full colour space with an eyedropper in it, and it only ever failed because of WHERE it
was: at the right-hand edge of a narrow panel, where its popup opened off the side of the window.

So the controls move to the block instead of the picker being rebuilt. A toolbar pinned to the selection
frame, in the middle of the picture, where the native popup has room. Above the frame, or below it when
the block sits near the top edge — a toolbar drawn off the top is the same unreachable control again.

Everything is on it now: size, alignment as three buttons rather than a select, colour, and delete. The
row per overlay under the canvas is gone. It was a second place to look — the block up here, the thing
that changes it down there — and every adjustment cost a glance each way.

The text is typed where it appears. Double-click, or the pencil. The contentEditable is uncontrolled on
purpose: re-rendering its children from state replaces the text node the caret stands in, so the caret
jumps to the end on every keystroke. The DOM owns the text while it is being typed and the effect only
writes back when the two have genuinely diverged.

Three things that follow from editing in place. A press on a block being edited places the caret and
does not start a drag. Enter is prevented, because a <br> does not survive textContent and the break
would vanish on save — width decides the breaks, as it already did. Paste is forced to plain text,
because a contentEditable takes markup whole and none of it can be stored or drawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 00:56:27 +01:00
co-authored by Claude Opus 5
parent e0a5f1b653
commit 671097877e
2 changed files with 218 additions and 193 deletions
-115
View File
@@ -1,115 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Input } from '@/components/ui/input';
/**
* A colour, chosen without leaving the panel.
*
* ── Why not `<input type="color">` ──
*
* It was one, and the browser's own picker is a window-level popup positioned by the browser. In a
* panel near the right edge it opened off the side of the window and was simply unreachable — nothing
* in CSS can move it, because it is not in the document.
*
* ── What replaces it ──
*
* Swatches and a hex field, in a panel anchored to the RIGHT of its trigger so it grows inward. Text
* over a photograph is white or black almost every time; the field is there for the times it is not.
*
* Deliberately not a gradient area and a hue strip. That is what the native control already does well,
* and rebuilding it badly to fix a positioning problem would be a worse control than the one being
* replaced.
*/
/** Enough to cover a photograph, and short enough to read as choices rather than as a palette. */
const PRESETS = [
'#ffffff',
'#f5f5f5',
'#cccccc',
'#888888',
'#333333',
'#000000',
'#f0c808',
'#e05a3a',
];
export const ColorField = ({ value, onChange }: { value: string; onChange: (color: string) => void }) => {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value);
const wrapper = useRef<HTMLDivElement | null>(null);
// Follows the value when it changes elsewhere — a different block being selected, or a load.
useEffect(() => setDraft(value), [value]);
/*
Close on a press anywhere else.
`pointerdown` rather than `click`, so it closes on the way down and a press meant for something
behind the panel does not land twice. Capture, because a control inside a panel below may stop
propagation for its own reasons.
*/
useEffect(() => {
if (!open) return;
const onDown = (event: PointerEvent) => {
if (!wrapper.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', onDown, true);
return () => document.removeEventListener('pointerdown', onDown, true);
}, [open]);
const commit = (next: string) => {
const trimmed = next.trim();
const full = /^#?[0-9a-fA-F]{6}$/.test(trimmed) ? `#${trimmed.replace('#', '')}`.toLowerCase() : null;
if (full) onChange(full);
};
return (
<div ref={wrapper} className="relative shrink-0">
<button
type="button"
onClick={() => setOpen((was) => !was)}
className="size-8 rounded border border-input p-0.5"
title="Colour"
>
{/* The swatch is the whole control's state. A checkered backdrop is not needed — these are
always opaque, because text with an alpha over a photograph is unreadable by definition. */}
<span className="block size-full rounded-sm" style={{ backgroundColor: value }} />
</button>
{open && (
<div className="absolute top-9 right-0 z-50 w-52 rounded-md border border-border bg-background p-2 shadow-lg">
<div className="grid grid-cols-8 gap-1">
{PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => {
onChange(preset);
setOpen(false);
}}
className={`size-5 rounded border ${
value.toLowerCase() === preset ? 'border-primary ring-1 ring-primary' : 'border-border'
}`}
style={{ backgroundColor: preset }}
title={preset}
/>
))}
</div>
<Input
value={draft}
onChange={(event) => setDraft(event.currentTarget.value)}
onBlur={() => commit(draft)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
commit(draft);
setOpen(false);
}
}}
placeholder="#ffffff"
className="mt-2 h-7 font-mono text-xs"
/>
</div>
)}
</div>
);
};
+198 -58
View File
@@ -1,8 +1,6 @@
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react'; import { AlignCenter, AlignLeft, AlignRight, Pencil, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ColorField } from './ColorField';
/** /**
* Text laid over a picture, positioned by dragging it. * Text laid over a picture, positioned by dragging it.
@@ -21,6 +19,19 @@ import { ColorField } from './ColorField';
* *
* The preview renders that with container query units — `cqw` — against the box, which is exactly what * 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 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 `<input type="color">` 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 = { export type Overlay = {
@@ -47,6 +58,74 @@ export type Overlay = {
export const emptyOverlay = (): Overlay => ({ text: '', x: 10, y: 70, w: 40, size: 4, color: '#ffffff', align: 'left' }); 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<HTMLSpanElement | null>(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 (
<span
ref={el}
contentEditable={editing}
suppressContentEditableWarning
className={editing ? 'outline-none' : ''}
style={{ cursor: editing ? 'text' : undefined, display: 'block', minHeight: '1em' }}
onInput={(event) => onText(event.currentTarget.textContent ?? '')}
onBlur={onDone}
onKeyDown={(event) => {
// Enter would insert a <br> or a <div>, 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 = ({ export const OverlayEditor = ({
overlays, overlays,
onChange, onChange,
@@ -62,15 +141,36 @@ export const OverlayEditor = ({
/** Resizing the selected block's width. Separate from a move, because the two start the same way. */ /** 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 resizing = useRef<{ index: number; startX: number; startW: number } | null>(null);
const [selected, setSelected] = useState<number | null>(null); const [selected, setSelected] = useState<number | null>(null);
/** Which block is taking keystrokes. At most one, and never the same one that is being dragged. */
const [editing, setEditing] = useState<number | null>(null);
const update = (index: number, patch: Partial<Overlay>) => const update = (index: number, patch: Partial<Overlay>) =>
onChange(overlays.map((overlay, i) => (i === index ? { ...overlay, ...patch } : overlay))); 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 onPointerDown = (event: React.PointerEvent, index: number) => {
const rect = box.current?.getBoundingClientRect(); const rect = box.current?.getBoundingClientRect();
if (!rect) return; if (!rect) return;
event.preventDefault();
event.stopPropagation(); 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); (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. // The grab offset, so the text does not jump its own corner under the cursor on the first move.
dragging.current = { dragging.current = {
@@ -115,6 +215,12 @@ export const OverlayEditor = ({
resizing.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 ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* {/*
@@ -129,7 +235,10 @@ export const OverlayEditor = ({
onPointerCancel={endGesture} onPointerCancel={endGesture}
// A press that reaches the picture is a press that missed every block, which is how somebody // 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. // says "none of them" without hunting for a Deselect.
onPointerDown={() => setSelected(null)} onPointerDown={() => {
setSelected(null);
setEditing(null);
}}
> >
{children} {children}
@@ -138,18 +247,20 @@ export const OverlayEditor = ({
key={index} key={index}
onPointerDown={(event) => { onPointerDown={(event) => {
setSelected(index); setSelected(index);
if (editing !== index) setEditing(null);
onPointerDown(event, index); onPointerDown(event, index);
}} }}
onDoubleClick={() => setEditing(index)}
/* /*
A frame, but only on the selected one. A frame, but only on the selected one.
Every block outlined at once turns the picture into a wireframe and hides the thing being 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 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. frame is what the toolbar is attached to.
*/ */
className={`absolute cursor-grab touch-none whitespace-pre-wrap active:cursor-grabbing ${ className={`absolute touch-none whitespace-pre-wrap ${
selected === index ? 'outline outline-1 outline-offset-4 outline-primary' : '' editing === index ? 'cursor-text' : 'cursor-grab active:cursor-grabbing'
}`} } ${selected === index ? 'outline outline-1 outline-offset-4 outline-primary' : ''}`}
style={{ style={{
left: `${overlay.x}%`, left: `${overlay.x}%`,
top: `${overlay.y}%`, top: `${overlay.y}%`,
@@ -168,7 +279,18 @@ export const OverlayEditor = ({
textShadow: '0 1px 3px rgba(0,0,0,.45)', textShadow: '0 1px 3px rgba(0,0,0,.45)',
}} }}
> >
{overlay.text || 'Text'} {/* 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 ? (
<EditableText
text={overlay.text}
editing={editing === index}
onText={(text) => update(index, { text })}
onDone={() => setEditing(null)}
/>
) : (
<span style={{ opacity: 0.6 }}>Text</span>
)}
{/* The handle, on the edge the block grows from. Bigger than it looks — a 3px target is a {/* 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. */} target nobody hits, so the visible bar sits inside a wider grab area. */}
@@ -182,77 +304,95 @@ export const OverlayEditor = ({
<div className="h-8 w-1 rounded bg-primary" /> <div className="h-8 w-1 rounded bg-primary" />
</div> </div>
)} )}
</div>
))}
</div>
{/* The controls, under the picture rather than floating over it. Two blocks means two rows, and a {selected === index && (
popover per block would hide the thing being compared. */}
<div className="flex flex-col gap-2">
{overlays.map((overlay, index) => (
<div <div
key={index} /*
onPointerDown={() => setSelected(index)} Above the block, unless the block is near the top of the picture — a toolbar drawn off
className={`flex items-center gap-2 rounded-md border p-2 ${ the top edge is the same unreachable control this whole change is about.
selected === index ? 'border-primary' : 'border-border'
}`}
>
<Input
value={overlay.text}
placeholder="Text"
className="min-w-0 flex-1"
onChange={(event) => update(index, { text: event.currentTarget.value })}
/>
{/* Size as a number the person can reason about — 4 means "a twenty-fifth of the picture Every inherited text property is reset. It is a child of a block set to `4cqw` in the
wide" is not a thought anybody has, so it is presented as a plain step. */} overlay's own colour with a shadow under it, and a toolbar that inherited those would
<div className="flex shrink-0 items-center gap-1"> be enormous, white and blurred.
<Button size="sm" variant="outline" className="size-7 p-0" onClick={() => update(index, { size: Math.max(0.5, Math.round((overlay.size - 0.5) * 10) / 10) })}> */
className={`absolute left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-md border border-border bg-background p-1 shadow-lg ${
overlay.y < 15 ? 'top-full mt-3' : 'bottom-full mb-3'
}`}
style={{ fontSize: '12px', lineHeight: 1.2, color: 'var(--foreground)', textShadow: 'none', textAlign: 'left' }}
// The toolbar is not part of the picture: a press here must not start a drag, deselect,
// or leave the field being edited.
onPointerDown={(event) => event.stopPropagation()}
>
<Button size="sm" variant="ghost" className="size-7 shrink-0 p-0" title="Edit text" onClick={() => setEditing(index)}>
<Pencil className="size-3.5" />
</Button>
<div className="mx-0.5 h-5 w-px bg-border" />
{/* 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. */}
<Button size="sm" variant="ghost" className="size-7 shrink-0 p-0" title="Smaller" onClick={() => update(index, { size: Math.max(0.5, Math.round((overlay.size - 0.5) * 10) / 10) })}>
− −
</Button> </Button>
<span className="w-8 text-center text-xs tabular-nums text-muted-foreground">{overlay.size}</span> <span className="w-6 shrink-0 text-center text-xs tabular-nums text-muted-foreground">{overlay.size}</span>
<Button size="sm" variant="outline" className="size-7 p-0" onClick={() => update(index, { size: Math.min(20, Math.round((overlay.size + 0.5) * 10) / 10) })}> <Button size="sm" variant="ghost" className="size-7 shrink-0 p-0" title="Larger" onClick={() => update(index, { size: Math.min(20, Math.round((overlay.size + 0.5) * 10) / 10) })}>
+ +
</Button> </Button>
</div>
<select <div className="mx-0.5 h-5 w-px bg-border" />
value={overlay.align}
onChange={(event) => update(index, { align: event.currentTarget.value as Overlay['align'] })} {ALIGNMENTS.map(({ value, Icon, label }) => (
className="h-8 shrink-0 rounded-md border border-input bg-transparent px-2 text-xs" <Button
key={value}
size="sm"
variant={overlay.align === value ? 'secondary' : 'ghost'}
className="size-7 shrink-0 p-0"
title={label}
onClick={() => update(index, { align: value })}
> >
<option value="left">Left</option> <Icon className="size-3.5" />
<option value="center">Centre</option> </Button>
<option value="right">Right</option> ))}
</select>
{/* Not `<input type="color">`. The browser's picker is a window-level popup and opened off <div className="mx-0.5 h-5 w-px bg-border" />
the side of the window in a panel near the right edge — see ColorField. */}
<ColorField value={overlay.color} onChange={(color) => update(index, { color })} /> {/*
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.
*/}
<input
type="color"
value={overlay.color}
onChange={(event) => update(index, { color: event.currentTarget.value })}
className="size-7 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5"
title="Colour"
/>
<div className="mx-0.5 h-5 w-px bg-border" />
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive" className="size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive"
onClick={() => onChange(overlays.filter((_, i) => i !== index))} title="Delete this text"
title="Remove" onClick={() => remove(index)}
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
</div> </div>
)}
</div>
))} ))}
</div>
<Button <Button size="sm" variant="outline" className="self-start" onClick={add} disabled={overlays.length >= 8}>
size="sm"
variant="outline"
className="self-start"
onClick={() => onChange([...overlays, emptyOverlay()])}
disabled={overlays.length >= 8}
>
<Plus className="size-3.5" /> <Plus className="size-3.5" />
Add text Add text
</Button> </Button>
</div> </div>
</div>
); );
}; };