Keep GPS watermark user-owned across presets; add pinch/double-tap zoom to photo preview; collapse chrome strip on WB selection
This commit is contained in:
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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) => (
|
||||
<View key={p.id} style={{ width }} className="justify-center items-center">
|
||||
<Image
|
||||
source={{ uri: p.uri }}
|
||||
style={{ width, height: '100%' }}
|
||||
className=""
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<ZoomableImage uri={p.uri} width={width} />
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
@@ -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 (
|
||||
<Animated.Image
|
||||
source={{ uri }}
|
||||
resizeMode="contain"
|
||||
onLayout={(e) => {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user