web: put the brushes in a FIX strip and the shapes in a GRADIENT MASK one
The FX row had grown into a flat list where a brush and a filter and a shape sat side by side, and it lied about what the tools are: HEAL and MOSAIC only paint, LINEAR and RADIAL only make a mask. The row now carries FIX and GRADIENT MASK, in that order, before MONOCHROME, and each one opens its own strip holding the tools that belong to it — turning amber when it has a spot or a mask to show for itself, so the state still reads from the row without opening anything. The two brushes moved into the FIX strip with the header FIX above them, the two shapes into the GRADIENT MASK strip under the header GRADIENT MASK, and each CLEAR moved in with the tool it clears instead of sitting at the end of the row. Opening FIX still arms the brush the same way — the strip only changes where the chip lives, not what clicking it does. The mask column (shine, bearing, feather, and the rest) still hangs off the shape you pick, so it now stands right after the options column: the strip that brought it out comes first, then the column it belongs to. Nothing else in the column order moved. ponytail: the two strips ride the existing openGroup and toggleGroup, so a strip key is just a widened GroupKey rather than new state to keep in sync; the option-strip body itself stayed where it was, keeping the diff to the chips that moved. Verified: - npx tsc --noEmit clean. - Frontend probes against the built production bundle on :8090 and the dev server: mask-probe 53/0, brush-edit-probe 35/0, heal-idle-probe 23/0, heal-zoom-drag-probe 28/0, grain-controls 20/0, temp-swatch 33/0, and landing, pro-gate, award-column, otp-code, tone-curve, hsl-panel, chip-edge, chips-desk, slider-reset all PASS rc=0 fails=0. - Backend npm test 180/0. - panel-test keeps exactly its four pre-existing failures (rail labels, WB swatch); they reproduce on the commit before this one.
This commit is contained in:
+112
-72
@@ -165,11 +165,16 @@ const SLIDER_SWATCH: Partial<Record<string, (v: number) => string>> = {
|
||||
};
|
||||
|
||||
// The option strips, one open at a time (phone's GroupKey set minus the groups
|
||||
// the web engine has no knob for).
|
||||
// the web engine has no knob for). Two strips are of another kind: FIX holds the
|
||||
// two brushes, GRADIENT MASK the two shapes, and their chips arm the POINTER
|
||||
// rather than pick a value — so they open a strip like the rest and are not
|
||||
// entries in groupDefs, which is where the strips that pick keep their options.
|
||||
type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'grain' | 'wm'
|
||||
| 'wmColor' | 'wmSize' | 'wmFont' | 'gpsColor' | 'gpsName' | 'gpsTime' | 'gpsFont';
|
||||
const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'wmFont', 'gpsColor', 'gpsName', 'gpsTime', 'gpsFont'];
|
||||
const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g));
|
||||
type ToolStrip = 'fix' | 'gradient';
|
||||
type StripKey = GroupKey | ToolStrip;
|
||||
const WM_SUBGROUPS: StripKey[] = ['wmColor', 'wmSize', 'wmFont', 'gpsColor', 'gpsName', 'gpsTime', 'gpsFont'];
|
||||
const isWmPanel = (g: StripKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g));
|
||||
// A slider row opened from a strip returns to it with "<" (COLOR TEMP -> TEMP).
|
||||
const PARAM_GROUP: Partial<Record<string, GroupKey>> = {
|
||||
temperature: 'temp',
|
||||
@@ -316,7 +321,7 @@ export function Workspace() {
|
||||
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 [openGroup, setOpenGroup] = useState<StripKey | null>(null);
|
||||
// The TEMP preset the user last tapped. AUTO and DAYLIGHT are the same pair on
|
||||
// the engine, so the pair alone cannot say which chip is lit — the phone keeps
|
||||
// the same memory (wbChoice in AdjustmentPanel).
|
||||
@@ -1792,7 +1797,7 @@ export function Workspace() {
|
||||
);
|
||||
|
||||
const toggleGroup = useCallback(
|
||||
(g: GroupKey) => {
|
||||
(g: StripKey) => {
|
||||
const next = openGroup === g ? null : g;
|
||||
setMaskTool(null);
|
||||
if (next) {
|
||||
@@ -2491,62 +2496,35 @@ export function Workspace() {
|
||||
groupChip('cxb'),
|
||||
];
|
||||
case 'fx': {
|
||||
// FX's two brushes are the tab's tools that are not a look: a chip arms
|
||||
// one, the wheel sizes it — the chip's own readout is that size, since
|
||||
// the brush has no size buttons to point at — and CLEAR hands the whole
|
||||
// photo back. HEAL repairs a speck of dust, MOSAIC hides a patch of the
|
||||
// photo from whoever looks at it; they sit above the look chips because
|
||||
// they change the file rather than grade it.
|
||||
// FX's tools are not looks: each is a strip of its own, opened from the
|
||||
// row and holding the two chips that arm the pointer — FIX's brushes and
|
||||
// GRADIENT MASK's shapes. The strip's own chip carries the name and glows
|
||||
// amber while there is something inside it to clear, so the row above the
|
||||
// look chips stays a row of looks.
|
||||
return [
|
||||
{
|
||||
key: 'heal',
|
||||
label: 'HEAL',
|
||||
value: brushTool === 'heal' ? `${(healR * 100).toFixed(1)}%` : undefined,
|
||||
active: brushTool === 'heal',
|
||||
amberValue: healSpots.length > 0,
|
||||
key: 'fix',
|
||||
label: 'FIX',
|
||||
active: openGroup === 'fix',
|
||||
amberValue: healSpots.length > 0 || mosaicSpots.length > 0,
|
||||
onClick: () => {
|
||||
// The tools that take the pointer on the photo never share it.
|
||||
setPicking(false);
|
||||
setMaskTool(null);
|
||||
setBrushTool((v) => (v === 'heal' ? null : 'heal'));
|
||||
setBrushTool(null);
|
||||
toggleGroup('fix');
|
||||
},
|
||||
},
|
||||
...(healSpots.length
|
||||
? [{ key: 'heal-clear', label: 'CLEAR', onClick: clearHealSpots }]
|
||||
: []),
|
||||
{
|
||||
key: 'mosaic',
|
||||
label: 'MOSAIC',
|
||||
value: brushTool === 'mosaic' ? `${(mosaicR * 100).toFixed(1)}%` : undefined,
|
||||
active: brushTool === 'mosaic',
|
||||
amberValue: mosaicSpots.length > 0,
|
||||
onClick: () => {
|
||||
setPicking(false);
|
||||
setMaskTool(null);
|
||||
setBrushTool((v) => (v === 'mosaic' ? null : 'mosaic'));
|
||||
},
|
||||
},
|
||||
...(mosaicSpots.length
|
||||
? [{ key: 'mosaic-clear', label: 'CLEAR', onClick: clearMosaicSpots }]
|
||||
: []),
|
||||
// LINEAR and RADIAL GRADIENT are the tab's other two tools that take
|
||||
// the pointer: a chip arms one and the next drag on the photo lays the
|
||||
// shape down, inside which the column beside it grades the pixels. The
|
||||
// chip carries the shape's own name rather than its count, so CLEAR
|
||||
// below it can, and the pair sits with HEAL and MOSAIC because all
|
||||
// four change the photo where it is rather than the look on top of it.
|
||||
...(['linear', 'radial'] as const).map((kind): ChipDef => ({
|
||||
key: kind,
|
||||
label: kind.toUpperCase(),
|
||||
active: maskTool === kind,
|
||||
amberValue: masks.some((m) => m.kind === kind),
|
||||
key: 'gradient-mask',
|
||||
label: 'GRADIENT MASK',
|
||||
active: openGroup === 'gradient',
|
||||
amberValue: masks.length > 0,
|
||||
onClick: () => {
|
||||
setPicking(false);
|
||||
setBrushTool(null);
|
||||
setMaskTool((v) => (v === kind ? null : kind));
|
||||
toggleGroup('gradient');
|
||||
},
|
||||
})),
|
||||
...(masks.length ? [{ key: 'masks-clear', label: 'CLEAR', onClick: clearMasks }] : []),
|
||||
},
|
||||
{ key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono },
|
||||
// GRAIN is a strip of its own — amount, size, and the count they add up
|
||||
// to — so it is one chip here and its two knobs live inside it; the
|
||||
@@ -2613,7 +2591,67 @@ export function Workspace() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
})();
|
||||
|
||||
const stripChips = (g: Exclude<GroupKey, 'wm'>): ChipDef[] => [
|
||||
// FIX's two brushes: a chip arms one, the wheel sizes it — the chip's own
|
||||
// readout is that size, since the brush has no size buttons to point at — and
|
||||
// CLEAR hands the whole photo back. HEAL repairs a speck of dust, MOSAIC hides
|
||||
// a patch of the photo from whoever looks at it.
|
||||
const fixChips: ChipDef[] = [
|
||||
{ key: 'hint-fix', label: 'FIX', disabled: true, onClick: () => {} },
|
||||
{
|
||||
key: 'heal',
|
||||
label: 'HEAL',
|
||||
value: brushTool === 'heal' ? `${(healR * 100).toFixed(1)}%` : undefined,
|
||||
active: brushTool === 'heal',
|
||||
amberValue: healSpots.length > 0,
|
||||
onClick: () => {
|
||||
// The tools that take the pointer on the photo never share it.
|
||||
setPicking(false);
|
||||
setMaskTool(null);
|
||||
setBrushTool((v) => (v === 'heal' ? null : 'heal'));
|
||||
},
|
||||
},
|
||||
...(healSpots.length ? [{ key: 'heal-clear', label: 'CLEAR', onClick: clearHealSpots }] : []),
|
||||
{
|
||||
key: 'mosaic',
|
||||
label: 'MOSAIC',
|
||||
value: brushTool === 'mosaic' ? `${(mosaicR * 100).toFixed(1)}%` : undefined,
|
||||
active: brushTool === 'mosaic',
|
||||
amberValue: mosaicSpots.length > 0,
|
||||
onClick: () => {
|
||||
setPicking(false);
|
||||
setMaskTool(null);
|
||||
setBrushTool((v) => (v === 'mosaic' ? null : 'mosaic'));
|
||||
},
|
||||
},
|
||||
...(mosaicSpots.length ? [{ key: 'mosaic-clear', label: 'CLEAR', onClick: clearMosaicSpots }] : []),
|
||||
];
|
||||
|
||||
// GRADIENT MASK's two shapes are the tab's other tools that take the pointer: a
|
||||
// chip arms one and the next drag on the photo lays the shape down, inside
|
||||
// which the column beside it grades the pixels. The chip carries the shape's
|
||||
// own name rather than its count, so CLEAR below it can.
|
||||
const gradientChips: ChipDef[] = [
|
||||
{ key: 'hint-gradient', label: 'GRADIENT MASK', disabled: true, onClick: () => {} },
|
||||
...(['linear', 'radial'] as const).map((kind): ChipDef => ({
|
||||
key: kind,
|
||||
label: kind.toUpperCase(),
|
||||
active: maskTool === kind,
|
||||
amberValue: masks.some((m) => m.kind === kind),
|
||||
onClick: () => {
|
||||
setPicking(false);
|
||||
setBrushTool(null);
|
||||
setMaskTool((v) => (v === kind ? null : kind));
|
||||
},
|
||||
})),
|
||||
...(masks.length ? [{ key: 'masks-clear', label: 'CLEAR', onClick: clearMasks }] : []),
|
||||
];
|
||||
|
||||
const stripChips = (g: Exclude<StripKey, 'wm'>): ChipDef[] => {
|
||||
// The two tool strips hold chips that arm the pointer rather than options to
|
||||
// pick, so groupDefs has nothing to say about them.
|
||||
if (g === 'fix') return fixChips;
|
||||
if (g === 'gradient') return gradientChips;
|
||||
return [
|
||||
{ key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} },
|
||||
...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
|
||||
@@ -2645,7 +2683,8 @@ export function Workspace() {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
];
|
||||
};
|
||||
|
||||
// RECIPES = this account's own files (SAVE RECENT) + what this session made
|
||||
// but never filed + the bundled presets. A CREATE that a signed-in account
|
||||
@@ -2851,27 +2890,7 @@ export function Workspace() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* column 2 — FX's GRADIENT MASK, while a mask is chosen: the shape's own
|
||||
name, the way to take it off the photo, and its knobs as the app's
|
||||
own rulers — one parameter to a row. A radial mask adds the feather
|
||||
it fades over, since that is the number its rim is made of. The
|
||||
column is the mask tool's, so putting that tool down — which is what
|
||||
another chip does — takes the column with it. */}
|
||||
{maskTool && selMask ? (
|
||||
<div className="col col-sub strip" data-col="mask">
|
||||
<ChipRow
|
||||
chips={[
|
||||
{ key: 'mask-kind', label: selMask.kind.toUpperCase(), active: true, onClick: () => {} },
|
||||
{ key: 'mask-delete', label: 'DELETE', onClick: deleteMask },
|
||||
]}
|
||||
/>
|
||||
{maskRulers.map((def) => (
|
||||
<SliderRow key={def.key} slider={def} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* column 2b — FRAME's WATERMARK chip opens the two collapses, one per
|
||||
{/* FRAME's WATERMARK chip opens the two collapses, one per
|
||||
watermark type: the header chip, then that mark's own controls */}
|
||||
{wmOpen ? (
|
||||
<div className="col col-sub strip wm-extra" data-col="wm">
|
||||
@@ -2971,6 +2990,27 @@ export function Workspace() {
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* FX's GRADIENT MASK strip, once one of its shapes is chosen: the
|
||||
shape's own name, the way to take it off the photo, and its knobs as
|
||||
the app's own rulers — one parameter to a row. A radial mask adds the
|
||||
feather it fades over, since that is the number its rim is made of.
|
||||
The column is the mask tool's, so putting that tool down — which is
|
||||
what another chip does — takes the column with it. It stands after
|
||||
the strip it came out of, the way a ruler stands after its own. */}
|
||||
{maskTool && selMask ? (
|
||||
<div className="col col-sub strip" data-col="mask">
|
||||
<ChipRow
|
||||
chips={[
|
||||
{ key: 'mask-kind', label: selMask.kind.toUpperCase(), active: true, onClick: () => {} },
|
||||
{ key: 'mask-delete', label: 'DELETE', onClick: deleteMask },
|
||||
]}
|
||||
/>
|
||||
{maskRulers.map((def) => (
|
||||
<SliderRow key={def.key} slider={def} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{tab === 'presets' && showRecipes ? (
|
||||
<div className="col col-sub strip" data-col="recipes">
|
||||
{recipeStrip.length === 1 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
|
||||
|
||||
Reference in New Issue
Block a user