the colour picker stays inside the panel
It was `<input type="color">`, 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 could not be reached — and nothing in CSS can move it, because it is not in the document. Swatches and a hex field instead, in a panel anchored to the RIGHT of its trigger so it grows inward and cannot leave the screen the same way. Deliberately not a gradient area and a hue strip. That is what the native control does well, and rebuilding it badly to fix a positioning problem would be a worse control than the one being replaced. Text over a photograph is white or black almost every time; the hex field is there for the times it is not. Closes on `pointerdown` anywhere else rather than `click`, so it closes on the way down and a press meant for something behind it does not land twice. Captured, because a control below may stop propagation for its own reasons. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<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>
|
||||
);
|
||||
};
|
||||
@@ -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 = ({
|
||||
<option value="right">Right</option>
|
||||
</select>
|
||||
|
||||
{/* 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. */}
|
||||
<input
|
||||
type="color"
|
||||
value={overlay.color}
|
||||
onChange={(event) => 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 `<input type="color">`. 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. */}
|
||||
<ColorField value={overlay.color} onChange={(color) => update(index, { color })} />
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
Reference in New Issue
Block a user