feat: 长按保存

This commit is contained in:
jinyaqiu 2025-07-31 10:41:59 +08:00
parent e74622b009
commit 0644d636d7
6 changed files with 248 additions and 29 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>

After

Width:  |  Height:  |  Size: 312 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-to-line-icon lucide-arrow-down-to-line"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@ -4,10 +4,14 @@ import YesSvg from "@/assets/icons/svg/yes.svg";
import { TFunction } from "i18next";
import React from "react";
import { FlatList, Image, Modal, StyleSheet, TouchableOpacity, View } from "react-native";
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Gesture } from 'react-native-gesture-handler';
import ContextMenu from "../gusture/contextMenu";
import { ThemedText } from "../ThemedText";
import { mergeArrays } from "./utils";
import CancelSvg from '@/assets/icons/svg/cancel.svg';
import DownloadSvg from '@/assets/icons/svg/download.svg';
interface SelectModelProps {
modalDetailsVisible: { visible: boolean, content: any };
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
@ -17,6 +21,7 @@ interface SelectModelProps {
t: TFunction;
}
const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setSelectedImages, selectedImages, t }: SelectModelProps) => {
const longPressGesture = Gesture.LongPress().onEnd((e, success) => {
if (success) {
console.log(`Long pressed for ${e.duration} ms!`);
@ -40,13 +45,13 @@ const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setS
<ThemedText style={detailsStyles.headerText}>{t('ask.selectPhoto', { ns: 'ask' })}</ThemedText>
<FolderSvg />
</View>
<View style={{ overflow: 'scroll', height: "100%" }}>
<View style={{ flex: 1 }}>
<FlatList
data={mergeArrays(modalDetailsVisible?.content?.image_material_infos || [], modalDetailsVisible?.content?.video_material_infos || [])}
numColumns={3}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={detailsStyles.flatListContent}
contentContainerStyle={detailsStyles.gridContainer}
initialNumToRender={12}
maxToRenderPerBatch={12}
updateCellsBatchingPeriod={50}
@ -63,20 +68,43 @@ const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setS
: [...prev, itemId]
);
};
return (
<TouchableOpacity
style={detailsStyles.gridItemContainer}
key={item.id}
>
<GestureDetector gesture={longPressGesture}>
<View style={detailsStyles.gridItem}>
<View style={detailsStyles.gridItemContainer} key={itemId}>
<View style={detailsStyles.gridItem}>
{isSelected && (
<ThemedText style={detailsStyles.imageNumber}>
{selectedImages?.map((image, index) => {
if (image === item.id || image === item.video?.id) {
return index + 1
}
})}
{selectedImages.indexOf(itemId) + 1}
</ThemedText>
)}
<ContextMenu
items={[
{
svg: <DownloadSvg width={20} height={20} />,
label: "保存",
onPress: () => console.log('保存图片'),
textStyle: { color: '#4C320C' }
},
{
svg: <CancelSvg width={20} height={20} color='red' />,
label: "取消",
onPress: () => console.log('取消'),
textStyle: { color: 'red' }
}
]}
menuStyle={{
backgroundColor: 'white',
borderRadius: 8,
padding: 8,
minWidth: 150,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
}}
>
<Image
source={{ uri: item?.preview_file_info?.url || item.video?.preview_file_info?.url }}
style={detailsStyles.image}
@ -84,19 +112,26 @@ const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setS
onLoad={() => console.log('Image loaded successfully')}
loadingIndicatorSource={require('@/assets/images/png/placeholder.png')}
/>
<TouchableOpacity
style={[
detailsStyles.circleMarker,
isSelected && detailsStyles.circleMarkerSelected
]}
onPress={toggleSelection}
activeOpacity={0.8}
>
{isSelected && <YesSvg width={16} height={16} />}
</TouchableOpacity>
</View>
</GestureDetector>
</TouchableOpacity>
</ContextMenu>
<TouchableOpacity
style={[
detailsStyles.circleMarker,
isSelected && detailsStyles.circleMarkerSelected
]}
onPress={() => {
setSelectedImages(prev =>
isSelected
? prev.filter(id => id !== itemId)
: [...prev, itemId]
);
}}
activeOpacity={0.8}
>
{isSelected && <YesSvg width={16} height={16} />}
</TouchableOpacity>
</View>
</View>
);
}}
/>
@ -127,6 +162,11 @@ const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setS
const detailsStyles = StyleSheet.create({
gridContainer: {
flex: 1,
paddingHorizontal: 8,
paddingTop: 8,
},
gridItemContainer: {
width: '33.33%',
aspectRatio: 1,
@ -199,6 +239,7 @@ const detailsStyles = StyleSheet.create({
image: {
width: '100%',
height: '100%',
resizeMode: 'cover',
},
circleMarker: {
position: 'absolute',

View File

@ -1,9 +1,9 @@
import i18n from '@/i18n';
import { PermissionService } from '@/lib/PermissionService';
import * as Haptics from 'expo-haptics';
import * as MediaLibrary from 'expo-media-library';
import React, { useRef } from 'react';
import { StyleSheet, TouchableOpacity, View } from 'react-native';
import i18n from '@/i18n';
import { PermissionService } from '@/lib/PermissionService';
import QRCode from 'react-native-qrcode-svg';
import { captureRef } from 'react-native-view-shot';

View File

@ -0,0 +1,174 @@
import React, { useRef, useState } from 'react';
import {
Dimensions,
Modal,
StyleProp,
StyleSheet,
Text,
TextStyle,
TouchableOpacity,
TouchableWithoutFeedback,
View,
ViewStyle
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { runOnJS } from 'react-native-reanimated';
interface MenuItem {
label: string;
svg?: React.ReactNode;
onPress: () => void;
textStyle?: StyleProp<TextStyle>;
}
interface ContextMenuProps {
children: React.ReactNode;
items: MenuItem[];
menuStyle?: StyleProp<ViewStyle>;
menuItemStyle?: StyleProp<ViewStyle>;
menuTextStyle?: StyleProp<TextStyle>;
dividerStyle?: StyleProp<ViewStyle>;
onOpen?: () => void;
onClose?: () => void;
longPressDuration?: number;
activeOpacity?: number;
}
const ContextMenu: React.FC<ContextMenuProps> = ({
children,
items,
menuStyle,
menuItemStyle,
menuTextStyle,
dividerStyle,
onOpen,
onClose,
longPressDuration = 500,
activeOpacity = 0.8,
}) => {
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
const [menuVisible, setMenuVisible] = useState(false);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
const containerRef = useRef<View>(null);
const showMenu = (x: number, y: number) => {
setMenuPosition({ x, y });
setMenuVisible(true);
onOpen?.();
};
const hideMenu = () => {
setMenuVisible(false);
onClose?.();
};
const handleItemPress = (onPress: () => void) => {
onPress();
hideMenu();
};
const gesture = Gesture.LongPress()
.minDuration(longPressDuration)
.onStart((e) => {
const absoluteX = e.absoluteX;
const absoluteY = e.absoluteY;
runOnJS(showMenu)(absoluteX, absoluteY);
});
return (
<>
<View ref={containerRef} collapsable={false} style={{ flex: 1 }}>
<GestureDetector gesture={gesture}>
<View style={{ flex: 1 }}>
{children}
</View>
</GestureDetector>
</View>
<Modal
visible={menuVisible}
transparent
animationType="fade"
onRequestClose={hideMenu}
>
<TouchableWithoutFeedback onPress={hideMenu}>
<View style={styles.modalOverlay} />
</TouchableWithoutFeedback>
<View
style={[
styles.menu,
{
position: 'absolute',
top: Math.min(
menuPosition.y,
screenHeight - 300
),
left: Math.min(
menuPosition.x > screenWidth / 2 ? menuPosition.x - 150 : menuPosition.x,
screenWidth - 160
),
},
menuStyle,
]}
onStartShouldSetResponder={() => true}
>
{items.map((item, index) => (
<React.Fragment key={item.label}>
<TouchableOpacity
style={[styles.menuItem, menuItemStyle]}
onPress={() => handleItemPress(item.onPress)}
activeOpacity={activeOpacity}
>
{item.svg}
<Text style={[styles.menuText, menuTextStyle, item.textStyle]}>
{item.label}
</Text>
</TouchableOpacity>
{index < items.length - 1 && (
<View style={[styles.divider, dividerStyle]} />
)}
</React.Fragment>
))}
</View>
</Modal>
</>
);
};
const styles = StyleSheet.create({
modalOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
},
menu: {
backgroundColor: 'white',
borderRadius: 8,
minWidth: 100,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 6,
elevation: 10,
zIndex: 1000,
},
menuItem: {
paddingVertical: 12,
paddingHorizontal: 16,
minWidth: 100,
flexDirection: 'row',
gap: 4,
alignItems: 'center'
},
menuText: {
fontSize: 16,
color: '#333',
},
divider: {
height: 1,
backgroundColor: '#f0f0f0',
marginHorizontal: 8,
},
});
export default ContextMenu;

View File

@ -1,7 +1,9 @@
import { I18nextProvider } from "react-i18next";
import { Platform } from 'react-native';
import 'react-native-gesture-handler';
import { GestureHandlerRootView } from "react-native-gesture-handler";
import 'react-native-reanimated';
import Toast, { BaseToast, ErrorToast, ToastConfig } from 'react-native-toast-message';
import { Provider as ReduxProvider } from "react-redux";
import { AuthProvider } from "./contexts/auth-context";