web: the mixer hangs a panel on the colour it read, and STRAIGHTEN becomes a scale on the photo
This commit is contained in:
+106
-35
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user