feat(crop): two-step crop with APPLY + hidden amber border, persisted in session
- FRAME/CROP: choosing a ratio shows the amber band (border + 0.55 dim); APPLY collapses the preview to the crop rect with an opaque mask and removes the amber stroke; RESET returns to 'none'. - Viewfinder: libCropView (k = min(vw/dw, vh/dh)) + cropScreenPx drive the mask/band, libViewMatrix folds the crop transform into the image groups, libCropTouchStyle keeps touch mapping aligned. - Persist cropApplied in the session snapshot and restore it only when it still matches cropRatio. - Add PLAN-2026-09-09.md with the measurements. tsc --noEmit unchanged at 14 pre-existing errors.
This commit is contained in:
+157
-137
@@ -36,6 +36,12 @@ type GroupKey =
|
||||
// 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[];
|
||||
@@ -53,6 +59,10 @@ interface AdjustmentPanelProps {
|
||||
// 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;
|
||||
// RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear
|
||||
@@ -205,6 +215,8 @@ export default function AdjustmentPanel({
|
||||
selectedFrame,
|
||||
cropRatio,
|
||||
onUpdateCropRatio,
|
||||
cropApplied,
|
||||
onApplyCrop,
|
||||
cropEnabled,
|
||||
useGeotag,
|
||||
openParam,
|
||||
@@ -271,7 +283,7 @@ export default function AdjustmentPanel({
|
||||
// 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(openGroup === 'wm');
|
||||
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).
|
||||
@@ -314,7 +326,13 @@ export default function AdjustmentPanel({
|
||||
: '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">
|
||||
<View
|
||||
key={c.key}
|
||||
className="mx-1"
|
||||
onLayout={(e) => {
|
||||
chipXRef.current[c.key] = e.nativeEvent.layout.x;
|
||||
}}
|
||||
>
|
||||
{c.trash || c.star ? (
|
||||
<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}>
|
||||
@@ -372,16 +390,14 @@ export default function AdjustmentPanel({
|
||||
|
||||
// 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 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,
|
||||
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
|
||||
@@ -406,15 +422,13 @@ export default function AdjustmentPanel({
|
||||
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),
|
||||
})
|
||||
);
|
||||
): ChipDef[] =>
|
||||
options.map((o) => ({
|
||||
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
|
||||
@@ -590,13 +604,15 @@ export default function AdjustmentPanel({
|
||||
// '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' },
|
||||
{ v: 'straighten', d: 'STRAIGHTEN (±45°)' },
|
||||
],
|
||||
onPick: (v) => {
|
||||
if (v === 'reset') {
|
||||
@@ -636,7 +652,7 @@ export default function AdjustmentPanel({
|
||||
// slider row above (the chip keeps naming the kelvin value meanwhile).
|
||||
temp: {
|
||||
label: 'TEMP', off: 'auto', value: wbValue(),
|
||||
options: [...WB_PRESETS, { v: 'color-temp', d: 'COLOR TEMP' }],
|
||||
options: [{ v: 'color-temp', d: 'COLOR TEMP' }, ...WB_PRESETS],
|
||||
onPick: (v) => (v === 'color-temp' ? toggleParam('temperature') : wbApply(v)),
|
||||
},
|
||||
gpsTime: {
|
||||
@@ -717,6 +733,39 @@ export default function AdjustmentPanel({
|
||||
];
|
||||
|
||||
// ---- 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<ScrollView>(null);
|
||||
const chipXRef = useRef<Record<string, number>>({});
|
||||
const firstAmberChipRef = useRef<string | null>(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 (
|
||||
<ScrollView
|
||||
ref={chipsScrollRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: padV }}
|
||||
>
|
||||
{chips.map(renderChip)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const chipsRow = (() => {
|
||||
switch (activeTab) {
|
||||
case 'recipes': {
|
||||
@@ -724,32 +773,26 @@ export default function AdjustmentPanel({
|
||||
// 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.
|
||||
const chips: ChipDef[] = [
|
||||
groupChip('style'),
|
||||
{
|
||||
key: 'recipes-toggle',
|
||||
label: 'RECIPES',
|
||||
active: showRecipes,
|
||||
onPress: () => {
|
||||
setOpenGroup(null);
|
||||
setShowRecipes((v) => !v);
|
||||
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),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{chips.map(renderChip)}
|
||||
</ScrollView>
|
||||
{
|
||||
key: 'create',
|
||||
label: 'CREATE',
|
||||
active: false,
|
||||
onPress: () => setCreateVisible(true),
|
||||
},
|
||||
],
|
||||
8
|
||||
);
|
||||
}
|
||||
|
||||
@@ -757,32 +800,27 @@ export default function AdjustmentPanel({
|
||||
// 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 (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{favs.length === 0
|
||||
? renderChip({
|
||||
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) =>
|
||||
renderChip({
|
||||
key: r.id,
|
||||
label: r.name,
|
||||
active: r.id === currentRecipeId,
|
||||
star: true,
|
||||
starred: true,
|
||||
onPress: () => onSelectRecipe(r),
|
||||
onPressStar: () => onToggleFavorite(r.id),
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
},
|
||||
]
|
||||
: 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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -790,69 +828,34 @@ export default function AdjustmentPanel({
|
||||
// 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>
|
||||
);
|
||||
return chipRow([...paramChips(paramDefs.iq), groupChip('dr')]);
|
||||
|
||||
case 'wb':
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
|
||||
>
|
||||
{renderChip(tempChip())}
|
||||
{paramChips(paramDefs.wb.filter((p) => p.key !== 'temperature'))}
|
||||
{(['cx', 'cxb'] as const).map((g) => renderChip(groupChip(g)))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
return chipRow([
|
||||
tempChip(),
|
||||
...paramChips(paramDefs.wb.filter((p) => p.key !== 'temperature')),
|
||||
...(['cx', 'cxb'] as const).map(groupChip),
|
||||
]);
|
||||
|
||||
case 'filters':
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{paramChips(paramDefs.filters)}
|
||||
</ScrollView>
|
||||
);
|
||||
return chipRow(paramChips(paramDefs.filters));
|
||||
|
||||
case 'frame':
|
||||
// FRAME → WATERMARK: the sub-panel row (breadcrumb back to the frames).
|
||||
if (openGroup === 'wm') {
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{renderChip({
|
||||
key: 'wm-back',
|
||||
label: '< FRAME',
|
||||
active: false,
|
||||
onPress: () => setOpenGroup(null),
|
||||
})}
|
||||
{wmChips().map(renderChip)}
|
||||
</ScrollView>
|
||||
// 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 (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{choiceChips(
|
||||
return chipRow(
|
||||
[
|
||||
...choiceChips(
|
||||
'fr',
|
||||
FRAMES.map((f) => ({ v: f.id, d: f.name.toUpperCase() })),
|
||||
selectedFrame,
|
||||
@@ -864,18 +867,32 @@ export default function AdjustmentPanel({
|
||||
setOpenGroup(null);
|
||||
onUpdateFrame(val as FrameId);
|
||||
}
|
||||
)}
|
||||
{cropEnabled && renderChip(groupChip('crop'))}
|
||||
{renderChip(rotateChip())}
|
||||
{selectedFrame === 'wallframe' &&
|
||||
renderChip({
|
||||
key: 'wall-orient',
|
||||
label: wallLandscape ? 'WALL LANDSCAPE' : 'WALL PORTRAIT',
|
||||
active: wallLandscape,
|
||||
onPress: onToggleWallLandscape,
|
||||
})}
|
||||
{renderChip(wmChip())}
|
||||
</ScrollView>
|
||||
),
|
||||
...(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:
|
||||
@@ -935,6 +952,7 @@ export default function AdjustmentPanel({
|
||||
{openGroup && openGroup !== 'wm' && (
|
||||
<View className="border-b border-zinc-800/60">
|
||||
<ScrollView
|
||||
key={openGroup}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
|
||||
@@ -946,7 +964,9 @@ export default function AdjustmentPanel({
|
||||
active: false,
|
||||
onPress: () => {},
|
||||
})}
|
||||
{choiceChips(openGroup, groupDefs[openGroup].options, groupDefs[openGroup].value, groupDefs[openGroup].onPick)}
|
||||
{choiceChips(openGroup, groupDefs[openGroup].options, groupDefs[openGroup].value, groupDefs[openGroup].onPick).map(
|
||||
renderChip
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user