diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 944422f..f6c306d 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -165,11 +165,16 @@ const SLIDER_SWATCH: Partial 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> = { 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(null); + const [openGroup, setOpenGroup] = useState(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): 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): 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() { - {/* 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 ? ( -
- {} }, - { key: 'mask-delete', label: 'DELETE', onClick: deleteMask }, - ]} - /> - {maskRulers.map((def) => ( - - ))} -
- ) : 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 ? (
@@ -2971,6 +2990,27 @@ export function Workspace() { ) : null}
) : 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 ? ( +
+ {} }, + { key: 'mask-delete', label: 'DELETE', onClick: deleteMask }, + ]} + /> + {maskRulers.map((def) => ( + + ))} +
+ ) : null} {tab === 'presets' && showRecipes ? (
{recipeStrip.length === 1 ?

{t('sec.savedEmpty')}

: null}