diff --git a/android/src/components/ZoomableImage.tsx b/android/src/components/ZoomableImage.tsx new file mode 100644 index 0000000..233bbca --- /dev/null +++ b/android/src/components/ZoomableImage.tsx @@ -0,0 +1,142 @@ +/** + * ZoomableImage — Pinch-to-Zoom + Pan fuer das Vollbild-Modal. + * + * Reine React-Native-Implementation ohne externe Lib: + * - 1 Finger: Pan wenn schon gezoomt + * - 2 Finger: Pinch fuer Zoom + Pan + * - Doppel-Tap: Toggle 1x ↔ 2.5x Zoom + * + * Scale wird auf [1, 5] gecapped, Translation auf das verfuegbare + * Image-Volumen (kein Out-of-bounds-Pan). + */ + +import React, { useRef } from 'react'; +import { Animated, PanResponder, View, StyleSheet, ImageStyle, StyleProp } from 'react-native'; + +interface Props { + uri: string; + containerWidth: number; + containerHeight: number; + style?: StyleProp; +} + +const ZoomableImage: React.FC = ({ uri, containerWidth, containerHeight, style }) => { + const scale = useRef(new Animated.Value(1)).current; + const translateX = useRef(new Animated.Value(0)).current; + const translateY = useRef(new Animated.Value(0)).current; + + // Aktuelle Werte (Animated.Value lesen ist async, wir tracken parallel) + const current = useRef({ scale: 1, x: 0, y: 0 }).current; + // State beim Geste-Start (touchStart-Snapshot) + const start = useRef({ scale: 1, x: 0, y: 0, distance: 0, focalX: 0, focalY: 0 }).current; + // Doppel-Tap-Erkennung + const lastTapAt = useRef(0); + + const distance = (touches: any[]) => { + const [a, b] = touches; + return Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY); + }; + + const focal = (touches: any[]) => { + const [a, b] = touches; + return { x: (a.pageX + b.pageX) / 2, y: (a.pageY + b.pageY) / 2 }; + }; + + const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)); + + const applyAndClamp = (newScale: number, newX: number, newY: number) => { + const s = clamp(newScale, 1, 5); + // Maximal-Translation: (imgSize * scale - imgSize) / 2 + const maxX = Math.max(0, (containerWidth * s - containerWidth) / 2); + const maxY = Math.max(0, (containerHeight * s - containerHeight) / 2); + const x = clamp(newX, -maxX, maxX); + const y = clamp(newY, -maxY, maxY); + current.scale = s; + current.x = x; + current.y = y; + scale.setValue(s); + translateX.setValue(x); + translateY.setValue(y); + }; + + const responder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onPanResponderGrant: (_e, gestureState) => { + const touches = gestureState as any; + const t = touches.numberActiveTouches || 1; + // Doppel-Tap-Erkennung (nur bei 1 Finger) + if (t === 1) { + const now = Date.now(); + if (now - lastTapAt.current < 280) { + // Doppel-Tap → Zoom-Toggle + if (current.scale > 1.1) { + applyAndClamp(1, 0, 0); + } else { + applyAndClamp(2.5, 0, 0); + } + lastTapAt.current = 0; + return; + } + lastTapAt.current = now; + } + start.scale = current.scale; + start.x = current.x; + start.y = current.y; + }, + onPanResponderMove: (e, gestureState) => { + const touches = e.nativeEvent.touches; + if (touches.length >= 2) { + // Pinch + Pan + if (start.distance === 0) { + // Initialisiere die Pinch-Referenz beim Uebergang 1→2 Finger + start.distance = distance(touches); + const f = focal(touches); + start.focalX = f.x; + start.focalY = f.y; + start.scale = current.scale; + start.x = current.x; + start.y = current.y; + return; + } + const newDistance = distance(touches); + const newFocal = focal(touches); + const scaleFactor = newDistance / start.distance; + const newScale = clamp(start.scale * scaleFactor, 1, 5); + // Pan-Anteil aus Focal-Bewegung + const newX = start.x + (newFocal.x - start.focalX); + const newY = start.y + (newFocal.y - start.focalY); + applyAndClamp(newScale, newX, newY); + } else if (touches.length === 1 && current.scale > 1.05) { + // Single-Finger-Pan nur wenn gezoomt + start.distance = 0; // Reset Pinch-Tracking + applyAndClamp(current.scale, start.x + gestureState.dx, start.y + gestureState.dy); + } + }, + onPanResponderRelease: () => { start.distance = 0; }, + onPanResponderTerminate: () => { start.distance = 0; }, + }), + ).current; + + return ( + + + + ); +}; + +export default ZoomableImage; diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index a52c1ea..e60ccf3 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -26,6 +26,8 @@ import { import AsyncStorage from '@react-native-async-storage/async-storage'; import RNFS from 'react-native-fs'; import { SvgUri } from 'react-native-svg'; +import { Dimensions } from 'react-native'; +import ZoomableImage from '../components/ZoomableImage'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import audioService from '../services/audio'; import wakeWordService from '../services/wakeword'; @@ -1378,25 +1380,32 @@ const ChatScreen: React.FC = () => { {/* Bild-Vollbild Modal */} setFullscreenImage(null)}> - setFullscreenImage(null)} - > + {fullscreenImage && ( /\.svg(?:\?|$)/i.test(fullscreenImage) ? ( - + // SVG: bisher keine Pinch-Zoom — Tap zum Schliessen + setFullscreenImage(null)}> - + ) : ( - ) )} - + {/* Close-Button oben rechts — die TouchableOpacity-uebergreifend funktioniert + wegen ZoomableImage-PanResponder nicht zuverlaessig fuer Tap-to-Close */} + setFullscreenImage(null)} + > + {'✕'} + + {/* Datei-Upload Modal */}