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>
259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
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.
|
||
*
|
||
* ── Why it is drawn on the picture rather than listed beside it ──
|
||
*
|
||
* The thing being decided is where a line of text sits on a photograph, and that is not a decision
|
||
* anybody can make from two number fields. The editor is the picture with the text on it, at the shape
|
||
* the page uses, so what is arranged is what is published.
|
||
*
|
||
* ── Everything is relative, and has to be ──
|
||
*
|
||
* `x` and `y` are percentages of the picture, so a position means the same thing on a phone and a
|
||
* desktop. `size` is a percentage of the picture's WIDTH rather than pixels, for the same reason: a
|
||
* headline set at 40px is a headline on a laptop and a caption on a large screen.
|
||
*
|
||
* 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.
|
||
*/
|
||
|
||
export type Overlay = {
|
||
text: string;
|
||
/** Percent of the picture. The text's own anchor corner, per `align`. */
|
||
x: number;
|
||
y: number;
|
||
/**
|
||
* The BOX's width, as a percent of the picture. Text wraps inside it.
|
||
*
|
||
* Without one a block is as wide as its longest line, so it changes shape as it is typed and there
|
||
* is nothing stable to drag — which is what made moving one feel like it was fighting back. A width
|
||
* also means a line break is a decision rather than an accident of the current text.
|
||
*
|
||
* Height is not stored. It is whatever the wrapped text needs, and a box that could be shorter than
|
||
* its own words would only ever clip them.
|
||
*/
|
||
w: number;
|
||
/** Percent of the picture's width. */
|
||
size: number;
|
||
color: string;
|
||
align: 'left' | 'center' | 'right';
|
||
};
|
||
|
||
export const emptyOverlay = (): Overlay => ({ text: '', x: 10, y: 70, w: 40, size: 4, color: '#ffffff', align: 'left' });
|
||
|
||
export const OverlayEditor = ({
|
||
overlays,
|
||
onChange,
|
||
children,
|
||
}: {
|
||
overlays: Overlay[];
|
||
onChange: (overlays: Overlay[]) => void;
|
||
/** The picture. Rendered underneath, at whatever shape its own slot decides. */
|
||
children: React.ReactNode;
|
||
}) => {
|
||
const box = useRef<HTMLDivElement | null>(null);
|
||
const dragging = useRef<{ index: number; dx: number; dy: number } | null>(null);
|
||
/** 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<number | null>(null);
|
||
|
||
const update = (index: number, patch: Partial<Overlay>) =>
|
||
onChange(overlays.map((overlay, i) => (i === index ? { ...overlay, ...patch } : overlay)));
|
||
|
||
const onPointerDown = (event: React.PointerEvent, index: number) => {
|
||
const rect = box.current?.getBoundingClientRect();
|
||
if (!rect) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
(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 = {
|
||
index,
|
||
dx: ((event.clientX - rect.left) / rect.width) * 100 - overlays[index]!.x,
|
||
dy: ((event.clientY - rect.top) / rect.height) * 100 - overlays[index]!.y,
|
||
};
|
||
};
|
||
|
||
const onResizeDown = (event: React.PointerEvent, index: number) => {
|
||
const rect = box.current?.getBoundingClientRect();
|
||
if (!rect) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
(event.target as Element).setPointerCapture?.(event.pointerId);
|
||
resizing.current = { index, startX: event.clientX, startW: overlays[index]!.w };
|
||
};
|
||
|
||
const onPointerMove = (event: React.PointerEvent) => {
|
||
const rect = box.current?.getBoundingClientRect();
|
||
|
||
const resize = resizing.current;
|
||
if (resize && rect) {
|
||
// The handle is on the trailing edge, so for a right-aligned block dragging left widens it.
|
||
const direction = overlays[resize.index]!.align === 'right' ? -1 : 1;
|
||
const delta = ((event.clientX - resize.startX) / rect.width) * 100 * direction;
|
||
// 5% is about a word at any sensible size; 100% is the whole picture and there is no more.
|
||
update(resize.index, { w: Math.min(100, Math.max(5, Math.round((resize.startW + delta) * 10) / 10)) });
|
||
return;
|
||
}
|
||
|
||
const drag = dragging.current;
|
||
if (!drag || !rect) return;
|
||
update(drag.index, {
|
||
x: Math.min(100, Math.max(0, ((event.clientX - rect.left) / rect.width) * 100 - drag.dx)),
|
||
y: Math.min(100, Math.max(0, ((event.clientY - rect.top) / rect.height) * 100 - drag.dy)),
|
||
});
|
||
};
|
||
|
||
const endGesture = () => {
|
||
dragging.current = null;
|
||
resizing.current = null;
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
{/*
|
||
`@container` so the overlays can be sized in `cqw` — a percentage of THIS box rather than of the
|
||
viewport. The picture is the thing text should scale with, and the panel is not the page width.
|
||
*/}
|
||
<div
|
||
ref={box}
|
||
className="@container relative"
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={endGesture}
|
||
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)}
|
||
>
|
||
{children}
|
||
|
||
{overlays.map((overlay, index) => (
|
||
<div
|
||
key={index}
|
||
onPointerDown={(event) => {
|
||
setSelected(index);
|
||
onPointerDown(event, 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.
|
||
*/
|
||
className={`absolute cursor-grab touch-none whitespace-pre-wrap active:cursor-grabbing ${
|
||
selected === index ? 'outline outline-1 outline-offset-4 outline-primary' : ''
|
||
}`}
|
||
style={{
|
||
left: `${overlay.x}%`,
|
||
top: `${overlay.y}%`,
|
||
// The box, not the text. Wrapping inside a known width is what makes a block a stable
|
||
// thing to drag — see the note on `w`.
|
||
width: `${overlay.w}%`,
|
||
// The anchor follows the alignment, so a right-aligned block grows leftwards from where
|
||
// it was put rather than sliding out of the picture.
|
||
transform: overlay.align === 'right' ? 'translateX(-100%)' : overlay.align === 'center' ? 'translateX(-50%)' : undefined,
|
||
fontSize: `${overlay.size}cqw`,
|
||
color: overlay.color,
|
||
textAlign: overlay.align,
|
||
lineHeight: 1.15,
|
||
// Only so the text stays readable while it is being placed over a busy photograph. The
|
||
// page draws it without one — this is scaffolding, not styling.
|
||
textShadow: '0 1px 3px rgba(0,0,0,.45)',
|
||
}}
|
||
>
|
||
{overlay.text || '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. */}
|
||
{selected === index && (
|
||
<div
|
||
onPointerDown={(event) => onResizeDown(event, index)}
|
||
className={`absolute inset-y-0 flex w-4 cursor-ew-resize items-center ${
|
||
overlay.align === 'right' ? '-left-4 justify-start' : '-right-4 justify-end'
|
||
}`}
|
||
>
|
||
<div className="h-8 w-1 rounded bg-primary" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 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. */}
|
||
<div className="flex flex-col gap-2">
|
||
{overlays.map((overlay, index) => (
|
||
<div
|
||
key={index}
|
||
onPointerDown={() => setSelected(index)}
|
||
className={`flex items-center gap-2 rounded-md border p-2 ${
|
||
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
|
||
wide" is not a thought anybody has, so it is presented as a plain step. */}
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
<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) })}>
|
||
−
|
||
</Button>
|
||
<span className="w-8 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>
|
||
</div>
|
||
|
||
<select
|
||
value={overlay.align}
|
||
onChange={(event) => update(index, { align: event.currentTarget.value as Overlay['align'] })}
|
||
className="h-8 shrink-0 rounded-md border border-input bg-transparent px-2 text-xs"
|
||
>
|
||
<option value="left">Left</option>
|
||
<option value="center">Centre</option>
|
||
<option value="right">Right</option>
|
||
</select>
|
||
|
||
{/* 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"
|
||
variant="ghost"
|
||
className="size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive"
|
||
onClick={() => onChange(overlays.filter((_, i) => i !== index))}
|
||
title="Remove"
|
||
>
|
||
<Trash2 className="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="self-start"
|
||
onClick={() => onChange([...overlays, emptyOverlay()])}
|
||
disabled={overlays.length >= 8}
|
||
>
|
||
<Plus className="size-3.5" />
|
||
Add text
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|