diff --git a/mobile/app/file-detail.tsx b/mobile/app/file-detail.tsx index 8c878aa..af0baad 100644 --- a/mobile/app/file-detail.tsx +++ b/mobile/app/file-detail.tsx @@ -1,29 +1,35 @@ -import React, { useRef, useState, useCallback } from 'react'; +import React, { useRef, useState } from 'react'; import { View, Text, StyleSheet, FlatList, + Image, Dimensions, ActivityIndicator, ScrollView, + Modal, + Pressable, } from 'react-native'; import { RouteProp, useRoute } from '@react-navigation/native'; import { useFile, useFileImage } from '../hooks/useFiles'; import { TagChip } from '../components/TagChip'; import { FileThumbnail } from '../components/FileThumbnail'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { ZoomableImage } from '../components/ZoomableImage'; import type { Thumbnail } from '../types'; const SCREEN_WIDTH = Dimensions.get('window').width; +type SelectedImage = { uri: string; width: number; height: number }; + type RootStackParamList = { FileDetail: { fileIds: string[]; initialIndex: number }; }; type FileDetailRouteProp = RouteProp; -function DetailItem({ fileId, onScaleChange }: { fileId: string; onScaleChange?: (scale: number) => void }) { +function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: (img: SelectedImage) => void }) { const { data: imageData, isLoading: imageLoading } = useFileImage(fileId); const { data: fileData } = useFile(fileId); const uri = imageData?.data?.url; @@ -39,24 +45,30 @@ function DetailItem({ fileId, onScaleChange }: { fileId: string; onScaleChange?: {hasPages ? ( fullThumbnails.map((thumb) => ( - - onSelectImage?.({ + uri: thumb.url, + width: SCREEN_WIDTH, + height: thumb.height * (SCREEN_WIDTH / thumb.width), + })} + > + - + )) ) : ( {uri ? ( - + onSelectImage?.({ uri, width: SCREEN_WIDTH, height: SCREEN_WIDTH })} + > + + ) : file ? ( (null); const [currentIndex, setCurrentIndex] = useState(initialIndex); - const [scrollEnabled, setScrollEnabled] = useState(true); - - const handleScaleChange = useCallback((scale: number) => { - setScrollEnabled(scale <= 1); - }, []); + const [selectedImage, setSelectedImage] = useState(null); return ( @@ -134,7 +142,6 @@ export function FileDetailScreen() { disableIntervalMomentum showsHorizontalScrollIndicator={false} initialScrollIndex={initialIndex} - scrollEnabled={scrollEnabled} getItemLayout={(_, index) => ({ length: SCREEN_WIDTH, offset: SCREEN_WIDTH * index, @@ -146,7 +153,7 @@ export function FileDetailScreen() { }} renderItem={({ item }) => ( - + )} /> @@ -156,6 +163,25 @@ export function FileDetailScreen() { {currentIndex + 1} / {fileIds.length} + + setSelectedImage(null)} + > + {selectedImage && ( + + setSelectedImage(null)} + /> + + )} + ); } @@ -180,11 +206,18 @@ const styles = StyleSheet.create({ alignItems: 'center', backgroundColor: '#000', }, + image: { + width: SCREEN_WIDTH, + height: SCREEN_WIDTH, + }, pageImageContainer: { alignItems: 'center', backgroundColor: '#000', paddingVertical: 4, }, + pageImage: { + width: SCREEN_WIDTH, + }, details: { backgroundColor: '#fff', borderTopLeftRadius: 16, diff --git a/mobile/components/ZoomableImage.tsx b/mobile/components/ZoomableImage.tsx index c82293d..d2c67e5 100644 --- a/mobile/components/ZoomableImage.tsx +++ b/mobile/components/ZoomableImage.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { StyleSheet } from 'react-native'; +import { StyleSheet, Pressable, View } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, @@ -16,14 +16,14 @@ interface ZoomableImageProps { uri: string; width: number; height: number; - onScaleChange?: (scale: number) => void; + onClose?: () => void; } const MAX_SCALE = 4; const DOUBLE_TAP_SCALE = 2.5; const SPRING_CONFIG = { damping: 20, stiffness: 200, mass: 0.5 }; -export function ZoomableImage({ uri, width, height, onScaleChange }: ZoomableImageProps) { +export function ZoomableImage({ uri, width, height, onClose }: ZoomableImageProps) { const scale = useSharedValue(1); const savedScale = useSharedValue(1); const translateX = useSharedValue(0); @@ -31,14 +31,13 @@ export function ZoomableImage({ uri, width, height, onScaleChange }: ZoomableIma const savedTranslateX = useSharedValue(0); const savedTranslateY = useSharedValue(0); - const notifyScale = useCallback((s: number) => { - onScaleChange?.(s); - }, [onScaleChange]); + const handleClose = useCallback(() => { + onClose?.(); + }, [onClose]); const pinch = Gesture.Pinch() .onUpdate((e) => { scale.value = Math.min(Math.max(savedScale.value * e.scale, 1), MAX_SCALE); - runOnJS(notifyScale)(scale.value); }) .onEnd(() => { if (scale.value < 1) { @@ -48,15 +47,13 @@ export function ZoomableImage({ uri, width, height, onScaleChange }: ZoomableIma savedScale.value = 1; savedTranslateX.value = 0; savedTranslateY.value = 0; - runOnJS(notifyScale)(1); } else { savedScale.value = scale.value; } }); const pan = Gesture.Pan() - .minDistance(10) - .activeOffsetX([-15, 15]) + .minDistance(5) .onUpdate((e) => { if (savedScale.value > 1) { translateX.value = savedTranslateX.value + e.translationX; @@ -72,6 +69,8 @@ export function ZoomableImage({ uri, width, height, onScaleChange }: ZoomableIma savedTranslateY.value = translateY.value; }); + const zoomGestures = Gesture.Simultaneous(pinch, pan); + const doubleTap = Gesture.Tap() .numberOfTaps(2) .maxDuration(250) @@ -83,15 +82,23 @@ export function ZoomableImage({ uri, width, height, onScaleChange }: ZoomableIma savedScale.value = 1; savedTranslateX.value = 0; savedTranslateY.value = 0; - runOnJS(notifyScale)(1); } else { scale.value = withTiming(DOUBLE_TAP_SCALE, { duration: 200 }); savedScale.value = DOUBLE_TAP_SCALE; - runOnJS(notifyScale)(DOUBLE_TAP_SCALE); } }); - const composed = Gesture.Exclusive(pinch, doubleTap, pan); + const singleTap = Gesture.Tap() + .maxDuration(250) + .onEnd(() => { + if (scale.value <= 1) { + runOnJS(handleClose)(); + } + }); + + const taps = Gesture.Exclusive(doubleTap, singleTap); + + const composed = Gesture.Race(zoomGestures, taps); const animatedStyle = useAnimatedStyle(() => ({ transform: [ @@ -102,18 +109,67 @@ export function ZoomableImage({ uri, width, height, onScaleChange }: ZoomableIma })); return ( - - - + + + + + {onClose && ( + + + + + + + )} + ); } +const CLOSE_SIZE = 36; +const CLOSE_LINE = 20; + const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000', + justifyContent: 'center', + alignItems: 'center', + }, image: { overflow: 'hidden', }, + closeButton: { + position: 'absolute', + top: 56, + right: 16, + width: CLOSE_SIZE, + height: CLOSE_SIZE, + borderRadius: CLOSE_SIZE / 2, + backgroundColor: 'rgba(255,255,255,0.25)', + justifyContent: 'center', + alignItems: 'center', + }, + closeIcon: { + width: CLOSE_LINE, + height: CLOSE_LINE, + justifyContent: 'center', + alignItems: 'center', + }, + closeLine: { + position: 'absolute', + width: CLOSE_LINE, + height: 2, + backgroundColor: '#fff', + borderRadius: 1, + }, + closeLine1: { + transform: [{ rotate: '45deg' }], + }, + closeLine2: { + transform: [{ rotate: '-45deg' }], + }, });