web: the three mixer knobs move the whole image, a frame chip toggles itself, ROTATE lets go when STRAIGHTEN steers

HUE, SAT and LUM leave the colour row: behind an IMAGE divider they are
hslHue/hslSat/hslLum, seeded into the shader's band accumulator at full
weight for every hue, while the eight band chips keep picking which colour
the panel on the photo edits. The image lightness term stays ungated so a
frame drained to grey by -SAT still answers +LUM.

FRAME loses its NO FRAME chip: pressing the frame already on the photo
takes it off.

ROTATE's quarter turns stop lighting the moment the fine angle leaves 0,
so the strip shows which of the two is steering the photo.
This commit is contained in:
2026-09-18 18:45:41 +07:00
parent 27035c4acb
commit 4057566a14
4 changed files with 73 additions and 17 deletions
+37 -13
View File
@@ -612,6 +612,16 @@ export function Workspace() {
[recipe.adjustments.hslBands, hslBand, setAdjustmentOnce]
);
// The mixer's overall move: one door for the three image-wide knobs, so the
// ruler and the FIELDS it writes are the same values whatever opened it.
const setHslGlobal = useCallback(
(which: 0 | 1 | 2, v: number) => {
const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum';
setAdjustmentOnce({ [key]: Math.max(-10, Math.min(10, Math.round(v))) });
},
[setAdjustmentOnce]
);
// --- crop -----------------------------------------------------------------
// Picking a ratio only opens the frame; the crop is committed by APPLY.
const pickCrop = useCallback(
@@ -1143,17 +1153,18 @@ export function Workspace() {
// photo (StraightenRuler, drawn by ImageStage) so the angle is read off the
// picture it is levelling; nothing opens here.
if (openParam === 'straighten') return null;
// The mixer's three knobs are not in PARAM_DEFS: they read and write one
// band of hslBands rather than a flat field of adjustments.
// The mixer's three knobs are not in PARAM_DEFS: they read and write the
// image's own move rather than a flat field of adjustments, and unlike the
// panel on the photo they are not tied to the band the mixer is aimed at.
if (openParam.startsWith('hsl.')) {
const which = openParam === 'hsl.h' ? 0 : openParam === 'hsl.s' ? 1 : 2;
const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const value = band[which];
const set = (v: number) => setBandKnob(which as 0 | 1 | 2, v);
const own = [recipe.adjustments.hslHue ?? 0, recipe.adjustments.hslSat ?? 0, recipe.adjustments.hslLum ?? 0];
const value = own[which];
const set = (v: number) => setHslGlobal(which as 0 | 1 | 2, v);
const name = openParam === 'hsl.h' ? 'HUE' : openParam === 'hsl.s' ? 'SAT' : 'LUM';
return {
key: openParam,
label: `${name} ${HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''}`,
label: `${name} IMAGE`,
value,
min: -10,
max: 10,
@@ -1189,7 +1200,7 @@ export function Workspace() {
},
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openParam, recipe.adjustments, setAdjustmentOnce, setBandKnob, hslBand, straighten, setStraightenOnce]);
}, [openParam, recipe.adjustments, setAdjustmentOnce, setHslGlobal, straighten, setStraightenOnce]);
// The eyedropper's report: print what was read, point the ruler at that
// colour's band, hang the mixer's panel on the spot it came from, and put the
@@ -1459,7 +1470,9 @@ export function Workspace() {
});
const framesRow = (): ChipDef[] => [
...FRAMES.map((frame) => ({
// NO FRAME is not a chip of its own: clicking the frame already on the
// photo takes it off again, so the row carries one entry per frame.
...FRAMES.filter((frame) => frame.id !== 'none').map((frame) => ({
key: `fr:${frame.id}`,
label: t(frame.label),
active: frameId === frame.id,
@@ -1467,7 +1480,7 @@ export function Workspace() {
setOpenParam(null);
setOpenGroup(null);
remember();
setFrameId(frame.id);
setFrameId(frameId === frame.id ? 'none' : frame.id);
},
})),
groupChip('crop'),
@@ -1588,13 +1601,16 @@ export function Workspace() {
case 'hsl': {
// PICK arms the eyedropper; the eight band chips choose which band the
// ruler edits, and the ruler itself is opened by the three knobs below.
const cur = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
// Those three are the image's own move, not the band's: the band chips
// pick which colour the panel on the photo edits, while HUE/SAT/LUM
// below the divider move every hue in the frame at once.
const own = [recipe.adjustments.hslHue ?? 0, recipe.adjustments.hslSat ?? 0, recipe.adjustments.hslLum ?? 0];
const knob = (key: string, label: string, i: number): ChipDef => ({
key,
label,
value: cur[i] > 0 ? `+${cur[i]}` : String(cur[i]),
value: own[i] > 0 ? `+${own[i]}` : String(own[i]),
active: openParam === key,
amberValue: openParam !== key && cur[i] !== 0,
amberValue: openParam !== key && own[i] !== 0,
onClick: () => toggleParam(key),
});
return [
@@ -1612,6 +1628,9 @@ export function Workspace() {
onClick: () => setHslBand(b.id),
};
}),
// The divider keeps the three image-wide sliders out of the colour
// row: the row above picks a colour, these three move them all.
{ key: 'hsl-image', label: 'IMAGE', disabled: true, onClick: () => {} },
knob('hsl.h', 'HUE', 0),
knob('hsl.s', 'SAT', 1),
knob('hsl.l', 'LUM', 2),
@@ -1634,7 +1653,12 @@ export function Workspace() {
const stripChips = (g: Exclude<GroupKey, 'wm'>): ChipDef[] => [
{ key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} },
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick),
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick).map((c) =>
// A hand-straightened frame is no longer on a clean quarter turn: the
// angle chips let go the moment the fine angle leaves 0, so the strip
// shows which of the two is actually steering the photo.
g === 'rotate' && straighten !== 0 && c.key !== 'rotate:reset' ? { ...c, active: false } : c
),
// The frame is on the photo but the crop is not in force until APPLY.
...(g === 'crop' && cropEditing
? [