2025-07-31 14:11:49 +08:00

179 lines
5.1 KiB
TypeScript

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,
cancel,
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);
});
useEffect(() => {
setMenuVisible(!cancel);
}, [cancel])
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;