feat: 长按保存
This commit is contained in:
parent
e74622b009
commit
0644d636d7
1
assets/icons/svg/cancel.svg
Normal file
1
assets/icons/svg/cancel.svg
Normal 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 |
1
assets/icons/svg/download.svg
Normal file
1
assets/icons/svg/download.svg
Normal 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 |
@ -4,10 +4,14 @@ import YesSvg from "@/assets/icons/svg/yes.svg";
|
|||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { FlatList, Image, Modal, StyleSheet, TouchableOpacity, View } from "react-native";
|
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 { ThemedText } from "../ThemedText";
|
||||||
import { mergeArrays } from "./utils";
|
import { mergeArrays } from "./utils";
|
||||||
|
|
||||||
|
import CancelSvg from '@/assets/icons/svg/cancel.svg';
|
||||||
|
import DownloadSvg from '@/assets/icons/svg/download.svg';
|
||||||
|
|
||||||
interface SelectModelProps {
|
interface SelectModelProps {
|
||||||
modalDetailsVisible: { visible: boolean, content: any };
|
modalDetailsVisible: { visible: boolean, content: any };
|
||||||
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
|
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
|
||||||
@ -17,6 +21,7 @@ interface SelectModelProps {
|
|||||||
t: TFunction;
|
t: TFunction;
|
||||||
}
|
}
|
||||||
const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setSelectedImages, selectedImages, t }: SelectModelProps) => {
|
const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setSelectedImages, selectedImages, t }: SelectModelProps) => {
|
||||||
|
|
||||||
const longPressGesture = Gesture.LongPress().onEnd((e, success) => {
|
const longPressGesture = Gesture.LongPress().onEnd((e, success) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
console.log(`Long pressed for ${e.duration} ms!`);
|
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>
|
<ThemedText style={detailsStyles.headerText}>{t('ask.selectPhoto', { ns: 'ask' })}</ThemedText>
|
||||||
<FolderSvg />
|
<FolderSvg />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ overflow: 'scroll', height: "100%" }}>
|
<View style={{ flex: 1 }}>
|
||||||
<FlatList
|
<FlatList
|
||||||
data={mergeArrays(modalDetailsVisible?.content?.image_material_infos || [], modalDetailsVisible?.content?.video_material_infos || [])}
|
data={mergeArrays(modalDetailsVisible?.content?.image_material_infos || [], modalDetailsVisible?.content?.video_material_infos || [])}
|
||||||
numColumns={3}
|
numColumns={3}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={detailsStyles.flatListContent}
|
contentContainerStyle={detailsStyles.gridContainer}
|
||||||
initialNumToRender={12}
|
initialNumToRender={12}
|
||||||
maxToRenderPerBatch={12}
|
maxToRenderPerBatch={12}
|
||||||
updateCellsBatchingPeriod={50}
|
updateCellsBatchingPeriod={50}
|
||||||
@ -63,20 +68,43 @@ const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setS
|
|||||||
: [...prev, itemId]
|
: [...prev, itemId]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<View style={detailsStyles.gridItemContainer} key={itemId}>
|
||||||
style={detailsStyles.gridItemContainer}
|
|
||||||
key={item.id}
|
<View style={detailsStyles.gridItem}>
|
||||||
>
|
{isSelected && (
|
||||||
<GestureDetector gesture={longPressGesture}>
|
|
||||||
<View style={detailsStyles.gridItem}>
|
|
||||||
<ThemedText style={detailsStyles.imageNumber}>
|
<ThemedText style={detailsStyles.imageNumber}>
|
||||||
{selectedImages?.map((image, index) => {
|
{selectedImages.indexOf(itemId) + 1}
|
||||||
if (image === item.id || image === item.video?.id) {
|
|
||||||
return index + 1
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</ThemedText>
|
</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
|
<Image
|
||||||
source={{ uri: item?.preview_file_info?.url || item.video?.preview_file_info?.url }}
|
source={{ uri: item?.preview_file_info?.url || item.video?.preview_file_info?.url }}
|
||||||
style={detailsStyles.image}
|
style={detailsStyles.image}
|
||||||
@ -84,19 +112,26 @@ const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setS
|
|||||||
onLoad={() => console.log('Image loaded successfully')}
|
onLoad={() => console.log('Image loaded successfully')}
|
||||||
loadingIndicatorSource={require('@/assets/images/png/placeholder.png')}
|
loadingIndicatorSource={require('@/assets/images/png/placeholder.png')}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
</ContextMenu>
|
||||||
style={[
|
|
||||||
detailsStyles.circleMarker,
|
<TouchableOpacity
|
||||||
isSelected && detailsStyles.circleMarkerSelected
|
style={[
|
||||||
]}
|
detailsStyles.circleMarker,
|
||||||
onPress={toggleSelection}
|
isSelected && detailsStyles.circleMarkerSelected
|
||||||
activeOpacity={0.8}
|
]}
|
||||||
>
|
onPress={() => {
|
||||||
{isSelected && <YesSvg width={16} height={16} />}
|
setSelectedImages(prev =>
|
||||||
</TouchableOpacity>
|
isSelected
|
||||||
</View>
|
? prev.filter(id => id !== itemId)
|
||||||
</GestureDetector>
|
: [...prev, itemId]
|
||||||
</TouchableOpacity>
|
);
|
||||||
|
}}
|
||||||
|
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({
|
const detailsStyles = StyleSheet.create({
|
||||||
|
gridContainer: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
gridItemContainer: {
|
gridItemContainer: {
|
||||||
width: '33.33%',
|
width: '33.33%',
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
@ -199,6 +239,7 @@ const detailsStyles = StyleSheet.create({
|
|||||||
image: {
|
image: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
resizeMode: 'cover',
|
||||||
},
|
},
|
||||||
circleMarker: {
|
circleMarker: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
|
import i18n from '@/i18n';
|
||||||
|
import { PermissionService } from '@/lib/PermissionService';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import * as MediaLibrary from 'expo-media-library';
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||||
import i18n from '@/i18n';
|
|
||||||
import { PermissionService } from '@/lib/PermissionService';
|
|
||||||
import QRCode from 'react-native-qrcode-svg';
|
import QRCode from 'react-native-qrcode-svg';
|
||||||
import { captureRef } from 'react-native-view-shot';
|
import { captureRef } from 'react-native-view-shot';
|
||||||
|
|
||||||
|
|||||||
174
components/gusture/contextMenu.tsx
Normal file
174
components/gusture/contextMenu.tsx
Normal 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;
|
||||||
@ -1,7 +1,9 @@
|
|||||||
|
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
import 'react-native-gesture-handler';
|
||||||
import { GestureHandlerRootView } from "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 Toast, { BaseToast, ErrorToast, ToastConfig } from 'react-native-toast-message';
|
||||||
import { Provider as ReduxProvider } from "react-redux";
|
import { Provider as ReduxProvider } from "react-redux";
|
||||||
import { AuthProvider } from "./contexts/auth-context";
|
import { AuthProvider } from "./contexts/auth-context";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user