web: the mixer hangs a panel on the colour it read, and STRAIGHTEN becomes a scale on the photo

This commit is contained in:
2026-09-18 18:27:28 +07:00
parent 10466e122a
commit 27035c4acb
5 changed files with 401 additions and 41 deletions
+106 -35
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ToolRail, type TabId } from './ui/ToolRail';
import { ChipRow, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn';
import { ChipRow, MiniSlider, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn';
import { ImageStage } from './ui/ImageStage';
import { StraightenRuler } from './ui/StraightenRuler';
import { TopBar } from './ui/TopBar';
import { AuthModal } from './ui/AuthModal';
import { NameModal } from './ui/NameModal';
@@ -195,6 +196,8 @@ export function Workspace() {
const [hslBand, setHslBand] = useState<HslBandId>('red');
const [picking, setPicking] = useState(false);
const [sample, setSample] = useState<{ r: number; g: number; b: number } | null>(null);
// Where on the photo that colour was read: the mixer's panel hangs there.
const [pickedAt, setPickedAt] = useState<{ fx: number; fy: number } | null>(null);
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
const [showRecipes, setShowRecipes] = useState(false);
const [tab, setTab] = useState<TabId>('presets');
@@ -592,6 +595,23 @@ export function Workspace() {
[remember]
);
// One band of the mixer, read and written through one door: the tab's ruler
// and the panel hung on the photo are the same three knobs. An all-zero band
// is dropped, so an untouched band never lands in the recipe and a band put
// back to zero leaves no trace.
const setBandKnob = useCallback(
(which: 0 | 1 | 2, v: number) => {
const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const next: HslBand = [band[0], band[1], band[2]];
next[which] = Math.round(v);
const bands = { ...recipe.adjustments.hslBands };
if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand];
else bands[hslBand] = next;
setAdjustmentOnce({ hslBands: bands });
},
[recipe.adjustments.hslBands, hslBand, setAdjustmentOnce]
);
// --- crop -----------------------------------------------------------------
// Picking a ratio only opens the frame; the crop is committed by APPLY.
const pickCrop = useCallback(
@@ -1077,10 +1097,28 @@ export function Workspace() {
setOpenParam(null);
setOpenGroup(null);
setShowRecipes(false);
// Leaving the mixer disarms the eyedropper: it is a mode of that tab.
// Leaving the mixer disarms the eyedropper and takes its panel off the
// photo: both are modes of that tab.
setPicking(false);
setPickedAt(null);
}, [tab]);
// STRAIGHTEN's scale is a mode of the photo, like the eyedropper: any click
// that lands outside it applies the angle and takes the scale off the photo —
// so reaching for another tab, chip or button levels the photo and puts the
// ruler away in the same gesture. Nothing is pending: the angle is already
// live in the render, this only ends the session.
useEffect(() => {
if (openParam !== 'straighten') return;
const dismiss = (e: PointerEvent) => {
const el = e.target as HTMLElement | null;
if (el?.closest('[data-key="straighten-ruler"], .chip[data-key="straighten"]')) return;
setOpenParam(null);
};
window.addEventListener('pointerdown', dismiss, true);
return () => window.removeEventListener('pointerdown', dismiss, true);
}, [openParam]);
// Continuous parameter chips: label + live value; amber text when edited,
// amber border while its ruler is open.
const paramChips = (defs: ContinuousParamDef[]): ChipDef[] =>
@@ -1100,42 +1138,18 @@ export function Workspace() {
const slider: SliderDef | null = useMemo(() => {
if (!openParam) return null;
// STRAIGHTEN is not a knob either: it is FRAME's own angle, and its ruler
// spans the full circle so a photo can be levelled either way round.
if (openParam === 'straighten') {
return {
key: 'straighten',
label: 'STRAIGHTEN',
value: straighten,
min: -180,
max: 180,
step: 1,
display: `${straighten > 0 ? '+' : ''}${straighten}°`,
edited: straighten !== 0,
onChange: setStraightenOnce,
onReset: () => setStraightenOnce(0),
onBack: () => {
setOpenParam(null);
setOpenGroup('rotate');
},
};
}
// STRAIGHTEN is not a knob either: it is FRAME's own angle, and it is not
// stacked in this column at all. Its ruler lies across the foot of the
// 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.
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) => {
const next: HslBand = [band[0], band[1], band[2]];
next[which] = Math.round(v);
const bands = { ...recipe.adjustments.hslBands };
// An all-zero band is dropped, so an untouched band never lands in the
// recipe and a band put back to zero leaves no trace.
if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand];
else bands[hslBand] = next;
setAdjustmentOnce({ hslBands: bands });
};
const set = (v: number) => setBandKnob(which as 0 | 1 | 2, v);
const name = openParam === 'hsl.h' ? 'HUE' : openParam === 'hsl.s' ? 'SAT' : 'LUM';
return {
key: openParam,
@@ -1175,16 +1189,64 @@ export function Workspace() {
},
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openParam, recipe.adjustments, setAdjustmentOnce, hslBand, straighten, setStraightenOnce]);
}, [openParam, recipe.adjustments, setAdjustmentOnce, setBandKnob, hslBand, straighten, setStraightenOnce]);
// The eyedropper's report: print what was read, point the ruler at that
// colour's band, and put the tool away — one pick is one colour.
const pickColor = useCallback((rgb: { r: number; g: number; b: number }) => {
// colour's band, hang the mixer's panel on the spot it came from, and put the
// tool away — one pick is one colour.
const pickColor = useCallback((rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => {
setSample(rgb);
setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h));
setPickedAt(at);
setPicking(false);
}, []);
const picked = sample ? rgbToHsl(sample.r, sample.g, sample.b) : null;
const bandName = HSL_BANDS.find((b) => b.id === hslBand)?.label ?? '';
// The colour as this band's knobs leave it: the panel's swatch, and what the
// mixer is doing to every pixel the band owns.
const mixed = (() => {
if (!picked) return null;
const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const h = picked.h + band[0];
const s = Math.max(0, Math.min(100, picked.s * 100 + band[1]));
const l = Math.max(0, Math.min(100, picked.l * 100 + band[2]));
return { hex: hslToHex(h, s, l) };
})();
const bandKnobs = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
// The panel the stage hangs on the picked point: the colour on top (it moves
// with the knobs), then HUE/SAT/LUM in one row under it. Each takes the wheel
// for one step and a double-click back to 0.
const pickPanel =
sample && picked && mixed && pickedAt ? (
<>
<div className="hsl-card-head">
<span
className="hsl-card-swatch"
data-key="hsl-panel-swatch"
data-color={mixed.hex}
style={{ background: mixed.hex }}
/>
<span className="hsl-card-name" data-key="hsl-panel-name">
{bandName}
<b>{mixed.hex}</b>
</span>
<button
type="button"
className="hsl-card-close"
data-key="hsl-panel-close"
aria-label="close"
onClick={() => setPickedAt(null)}
>
✕
</button>
</div>
<div className="hsl-card-knobs">
<MiniSlider label="HUE" value={bandKnobs[0]} onChange={(v) => setBandKnob(0, v)} onReset={() => setBandKnob(0, 0)} />
<MiniSlider label="SAT" value={bandKnobs[1]} onChange={(v) => setBandKnob(1, v)} onReset={() => setBandKnob(1, 0)} />
<MiniSlider label="LUM" value={bandKnobs[2]} onChange={(v) => setBandKnob(2, v)} onReset={() => setBandKnob(2, 0)} />
</div>
</>
) : null;
// Option strips: a chip opens one, each option picks a value. `off` is the
// neutral value the chip glows amber against.
@@ -1543,6 +1605,8 @@ export function Workspace() {
return {
key: `hsl-band-${b.id}`,
label: b.label,
// The chip names a colour, so it shows the colour it names.
color: hslToHex(b.hue, 70, 50),
active: hslBand === b.id,
amberValue: hslBand !== b.id && moved,
onClick: () => setHslBand(b.id),
@@ -1900,6 +1964,13 @@ export function Workspace() {
canCompare={crop === 'none'}
picking={picking}
onPickColor={pickColor}
pickPanel={pickPanel}
pickPanelAt={pickedAt}
straightenRuler={
openParam === 'straighten' ? (
<StraightenRuler value={straighten} onChange={setStraightenOnce} onReset={() => setStraightenOnce(0)} />
) : null
}
/>
</div>