220 lines
6.1 KiB
JavaScript
220 lines
6.1 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",
|
|
},
|
|
});
|