diff --git a/web/ColorField.tsx b/web/ColorField.tsx new file mode 100644 index 0000000..979ae63 --- /dev/null +++ b/web/ColorField.tsx @@ -0,0 +1,115 @@ +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 488e8f4..b5b84d9 100644 --- a/web/OverlayEditor.tsx +++ b/web/OverlayEditor.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from 'react'; import { 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. @@ -225,15 +226,9 @@ export const OverlayEditor = ({ - {/* A native colour input: it is the one control every platform already knows how to draw, - and a hand-rolled picker here would be worse on every one of them. */} - update(index, { color: event.currentTarget.value })} - className="size-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5" - title="Colour" - /> + {/* 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 })} />