From f2de481708fd98ff685e60291ece5ccda555761a Mon Sep 17 00:00:00 2001 From: locphamtran Date: Wed, 9 Sep 2026 21:40:28 +0700 Subject: [PATCH] Keep GPS watermark user-owned across presets; add pinch/double-tap zoom to photo preview; collapse chrome strip on WB selection --- App.tsx | 4 +- src/components/AdjustmentPanel.tsx | 10 +- src/components/PhotoViewerModal.tsx | 10 +- src/components/ZoomableImage.tsx | 163 ++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 src/components/ZoomableImage.tsx diff --git a/App.tsx b/App.tsx index 6d8b7f2..0fbec5d 100644 --- a/App.tsx +++ b/App.tsx @@ -295,7 +295,9 @@ export default function App() { setSelectedRecipe(recipe); setAdjustments(recipe.adjustments); setSelectedFrame(recipe.frameId || 'none'); - setUseGeotag(recipe.useGeotag); + // GPS watermark is the user's own setting (FRAME tab), not part of a + // preset look: recipes carry a useGeotag field but selecting one must + // never flip the watermark on/off. }; const handleUpdateAdjustments = (updates: Partial) => { diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 2238f0d..9c46586 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -122,12 +122,17 @@ export default function AdjustmentPanel({ 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(); - setOpenGroup((prev) => (prev === g ? null : g)); + 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; @@ -245,6 +250,9 @@ export default function AdjustmentPanel({ }; 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 }); }; diff --git a/src/components/PhotoViewerModal.tsx b/src/components/PhotoViewerModal.tsx index dd89c03..45aaad5 100644 --- a/src/components/PhotoViewerModal.tsx +++ b/src/components/PhotoViewerModal.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useRef, useState } from 'react'; -import { View, Text, Modal, Image, TouchableOpacity, ScrollView, Share, Alert, useWindowDimensions } from 'react-native'; +import { View, Text, Modal, TouchableOpacity, ScrollView, Share, Alert, useWindowDimensions } from 'react-native'; import * as Sharing from 'expo-sharing'; import { X, Share2, Download, Trash2 } from 'lucide-react-native'; import { PhotoEntry } from '../types'; +import ZoomableImage from './ZoomableImage'; // Session photo viewer: swipe left/right across every photo taken/exported in // this session. Top shows preset name + index/total; trash deletes the photo @@ -168,12 +169,7 @@ export default function PhotoViewerModal({ > {photos.map((p) => ( - + ))} diff --git a/src/components/ZoomableImage.tsx b/src/components/ZoomableImage.tsx new file mode 100644 index 0000000..acdd87d --- /dev/null +++ b/src/components/ZoomableImage.tsx @@ -0,0 +1,163 @@ +import React, { useRef } from 'react'; +import { Animated, PanResponder } from 'react-native'; + +// Pinch / double-tap zoom for one preview photo inside the paging strip. +// Core PanResponder + Animated only (no react-native-gesture-handler dep). +// While zoomed in, responder termination is refused so the parent ScrollView +// cannot steal the gesture — the photo pans instead of paging; swiping to the +// next photo resumes after zooming back out (double tap or pinch below 1.15). +// +// ponytail: pinch zooms around the image center, not the finger midpoint; +// focal zoom needs per-touch location math against the image's screen rect. +// MAX_SCALE is a hard cap — add a dedicated slider/stepper when 4x is not +// enough. + +interface ZoomableImageProps { + uri: string; + width: number; +} + +const MAX_SCALE = 4; +const TAP_SCALE = 2.5; +const TAP_MS = 300; + +export default function ZoomableImage({ uri, width }: ZoomableImageProps) { + const scaleAn = useRef(new Animated.Value(1)).current; + const txAn = useRef(new Animated.Value(0)).current; + const tyAn = useRef(new Animated.Value(0)).current; + + const st = useRef({ + scale: 1, + tx: 0, + ty: 0, + w: width, + h: 0, + pinchStart: null as { scale: number; dist: number } | null, + lastDx: 0, + lastDy: 0, + moved: false, + grantAt: 0, + lastTap: 0, + }).current; + + const apply = () => { + scaleAn.setValue(st.scale); + txAn.setValue(st.tx); + tyAn.setValue(st.ty); + }; + + const clampTx = () => { + // Keep the scaled image covering the center; pan bounds grow with zoom. + const k = (st.scale - 1) / 2; + const mx = st.w * k; + st.tx = Math.max(-mx, Math.min(mx, st.tx)); + if (st.h > 0) { + const my = st.h * k; + st.ty = Math.max(-my, Math.min(my, st.ty)); + } + }; + + const settle = (scale: number, tx: number, ty: number) => { + st.scale = scale; + st.tx = tx; + st.ty = ty; + Animated.parallel([ + Animated.spring(scaleAn, { toValue: scale, friction: 8, tension: 80, useNativeDriver: false }), + Animated.spring(txAn, { toValue: tx, friction: 8, tension: 80, useNativeDriver: false }), + Animated.spring(tyAn, { toValue: ty, friction: 8, tension: 80, useNativeDriver: false }), + ]).start(); + }; + + const finish = () => { + const wasTap = + !st.moved && Date.now() - st.grantAt < 400 && st.pinchStart === null; + st.pinchStart = null; + st.lastDx = 0; + st.lastDy = 0; + st.moved = false; + if (wasTap) { + if (Date.now() - st.lastTap < TAP_MS) { + st.lastTap = 0; + if (st.scale > 1.05) settle(1, 0, 0); + else settle(TAP_SCALE, 0, 0); + } else { + st.lastTap = Date.now(); + } + return; + } + if (st.scale < 1.15) { + if (st.scale !== 1 || st.tx !== 0 || st.ty !== 0) settle(1, 0, 0); + } else { + clampTx(); + apply(); + } + }; + + const pan = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + // Page the strip while zoom is off; once zoomed (or mid-pinch) the + // image keeps the gesture for panning/zooming itself. + onPanResponderTerminationRequest: () => st.scale <= 1.001, + onPanResponderGrant: () => { + st.grantAt = Date.now(); + st.moved = false; + st.lastDx = 0; + st.lastDy = 0; + }, + onPanResponderMove: (e, g) => { + const touches = e.nativeEvent.touches; + if (touches.length >= 2) { + const a = touches[0]; + const b = touches[1]; + const dist = Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY); + if (!st.pinchStart) st.pinchStart = { scale: st.scale, dist: dist || 1 }; + const next = Math.max( + 1, + Math.min(MAX_SCALE, st.pinchStart.scale * (dist / st.pinchStart.dist)) + ); + st.scale = next; + if (next <= 1.001) { + st.tx = 0; + st.ty = 0; + } + clampTx(); + apply(); + st.moved = true; + st.lastDx = g.dx; + st.lastDy = g.dy; + } else { + st.pinchStart = null; + if (Math.abs(g.dx) > 8 || Math.abs(g.dy) > 8) st.moved = true; + if (st.scale > 1.001) { + st.tx += g.dx - st.lastDx; + st.ty += g.dy - st.lastDy; + clampTx(); + apply(); + } + st.lastDx = g.dx; + st.lastDy = g.dy; + } + }, + onPanResponderRelease: finish, + onPanResponderTerminate: finish, + }) + ).current; + + return ( + { + st.w = e.nativeEvent.layout.width; + st.h = e.nativeEvent.layout.height; + }} + style={[ + { width, height: '100%' }, + { transform: [{ translateX: txAn }, { translateY: tyAn }, { scale: scaleAn }] }, + ]} + {...pan.panHandlers} + /> + ); +}