7b6a1a805f
The strip a chip opened kept a row of its own while a knob's ruler opened over it, so WB's COLOR TEMP put the panel on the screen twice — the rows in the strip and the panel under them — and charged the photo a row for the saying. The path under the bar (.crumb) already names the panel the ruler came from and is one tap from its strip, so the ruler now stands in the strip's slot and the strip stands down with the bar: on 390x844 LIGHT's WB strip is 42px and the photo 639px, COLOR TEMP's ruler 60px and the photo 621px, the path TABS > LIGHT > WB > COLOR TEMP walking back to the strip and then to the bar. A sub-chip also wore the initials of its own label on a tile over it — COLOR TEMP as CT, PRO NEG HI as PNH — which is the name the pill already prints, spelled twice and set 60px tall: LIGHT's WB strip measured 73px on that tile, 42px as the name alone. The ruler's own "<" goes with them: the path names the strip it would reopen, and a wide screen draws neither the path nor a ruler in the bar. Checks: - npm run build (tsc --noEmit + vite) clean: dist/assets/index-2IP_1LL_.css 71.42 kB. - Chromium 390x844, light and dark, LIGHT: the bar 40px / the photo 641px; WB open, the bar gone and its strip 42px in the slot, the photo 639px; COLOR TEMP's ruler 60px in that slot, the photo 621px, the path TABS > LIGHT > WB > COLOR TEMP; crumb-strip lands back on the WB strip and crumb-tab on the bar at 641px. No page error and no sideways scroll (390px of 390px) in any state; on 1280x900 .dev-panels is painted and .dev-chips/.crumb are not.
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 }: { 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}
|
|
{/* 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>
|
|
);
|
|
}
|