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:
2026-09-11 17:29:51 +07:00
parent 1b632ba78c
commit 6faef4aef7
4 changed files with 904 additions and 274 deletions
+157 -137
View File
@@ -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>
)}