300 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|