feat(app): Pinch-Zoom + Pan im Vollbild-Modal
Neue ZoomableImage-Komponente — reine RN-Implementation mit PanResponder + Animated, ohne extra Dependency. - 2-Finger-Pinch: Zoom 1x..5x, Focal-Point folgt der Geste - 1-Finger-Pan: nur aktiv wenn gezoomt, mit Bounds-Clamping - Doppel-Tap: Toggle 1x ↔ 2.5x Vollbild-Modal ersetzt das simple <Image> durch ZoomableImage fuer JPEG/PNG/etc. SVGs bleiben non-zoomable (SvgUri-Limitation), Tap schliesst sie. Plus dedicated ✕-Close-Button oben rechts da Tap-to- Close mit PanResponder kollidiert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ImageStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZoomableImage: React.FC<Props> = ({ 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 (
|
||||||
|
<View style={StyleSheet.absoluteFill} {...responder.panHandlers}>
|
||||||
|
<Animated.Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={[
|
||||||
|
style,
|
||||||
|
{
|
||||||
|
transform: [
|
||||||
|
{ translateX },
|
||||||
|
{ translateY },
|
||||||
|
{ scale },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ZoomableImage;
|
||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import RNFS from 'react-native-fs';
|
import RNFS from 'react-native-fs';
|
||||||
import { SvgUri } from 'react-native-svg';
|
import { SvgUri } from 'react-native-svg';
|
||||||
|
import { Dimensions } from 'react-native';
|
||||||
|
import ZoomableImage from '../components/ZoomableImage';
|
||||||
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import wakeWordService from '../services/wakeword';
|
import wakeWordService from '../services/wakeword';
|
||||||
@@ -1378,25 +1380,32 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
{/* Bild-Vollbild Modal */}
|
{/* Bild-Vollbild Modal */}
|
||||||
<Modal visible={!!fullscreenImage} transparent animationType="fade" onRequestClose={() => setFullscreenImage(null)}>
|
<Modal visible={!!fullscreenImage} transparent animationType="fade" onRequestClose={() => setFullscreenImage(null)}>
|
||||||
<TouchableOpacity
|
<View style={styles.fullscreenOverlay}>
|
||||||
style={styles.fullscreenOverlay}
|
|
||||||
activeOpacity={1}
|
|
||||||
onPress={() => setFullscreenImage(null)}
|
|
||||||
>
|
|
||||||
{fullscreenImage && (
|
{fullscreenImage && (
|
||||||
/\.svg(?:\?|$)/i.test(fullscreenImage) ? (
|
/\.svg(?:\?|$)/i.test(fullscreenImage) ? (
|
||||||
<View style={styles.fullscreenImage}>
|
// SVG: bisher keine Pinch-Zoom — Tap zum Schliessen
|
||||||
|
<TouchableOpacity style={styles.fullscreenImage} activeOpacity={1} onPress={() => setFullscreenImage(null)}>
|
||||||
<SvgUri uri={fullscreenImage} width="100%" height="100%" preserveAspectRatio="xMidYMid meet" />
|
<SvgUri uri={fullscreenImage} width="100%" height="100%" preserveAspectRatio="xMidYMid meet" />
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
// Pixel-Bild: Pinch-Zoom + Pan ueber ZoomableImage
|
||||||
source={{ uri: fullscreenImage }}
|
<ZoomableImage
|
||||||
|
uri={fullscreenImage}
|
||||||
|
containerWidth={Dimensions.get('window').width}
|
||||||
|
containerHeight={Dimensions.get('window').height}
|
||||||
style={styles.fullscreenImage}
|
style={styles.fullscreenImage}
|
||||||
resizeMode="contain"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{/* Close-Button oben rechts — die TouchableOpacity-uebergreifend funktioniert
|
||||||
|
wegen ZoomableImage-PanResponder nicht zuverlaessig fuer Tap-to-Close */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ position: 'absolute', top: 32, right: 16, padding: 12, backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 24 }}
|
||||||
|
onPress={() => setFullscreenImage(null)}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 22 }}>{'✕'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Datei-Upload Modal */}
|
{/* Datei-Upload Modal */}
|
||||||
|
|||||||
Reference in New Issue
Block a user