Files
RecipesCam/docker/frontend/src/ui/ChipColumn.tsx
T
3dtours 6996d1bdb0 The marks get a tab of their own and the rulers read on one line
Four asks, one reading of the LIGHT column. EV's ruler sat under PROFILE, which
is the film simulation and nothing else — the range a picture is on is a knob of
the tonal range, not of the stock it was shot on. D.RANGE sat with the tone
controls for the same wrong reason: it is a hold on the whole frame, so it
belongs at the foot of the effects. The two Color Chromes had gone missing from
the eye, because the only place they were drawn was the effects slot and
DETAIL & EFFECTS now opens folded. And every ruler was two lines: a name with
its number on one, the track alone under them, where Lightroom reads a ruler as
one line. The four feature buttons answered a fifth ask that arrived with them:
AUTO, FIX, GRADIENT MASK and MONOCHROME do not name a look, they make a move on
the frame, and they now share a tab of their own.

TOOLS is the rail's ninth tab, between LIGHT and HSL, marked with the hammer and
pick (`⚒`) because the four features work ON the photo rather than pick a look
for it. The chips keep their keys, their amber-value logic and their pointer
behaviour — FIX and GRADIENT MASK still put down whatever tool was up before
they open their strip — and each carries its own mark at the head of its label
so the four read as one set: `✦ AUTO`, `✚ FIX`, `▚ GRADIENT MASK`,
`◑ MONOCHROME`. The marks were probed in the page's own stack before they were
spent (Inter, then the system sans): four-pointed star, thick cross, two
diagonal quadrants, half-filled circle — 26px, 47px, 77px and 15px of ink at
11px against the 36px a tofu box draws, so none of them is a missing glyph. The
strips still open in the last column, the same `stripChips` any other tab uses.

LIGHT keeps the one chip that is not a knob — TONE CURVE, `∿ TONE CURVE`, which
opens the graph on the photo rather than a ruler in the column, and stands over
the TONE panel whose shape it is — and gains the pair under it. COLOR CHROME and
CHROME BLUE are the same switch over two axes of one colour, so they are read
against each other rather than scanned in turn: one row, two chips, the table
layout the WB presets already wear (`.chip-row.grid`), 147px each side by side
at top 123 with nothing overflowing. Each names the strength it is on, and names
`OFF` when it is on `none` — the pair is compared, so the neutral value is one
of the two being read (`groupChip(g, nameAlways)`). Either one still opens its
own strip in the last column; picking STRONG lands on the chip and, at rest,
goes amber.

Every ruler is one line now: the knob's name at the head of the row, the track
taking the width they leave, the number it is on at the foot. The track moved
inside `MiniSlider`'s `.mini-head` and inside `SliderRow`'s `.head`, and both
rules became a flex row (`span` and `b` `flex: 0 0 auto`, the input
`flex: 1 1 40px; min-width: 0`). The eight TONE rows, the six of DETAIL &
EFFECTS and the mask column's eleven all read as one line, track included. The
HSL mixer's three knobs are the exception and keep the stacked form: three to a
narrow panel, a track between two labels is a sliver, so `.hsl-card-knobs`
wraps the readout onto its own line and lets the track take the second. The mask
column takes the ruler column's own width (`col-slider`, 232px) rather than a
strip's 168px, which is what the one-line form costs: eleven rows, and in a
strip's width the labels leave the tracks nothing.

PROFILE is the simulation strip alone, TONE leads with EV and then the finer
move on the same axis, and DETAIL & EFFECTS closes with the D.RANGE strip.

Verified: `npx tsc --noEmit` clean, `npm run build` clean
(`dist/assets/index-CJSnLD0u.js`). Off the running page, through a probe: the
rail reads `◉ PRESETS ★ FAVORITED ☀ LIGHT ⚒ TOOLS ◍ HSL ▣ FRAME ⤓ SAVE RECENT
✎ CREATE RECIPES`; LIGHT's non-grid row is `[{key: curve, label: "∿ TONE
CURVE"}]` and the grid row under it is `["grp-cx", "grp-cxb"]` — 147px + 147px
in a 300px column, both boxes at top 123, nothing overflowing, `COLOR CHROME
OFF` and `CHROME BLUE OFF` each on its chip; its strip is
`[hint-cx, cx:none, cx:weak, cx:strong]` and picking STRONG lands
`{value: "STRONG", amber: false, on: true}` and then `{value: "STRONG", amber:
true}` once the strip closes. The TONE rows come back `EV 15+229+36
one-line=true`, `EXPOSURE 58+198+24`, `CONTRAST 60+214+6`, `HIGHLIGHT
60+214+6`, `SHADOW 50+224+6`, `WHITE 36+238+6`, `BLACK 38+236+6` (label, track,
readout; one line on every one), and the mask column's eleven the same at 232px.
PROFILE holds `{ev: false, dr: false, sim: […11 sims]}` and DETAIL & EFFECTS
holds `{dr: [dr:auto, dr:100, dr:200, dr:400], chrome: false, last: "AUTO DR100
DR200 DR400"}`. The TOOLS row is `[✦ AUTO, ✚ FIX, ▚ GRADIENT MASK, ◑
MONOCHROME]` at 148px each, the marks drawn 26/47/77/15px against the 36px tofu
control, and FIX's and GRADIENT MASK's strips still open
(`[hint-fix, heal, mosaic]`, `[hint-gradient, linear, radial]`) with MONOCHROME
still switching `false → true`. No page errors. `studio-open-check` (updated
for the pair and the single LIGHT chip), `library-check` and `scan-nav-check`
all pass, and the column was viewed with TONE and DETAIL & EFFECTS open.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 22:09:53 +07:00

250 lines
9.5 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;
// 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) => (
<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">&lt;</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>
);
}