import React, { useEffect, useMemo, useRef, useState } from 'react'; import { View, Text, TextInput, TouchableOpacity, ScrollView } from 'react-native'; import Slider from '@react-native-community/slider'; import * as Haptics from 'expo-haptics'; import { Star, Trash2 } from 'lucide-react-native'; import { Skia } from '@shopify/react-native-skia'; import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio } from '../types'; import { FRAMES } from '../utils/frameUtils'; import { PRO_FRAMES } from '../utils/entitlement'; import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs'; import { grainPerInch } from '../utils/grainShader'; import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes'; import RecipeCreateModal from './RecipeCreateModal'; // Compact bottom sheet above the tool rail: // bottom row = the tool's chips (presets / parameters), horizontally scrollable // rows above = one full-width slider per OPEN parameter, labeled with its name. // Tapping a parameter chip toggles its slider row; chips stay visible. // Darkroom-flavoured: amber = active/edited, red = destructive, mono labels. type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'frame'; // Choice-group keys that open a strip above the chip row: color chrome groups // (wb tab) and the D.RANGE selector (iq tab). type GroupKey = | 'style' | 'temp' | 'grain' | 'cx' | 'cxb' | 'dr' | 'wmColor' | 'wmSize' | 'wmFont' | 'gpsColor' | 'gpsName' | 'gpsTime' | 'crop' | 'rotate' // Not a strip: the WATERMARK row REPLACES the frame chips (sub-panel). | 'wm'; // The watermark's own option strips (TEXT COLOR / SIZE / FONT, GPS name+time). // They hang OFF the sub-panel, so opening one must NOT close the sub-panel's // chip row — that swap is what hid the edit chips behind the strip. const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'wmFont', 'gpsColor', 'gpsName', 'gpsTime']; const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g)); interface AdjustmentPanelProps { activeTab: TabId; recipes: Recipe[]; currentRecipeId: string; adjustments: ColorAdjustments; // RESET: the app's own defaults, from any tab — the startup look, every // parameter, the frame and the watermarks. onResetAll: () => void; // True while the look differs from a clean start: the RESET chip says so // ("RESET *", amber) instead of silently doing nothing visible. resetDirty: boolean; selectedFrame: FrameId; // FRAME tab CROP. Only meaningful on the library's plain frames (the camera // composes through its ratio band, polaroid/wall through their own window), // so App hides the chip everywhere else. cropRatio: CropRatio; onUpdateCropRatio: (ratio: CropRatio) => void; // The ratio APPLY has committed; the strip offers APPLY only while the // picked ratio is still uncommitted. cropApplied?: CropRatio | null; onApplyCrop: () => void; cropEnabled?: boolean; useGeotag: boolean; // Runtime tier: PRO apk or a LITE apk that bought the unlock. A LITE build // keeps every PRO chip on screen, greyed with its corner tag, so the panel // has to read the value at render time rather than at build time. pro: boolean; // RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear // Settings sheet (gear between library picker and shutter) — no longer // tool-rail tabs. // The parameter whose slider row is open — lifted to App so it can also drive // drag-anywhere-on-the-image adjustment while the row is open. openParam: string | null; onOpenParamChange: (key: string | null) => void; // Base filter of the currently applied recipe — seeds the CREATE modal's // SIMULATION row so quick-input starts from the active look. currentBaseFilter?: BaseFilter; // CREATE quick-input saves a brand-new recipe (name + adjustments from the // modal) and applies it, unlike onSaveRecipe which snapshots the live state. onCreateRecipe: (recipe: Omit) => void; // Recipe being edited (custom ones only) — non-null puts the CREATE form // into editor mode, prefilled with that recipe's name and parameters. editingRecipe?: Recipe | null; onCloseEdit: () => void; // SAVE from the editor: the caller decides overwrite (same name) vs // save-as (new name). onUpdateRecipe: (recipe: Omit) => void; onSelectRecipe: (recipe: Recipe) => void; // FAVORITED tab + the star trailing every recipe chip. Ids only: the star is // metadata, so it survives RESET and never changes the current look. favoriteIds: string[]; onToggleFavorite: (id: string) => void; onUpdateAdjustments: (adjustments: Partial) => void; onUpdateFrame: (frameId: FrameId) => void; // FRAME tab: turn the inserted library photo by a quarter turn (0/90/180/ // 270deg clockwise) so it fits the chosen frame better, plus a fine // STRAIGHTEN offset (-45..45deg) applied on top of that turn. photoRotation: 0 | 90 | 180 | 270; photoStraighten: number; onPickRotation: (deg: 0 | 90 | 180 | 270) => void; onStraighten: (deg: number) => void; // FRAME-tab AUTO: ask App to read the tilt of the photo itself and set the // STRAIGHTEN angle that cancels it. A one-shot, not a mode. onAutoStraighten: () => void; // FRAME tab (WALL FRAME only): hang the artwork landscape instead of the // default portrait rotation. wallLandscape: boolean; onToggleWallLandscape: () => void; onToggleGeotag: (enabled: boolean) => void; onSaveRecipe: (name: string) => void; // IMPORT: pick a recipe file shared from another phone and store it as a // normal custom recipe. onImportRecipe: () => void; // LITE: this many of its own recipes exist, so the next one is the paid // step. The strip's trailing + chip reads it, the save paths gate on it. recipeLimitReached: boolean; onRecipeLimit: () => void; onDeleteRecipe: (id: string) => void; // WATERMARK tab state — lifted to App so the live Viewfinder overlay, this // input and the export options all read the same object. customWm: CustomWatermark; onUpdateCustomWm: (wm: CustomWatermark) => void; // GPS mark styling — the SAME object the preview and the export read, so a // chip press repaints both. gpsWm: GpsWatermark; onUpdateGpsWm: (wm: GpsWatermark) => void; // ACCEPT: copy the live draft into the mark the export burns in. Edits stay a // draft (viewfinder preview only) until this is pressed. onAcceptCustomWm: () => void; // CANCEL: throw the un-accepted draft away — back to the mark that was last // ACCEPTED (or none at all) — and close the editor. The way out of a running // watermark edit that previously did not exist. onCancelCustomWm: () => void; // Bumped by a double-tap on the mark: put the caret back in the text input. wmEditNonce: number; // WATERMARK tab ROTATE chip: clockwise degrees (0/90/180/270) applied to both // the GPS lines and the custom text, in the preview and in the export. wmRotation: number; onRotateWm: () => void; // FRAME → WATERMARK sub-panel is open: App needs this to arm the mark's // drag/pinch layer on the image (the old `activeTab === 'watermark'`). onWmPanelChange: (open: boolean) => void; // BACK (undo) and PEEK (hold to compare) both work on the whole LOOK, not on // one parameter, and both left this panel: BACK is a button in the top bar, // PEEK is a hold on the picture itself (TopBar, Viewfinder). } interface ChipDef { key: string; label: string; active: boolean; onPress: () => void; onPressDelete?: () => void; // custom presets carry an inline trash button trash?: boolean; star?: boolean; // trailing star button (recipes + FAVORITED) starred?: boolean; // filled/amber star onPressStar?: () => void; disabled?: boolean; // informational label, not tappable amberValue?: boolean; // amber text driven by edited value, not selection pro?: boolean; // a PRO feature: LITE draws the corner tag on it } interface ParamDef { key: string; label: string; value: number; default: number; min: number; max: number; step: number; display: (v: number) => string; onChange: (v: number) => void; pro?: boolean; // PRO-only: the LITE build cannot open this row } const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); // WB preset → engine pair. AUTO and DAYLIGHT both land on the neutral // 5500K / 0 pair (the engine has no scene meter), so the pair alone cannot say // which chip is active — the last tapped preset is remembered to disambiguate. const WB_PAIRS: Record = { auto: { t: 5500, ti: 0 }, daylight: { t: 5500, ti: 0 }, 'daylight-3r': { t: 5500, ti: -3 }, cloudy: { t: 6500, ti: 1 }, shade: { t: 7500, ti: 2 }, tungsten: { t: 3200, ti: 0 }, fluorescent: { t: 4000, ti: 3 }, }; // One list serves both the TEMP strip and the TEMP chip's label. const WB_PRESETS: { v: string; d: string }[] = [ { v: 'auto', d: 'AUTO' }, { v: 'daylight', d: 'DAYLIGHT' }, { v: 'daylight-3r', d: 'DAYLIGHT -3R' }, { v: 'cloudy', d: 'CLOUDY' }, { v: 'shade', d: 'SHADE' }, { v: 'tungsten', d: 'TUNGSTEN' }, { v: 'fluorescent', d: 'FLUOR' }, ]; // WATERMARK tab styling choices: swatches for TEXT COLOR, multipliers on the // base 3.2%-of-frame-width size for TEXT SIZE. const WM_DEFAULT_COLOR = '#f59e0b'; const WM_COLORS: { v: string; d: string }[] = [ { v: '#f59e0b', d: 'AMBER' }, { v: '#ffffff', d: 'WHITE' }, { v: '#111111', d: 'BLACK' }, { v: '#ef4444', d: 'RED' }, { v: '#3b82f6', d: 'BLUE' }, { v: '#22c55e', d: 'GREEN' }, { v: '#a1a1aa', d: 'GREY' }, ]; const WM_SIZES: { v: string; d: string }[] = [ { v: '0.6', d: 'S' }, { v: '1', d: 'M' }, { v: '1.6', d: 'L' }, { v: '2.5', d: 'XL' }, ]; export default function AdjustmentPanel({ activeTab, recipes, currentRecipeId, adjustments, onResetAll, resetDirty, selectedFrame, cropRatio, onUpdateCropRatio, cropApplied, onApplyCrop, cropEnabled, useGeotag, pro, openParam, onOpenParamChange, currentBaseFilter, onCreateRecipe, editingRecipe, onCloseEdit, onUpdateRecipe, onSelectRecipe, favoriteIds, onToggleFavorite, onUpdateAdjustments, onUpdateFrame, photoRotation, photoStraighten, onPickRotation, onStraighten, onAutoStraighten, wallLandscape, onToggleWallLandscape, onToggleGeotag, onImportRecipe, recipeLimitReached, onRecipeLimit, onDeleteRecipe, customWm, onUpdateCustomWm, gpsWm, onUpdateGpsWm, onAcceptCustomWm, onCancelCustomWm, wmEditNonce, wmRotation, onRotateWm, onWmPanelChange, }: AdjustmentPanelProps) { // Double-tap on the mark in the viewfinder bumps wmEditNonce -> focus the // input again so the text can be retyped after the keyboard was dismissed. const wmInputRef = useRef(null); useEffect(() => { if (wmEditNonce > 0) { // BACK hides the IME but leaves the input focused, so a bare focus() is a // no-op and the keyboard never returns. Blur first so the re-focus is a // real focus change the IME reacts to. wmInputRef.current?.blur(); const id = setTimeout(() => wmInputRef.current?.focus(), 60); return () => clearTimeout(id); } }, [wmEditNonce]); // The single choice-group whose strip (OFF/WEAK/STRONG, or the DR ladder) is // open above the chips (same one-row-at-a-time pattern as openParam — which // lives in App now, since an open row also arms image-drag adjustment). const [openGroup, setOpenGroup] = useState(null); // PRESETS tab only: RECIPES chip expands the user-recipe strip above the main // row; CREATE chip opens the quick-input modal. const [showRecipes, setShowRecipes] = useState(false); const [createVisible, setCreateVisible] = useState(false); // WB tab: preset last tapped while its engine pair is still in effect (AUTO // vs DAYLIGHT share the neutral pair, so selection can't be derived). const [wbChoice, setWbChoice] = useState(null); useEffect(() => { setOpenGroup(null); setShowRecipes(false); setCreateVisible(false); }, [activeTab]); // The WATERMARK sub-panel is what arms the mark's drag layer on the image: // one effect on the strip state keeps App's flag true exactly while it shows. useEffect(() => { onWmPanelChange(isWmPanel(openGroup)); }, [openGroup]); // CROP is a plain-frame tool: the moment the selected frame drops it the chip // unmounts, so the ratio strip must close with it (no orphan CROP row). useEffect(() => { if (!cropEnabled) setOpenGroup((g) => (g === 'crop' ? null : g)); }, [cropEnabled]); // The strip a slider row was opened from: the "<" back button returns to it // (STRAIGHTEN lives in ROTATE, COLOR TEMP in TEMP). Params without a parent // strip just close the row. const PARAM_GROUP: Partial> = { straighten: 'rotate', temperature: 'temp', grain: 'grain', grainSize: 'grain', }; const toggleParam = (key: string) => { haptic(); // One auxiliary row at a time: opening a slider closes any option strip. setOpenGroup(null); onOpenParamChange(openParam === key ? null : key); }; const toggleGroup = (g: GroupKey) => { haptic(); const next = openGroup === g ? null : g; // Opening an options strip closes any open slider row and the expanded // recipe strip (single-strip rule). if (next) { onOpenParamChange(null); setShowRecipes(false); } setOpenGroup(next); }; const a = adjustments; const renderChip = (c: ChipDef) => { const tone = c.active ? 'border-amber-500/70 bg-amber-500/15' : 'border-zinc-700/60 bg-titan/80'; const textTone = c.active || c.amberValue ? 'text-amber-500' : 'text-zinc-300'; // LITE only: corner tag on every chip whose look LITE cannot export. const proTag = c.pro && !pro; return ( { chipXRef.current[c.key] = e.nativeEvent.layout.x; }} > {proTag && ( PRO )} {c.trash || c.star ? ( { haptic(); c.onPress(); }} activeOpacity={0.7}> {c.label} {c.star && ( { e.stopPropagation(); haptic(); c.onPressStar && c.onPressStar(); }} className="p-1.5 ml-1" hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} > )} {c.trash && ( { e.stopPropagation(); haptic(); c.onPressDelete && c.onPressDelete(); }} className="p-1.5 ml-1" hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} > )} ) : c.disabled ? ( {c.label} ) : ( { haptic(); c.onPress(); }} className={`flex-row items-center rounded-full border px-3 py-1.5 ${tone}`} activeOpacity={0.7} > {c.label} )} ); }; // Continuous parameter chips: label + live value; amber text when edited; // amber border while its slider row is open. const paramChips = (params: ParamDef[]): ChipDef[] => params.map((p) => ({ key: p.key, label: `${p.label.toUpperCase()} ${p.display(p.value)}`, active: openParam === p.key, amberValue: openParam !== p.key && p.value !== p.default, // A PRO-only parameter stays visible in LITE as a greyed chip with its // corner PRO tag — named where it is, which is the only place it can be. pro: p.pro, disabled: !!p.pro && !pro, onPress: () => toggleParam(p.key), })); // Every tab carries the same RESET. It is pinned OUTSIDE the scroll row on // purpose: the rows keep their scroll offset across tabs, so a chip inside // one can sit off-screen just when it is wanted. const resetChip = (): ChipDef => ({ key: 'reset-all', // Trailing star + amber text: there is a change to undo (the chip is // otherwise a plain neutral RESET that puts the code back to a clean start). label: resetDirty ? 'RESET *' : 'RESET', active: false, amberValue: resetDirty, onPress: () => { // RESET is a parameter switch too: drop any open row/strip with it. setOpenGroup(null); if (openParam) onOpenParamChange(null); onResetAll(); }, }); const choiceChips = ( keyPrefix: string, options: { v: string; d: string; pro?: boolean }[], current: string, onPick: (v: string) => void ): ChipDef[] => options.map((o) => ({ key: `${keyPrefix}:${o.v}`, label: o.d, active: current === o.v, pro: o.pro, onPress: () => onPick(o.v), })); // ---- per-tab parameter definitions --------------------------------------- // Static bounds/labels/accessors live in PARAM_DEFS (shared with App, which // needs the same metadata to drive drag-anywhere adjustment); here each row // is bound to the live adjustments value + an update callback. const toRows = (defs: ContinuousParamDef[]): ParamDef[] => defs.map((d) => ({ key: d.key, label: d.label, value: d.get(a), default: d.defaultValue, pro: d.pro, min: d.min, max: d.max, step: d.step, display: d.display, onChange: (v: number) => onUpdateAdjustments(d.set(v)), })); const paramDefs: Record = { recipes: [], favorites: [], iq: toRows(PARAM_DEFS.iq), wb: toRows(PARAM_DEFS.wb).map((p) => p.key === 'temperature' // Dragging COLOR TEMP is a hand-set balance: forget the preset so the // TEMP chip falls back to the bare kelvin value. ? { ...p, onChange: (v: number) => { setWbChoice(null); p.onChange(v); } } : p ), filters: toRows(PARAM_DEFS.filters), // FRAME carries one slider row: the fine STRAIGHTEN angle. The ROTATE // strip's STRAIGHTEN option opens it, exactly like COLOR TEMP's. frame: [ { key: 'straighten', label: 'STRAIGHTEN', value: photoStraighten, default: 0, min: -45, max: 45, step: 0.5, display: (v: number) => `${v > 0 ? '+' : ''}${v}°`, onChange: (v: number) => onStraighten(v), }, ], }; // TEMP chip's value: the remembered preset while the engine pair still // matches it; otherwise the preset the pair maps to (neutral ⇒ AUTO); else // the bare kelvin value — what COLOR TEMP dragged by hand lands on. const wbValue = (): string => { const t = a.temperature ?? 5500; const ti = a.tint ?? 0; if (wbChoice) { const p = WB_PAIRS[wbChoice]; if (p && p.t === t && p.ti === ti) return wbChoice; } const hit = (Object.keys(WB_PAIRS) as string[]).find((k) => { const p = WB_PAIRS[k]; return p.t === t && p.ti === ti; }); return hit ?? `${t}K`; }; // The chip is the only place the white balance is named, so it carries the // value: TEMP AUTO / TEMP FLUOR / TEMP 4000K. const wbTempLabel = (): string => { const v = wbValue(); const preset = WB_PRESETS.find((o) => o.v === v); return `TEMP ${preset ? preset.d : v}`; }; const wbApply = (val: string) => { setWbChoice(val); // Choosing a preset is a deliberate look switch: collapse any open chrome // options strip so it never lingers above the chips row. setOpenGroup(null); const p = WB_PAIRS[val] ?? WB_PAIRS.auto; onUpdateAdjustments({ temperature: p.t, tint: p.ti }); }; // Choice-groups: one toggle chip sits in the chip row (COLOR CHROME / // CHROME BLUE among the WB chips, D.RANGE after the LIGHT params); tapping it // reveals a strip above the chip row with that group's options. Amber text // while a value differs from the group's off value, amber border while its // strip is open — same affordances as the parameter chips. const chromeOptions = [ { v: 'none', d: 'OFF' }, { v: 'weak', d: 'WEAK' }, { v: 'strong', d: 'STRONG' }, ]; const drOptions = [ { v: 'auto', d: 'AUTO' }, { v: '100', d: 'DR100' }, { v: '200', d: 'DR200' }, { v: '400', d: 'DR400' }, ]; // WATERMARK tab styling strips. FONT lists every family the PLATFORM font // manager exposes (the device's own system fonts); DEFAULT keeps the face // the mark shipped with. const wmFontOptions = useMemo(() => { // LITE ships a single basic face: the platform's own families (what // DEFAULT is not) are the paid half of the FONT strip. if (!pro) return [{ v: 'DEFAULT', d: 'DEFAULT' }]; const mgr = Skia.FontMgr.System(); const names = new Set(); for (let i = 0; i < mgr.countFamilies() && names.size < 120; i++) { const n = mgr.getFamilyName(i); if (n && !/fallback/i.test(n)) names.add(n); } return [ { v: 'DEFAULT', d: 'DEFAULT' }, ...[...names].sort().map((n) => ({ v: n, d: n })), ]; }, [pro]); const groupDefs: Record, { label: string; off: string; value: string; options: { v: string; d: string }[]; onPick: (v: string) => void }> = { // MONOCHROME GRAIN: the amount and the size of the clumps, then the count // they add up to. The two knobs and their readout share one strip because // they are one subject; the chip above them carries the amount, the way TEMP // carries the kelvin. grain: { label: 'MONOCHROME GRAIN', off: '0', // While a ruler is open its own entry is what the chip names, the way // ROTATE's STRAIGHTEN does; otherwise the chip carries the amount, which is // the one the strip is about. value: openParam === 'grain' ? 'amount' : openParam === 'grainSize' ? 'size' : String(a.grain), options: [ { v: 'amount', d: 'AMOUNT' }, { v: 'size', d: 'SIZE' }, ], onPick: (v) => toggleParam(v === 'size' ? 'grainSize' : 'grain'), }, cx: { label: 'COLOR CHROME', off: 'none', value: a.colorChrome || 'none', options: chromeOptions, onPick: (v) => onUpdateAdjustments({ colorChrome: v as ColorAdjustments['colorChrome'] }), }, cxb: { label: 'CHROME BLUE', off: 'none', value: a.colorChromeBlue || 'none', options: chromeOptions, onPick: (v) => onUpdateAdjustments({ colorChromeBlue: v as ColorAdjustments['colorChromeBlue'] }), }, dr: { label: 'D.RANGE', off: 'auto', value: String(a.dynamicRange ?? 'auto'), options: drOptions, onPick: (v) => onUpdateAdjustments({ dynamicRange: (v === 'auto' ? 'auto' : parseInt(v, 10)) as ColorAdjustments['dynamicRange'], }), }, wmColor: { label: 'TEXT COLOR', off: WM_DEFAULT_COLOR, value: customWm.color ?? WM_DEFAULT_COLOR, options: WM_COLORS, onPick: (v) => onUpdateCustomWm({ ...customWm, color: v }), }, wmSize: { label: 'TEXT SIZE', off: '1', value: String(customWm.size ?? 1), options: WM_SIZES, onPick: (v) => onUpdateCustomWm({ ...customWm, size: Number(v) }), }, wmFont: { label: 'TEXT FONT', off: 'DEFAULT', value: customWm.font ?? 'DEFAULT', options: wmFontOptions, onPick: (v) => onUpdateCustomWm({ ...customWm, font: v === 'DEFAULT' ? null : v }), }, // GPS mark's own features — one chip each, opening its options above (the // same one-row-at-a-time strip the custom mark's styling uses). gpsColor: { label: 'COLOR', off: WM_DEFAULT_COLOR, value: gpsWm.color ?? WM_DEFAULT_COLOR, options: WM_COLORS, onPick: (v) => onUpdateGpsWm({ ...gpsWm, color: v }), }, gpsName: { label: 'PLACE NAME', off: 'ON', value: gpsWm.showName === false ? 'OFF' : 'ON', options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }], onPick: (v) => onUpdateGpsWm({ ...gpsWm, showName: v === 'on' }), }, crop: { label: 'CROP', off: 'none', value: cropRatio, // 'free' keeps an explicit user-dragged rectangle; the rest center-crop // the photo to the fixed shape the export writes. options: [ { v: 'none', d: 'NONE' }, { v: 'free', d: 'FREE' }, { v: '1:1', d: '1:1' }, { v: '2:3', d: '2:3' }, { v: '3:2', d: '3:2' }, { v: '3:4', d: '3:4' }, { v: '4:3', d: '4:3' }, { v: '16:9', d: '16:9' }, ], onPick: (v) => onUpdateCropRatio(v as CropRatio), }, // ROTATE: the quarter turns, the fine STRAIGHTEN slider and the AUTO read // in one strip. Its RESET undoes this parameter only (turn + angle). rotate: { label: 'ROTATE', off: '0', // 'straighten' while its slider row is open, so that chip highlights. value: openParam === 'straighten' ? 'straighten' : String(photoRotation), options: [ // The slider entry leads: the strip starts at offset 0, and this is the // only option here that opens an editor. { v: 'straighten', d: 'STRAIGHTEN (±45°)' }, { v: 'reset', d: 'RESET' }, { v: 'auto', d: 'AUTO' }, { v: '0', d: '0' }, { v: '90', d: '90' }, { v: '180', d: '180' }, { v: '270', d: '270' }, ], onPick: (v) => { if (v === 'reset') { onPickRotation(0); onStraighten(0); } else if (v === 'straighten') { toggleParam('straighten'); } else if (v === 'auto') { // AUTO reads the photo and REPLACES the fine angle with the one // that levels its dominant line, so pressing it twice is the same // as pressing it once. onAutoStraighten(); } else { onPickRotation(Number(v) as 0 | 90 | 180 | 270); } }, }, // PHOTO STYLE: the eight film simulations, collapsed into one strip chip. // Its value is the applied sim's id — or '' when a saved recipe is active // (then the chip reads as "not the startup sim" and glows amber). The label // carries no value on purpose: the top bar already names the active look. style: { label: 'PHOTO STYLE', off: 'sim-provia', value: FILM_SIMS.some((s) => s.id === currentRecipeId) ? currentRecipeId : '', options: FILM_SIMS.map((s) => ({ v: s.id, d: s.name })), onPick: (v) => { const sim = FILM_SIMS.find((s) => s.id === v); if (!sim) return; // Same deliberate switch as the old inline chips: collapse the RECIPES // strip so it never lingers over the freshly-applied sim. setShowRecipes(false); onSelectRecipe(filmSimToRecipe(sim)); }, }, // TEMP: the seven white-balance presets plus the COLOR TEMP slider, in a // strip of their own. COLOR TEMP is not a value — picking it opens the // slider row above (the chip keeps naming the kelvin value meanwhile). temp: { label: 'TEMP', off: 'auto', value: wbValue(), options: [{ v: 'color-temp', d: 'COLOR TEMP' }, ...WB_PRESETS], onPick: (v) => (v === 'color-temp' ? toggleParam('temperature') : wbApply(v)), }, gpsTime: { label: 'TIME', off: 'ON', value: gpsWm.showTime === false ? 'OFF' : 'ON', options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }], onPick: (v) => onUpdateGpsWm({ ...gpsWm, showTime: v === 'on' }), }, }; const groupChip = (g: Exclude): ChipDef => ({ key: `grp-${g}`, label: groupDefs[g].label, active: openGroup === g, amberValue: openGroup !== g && groupDefs[g].value !== groupDefs[g].off, onPress: () => toggleGroup(g), }); // TEMP is the one group whose chip shows its value (nothing else names it). const tempChip = (): ChipDef => ({ ...groupChip('temp'), label: wbTempLabel(), }); // GRAIN's chip names the amount the way TEMP names the kelvin: MONOCHROME // GRAIN alone would not say whether any is dialled in, and the amount is what // the strip is about. The SIZE and the count live inside the strip. const grainChip = (): ChipDef => ({ ...groupChip('grain'), label: `MONOCHROME GRAIN ${a.grain % 1 ? a.grain.toFixed(1) : a.grain}`, }); // ROTATE's chip names every part of the angle it applies: the turn and the // fine angle AUTO (or the slider) left on it. const rotateChip = (): ChipDef => { const parts = [ `ROTATE ${photoRotation}`, photoStraighten ? `${photoStraighten > 0 ? '+' : ''}${photoStraighten}°` : null, ]; return { ...groupChip('rotate'), label: parts.filter(Boolean).join(' · '), amberValue: openGroup !== 'rotate' && (photoRotation !== 0 || photoStraighten !== 0), }; }; // WATERMARK is a sub-panel of FRAME now (the rail tab is gone): its chip swaps // the frame chips for the watermark controls, behind a breadcrumb back. const wmOn = useGeotag || customWm.enabled; const wmChip = (): ChipDef => ({ key: 'grp-wm', label: wmOn ? 'WATERMARK ON' : 'WATERMARK', active: openGroup === 'wm', amberValue: openGroup !== 'wm' && wmOn, onPress: () => toggleGroup('wm'), }); const wmChips = (): ChipDef[] => [ { key: 'gps', label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF', active: useGeotag, pro: true, onPress: () => onToggleGeotag(!useGeotag), }, ...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsName'), groupChip('gpsTime')] : []), { key: 'custom-wm', label: customWm.enabled ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF', active: customWm.enabled, onPress: () => onUpdateCustomWm({ ...customWm, enabled: !customWm.enabled }), }, ...(wmOn ? [{ key: 'wm-rotate', label: `ROTATE ${wmRotation}°`, active: wmRotation !== 0, onPress: onRotateWm, }] : []), ...(customWm.enabled ? [ { key: 'custom-wm-accept', label: 'ACCEPT', active: true, onPress: onAcceptCustomWm }, { key: 'custom-wm-cancel', label: 'CANCEL', active: false, onPress: onCancelCustomWm }, groupChip('wmColor'), groupChip('wmSize'), groupChip('wmFont'), ] : []), ]; // ---- per-tab chip rows (bottom, above the tool rail) ----------------------- // The rail rows keep their scroll offset across tabs, so a chip that was // edited can sit off-screen on the tab you just came back to: every row // scrolls to its first EDITED chip (amber), or to the start when none is. // Offsets come from onLayout — x is already the content coordinate scrollTo // wants — and land one frame after the new row has laid out. The watermark // sub-panel swaps the frame row for another one, so it re-aims too (it used // to inherit the frame row's offset, which pushed its chips off-screen). const chipsScrollRef = useRef(null); const chipXRef = useRef>({}); const firstAmberChipRef = useRef(null); useEffect(() => { const id = setTimeout(() => { const key = firstAmberChipRef.current; const x = key ? chipXRef.current[key] ?? 0 : 0; chipsScrollRef.current?.scrollTo({ x: Math.max(0, x - 8), animated: false }); }, 80); return () => clearTimeout(id); }, [activeTab, isWmPanel(openGroup)]); const chipRow = (chips: ChipDef[], padV = 6) => { firstAmberChipRef.current = chips.find((c) => c.amberValue)?.key ?? null; return ( {chips.map(renderChip)} ); }; const chipsRow = (() => { switch (activeTab) { case 'recipes': { // Main row: PHOTO STYLE (the eight film simulations, in a strip of its // own), then the RECIPES toggle (expands the user/bundled recipe strip // above) and CREATE (quick-input modal). Recipes themselves are no // longer inline chips here. return chipRow( [ groupChip('style'), { key: 'recipes-toggle', label: 'RECIPES', active: showRecipes, onPress: () => { setOpenGroup(null); setShowRecipes((v) => !v); }, }, { key: 'create', label: 'CREATE', active: false, onPress: () => setCreateVisible(true), }, { key: 'import', label: 'IMPORT', active: false, onPress: onImportRecipe, }, ], 8 ); } case 'favorites': { // FAVORITED: every starred recipe as a chip (star to un-star), newest // look selectable in one tap. Ids whose recipe was deleted simply drop. const favs = recipes.filter((r) => favoriteIds.includes(r.id)); return chipRow( favs.length === 0 ? [ { key: 'no-favorites', label: 'NO FAVORITES YET - TAP THE STAR ON A RECIPE', disabled: true, active: false, onPress: () => {}, }, ] : favs.map((r) => ({ key: r.id, label: r.name, active: r.id === currentRecipeId, star: true, starred: true, onPress: () => onSelectRecipe(r), onPressStar: () => onToggleFavorite(r.id), })), 8 ); } case 'iq': // LIGHT: continuous params on one row; D.RANGE is a toggle chip that // opens its AUTO/DR100/DR200/DR400 strip above (same as the chrome // groups on WB). return chipRow([...paramChips(paramDefs.iq), groupChip('dr')]); case 'wb': return chipRow([ tempChip(), ...paramChips(paramDefs.wb.filter((p) => p.key !== 'temperature')), ...(['cx', 'cxb'] as const).map(groupChip), ]); case 'filters': // 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 chip // keeps the place the knob had in this row. return chipRow([ ...paramChips(paramDefs.filters.filter((p) => p.key !== 'grain' && p.key !== 'grainSize')), grainChip(), ]); case 'frame': // FRAME -> WATERMARK: the sub-panel row (breadcrumb back to the frames). // It stays up while ANY watermark option strip is open — the strips are // choices of the mark, not another panel. if (isWmPanel(openGroup)) { return chipRow( [ { key: 'wm-back', label: '< FRAME', active: false, onPress: () => setOpenGroup(null) }, ...wmChips(), ], 8 ); } return chipRow( [ ...choiceChips( 'fr', FRAMES.map((f) => ({ v: f.id, d: f.name.toUpperCase(), pro: PRO_FRAMES.includes(f.id), })), selectedFrame, (val) => { // Picking another frame is another parameter: the open slider // row (STRAIGHTEN) and the ROTATE strip go with the previous // one — so the bubble-level line leaves the image too. if (openParam) onOpenParamChange(null); setOpenGroup(null); onUpdateFrame(val as FrameId); } ), ...(cropEnabled ? [groupChip('crop')] : []), // APPLY commits the framed ratio: the viewer swaps the band (amber // outline + dim surround) for the crop alone. Only while a fixed // ratio is framed but not committed — RESET or another ratio is // the way back to the framing state. ...(cropEnabled && cropRatio !== 'none' && cropRatio !== 'free' && cropApplied !== cropRatio ? [{ key: 'crop-apply', label: 'APPLY', active: true, onPress: onApplyCrop }] : []), rotateChip(), ...(selectedFrame === 'wallframe' ? [ { key: 'wall-orient', label: wallLandscape ? 'WALL LANDSCAPE' : 'WALL PORTRAIT', active: wallLandscape, onPress: onToggleWallLandscape, }, ] : []), wmChip(), ], 8 ); default: return null; } })(); // ---- the single open slider row (ABOVE the chips row) ---------------------- const openParamDef = paramDefs[activeTab].find((p) => p.key === openParam) ?? null; return ( {openParamDef && ( { // Back: close the row and bring back the strip it came from. toggleParam(openParamDef.key); const g = PARAM_GROUP[openParamDef.key]; if (g) setOpenGroup(g); }} className="mr-3 rounded-full border border-zinc-700 bg-titan px-3 py-2" activeOpacity={0.7} > < {openParamDef.label} {openParamDef.display(openParamDef.value)} )} {openGroup && openGroup !== 'wm' && ( {renderChip({ key: `grp-hint-${openGroup}`, label: groupDefs[openGroup].label, disabled: true, active: false, onPress: () => {}, })} {choiceChips(openGroup, groupDefs[openGroup].options, groupDefs[openGroup].value, groupDefs[openGroup].onPick).map( renderChip )} {/* GRAIN's third entry is a READOUT: the clump count the two knobs add up to, in the print's own terms. Nothing to pick, so it is inert — it moves when a knob moves. */} {openGroup === 'grain' && renderChip({ key: 'grain-inch', label: `${grainPerInch(a.grainSize ?? 100)}/INCH`, disabled: true, active: false, onPress: () => {}, })} )} {/* PRESETS: expanded recipe strip (above the main chip row) while the RECIPES chip is open — bundled defaults + user-created, custom deletable inline. */} {activeTab === 'recipes' && showRecipes && ( {recipes.length === 0 ? renderChip({ key: 'no-recipes', label: 'NO RECIPES YET - TAP CREATE', disabled: true, active: false, onPress: () => {}, }) : recipes.map((r) => renderChip({ key: r.id, label: r.name, active: r.id === currentRecipeId, star: true, starred: favoriteIds.includes(r.id), trash: !!r.isCustom, onPress: () => onSelectRecipe(r), onPressStar: () => onToggleFavorite(r.id), onPressDelete: () => onDeleteRecipe(r.id), }) )} {/* The next recipe, right after the last one. Past the LITE limit this is the only chip that cannot be pressed: it carries the PRO tag and names the build instead of opening the form. */} {renderChip({ key: 'add-recipe', label: '+', active: false, pro: recipeLimitReached, onPress: () => (recipeLimitReached ? onRecipeLimit() : setCreateVisible(true)), })} )} {activeTab === 'frame' && openGroup === 'wm' && customWm.enabled && ( onUpdateCustomWm({ ...customWm, text })} placeholder="TYPE WATERMARK TEXT" placeholderTextColor="#a1a1aa" className="rounded-md border border-amber-500/70 bg-black/40 px-3 py-2 font-mono text-sm font-bold text-amber-300" maxLength={80} /> )} {renderChip(resetChip())} {chipsRow} { setCreateVisible(false); onCloseEdit(); }} onSave={(recipe) => { if (editingRecipe) onUpdateRecipe(recipe); else onCreateRecipe(recipe); setShowRecipes(true); }} /> ); }