fix(studio): stroke clustering for HEAL, mobile WebGL inpaint stability, and reorganize LIGHT panels with HSL
This commit is contained in:
+175
-85
@@ -3616,64 +3616,6 @@ export function Workspace() {
|
||||
{ key: 'tools-rule', divider: true, label: '', onClick: () => {} },
|
||||
...shapeChips,
|
||||
];
|
||||
case 'hsl': {
|
||||
// The mixer is PRO: the picker, the eight bands and the three
|
||||
// image-wide knobs all come with the account, so a guest is offered the
|
||||
// reason rather than a wall of dead chips.
|
||||
if (!pro) return [{ key: 'hsl-pro', label: t('pro.hslChip'), pro: true, onClick: promptPro }];
|
||||
// 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.
|
||||
// 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: own[i] > 0 ? `+${own[i]}` : String(own[i]),
|
||||
active: openParam === key,
|
||||
amberValue: openParam !== key && own[i] !== 0,
|
||||
onClick: () => toggleParam(key),
|
||||
});
|
||||
return [
|
||||
{ key: 'hsl-pick', label: '⌖ PICK', active: picking, onClick: () => { setBrushTool(null); setMaskTool(null); setPicking((v) => !v); } },
|
||||
// PICK is not a band: it reads a colour off the photo to aim the
|
||||
// mixer, and every chip under the rule edits what it read, so the
|
||||
// rule is where the row stops being the tool and starts being the
|
||||
// colour.
|
||||
{ key: 'hsl-pick-rule', divider: true, label: '', onClick: () => {} },
|
||||
...HSL_BANDS.map((b): ChipDef => {
|
||||
const band = recipe.adjustments.hslBands?.[b.id];
|
||||
const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0);
|
||||
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,
|
||||
// A band chip aims the mixer AND opens its panel, the same panel
|
||||
// the eyedropper opens: the colour it starts from is the colour
|
||||
// the chip is named after, and this band's knobs push it from
|
||||
// there. A pick already on the photo keeps its spot — only a
|
||||
// mixer with nowhere to hang takes the middle of the frame.
|
||||
onClick: () => {
|
||||
setHslBand(b.id);
|
||||
setSample(hexToRgb(hslToHex(b.hue, 70, 50)));
|
||||
setPickedAt((at) => at ?? { fx: 0.5, fy: 0.5 });
|
||||
},
|
||||
};
|
||||
}),
|
||||
// The divider keeps the three image-wide sliders out of the colour
|
||||
// row: the rule stands where the colour ends and the knobs that move
|
||||
// every colour at once begin, and IMAGE names what they move.
|
||||
{ key: 'hsl-image-rule', divider: true, label: '', onClick: () => {} },
|
||||
{ key: 'hsl-image', label: 'IMAGE', disabled: true, onClick: () => {} },
|
||||
knob('hsl.h', 'HUE', 0),
|
||||
knob('hsl.s', 'SAT', 1),
|
||||
knob('hsl.l', 'LUM', 2),
|
||||
];
|
||||
}
|
||||
case 'frame':
|
||||
// WATERMARK is a chip like the rest: its own sub-panel opens as the next
|
||||
// column (see the wm column in the render), so the frame chips stay put.
|
||||
@@ -3997,6 +3939,180 @@ export function Workspace() {
|
||||
<ChipRow chips={choiceChips('dr', DR_OPTIONS, String(recipe.adjustments.dynamicRange ?? 'auto'), groupDefs.dr.onPick)} />
|
||||
</div>
|
||||
),
|
||||
hsl: (
|
||||
<div className="dev-box" data-key="dev-box-hsl" style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`chip${picking ? ' on' : ''}`}
|
||||
onClick={() => {
|
||||
setBrushTool(null);
|
||||
setMaskTool(null);
|
||||
setPicking((v) => !v);
|
||||
}}
|
||||
>
|
||||
<span className="glyph">⌖</span> PICK
|
||||
</button>
|
||||
{sample && picked ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', color: 'var(--text-dim)' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
background: `rgb(${sample.r}, ${sample.g}, ${sample.b})`,
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
/>
|
||||
<span>R {sample.r} · G {sample.g} · B {sample.b}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="dev-box-title" style={{ marginBottom: '6px', display: 'block' }}>COLOR BANDS</span>
|
||||
<ChipRow
|
||||
grid
|
||||
chips={HSL_BANDS.map((b) => {
|
||||
const band = recipe.adjustments.hslBands?.[b.id];
|
||||
const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0);
|
||||
return {
|
||||
key: `dev-hsl-${b.id}`,
|
||||
label: b.label,
|
||||
color: hslToHex(b.hue, 70, 50),
|
||||
active: hslBand === b.id,
|
||||
amberValue: hslBand !== b.id && moved,
|
||||
onClick: () => {
|
||||
setHslBand(b.id);
|
||||
setSample(hexToRgb(hslToHex(b.hue, 70, 50)));
|
||||
setPickedAt((at) => at ?? { fx: 0.5, fy: 0.5 });
|
||||
},
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hslBand && (
|
||||
<div className="dev-subgroup" style={{ marginTop: '4px' }}>
|
||||
<div className="dev-subgroup-title" style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>BAND: {HSL_BANDS.find((b) => b.id === hslBand)?.label}</span>
|
||||
{(recipe.adjustments.hslBands?.[hslBand]?.[0] || recipe.adjustments.hslBands?.[hslBand]?.[1] || recipe.adjustments.hslBands?.[hslBand]?.[2]) ? (
|
||||
<button
|
||||
type="button"
|
||||
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '10px' }}
|
||||
onClick={() => {
|
||||
remember();
|
||||
const bands = { ...(recipe.adjustments.hslBands ?? {}) };
|
||||
delete bands[hslBand];
|
||||
setAdjustment({ hslBands: bands });
|
||||
}}
|
||||
>
|
||||
RESET
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{(() => {
|
||||
const bandVals = recipe.adjustments.hslBands?.[hslBand] ?? [0, 0, 0];
|
||||
const updateBand = (idx: number, val: number) => {
|
||||
remember();
|
||||
const cur = [...(recipe.adjustments.hslBands?.[hslBand] ?? [0, 0, 0])] as [number, number, number];
|
||||
cur[idx] = val;
|
||||
setAdjustment({
|
||||
hslBands: {
|
||||
...(recipe.adjustments.hslBands ?? {}),
|
||||
[hslBand]: cur,
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="dev-row">
|
||||
<span className="dev-row-label">Hue</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-30}
|
||||
max={30}
|
||||
value={bandVals[0]}
|
||||
onChange={(e) => updateBand(0, Number(e.target.value))}
|
||||
/>
|
||||
<span className="dev-row-value">{bandVals[0] > 0 ? `+${bandVals[0]}` : bandVals[0]}</span>
|
||||
</div>
|
||||
<div className="dev-row">
|
||||
<span className="dev-row-label">Saturation</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={bandVals[1]}
|
||||
onChange={(e) => updateBand(1, Number(e.target.value))}
|
||||
/>
|
||||
<span className="dev-row-value">{bandVals[1] > 0 ? `+${bandVals[1]}` : bandVals[1]}</span>
|
||||
</div>
|
||||
<div className="dev-row">
|
||||
<span className="dev-row-label">Luminance</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={bandVals[2]}
|
||||
onChange={(e) => updateBand(2, Number(e.target.value))}
|
||||
/>
|
||||
<span className="dev-row-value">{bandVals[2] > 0 ? `+${bandVals[2]}` : bandVals[2]}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dev-subgroup" style={{ marginTop: '4px' }}>
|
||||
<div className="dev-subgroup-title">IMAGE MASTER</div>
|
||||
<div className="dev-row">
|
||||
<span className="dev-row-label">Hue</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-180}
|
||||
max={180}
|
||||
value={recipe.adjustments.hslHue ?? 0}
|
||||
onChange={(e) => {
|
||||
remember();
|
||||
setAdjustment({ hslHue: Number(e.target.value) });
|
||||
}}
|
||||
/>
|
||||
<span className="dev-row-value">{(recipe.adjustments.hslHue ?? 0) > 0 ? `+${recipe.adjustments.hslHue}` : recipe.adjustments.hslHue ?? 0}</span>
|
||||
</div>
|
||||
<div className="dev-row">
|
||||
<span className="dev-row-label">Saturation</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={recipe.adjustments.hslSat ?? 0}
|
||||
onChange={(e) => {
|
||||
remember();
|
||||
setAdjustment({ hslSat: Number(e.target.value) });
|
||||
}}
|
||||
/>
|
||||
<span className="dev-row-value">{(recipe.adjustments.hslSat ?? 0) > 0 ? `+${recipe.adjustments.hslSat}` : recipe.adjustments.hslSat ?? 0}</span>
|
||||
</div>
|
||||
<div className="dev-row">
|
||||
<span className="dev-row-label">Luminance</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={recipe.adjustments.hslLum ?? 0}
|
||||
onChange={(e) => {
|
||||
remember();
|
||||
setAdjustment({ hslLum: Number(e.target.value) });
|
||||
}}
|
||||
/>
|
||||
<span className="dev-row-value">{(recipe.adjustments.hslLum ?? 0) > 0 ? `+${recipe.adjustments.hslLum}` : recipe.adjustments.hslLum ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
// The panel is a cascade of columns (see styles/app.css): the tab's chips, the
|
||||
@@ -4290,33 +4406,7 @@ export function Workspace() {
|
||||
their column is the visitor's to widen (see ColGrip). */}
|
||||
{listTab ? <ColGrip col="main" /> : null}
|
||||
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
|
||||
{/* The mixer's readout: the colour the eyedropper last read, as the
|
||||
RGB bytes and the HSL the band is indexed by. */}
|
||||
{tab === 'hsl' && pro ? (
|
||||
<div className="hsl-readout" data-key="hsl-readout">
|
||||
{sample && picked ? (
|
||||
<>
|
||||
<span
|
||||
className="hsl-swatch"
|
||||
data-key="hsl-swatch"
|
||||
data-color={`rgb(${sample.r}, ${sample.g}, ${sample.b})`}
|
||||
style={{ background: `rgb(${sample.r}, ${sample.g}, ${sample.b})` }}
|
||||
/>
|
||||
<span className="hsl-line" data-key="hsl-rgb">
|
||||
R {sample.r} · G {sample.g} · B {sample.b}
|
||||
</span>
|
||||
<span className="hsl-line" data-key="hsl-hsl">
|
||||
{Math.round(picked.h)}° · {Math.round(picked.s * 100)}% · {Math.round(picked.l * 100)}%
|
||||
</span>
|
||||
<span className="hsl-line" data-key="hsl-band">
|
||||
{HSL_BANDS.find((b) => b.id === hslBand)?.label}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<p className="hint">{t('hsl.hint')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* RESET is the way out of an edit, not one more chip: it sits under
|
||||
a rule at the foot of the column so it reads as its own thing. */}
|
||||
<div className="reset-row">
|
||||
|
||||
Reference in New Issue
Block a user