Files
RecipesCam/docker/frontend/src/ui/DevelopPanels.tsx
T

300 lines
10 KiB
TypeScript

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<string, 'temp' | 'tint' | undefined> = { 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<ColorAdjustments>) => 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 (
<button type="button" className="dev-row locked" data-key={`dev-${paramKey}`} onClick={() => onPro?.()}>
{def.label}
<span className="dev-pro">PRO</span>
</button>
);
}
return (
<MiniSlider
label={def.label}
value={def.get(adj)}
min={def.min}
max={def.max}
step={def.step}
defaultValue={def.defaultValue}
track={TRACK[paramKey]}
format={def.display}
prefix="dev"
dataKey={`dev-${paramKey}`}
onChange={(v) => 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<ColorAdjustments>) => void;
pro?: boolean;
onPro?: () => void;
slots?: { wb?: ReactNode; wbFoot?: ReactNode; toneFoot?: ReactNode; effects?: ReactNode };
}) {
const [soloMode, setSoloMode] = useState<boolean>(() => recall(SOLO_KEY) === 'true');
const [open, setOpen] = useState<Record<string, boolean>>(() => {
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<string, boolean>) => {
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 (
<div className="dev-panels">
{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 (
<section key={panel.slug} className="dev-panel" data-key={`dev-panel-${panel.slug}`}>
<button
type="button"
className="dev-panel-head"
aria-expanded={isOpen}
onClick={() => fold(panel.slug, isOpen)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: Math.min(e.clientX, window.innerWidth - 180), y: Math.min(e.clientY, window.innerHeight - 150) });
}}
>
<span className="dev-panel-label">{panel.title}</span>
<span className="dev-caret" aria-hidden="true">{isOpen ? '▾' : '▸'}</span>
</button>
{isOpen ? (
<div className="dev-panel-body">
{panel.slotFirst ? slot : null}
{panel.rows.map((key) => (
<DevRow key={key} paramKey={key} adj={adj} set={set} pro={pro} onPro={onPro} />
))}
{panel.slotFirst ? null : slot}
{foot}
</div>
) : null}
</section>
);
})}
{contextMenu ? (
<div
className="adm-context-menu"
style={{
position: 'fixed',
top: contextMenu.y,
left: contextMenu.x,
zIndex: 99999,
background: 'var(--color-bg-elevated, #232328)',
border: '1px solid var(--color-border, #3a3a42)',
borderRadius: '6px',
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
padding: '4px 0',
minWidth: '150px',
fontSize: '12px',
color: '#eee',
}}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
padding: '6px 12px',
border: 'none',
background: 'transparent',
color: 'currentColor',
cursor: 'pointer',
textAlign: 'left',
fontSize: '12px',
}}
onClick={toggleSolo}
>
<input type="checkbox" checked={soloMode} readOnly style={{ pointerEvents: 'none', cursor: 'pointer' }} />
<span>Solo Mode</span>
</button>
<div style={{ height: '1px', background: 'var(--color-border, #3a3a42)', margin: '4px 0' }} />
<button
type="button"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
padding: '6px 12px',
border: 'none',
background: 'transparent',
color: 'currentColor',
cursor: 'pointer',
textAlign: 'left',
fontSize: '12px',
}}
onClick={collapseAll}
>
<span>Collapse All</span>
</button>
<button
type="button"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
padding: '6px 12px',
border: 'none',
background: 'transparent',
color: 'currentColor',
cursor: 'pointer',
textAlign: 'left',
fontSize: '12px',
}}
onClick={expandAll}
>
<span>Expand All</span>
</button>
</div>
) : null}
</div>
);
}