Files
musicland/src/components/SongStructureDragDrop.js
T
2026-01-12 16:01:32 +01:00

215 lines
6.0 KiB
JavaScript

import { View, Text, ScrollView, StyleSheet, Dimensions, TouchableOpacity } from 'react-native'
import React, { useEffect, useMemo, useState } from 'react'
import Animated, {
runOnJS,
useAnimatedGestureHandler,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated'
import { PanGestureHandler } from 'react-native-gesture-handler'
const { width } = Dimensions.get('window')
// Child component to respect Rules of Hooks (no hooks in loops)
const DraggableItem = ({
item,
gestureHandler,
draggingItem,
translateX,
translateY,
onActivate,
}) => {
const style = useAnimatedStyle(() => ({
transform: [
{ translateX: draggingItem.value === item.id ? translateX.value : 0 },
{ translateY: draggingItem.value === item.id ? translateY.value : 0 },
],
zIndex: draggingItem.value === item.id ? 10 : 0,
}))
return (
<PanGestureHandler
onGestureEvent={gestureHandler}
onHandlerStateChange={() => onActivate(item.id)}
>
<Animated.View style={[styles.item, style]}>
<Text style={styles.text}>{item.label ?? String(item)}</Text>
</Animated.View>
</PanGestureHandler>
)
}
// Props:
// - sourceItems: array of { id, label, value } or strings
// - initialSelected: array of ids to prefill right side (optional)
// - onChange: callback with array of values (or strings) in right order
const SongStructureDragDrop = ({ sourceItems, initialSelected, onChange }) => {
const normalize = (arr = []) =>
arr.map((item, idx) =>
typeof item === 'string' ? { id: `${item}-${idx}`, label: item, value: item } : item
)
const normalizedSource = useMemo(() => normalize(sourceItems || []), [sourceItems])
const [rightItems, setRightItems] = useState([])
// Compare arrays by item id and order to avoid unnecessary state churn
const sameById = (a = [], b = []) => {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
for (let idx = 0; idx < a.length; idx++) {
if (a[idx]?.id !== b[idx]?.id) return false
}
return true
}
useEffect(() => {
const selected = Array.isArray(initialSelected) ? initialSelected : []
const selectedSet = new Set(selected)
const nextRight = normalizedSource.filter((i) => selectedSet.has(i.id))
setRightItems((prev) => (sameById(prev, nextRight) ? prev : nextRight))
}, [normalizedSource, initialSelected])
const leftItems = useMemo(() => {
if (!rightItems || rightItems.length === 0) return normalizedSource
const rightIds = new Set(rightItems.map((item) => item.id))
return normalizedSource.filter((item) => !rightIds.has(item.id))
}, [normalizedSource, rightItems])
const draggingItem = useSharedValue(null)
const translateX = useSharedValue(0)
const translateY = useSharedValue(0)
const onActivate = (id) => {
draggingItem.value = id
}
const moveItem = (itemId) => {
const item = normalizedSource.find((i) => i.id === itemId)
if (!item) return
setRightItems((current) => {
if (current.some((i) => i.id === itemId)) return current
const next = [...current, item]
onChange?.(next.map((i) => i.value ?? i.label))
return next
})
}
const removeItem = (itemId) => {
setRightItems((current) => {
if (!current.some((i) => i.id === itemId)) return current
const next = current.filter((i) => i.id !== itemId)
onChange?.(next.map((i) => i.value ?? i.label))
return next
})
}
const gestureHandler = useAnimatedGestureHandler({
onStart: (_, ctx) => {
ctx.startX = translateX.value
ctx.startY = translateY.value
},
onActive: (event, ctx) => {
translateX.value = ctx.startX + event.translationX
translateY.value = ctx.startY + event.translationY
},
onEnd: () => {
if (translateX.value > width / 2) {
runOnJS(moveItem)(draggingItem.value)
}
translateX.value = withSpring(0)
translateY.value = withSpring(0)
draggingItem.value = null
},
})
return (
<View style={styles.scrollWrapper}>
<ScrollView style={{ ...styles.scroll, zIndex: 2 }}>
<Text style={styles.title}>Left</Text>
{leftItems.map((item) => (
<DraggableItem
key={item.id}
item={item}
gestureHandler={gestureHandler}
draggingItem={draggingItem}
translateX={translateX}
translateY={translateY}
onActivate={onActivate}
/>
))}
</ScrollView>
<ScrollView style={{ ...styles.scroll, zIndex: -1 }}>
<Text style={styles.title}>Right</Text>
<Text style={styles.helpText}>Appuyez pour retirer un élément</Text>
{rightItems.map((item) => (
<TouchableOpacity
key={item.id}
style={styles.selectedItem}
onPress={() => removeItem(item.id)}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={`Retirer ${item.label ?? String(item)}`}
>
<Text style={styles.text}>{item.label ?? String(item)}</Text>
<Text style={styles.removeHint}>Retirer</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
)
}
export default SongStructureDragDrop
const styles = StyleSheet.create({
scrollWrapper: {
flexDirection: 'row',
justifyContent: 'space-around',
},
scroll: {
width: width / 2.2,
height: '90%',
backgroundColor: '#f0f0f0',
borderRadius: 10,
margin: 5,
padding: 10,
},
title: {
fontWeight: 'bold',
fontSize: 16,
marginBottom: 10,
},
item: {
backgroundColor: '#aaf',
padding: 15,
marginVertical: 5,
borderRadius: 10,
},
selectedItem: {
backgroundColor: '#bdf',
padding: 15,
marginVertical: 5,
borderRadius: 10,
alignItems: 'center',
},
helpText: {
fontSize: 12,
color: '#666',
marginBottom: 8,
textAlign: 'center',
},
removeHint: {
fontSize: 12,
color: '#555',
marginTop: 4,
},
text: {
color: '#333',
textAlign: 'center',
},
})