import { useState, useEffect } from 'react'; import type { ReactNode } from 'react'; import type { ColorAdjustments } from '../../shared/types'; import { PARAM_DEFS } from '../../shared/utils/paramDefs'; import { keep, recall } from '../remember'; import { MiniSlider } from './ChipColumn'; // One lookup for every row. The defs are split over the three tabs' tables and // the sidebar reads them all at once, so the map is built once here; a key that // is not in it draws no row rather than a broken one. const DEF_BY_KEY = new Map( [...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters].map((def) => [def.key, def]) ); // The stack, top to bottom, and the rows each panel owns. The two WB tracks are // the only rows whose travel is a colour, so the class each input wears is named // here with them; anything else is the bare slider. const TRACK: Record = { temperature: 'temp', tint: 'tint' }; const PANELS: { slug: string; title: string; rows: string[]; // A slot rides inside its panel, where the parent's own select belongs in the // reading order — before the panel's knobs except in DETAIL & EFFECTS, whose // D.RANGE selects close the column. slot?: 'wb' | 'effects'; // A pick that closes its panel rather than opening it: drawn under the rows, // where WB's two Color Chromes belong (they are read after the pair of tracks // they are the other axis of) and where TONE's curve belongs, standing under // the range it is the shape of. foot?: 'wbFoot' | 'toneFoot'; slotFirst?: boolean; }[] = [ { slug: 'wb', title: 'WB', rows: ['temperature', 'tint'], slot: 'wb', slotFirst: true, foot: 'wbFoot' }, // EV leads TONE: it is the one number the whole range hangs off, and the // exposure below it is the finer move on the same axis. { slug: 'tone', title: 'TONE', rows: ['ev', 'exposure', 'contrast', 'highlight', 'shadow', 'whites', 'blacks'], foot: 'toneFoot', }, { slug: 'presence', title: 'PRESENCE', rows: ['clarity', 'dehaze', 'vibrance', 'color'] }, { slug: 'effects', title: 'DETAIL & EFFECTS', rows: ['denoise', 'sharpening', 'vignette', 'hdf', 'grain', 'grainSize'], slot: 'effects', }, ]; function DevRow({ paramKey, adj, set, pro, onPro, }: { paramKey: string; adj: ColorAdjustments; set: (patch: Partial) => void; pro: boolean; onPro?: () => void; }) { const def = DEF_BY_KEY.get(paramKey); if (!def) return null; // A PRO knob on the LITE build is a label with nothing under it: the value // stays at its default, and the row's own click is handed to the parent's // upgrade path instead of a slider that would move nothing on the photo. if (def.pro && !pro) { return ( ); } return ( set(def.set(v))} onReset={() => set(def.set(def.defaultValue))} /> ); } // Which panels the studio draws open, under this key. The list is the panels' // slugs, comma-joined; a slug no panel answers to is dropped on the way in, so a // panel that is renamed or taken out does not leave a hole in the column. const OPEN_KEY = 'recipescam.develop.open'; const SOLO_KEY = 'recipescam.develop.soloMode'; // The studio's right column: every panel's knobs behind a disclosure that folds // its own rows away, and the column opens with all of them folded — a dozen rows // of sliders is a wall to scroll past, and the knob being looked for is named by // its panel's head. What the reader opens is remembered, so the second visit // opens the way the first was left. One undo step is the parent's `set`. export function DevelopPanels({ adj, set, pro = false, onPro, slots, }: { adj: ColorAdjustments; set: (patch: Partial) => void; pro?: boolean; onPro?: () => void; slots?: { wb?: ReactNode; wbFoot?: ReactNode; toneFoot?: ReactNode; effects?: ReactNode }; }) { const [soloMode, setSoloMode] = useState(() => recall(SOLO_KEY) === 'true'); const [open, setOpen] = useState>(() => { const saved = new Set((recall(OPEN_KEY) ?? '').split(',')); return Object.fromEntries(PANELS.filter((panel) => saved.has(panel.slug)).map((panel) => [panel.slug, true])); }); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); useEffect(() => { if (!contextMenu) return; const close = () => setContextMenu(null); window.addEventListener('click', close); window.addEventListener('keydown', (e) => e.key === 'Escape' && close()); return () => { window.removeEventListener('click', close); }; }, [contextMenu]); const saveOpen = (next: Record) => { setOpen(next); keep(OPEN_KEY, PANELS.filter((panel) => next[panel.slug]).map((panel) => panel.slug).join(',')); }; const fold = (slug: string, isOpen: boolean) => { if (soloMode) { if (isOpen) { saveOpen({ ...open, [slug]: false }); } else { const next = Object.fromEntries(PANELS.map((p) => [p.slug, p.slug === slug])); saveOpen(next); } } else { saveOpen({ ...open, [slug]: !isOpen }); } }; const toggleSolo = () => { const nextSolo = !soloMode; setSoloMode(nextSolo); keep(SOLO_KEY, String(nextSolo)); if (nextSolo) { const activeSlug = PANELS.find((p) => open[p.slug])?.slug ?? 'wb'; const next = Object.fromEntries(PANELS.map((p) => [p.slug, p.slug === activeSlug])); saveOpen(next); } setContextMenu(null); }; const collapseAll = () => { saveOpen(Object.fromEntries(PANELS.map((p) => [p.slug, false]))); setContextMenu(null); }; const expandAll = () => { if (soloMode) { setSoloMode(false); keep(SOLO_KEY, 'false'); } saveOpen(Object.fromEntries(PANELS.map((p) => [p.slug, true]))); setContextMenu(null); }; return (
{PANELS.map((panel) => { const isOpen = open[panel.slug]; const slot = panel.slot ? slots?.[panel.slot] : null; const foot = panel.foot ? slots?.[panel.foot] : null; return (
{isOpen ? (
{panel.slotFirst ? slot : null} {panel.rows.map((key) => ( ))} {panel.slotFirst ? null : slot} {foot}
) : null}
); })} {contextMenu ? (
e.stopPropagation()} >
) : null}
); }