import { PATTERN_SCALE, RADIAL_RADIUS, stopAlpha, type BookGradientPaint, type BookPaint, type BookPattern, type BookPatternPaint, } from '@trek/shared'; import { ArrowLeftRight, Ban, Trash2 } from 'lucide-react'; import { useRef, useState } from 'react'; import '../../styles/studio-paint.css'; import { NumField } from './StudioControls'; import type { StudioT } from './StudioSidebar'; import { Swatches } from './StudioSwatches'; import { AngleDial } from './paint/AngleDial'; import { GradientStopBar } from './paint/GradientStopBar'; import { PatternSwatches } from './paint/PatternSwatches'; import { RadialPad } from './paint/RadialPad'; import { PATTERN_ANGLE, clampRadius, clampScale, cssPaint, gradientAs, kindOf, normAngle, paintAs, removeStop, reverseStops, updateStop, type PaintKind, } from './paint/paintEdit'; import type { PaintChannel } from './paint/usePaintChannel'; /** * Paint, picked: none, one colour, a gradient or a pattern. * * ── How it is laid out ─────────────────────────────────────────────────── * * One row of four tabs says what kind of paint it is, each with a swatch of * what choosing it gives, and below it only the controls that kind has. The * tab is the question people ask first ("flat or a gradient?"), so it is a * row they can read at a glance rather than a menu. * * Switching kind keeps the colour: a solid becomes a gradient from that * colour to a lighter partner, a gradient becomes a pattern in its first * colour, and coming back to a kind already visited in this session brings * it back as it was left, so trying a pattern costs nothing. * * Every drag (a stop, the dial, the centre, a slider) writes live and is one * undo step when it is let go, through the channel the caller hands in. */ const KINDS: readonly PaintKind[] = ['none', 'solid', 'gradient', 'pattern']; /** A kind's name on its tab. */ function kindLabel(k: Exclude, t: StudioT): string { if (k === 'solid') return t('journey.studio.paint.solid'); return k === 'gradient' ? t('journey.studio.paint.gradient') : t('journey.studio.paint.pattern'); } export function StudioPaintPicker({ value, channel, t, noneLabel, kinds = KINDS, }: { value: BookPaint | null; channel: PaintChannel; t: StudioT; /** What "no paint" is called here: no fill on a shape, the paper on a page. */ noneLabel: string; /** * The kinds offered as tabs; all four by default. With one, the row of tabs * is left out: the letters of a text pick gradient or pattern beside their * other fills and only need that kind's controls here. */ kinds?: readonly PaintKind[]; }) { const kind = kindOf(value); // The last paint of each kind shown here, so a kind visited again comes back as it was left. const [memory, setMemory] = useState>>({}); const [stop, setStop] = useState(0); const write = (paint: BookPaint | null, done = true) => { const k = kindOf(paint); if (paint && k !== 'none') setMemory((m) => ({ ...m, [k]: paint })); if (done) channel.commit(paint); else channel.live(paint); }; const choose = (next: PaintKind) => { if (next === kind) return; const kept = value ? { ...memory, [kind]: value } : memory; if (value) setMemory((m) => ({ ...m, [kind]: value })); setStop(0); write(paintAs(next, value, kept)); }; const tabPreview = (k: PaintKind) => k === 'none' ? undefined : cssPaint(k === kind ? value : paintAs(k, value, memory)); return (
{kinds.length > 1 && (
{kinds.map((k) => ( ))}
)} {value?.type === 'solid' && ( write({ ...value, color })} alpha={stopAlpha(value)} onAlpha={(alpha, done) => write(withAlpha(value, alpha), done)} /> )} {(value?.type === 'linear' || value?.type === 'radial') && ( )} {value?.type === 'pattern' && }
); } const withAlpha = (o: T, alpha: number): T => { const { alpha: _old, ...rest } = o; return (alpha >= 1 ? rest : { ...rest, alpha: Math.round(alpha * 100) / 100 }) as T; }; type Write = (paint: BookPaint | null, done?: boolean) => void; function GradientControls({ paint, stop, onStop, write, t, }: { paint: BookGradientPaint; stop: number; onStop: (i: number) => void; write: Write; t: StudioT; }) { const current = paint.stops[stop]; const canRemove = paint.stops.length > 2; return (
{(['linear', 'radial'] as const).map((type) => ( ))}
write(p, done)} labels={{ bar: t('journey.studio.paint.addStopHint'), stop: (pos) => t('journey.studio.paint.stopAt', { pos }), }} /> {current && (
{t('journey.studio.paint.stopAt', { pos: Math.round(current.at * 100) })}
write(updateStop(paint, stop, { color }))} alpha={stopAlpha(current)} onAlpha={(alpha, done) => write(updateStop(paint, stop, { alpha }), done)} />
)} {paint.type === 'linear' ? (
write({ ...paint, angle }, done)} /> write({ ...paint, angle: normAngle(angle) })} />
) : (
write(p, done)} /> write({ ...paint, r: clampRadius(r) }, done)} />
)}
); } function PatternControls({ paint, write, t }: { paint: BookPatternPaint; write: Write; t: StudioT }) { const pick = (pattern: BookPattern) => write({ ...paint, pattern, angle: PATTERN_ANGLE[pattern] ?? paint.angle }); return (
t(`journey.studio.pattern.${p}`)} />
{t('journey.studio.paint.ink')} write({ ...paint, fg })} />
{t('journey.studio.paint.ground')}
{paint.bg !== null && write({ ...paint, bg })} />}
write({ ...paint, angle }, done)} /> write({ ...paint, scale: clampScale(scale) }, done)} />
); } /** A labelled slider with its value beside it; `done` as for the dial. */ function Slider({ label, value, min, max, shown, onChange, log = false, }: { label: string; value: number; min: number; max: number; shown: string; onChange: (v: number, done: boolean) => void; /** Even steps by ratio rather than by difference: a pattern's scale doubles, it does not add. */ log?: boolean; }) { const steps = 100; const toValue = (raw: string) => { const t = Number(raw) / steps; return log ? min * (max / min) ** t : min + (max - min) * t; }; const pos = Math.round((log ? Math.log(value / min) / Math.log(max / min) : (value - min) / (max - min)) * steps); // What the slider was last moved to: a controlled input may still show the old value when it is let go. const moved = useRef(value); return ( ); }