264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
|
|
// One strip at a time — the same shape as the phone's AdjustmentPanel: a ruler
|
|
// row for the open slider, an options strip for the open group, then the tab's
|
|
// chip row. Chip labels are the phone's own strings (they are not translated
|
|
// there either).
|
|
export interface ChipDef {
|
|
key: string;
|
|
label: string;
|
|
value?: string;
|
|
// amber border while this chip's row/strip is open, amber text when the value
|
|
// it carries is not its default.
|
|
active?: boolean;
|
|
amberValue?: boolean;
|
|
pro?: boolean;
|
|
disabled?: boolean;
|
|
// CSS colour drawn as a dot on the chip's leading edge — the HSL mixer's band
|
|
// chips name a colour, so they show it too.
|
|
color?: string;
|
|
// The chip's own FILL and the text colour that fill can carry (WB's preset
|
|
// table: the swatch the pick paints, printed on the button itself). `bg` wins
|
|
// over the class's own background, so an active button keeps its swatch and
|
|
// shows its state through the border alone.
|
|
bg?: string;
|
|
fg?: string;
|
|
// A rule instead of a button: the line the row is read across, between two
|
|
// sets that are not the same kind of thing (the mixer's PICK, the bands, the
|
|
// three image-wide knobs). It takes a line of its own and nothing else — the
|
|
// label it separates is the chip below it.
|
|
divider?: boolean;
|
|
// Present when the chip itself can be dragged somewhere: the payload the drop
|
|
// target reads back (the recipe's id, for the FAVORITED rail button).
|
|
drag?: string;
|
|
// HOVER PREVIEW: a look the pointer lays on the photo before any click. Only
|
|
// the chips that carry a whole look (a sim, a recipe) have one; the pair is
|
|
// always both or neither, so leaving the chip always takes it back off.
|
|
onHover?: () => void;
|
|
onHoverEnd?: () => void;
|
|
onClick: () => void;
|
|
}
|
|
|
|
export interface SliderDef {
|
|
key: string;
|
|
label: string;
|
|
value: number;
|
|
min: number;
|
|
max: number;
|
|
step: number;
|
|
display: string;
|
|
// amber value readout once the knob leaves its default (phone's ruler row)
|
|
edited: boolean;
|
|
// CSS colour the knob is currently driving (WB's temperature/tint), drawn as a
|
|
// swatch under the ruler so the hue moves with the number. Absent = no swatch.
|
|
swatch?: string;
|
|
onChange: (value: number) => void;
|
|
// Double-clicking the track puts the parameter back to its default.
|
|
onReset: () => void;
|
|
// "<" — closes the ruler and reopens the strip it was launched from. Left out
|
|
// by a panel that IS the strip: a mask's own column has nowhere to go back to.
|
|
onBack?: () => void;
|
|
}
|
|
|
|
// `grid` lays the row out as the table a set of same-kind values is read as
|
|
// (WB's presets, LIGHT's two Color Chromes) instead of the wrapping strip the
|
|
// option rows use.
|
|
export function ChipRow({ chips, grid, grid3 }: { chips: ChipDef[]; grid?: boolean; grid3?: boolean }) {
|
|
return (
|
|
<div className={`chip-row${grid3 ? ' grid-3' : grid ? ' grid' : ''}`}>
|
|
{chips.map((chip) =>
|
|
chip.divider ? (
|
|
<span key={chip.key} className="chip-rule" role="separator" aria-orientation="horizontal" />
|
|
) : (
|
|
<button
|
|
key={chip.key}
|
|
type="button"
|
|
data-key={chip.key}
|
|
className={`chip${chip.active ? ' on' : ''}${chip.amberValue ? ' amber' : ''}${chip.bg ? ' tinted' : ''}`}
|
|
style={chip.bg ? { background: chip.bg, color: chip.fg } : undefined}
|
|
aria-pressed={!!chip.active}
|
|
disabled={chip.disabled}
|
|
draggable={!!chip.drag}
|
|
onDragStart={
|
|
chip.drag
|
|
? (e) => {
|
|
e.dataTransfer.setData('text/plain', chip.drag!);
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
}
|
|
: undefined
|
|
}
|
|
onMouseEnter={chip.onHover}
|
|
onMouseLeave={chip.onHoverEnd}
|
|
// The click takes the preview off first: a click that applies the look
|
|
// (and closes the column the chip was in) would otherwise leave a
|
|
// preview nothing is left to take off — and every later knob would
|
|
// move the recipe under a photo still painted from the preview.
|
|
onClick={() => {
|
|
chip.onHoverEnd?.();
|
|
chip.onClick();
|
|
}}
|
|
>
|
|
{chip.color ? <span className="chip-dot" data-color={chip.color} style={{ background: chip.color }} /> : null}
|
|
{/* The name is a box, not the bare text node it used to be: a flex
|
|
container turns a loose text node into an anonymous item of its
|
|
own, and on iOS that item stops being painted until the column
|
|
it sits in is touched. Same fault, same shape as the panel head
|
|
(see `dev-panel-label` in DevelopPanels). No rule of its own. */}
|
|
<span className="chip-label">{chip.label}</span>
|
|
{chip.value ? <span className="val">{chip.value}</span> : null}
|
|
{chip.pro ? <span className="pro">PRO</span> : null}
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SliderRow({ slider }: { slider: SliderDef }) {
|
|
// The wheel belongs to the open ruler: one notch is one step, held inside the
|
|
// parameter's own range. The listener is native because React's is passive —
|
|
// the panel must not scroll away under the cursor while the value moves.
|
|
const rowRef = useRef<HTMLDivElement>(null);
|
|
const live = useRef(slider);
|
|
live.current = slider;
|
|
useEffect(() => {
|
|
const el = rowRef.current;
|
|
if (!el) return;
|
|
const onWheel = (e: WheelEvent) => {
|
|
e.preventDefault();
|
|
const s = live.current;
|
|
const step = s.step || 1;
|
|
const next = Math.min(s.max, Math.max(s.min, s.value + (e.deltaY < 0 ? step : -step)));
|
|
if (next !== s.value) s.onChange(next);
|
|
};
|
|
el.addEventListener('wheel', onWheel, { passive: false });
|
|
return () => el.removeEventListener('wheel', onWheel);
|
|
}, []);
|
|
return (
|
|
// No data-key on the row itself: the parameter's own key is already on the
|
|
// chip that opened it, and a second element answering to it would make a
|
|
// probe's own selector ambiguous.
|
|
<div className="slider-row" ref={rowRef}>
|
|
{slider.onBack ? (
|
|
<button type="button" className="back" onClick={slider.onBack} aria-label="back"><</button>
|
|
) : null}
|
|
<div className="ruler">
|
|
{/* Label, track, readout: one line, the way a ruler is read — the name
|
|
of the knob at the head of the row, the number it is on at the foot,
|
|
and the travel between them taking the width that is left. */}
|
|
<div className="head">
|
|
<span>{slider.label}</span>
|
|
<input
|
|
type="range"
|
|
min={slider.min}
|
|
max={slider.max}
|
|
step={slider.step}
|
|
value={slider.value}
|
|
aria-label={slider.label}
|
|
onChange={(e) => slider.onChange(Number(e.target.value))}
|
|
onDoubleClick={slider.onReset}
|
|
/>
|
|
<b className={slider.edited ? 'amber' : ''}>{slider.display}</b>
|
|
</div>
|
|
{slider.swatch ? (
|
|
<div
|
|
className="swatch"
|
|
data-key={`swatch-${slider.key}`}
|
|
data-color={slider.swatch}
|
|
style={{ background: slider.swatch }}
|
|
title={slider.swatch}
|
|
>
|
|
<span>{slider.swatch}</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// The same ruler without its own column: one knob of the mixer's inline panel,
|
|
// where HUE/SAT/LUM sit side by side under the colour they are moving — and, in
|
|
// the same shape, the knobs a gradient mask carries. Wheel steps it, double-click
|
|
// puts it back to 0. A mask's knobs are not the mixer's -10..+10 integers (an
|
|
// exposure is a stop, a feather is a percentage), so the range and the readout
|
|
// come in with them; the defaults are the mixer's own, unchanged.
|
|
export function MiniSlider({
|
|
label,
|
|
value,
|
|
min = -10,
|
|
max = 10,
|
|
step = 1,
|
|
prefix = 'hsl-knob',
|
|
format,
|
|
defaultValue = 0,
|
|
track,
|
|
dataKey,
|
|
onChange,
|
|
onReset,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
min?: number;
|
|
max?: number;
|
|
step?: number;
|
|
prefix?: string;
|
|
format?: (value: number) => string;
|
|
// The value the readout stays grey at. 0 is the mixer's own default, but the
|
|
// studio's WB rows are not the only knobs here: a temperature's default is
|
|
// 5500K and a grain size's is 100, so the caller that knows says so.
|
|
defaultValue?: number;
|
|
// A coloured track for the two WB knobs (CSS track-temp / track-tint), whose
|
|
// travel is a hue rather than a number.
|
|
track?: 'temp' | 'tint';
|
|
// Replaces the `<prefix>-<label>` key for a caller that addresses a row by the
|
|
// parameter's own key (the sidebar's dev-<key>) instead of its label.
|
|
dataKey?: string;
|
|
onChange: (value: number) => void;
|
|
onReset: () => void;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const live = useRef({ value, onChange });
|
|
live.current = { value, onChange };
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const onWheel = (e: WheelEvent) => {
|
|
// The panel lives on the stage, whose own wheel zooms the photo: the
|
|
// knob takes the notch and keeps it.
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const s = live.current;
|
|
const next = Math.max(min, Math.min(max, s.value + (e.deltaY < 0 ? step : -step)));
|
|
if (next !== s.value) s.onChange(next);
|
|
};
|
|
el.addEventListener('wheel', onWheel, { passive: false });
|
|
return () => el.removeEventListener('wheel', onWheel);
|
|
}, [min, max, step]);
|
|
return (
|
|
<div className="mini-slider" ref={ref} data-key={dataKey ?? `${prefix}-${label}`}>
|
|
{/* The studio's rows are one line each, the way a ruler is read: the
|
|
knob's name, the track, then the number it is on. The mixer's three
|
|
knobs are the exception — they sit three to a narrow panel, where a
|
|
track between two labels would be a sliver, so they stack (see
|
|
.hsl-card-knobs in app.css). */}
|
|
<div className="mini-head">
|
|
<span>{label}</span>
|
|
<input
|
|
type="range"
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={value}
|
|
aria-label={label}
|
|
className={track ? `track-${track}` : undefined}
|
|
onChange={(e) => onChange(Number(e.target.value))}
|
|
onDoubleClick={onReset}
|
|
/>
|
|
<b className={value !== defaultValue ? 'amber' : ''} data-key={`${prefix}-${label}-value`}>
|
|
{format ? format(value) : value > 0 ? `+${value}` : String(value)}
|
|
</b>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|