diff --git a/web/ColorField.tsx b/web/ColorField.tsx deleted file mode 100644 index 979ae63..0000000 --- a/web/ColorField.tsx +++ /dev/null @@ -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 `` ── - * - * 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(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 ( -
- - - {open && ( -
-
- {PRESETS.map((preset) => ( -
- - 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" - /> -
- )} -
- ); -}; diff --git a/web/OverlayEditor.tsx b/web/OverlayEditor.tsx index b5b84d9..1b93071 100644 --- a/web/OverlayEditor.tsx +++ b/web/OverlayEditor.tsx @@ -1,8 +1,6 @@ -import { useRef, useState } from 'react'; -import { Plus, Trash2 } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { AlignCenter, AlignLeft, AlignRight, Pencil, Plus, Trash2 } from 'lucide-react'; 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. @@ -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 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 = { @@ -47,6 +58,74 @@ export type Overlay = { 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, @@ -62,15 +141,36 @@ export const OverlayEditor = ({ /** 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.preventDefault(); 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 = { @@ -115,6 +215,12 @@ export const OverlayEditor = ({ 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 (
{/* @@ -129,7 +235,10 @@ export const OverlayEditor = ({ 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)} + onPointerDown={() => { + setSelected(null); + setEditing(null); + }} > {children} @@ -138,18 +247,20 @@ export const OverlayEditor = ({ key={index} onPointerDown={(event) => { 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 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 ${ - selected === index ? 'outline outline-1 outline-offset-4 outline-primary' : '' - }`} + 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}%`, @@ -168,7 +279,18 @@ export const OverlayEditor = ({ 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 ? ( + 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. */} @@ -182,77 +304,95 @@ export const OverlayEditor = ({
)} + + {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" + /> + +
+ + +
+ )}
))}
- {/* 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) => ( -
setSelected(index)} - className={`flex items-center gap-2 rounded-md border p-2 ${ - selected === index ? 'border-primary' : 'border-border' - }`} - > - 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} - -
- - - - {/* Not ``. The browser's picker is a window-level popup and opened off - the side of the window in a panel near the right edge — see ColorField. */} - update(index, { color })} /> - - -
- ))} - - -
+
); };