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 (
-
- );
-};
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 })} />
-
-
-