f7031c1809
Three small moves in the column, plus the rule they needed. LIGHT: the PROFILE panel is gone — PHOTO STYLE under PRESETS already covers what it did — and the tone curve chip leaves the column's chip row. It becomes the last line of the TONE panel's body, at the foot TONE always had room for. PRESETS: IMPORT .RECIPE leaves the recipes strip and sits in the main column, directly under the RECIPES chip, which is the set it imports into. The strip now carries the recipes and nothing else, so its empty hint fires at length zero. HSL: the mixer's PICK and the IMAGE label each get a rule above them. The rule is a chip — divider: true in ChipDef — that renders a separator instead of a button, so a row can say that what follows is a different kind of thing. Verified by probe against the built app (exit 0, no console errors): - LIGHT panel heads read WB, TONE, PRESENCE, DETAIL & EFFECTS, with no PROFILE; the TONE body's last row is the "∿ TONE CURVE" chip, box [94,647,300,27]. - PRESETS main chips read PHOTO STYLE, RECIPES, IMPORT .RECIPE (y 159, under RECIPES at y 124), RESET; the strip no longer carries the import chip. - HSL order is PICK [94,91,148,27], rule [94,127,148,1], the eight bands, rule [94,404,148,1], IMAGE, then HUE/SAT/LUM. The same order holds at 393x852, where the rules measure [8,560,377,1] and [8,639,377,1]. - The rule is a 1px line: width 100% with height 1px, which keeps it a line in both the desktop column and the phone's wrapped strip. Co-authored-by: PenguinHarness <noreply@penguin.local>
259 lines
10 KiB
TypeScript
259 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 }: { chips: ChipDef[]; grid?: boolean }) {
|
|
return (
|
|
<div className={`chip-row${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}
|
|
{chip.label}
|
|
{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>
|
|
);
|
|
}
|