UNPKG

react-native-debug-toolkit

Version:

A simple yet powerful debugging toolkit for React Native with a convenient floating UI for development

698 lines (631 loc) 17.7 kB
import React, { Component } from 'react' import { View, Text, StyleSheet, Animated, PanResponder, Dimensions, Pressable, SafeAreaView, ScrollView, TouchableOpacity, Modal, FlatList, } from 'react-native' // import AsyncStorage from '@react-native-async-storage/async-storage' import { IconRadius, DebugColors } from '../utils/DebugConst' import SubViewHTTPLogs from './SubViewHTTPLogs' // import SubViewPerformance from './SubViewPerformance' import SubViewConsoleLogs from './SubViewConsoleLogs' import SubViewZustandLogs from './SubViewZustandLogs' import SubViewNavigationLogs from './SubViewNavigationLogs' import SubViewThirdPartyLibs from './SubViewThirdPartyLibs' import SubViewTrackLogs from './SubViewTrackLogs' const { width: screenWidth, height: screenHeight } = Dimensions.get('window') export default class FloatPanelView extends Component { constructor(props) { super(props) // Calculate initial position (right edge, middle of screen) const initialPosition = { x: screenWidth - IconRadius - 20, y: screenHeight / 2 - IconRadius / 2, } this.state = { isOpen: false, toFloat: true, pan: new Animated.ValueXY(initialPosition), scale: new Animated.Value(1), lastPosition: initialPosition, activeTab: 0, panelTranslateY: new Animated.Value(screenHeight), backdropOpacity: new Animated.Value(0), isTabMenuVisible: false, panelLayout: { width: 0, height: 0 }, } this.tabScrollViewRef = React.createRef() this.gestureResponder = PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderGrant: this._handlePanResponderGrant, onPanResponderMove: this._handlePanResponderMove, onPanResponderRelease: this._handlePanResponderRelease, onPanResponderTerminate: this._handlePanResponderRelease, }) this.panelResponder = PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: (_, gestureState) => { // Only respond to downward swipes return gestureState.dy > 5 }, onPanResponderMove: (_, gestureState) => { if (gestureState.dy > 0) { this.state.panelTranslateY.setValue(gestureState.dy) // Fade backdrop based on drag distance const newOpacity = Math.max(0, 1 - gestureState.dy / 200) this.state.backdropOpacity.setValue(newOpacity) } }, onPanResponderRelease: (_, gestureState) => { if (gestureState.dy > 100) { this._closePanel() } else { Animated.spring(this.state.panelTranslateY, { toValue: 0, friction: 8, tension: 50, useNativeDriver: true, }).start() Animated.timing(this.state.backdropOpacity, { toValue: 1, duration: 200, useNativeDriver: true, }).start() } }, }) } async componentDidMount() { try { // const savedPosition = await AsyncStorage.getItem('@debug_toolkit_position') // if (savedPosition) { // const position = JSON.parse(savedPosition) // // Ensure position is within bounds // const boundedPosition = { // x: Math.max(0, Math.min(position.x, screenWidth - IconRadius)), // y: Math.max(0, Math.min(position.y, screenHeight - IconRadius)), // } // this.setState({ lastPosition: boundedPosition }) // this.state.pan.setValue(boundedPosition) // } } catch (error) { console.error('Failed to load debug toolkit position:', error) } } _handlePanResponderGrant = () => { this.setState({ toFloat: true }) Animated.spring(this.state.scale, { toValue: 1.2, friction: 5, useNativeDriver: true, }).start() } _handlePanResponderMove = (e, gestureState) => { const { dx, dy } = gestureState const newX = this.state.lastPosition.x + dx const newY = this.state.lastPosition.y + dy // Keep within screen bounds while dragging const boundedX = Math.max(0, Math.min(newX, screenWidth - IconRadius)) const boundedY = Math.max(0, Math.min(newY, screenHeight - IconRadius)) this.state.pan.setValue({ x: boundedX, y: boundedY, }) } _handlePanResponderRelease = async (e, gestureState) => { const { dx, dy } = gestureState // If it's a tap (minimal movement) if (Math.abs(dx) < 5 && Math.abs(dy) < 5) { this._togglePanel() return } // Animate button scale back to normal Animated.spring(this.state.scale, { toValue: 1, friction: 5, useNativeDriver: true, }).start() // Calculate final position let newX = this.state.lastPosition.x + dx let newY = this.state.lastPosition.y + dy // Keep within screen bounds newX = Math.max(0, Math.min(newX, screenWidth - IconRadius)) newY = Math.max(0, Math.min(newY, screenHeight - IconRadius)) const newPosition = { x: newX, y: newY } // Save position try { // await AsyncStorage.setItem('@debug_toolkit_position', JSON.stringify(newPosition)) } catch (error) { console.error('Failed to save debug toolkit position:', error) } this.setState({ lastPosition: newPosition, toFloat: true, }) } _togglePanel = () => { const { isOpen } = this.state if (isOpen) { this._closePanel() } else { this._openPanel() } } _openPanel = () => { this.setState({ isOpen: true, toFloat: false }, () => { Animated.parallel([ Animated.spring(this.state.panelTranslateY, { toValue: 0, friction: 8, tension: 65, useNativeDriver: true, }), Animated.timing(this.state.backdropOpacity, { toValue: 1, duration: 250, useNativeDriver: true, }), ]).start() }) } _closePanel = () => { this.setState({ isTabMenuVisible: false }) Animated.parallel([ Animated.spring(this.state.panelTranslateY, { toValue: screenHeight, friction: 8, tension: 65, useNativeDriver: true, }), Animated.timing(this.state.backdropOpacity, { toValue: 0, duration: 200, useNativeDriver: true, }), ]).start(() => { this.setState({ isOpen: false, toFloat: true }) }) } _toggleTabMenu = () => { this.setState(prevState => ({ isTabMenuVisible: !prevState.isTabMenuVisible })) } _handleTabChange = (index) => { this.setState({ activeTab: index, isTabMenuVisible: false }, () => { // Scroll to make active tab visible if (this.tabScrollViewRef.current) { const tabWidth = 80; // Approximate tab width this.tabScrollViewRef.current.scrollTo({ x: Math.max(0, index * tabWidth - 50), // Center the tab animated: true }); } }) } renderFloatBtn() { const { isOpen, toFloat } = this.state if (!isOpen && toFloat) { return ( <Animated.View {...this.gestureResponder.panHandlers} style={[ styles.floatBtn, { transform: [ { translateX: this.state.pan.x }, { translateY: this.state.pan.y }, { scale: this.state.scale }, ], }, ]}> <Pressable onPress={this._togglePanel} style={styles.floatBtnInner}> <View style={styles.indicatorDot} /> </Pressable> </Animated.View> ) } return null } getFeatureTabs() { const { features } = this.props if (!features || !features.length) { return [] } return features.map((feature) => ({ label: feature.label, id: feature.name, })) } renderFeatureContent(featureId) { const { features } = this.props if (!features || !features.length) { return <Text style={styles.emptyText}>No features available</Text> } const feature = features.find((f) => f.name === featureId) if (!feature) { return <Text style={styles.emptyText}>Feature not found</Text> } const data = feature.getData() // Special handling for network logs if (feature.name === 'network') { return <SubViewHTTPLogs logs={data} /> } // Special handling for performance view // if (feature.name === 'performance') { // return <SubViewPerformance /> // } if (feature.name === 'console') { return <SubViewConsoleLogs logs={data} /> } if (feature.name === 'zustand') { return <SubViewZustandLogs logs={data} /> } if (feature.name === 'navigation') { return <SubViewNavigationLogs logs={data} /> } if (feature.name === 'track') { return <SubViewTrackLogs logs={data} /> } if (feature.name === 'thirdPartyLibs') { return <SubViewThirdPartyLibs libraries={data} /> } // Generic fallback for other feature types return ( <View style={styles.genericContent}> <View style={styles.contentHeader}> <Text style={styles.contentTitle}>{feature.label}</Text> </View> <Text style={styles.jsonContent}>{JSON.stringify(data, null, 2)}</Text> </View> ) } renderContent() { const tabs = this.getFeatureTabs() const { activeTab } = this.state if (!tabs.length) { return <Text style={styles.emptyText}>No debug features available</Text> } return this.renderFeatureContent(tabs[activeTab].id) } renderTabBar() { const tabs = this.getFeatureTabs() const { activeTab } = this.state if (!tabs.length) return null return ( <View style={styles.tabBarContainer}> <View style={styles.tabsRow}> <ScrollView ref={this.tabScrollViewRef} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabsScrollContainer}> {tabs.map((tab, index) => { const isActive = index === activeTab return ( <TouchableOpacity key={`tab-${tab.id}`} style={[styles.tab, isActive && styles.activeTab]} onPress={() => this._handleTabChange(index)}> <Text style={[styles.tabText, isActive && styles.activeTabText]} numberOfLines={1} ellipsizeMode="tail"> {tab.label} </Text> </TouchableOpacity> ) })} </ScrollView> </View> </View> ) } renderPanel() { const { isOpen, panelTranslateY, backdropOpacity } = this.state const { clearAll } = this.props if (!isOpen) { return null } return ( <View style={styles.panelContainer}> <Animated.View style={[styles.backdrop, { opacity: backdropOpacity }]}> <Pressable style={styles.backdropPressable} onPress={this._closePanel} /> </Animated.View> <Animated.View style={[ styles.panel, { transform: [{ translateY: panelTranslateY }] }, ]} onLayout={(e) => { const { width, height } = e.nativeEvent.layout this.setState({ panelLayout: { width, height } }) }}> <View {...this.panelResponder.panHandlers} style={styles.dragHandle}> <View style={styles.dragIndicator} /> </View> <SafeAreaView style={styles.panelContent}> <View style={styles.header}> <Text style={styles.headerTitle}>Debug Toolkit</Text> <View style={styles.headerButtons}> {clearAll && ( <TouchableOpacity onPress={() => { clearAll() this._closePanel() }} style={[styles.clearButton, styles.clearAllButton]} > <Text style={styles.clearButtonText}>Clear & Close</Text> </TouchableOpacity> )} <Pressable onPress={this._closePanel} style={styles.closeButton}> <Text style={styles.closeButtonText}>×</Text> </Pressable> </View> </View> {this.renderTabBar()} <View style={styles.contentContainer}> {this.renderContent()} </View> </SafeAreaView> </Animated.View> </View> ) } render() { return ( <View style={styles.container} pointerEvents='box-none'> {this.renderFloatBtn()} {this.renderPanel()} </View> ) } } const styles = StyleSheet.create({ container: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 999, }, floatBtn: { position: 'absolute', width: IconRadius, height: IconRadius, borderRadius: IconRadius / 2, backgroundColor: 'rgba(255, 255, 255, 0.7)', borderWidth: 1, borderColor: DebugColors.blue, elevation: 5, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, shadowRadius: 3.84, }, floatBtnInner: { width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center', }, indicatorDot: { width: 12, height: 12, borderRadius: 6, backgroundColor: DebugColors.blue, }, panelContainer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'flex-end', }, backdrop: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', }, backdropPressable: { flex: 1, }, panel: { width: '100%', height: '90%', backgroundColor: DebugColors.white, borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', elevation: 24, shadowColor: '#000', shadowOffset: { width: 0, height: -5 }, shadowOpacity: 0.3, shadowRadius: 10.0, }, dragHandle: { width: '100%', height: 30, alignItems: 'center', justifyContent: 'center', backgroundColor: DebugColors.white, }, dragIndicator: { width: 40, height: 5, borderRadius: 3, backgroundColor: '#CCCCCC', }, panelContent: { flex: 1, }, contentContainer: { flex: 1, }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 15, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#e5e5e5', backgroundColor: '#f9f9f9', shadowColor: "#000", shadowOffset: { width: 0, height: 1, }, shadowOpacity: 0.1, shadowRadius: 1.5, elevation: 1, }, headerTitle: { fontSize: 18, fontWeight: '700', color: '#2c3e50', }, headerButtons: { flexDirection: 'row', alignItems: 'center', }, clearButton: { backgroundColor: DebugColors.blue, paddingHorizontal: 10, paddingVertical: 5, borderRadius: 4, marginRight: 10, }, clearAllButton: { backgroundColor: '#3498db', paddingHorizontal: 14, paddingVertical: 9, borderRadius: 6, marginRight: 12, shadowColor: "#000", shadowOffset: { width: 0, height: 1, }, shadowOpacity: 0.12, shadowRadius: 1, elevation: 2, }, clearButtonText: { color: '#fff', fontSize: 13, fontWeight: '600', }, closeButton: { width: 30, height: 30, borderRadius: 15, backgroundColor: '#ecf0f1', alignItems: 'center', justifyContent: 'center', shadowColor: "#000", shadowOffset: { width: 0, height: 1, }, shadowOpacity: 0.1, shadowRadius: 1, elevation: 1, }, closeButtonText: { fontSize: 20, fontWeight: 'bold', color: '#34495e', lineHeight: 20, }, tabBarContainer: { borderBottomWidth: 1, borderBottomColor: DebugColors.border, backgroundColor: DebugColors.background, }, tabsRow: { flexDirection: 'row', alignItems: 'center', }, tabsScrollContainer: { paddingHorizontal: 15, flexDirection: 'row', alignItems: 'center', }, tab: { paddingVertical: 12, paddingHorizontal: 8, marginRight: 4, alignItems: 'center', justifyContent: 'center', minWidth: 50, }, activeTab: { borderBottomWidth: 2, borderBottomColor: DebugColors.blue, }, tabText: { fontSize: 13, color: DebugColors.textLight, textAlign: 'center', }, activeTabText: { color: DebugColors.blue, fontWeight: '600', }, emptyText: { padding: 20, textAlign: 'center', color: '#999', }, genericContent: { padding: 15, flex: 1, }, contentHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, }, contentTitle: { fontSize: 16, fontWeight: 'bold', color: DebugColors.text, }, copyButton: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: DebugColors.blue, borderRadius: 4, }, copyButtonText: { color: '#FFF', fontSize: 14, }, jsonContent: { fontFamily: 'monospace', fontSize: 12, color: DebugColors.text, } })