d7a8e0681e
The custom watermark captured EVERY image touch whenever the chip was ON with text, in both modes: after switching from a library photo to the camera there was no way to give the image back to tap-to-focus, and a frame picked for the still rode along onto the live preview (and into the capture). - AdjustPanel: CANCEL chip next to ACCEPT. It drops the un-accepted draft and restores the last ACCEPTED mark (none if nothing was accepted). - Viewfinder: draws the mark whenever the chip is ON with text, but only lets it CAPTURE touches while its editor is open (new wmEditing prop). A double-tap ON the mark still re-opens that editor from the library viewer. - App: ACCEPT and CANCEL now close the editor (that closes the placement layer with it). Switching mode closes the deck panels, and leaves the frame behind: camera mode has no FRAME picker to undo one. Verified on device (9a6a7277): camera tap locks AE/AF with the watermark left ON; library polaroid no longer appears on the live preview after the switch; CANCEL reverts the chip to CUSTOM WATERMARK OFF and closes the panel; ACCEPT closes it and the next capture embeds the mark (amber text found at 0.5/0.375 of the 3000x4000 export), while a capture before ACCEPT carries none.
671 lines
24 KiB
TypeScript
671 lines
24 KiB
TypeScript
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 { Trash2 } from 'lucide-react-native';
|
|
import { Skia } from '@shopify/react-native-skia';
|
|
import { Recipe, ColorAdjustments, CustomWatermark, FrameId, BaseFilter } from '../types';
|
|
import { FRAMES } from '../utils/frameUtils';
|
|
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
|
|
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' | 'iq' | 'wb' | 'filters' | 'frame' | 'watermark';
|
|
// 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 = 'cx' | 'cxb' | 'dr' | 'wmColor' | 'wmSize' | 'wmFont';
|
|
|
|
interface AdjustmentPanelProps {
|
|
activeTab: TabId;
|
|
recipes: Recipe[];
|
|
currentRecipeId: string;
|
|
adjustments: ColorAdjustments;
|
|
selectedFrame: FrameId;
|
|
useGeotag: 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<Recipe, 'id' | 'isCustom'>) => void;
|
|
onSelectRecipe: (recipe: Recipe) => void;
|
|
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => void;
|
|
onUpdateFrame: (frameId: FrameId) => void;
|
|
// FRAME tab: turn the inserted library photo 90deg (landscape <-> portrait)
|
|
// so it fits the chosen frame better.
|
|
photoRotation: 0 | 90;
|
|
onToggleRotation: () => void;
|
|
onToggleGeotag: (enabled: boolean) => void;
|
|
onSaveRecipe: (name: string) => 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;
|
|
// 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;
|
|
}
|
|
|
|
interface ChipDef {
|
|
key: string;
|
|
label: string;
|
|
active: boolean;
|
|
onPress: () => void;
|
|
onPressDelete?: () => void; // custom presets carry an inline trash button
|
|
trash?: boolean;
|
|
disabled?: boolean; // informational label, not tappable
|
|
amberValue?: boolean; // amber text driven by edited value, not selection
|
|
}
|
|
|
|
interface ParamDef {
|
|
key: string;
|
|
label: string;
|
|
value: number;
|
|
default: number;
|
|
min: number;
|
|
max: number;
|
|
step: number;
|
|
display: (v: number) => string;
|
|
onChange: (v: number) => void;
|
|
}
|
|
|
|
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<string, { t: number; ti: number }> = {
|
|
auto: { t: 5500, ti: 0 },
|
|
daylight: { t: 5500, ti: 0 },
|
|
'daylight-3r': { t: 5500, ti: -3 },
|
|
cloudy: { t: 6500, ti: 1 },
|
|
shade: { t: 8000, ti: 2 },
|
|
tungsten: { t: 3200, ti: 0 },
|
|
fluorescent: { t: 4000, ti: 3 },
|
|
};
|
|
|
|
// 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,
|
|
selectedFrame,
|
|
useGeotag,
|
|
openParam,
|
|
onOpenParamChange,
|
|
currentBaseFilter,
|
|
onCreateRecipe,
|
|
onSelectRecipe,
|
|
onUpdateAdjustments,
|
|
onUpdateFrame,
|
|
photoRotation,
|
|
onToggleGeotag,
|
|
onDeleteRecipe,
|
|
customWm,
|
|
onUpdateCustomWm,
|
|
onAcceptCustomWm,
|
|
onCancelCustomWm,
|
|
wmEditNonce,
|
|
onToggleRotation,
|
|
}: 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<TextInput>(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<GroupKey | null>(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<string | null>(null);
|
|
useEffect(() => {
|
|
setOpenGroup(null);
|
|
setShowRecipes(false);
|
|
setCreateVisible(false);
|
|
}, [activeTab]);
|
|
|
|
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 (single-row rule).
|
|
if (next) onOpenParamChange(null);
|
|
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';
|
|
return (
|
|
<View key={c.key} className="mx-1">
|
|
{c.trash ? (
|
|
<View className={`flex-row items-center rounded-full border py-1.5 pl-3 pr-1 ${tone}`}>
|
|
<TouchableOpacity onPress={() => { haptic(); c.onPress(); }} activeOpacity={0.7}>
|
|
<Text className={`font-mono text-[11px] font-bold ${textTone}`}>{c.label}</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={(e) => {
|
|
e.stopPropagation();
|
|
haptic();
|
|
c.onPressDelete && c.onPressDelete();
|
|
}}
|
|
className="p-1.5 ml-1"
|
|
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
|
>
|
|
<Trash2 size={12} color="#ef4444" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
) : c.disabled ? (
|
|
<View className="mx-1 flex-row items-center rounded-full border border-zinc-800/80 bg-black/30 px-3 py-1.5">
|
|
<Text className="font-mono text-[10px] font-semibold text-zinc-500">{c.label}</Text>
|
|
</View>
|
|
) : (
|
|
<TouchableOpacity
|
|
onPress={() => { haptic(); c.onPress(); }}
|
|
className={`flex-row items-center rounded-full border px-3 py-1.5 ${tone}`}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text className={`font-mono text-[11px] font-bold tracking-wide ${textTone}`}>
|
|
{c.label}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// Continuous parameter chips: label + live value; amber text when edited;
|
|
// amber border while its slider row is open.
|
|
const paramChips = (params: ParamDef[]) =>
|
|
params.map((p) =>
|
|
renderChip({
|
|
key: p.key,
|
|
label: `${p.label.toUpperCase()} ${p.display(p.value)}`,
|
|
active: openParam === p.key,
|
|
amberValue: openParam !== p.key && p.value !== p.default,
|
|
onPress: () => toggleParam(p.key),
|
|
})
|
|
);
|
|
|
|
const choiceChips = (
|
|
keyPrefix: string,
|
|
options: { v: string; d: string }[],
|
|
current: string,
|
|
onPick: (v: string) => void
|
|
) =>
|
|
options.map((o) =>
|
|
renderChip({
|
|
key: `${keyPrefix}:${o.v}`,
|
|
label: o.d,
|
|
active: current === o.v,
|
|
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,
|
|
min: d.min,
|
|
max: d.max,
|
|
step: d.step,
|
|
display: d.display,
|
|
onChange: (v: number) => onUpdateAdjustments(d.set(v)),
|
|
}));
|
|
const paramDefs: Record<TabId, ParamDef[]> = {
|
|
recipes: [],
|
|
iq: toRows(PARAM_DEFS.iq),
|
|
wb: toRows(PARAM_DEFS.wb),
|
|
filters: toRows(PARAM_DEFS.filters),
|
|
frame: [],
|
|
watermark: [],
|
|
};
|
|
|
|
// Active WB chip: the remembered preset wins while the engine pair still
|
|
// matches it; otherwise derive from the pair (neutral ⇒ AUTO; any unlisted
|
|
// pair also reads as AUTO — no preset is a stronger claim there).
|
|
const wbActive = (): 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 ?? 'auto';
|
|
};
|
|
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(() => {
|
|
const mgr = Skia.FontMgr.System();
|
|
const names = new Set<string>();
|
|
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 })),
|
|
];
|
|
}, []);
|
|
const groupDefs: Record<GroupKey, { label: string; off: string; value: string; options: { v: string; d: string }[]; onPick: (v: string) => void }> = {
|
|
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 }),
|
|
},
|
|
};
|
|
const groupChip = (g: GroupKey): ChipDef => ({
|
|
key: `grp-${g}`,
|
|
label: groupDefs[g].label,
|
|
active: openGroup === g,
|
|
amberValue: openGroup !== g && groupDefs[g].value !== groupDefs[g].off,
|
|
onPress: () => toggleGroup(g),
|
|
});
|
|
|
|
// ---- per-tab chip rows (bottom, above the tool rail) -----------------------
|
|
const chipsRow = (() => {
|
|
switch (activeTab) {
|
|
case 'recipes': {
|
|
// Main row: the six standard film simulations, then the RECIPES toggle
|
|
// (expands the user/bundled recipe strip above) and CREATE (quick-input
|
|
// modal). Recipes themselves are no longer inline chips here.
|
|
const chips: ChipDef[] = [
|
|
...FILM_SIMS.map((s) => ({
|
|
key: s.id,
|
|
label: s.name,
|
|
active: currentRecipeId === s.id,
|
|
// Choosing a simulation is a deliberate switch away from the saved
|
|
// recipes: collapse the RECIPES strip above so it never lingers
|
|
// over the freshly-applied sim.
|
|
onPress: () => {
|
|
setShowRecipes(false);
|
|
onSelectRecipe(filmSimToRecipe(s));
|
|
},
|
|
})),
|
|
{
|
|
key: 'recipes-toggle',
|
|
label: 'RECIPES',
|
|
active: showRecipes,
|
|
onPress: () => setShowRecipes((v) => !v),
|
|
},
|
|
{
|
|
key: 'create',
|
|
label: 'CREATE',
|
|
active: false,
|
|
onPress: () => setCreateVisible(true),
|
|
},
|
|
];
|
|
return (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
|
>
|
|
{chips.map(renderChip)}
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
|
|
>
|
|
{paramChips(paramDefs.iq)}
|
|
{renderChip(groupChip('dr'))}
|
|
</ScrollView>
|
|
);
|
|
|
|
case 'wb':
|
|
return (
|
|
<View>
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
|
|
>
|
|
{choiceChips(
|
|
'wb',
|
|
[
|
|
{ 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' },
|
|
],
|
|
wbActive(),
|
|
wbApply
|
|
)}
|
|
{paramChips(paramDefs.wb)}
|
|
{(['cx', 'cxb'] as const).map((g) => renderChip(groupChip(g)))}
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
|
|
case 'filters':
|
|
return (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
|
>
|
|
{paramChips(paramDefs.filters)}
|
|
</ScrollView>
|
|
);
|
|
|
|
case 'frame':
|
|
return (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
|
>
|
|
{choiceChips(
|
|
'fr',
|
|
FRAMES.map((f) => ({ v: f.id, d: f.name.toUpperCase() })),
|
|
selectedFrame,
|
|
(val) => onUpdateFrame(val as FrameId)
|
|
)}
|
|
{renderChip({
|
|
key: 'rotate',
|
|
label: photoRotation === 0 ? 'ROTATE 90' : 'ROTATE 0',
|
|
active: photoRotation !== 0,
|
|
onPress: onToggleRotation,
|
|
})}
|
|
</ScrollView>
|
|
);
|
|
|
|
case 'watermark':
|
|
return (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
|
>
|
|
{renderChip({
|
|
key: 'gps',
|
|
label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF',
|
|
active: useGeotag,
|
|
onPress: () => onToggleGeotag(!useGeotag),
|
|
})}
|
|
{renderChip({
|
|
key: 'custom-wm',
|
|
label: customWm.enabled ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF',
|
|
active: customWm.enabled,
|
|
onPress: () => onUpdateCustomWm({ ...customWm, enabled: !customWm.enabled }),
|
|
})}
|
|
{customWm.enabled &&
|
|
renderChip({
|
|
key: 'custom-wm-accept',
|
|
label: 'ACCEPT',
|
|
active: true,
|
|
onPress: onAcceptCustomWm,
|
|
})}
|
|
{customWm.enabled &&
|
|
renderChip({
|
|
key: 'custom-wm-cancel',
|
|
label: 'CANCEL',
|
|
active: false,
|
|
onPress: onCancelCustomWm,
|
|
})}
|
|
{customWm.enabled && renderChip(groupChip('wmColor'))}
|
|
{customWm.enabled && renderChip(groupChip('wmSize'))}
|
|
{customWm.enabled && renderChip(groupChip('wmFont'))}
|
|
</ScrollView>
|
|
);
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
// ---- the single open slider row (ABOVE the chips row) ----------------------
|
|
const openParamDef = paramDefs[activeTab].find((p) => p.key === openParam) ?? null;
|
|
|
|
return (
|
|
<View>
|
|
{openParamDef && (
|
|
<View
|
|
key={openParamDef.key}
|
|
className="flex-row items-center border-b border-zinc-800/60 px-3 py-1.5"
|
|
>
|
|
<TouchableOpacity
|
|
onPress={() => toggleParam(openParamDef.key)}
|
|
className="mr-3 rounded-full border border-zinc-700 bg-titan px-3 py-2"
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text className="font-mono text-xs font-bold text-zinc-300"><</Text>
|
|
</TouchableOpacity>
|
|
<View className="flex-1">
|
|
<View className="flex-row justify-between items-center mb-0.5">
|
|
<Text className="text-zinc-400 font-mono text-[10px] font-bold tracking-widest">
|
|
{openParamDef.label}
|
|
</Text>
|
|
<Text
|
|
className={`font-mono text-xs font-bold ${
|
|
openParamDef.value !== openParamDef.default ? 'text-amber-500' : 'text-zinc-300'
|
|
}`}
|
|
>
|
|
{openParamDef.display(openParamDef.value)}
|
|
</Text>
|
|
</View>
|
|
<Slider
|
|
value={openParamDef.value}
|
|
minimumValue={openParamDef.min}
|
|
maximumValue={openParamDef.max}
|
|
step={openParamDef.step}
|
|
onValueChange={openParamDef.onChange}
|
|
onSlidingComplete={haptic}
|
|
minimumTrackTintColor="#f59e0b"
|
|
maximumTrackTintColor="#27272a"
|
|
thumbTintColor="#f59e0b"
|
|
/>
|
|
</View>
|
|
</View>
|
|
)}
|
|
{openGroup && (
|
|
<View className="border-b border-zinc-800/60">
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
|
|
>
|
|
{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)}
|
|
</ScrollView>
|
|
</View>
|
|
)}
|
|
{/* 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 && (
|
|
<View className="border-b border-zinc-800/60">
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
|
|
>
|
|
{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,
|
|
trash: !!r.isCustom,
|
|
onPress: () => onSelectRecipe(r),
|
|
onPressDelete: () => onDeleteRecipe(r.id),
|
|
})
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
)}
|
|
{activeTab === 'watermark' && customWm.enabled && (
|
|
<View className="border-b border-amber-500/30 bg-amber-500/5 px-3 py-2">
|
|
<TextInput
|
|
ref={wmInputRef}
|
|
value={customWm.text}
|
|
onChangeText={(text) => 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}
|
|
/>
|
|
</View>
|
|
)}
|
|
{chipsRow}
|
|
<RecipeCreateModal
|
|
visible={createVisible}
|
|
currentBase={currentBaseFilter}
|
|
onClose={() => setCreateVisible(false)}
|
|
onSave={(recipe) => {
|
|
onCreateRecipe(recipe);
|
|
setShowRecipes(true);
|
|
}}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|