diff --git a/src/screens/Writing/CustomizeSongStructure.web.js b/src/screens/Writing/CustomizeSongStructure.web.js
index e9ce9be..bdac674 100644
--- a/src/screens/Writing/CustomizeSongStructure.web.js
+++ b/src/screens/Writing/CustomizeSongStructure.web.js
@@ -7,6 +7,7 @@ import React, {
} from "react";
import {
Image,
+ Pressable,
ScrollView,
StyleSheet,
Text,
@@ -28,6 +29,29 @@ import CreateLyricsHeader from "./components/CreateLyricsHeader";
const ROW_HEIGHT = 54;
+const PALETTE_SEGMENT_OVERRIDES = [
+ {
+ type: "pont",
+ description:
+ "Transition contrastée qui relance l’énergie avant d’entrer dans la dernière partie.",
+ },
+ {
+ type: "interlude_melodique",
+ description:
+ "Respiration instrumentale qui met la mélodie à l’honneur sans paroles.",
+ },
+ {
+ type: "final_apogee",
+ description:
+ "Clôture apothéose qui amplifie l’émotion pour un final impactant.",
+ },
+ {
+ type: "fade_out",
+ description:
+ "Sortie progressive où le morceau disparaît doucement dans le mix.",
+ },
+];
+
const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0);
const randomSuffix = () => Math.random().toString(36).slice(2, 8);
@@ -62,11 +86,19 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
buildSortableItems(sanitizedBase)
);
const [activeItemId, setActiveItemId] = useState(null);
+ const [insertPreviewIndex, setInsertPreviewIndex] = useState(null);
const containerRef = useRef(null);
+ const sortableItemsRef = useRef(sortableItems);
const dragStateRef = useRef({
itemId: null,
+ paletteType: null,
+ pointerId: null,
containerTop: 0,
+ containerRect: null,
+ targetIndex: null,
+ isOverContainer: false,
});
+ const globalListenersRef = useRef({ move: null, up: null });
const extractValues = useCallback(
(items) =>
@@ -84,6 +116,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
});
}, [sanitizedBase, extractValues]);
+ useEffect(() => {
+ sortableItemsRef.current = sortableItems;
+ }, [sortableItems]);
+
useEffect(() => {
onChange?.(extractValues(sortableItems));
}, [sortableItems, extractValues, onChange]);
@@ -103,11 +139,18 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
});
}, [sortableItems]);
- const optionalSegments = useMemo(
+ const paletteSegments = useMemo(
() =>
OPTIONAL_STRUCTURE_SEGMENTS.map((type) => {
const meta = getSegmentMeta(type);
- return { type, label: meta?.label || formatStructureLabel(type, 1) };
+ const override = PALETTE_SEGMENT_OVERRIDES.find(
+ (segment) => segment.type === type
+ );
+ return {
+ type,
+ label: meta?.label || formatStructureLabel(type, 1),
+ description: override?.description || "",
+ };
}),
[]
);
@@ -131,8 +174,8 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
[extractValues]
);
- const handleOptionalSegmentPress = useCallback(
- (type) => {
+ const addOptionalSegment = useCallback(
+ (type, insertIndex = null) => {
const meta = getSegmentMeta(type);
setSortableItems((prev) => {
const currentValues = extractValues(prev);
@@ -145,14 +188,16 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
});
}
- const allowsMultiple = meta?.allowMultiple !== false;
- if (!allowsMultiple && nextValues.includes(type)) {
- const filtered = nextValues.filter((value) => value !== type);
- const sanitized = sanitizeStructureList(filtered);
- return buildSortableItems(sanitized);
+ if (meta?.allowMultiple === false) {
+ nextValues = nextValues.filter((value) => value !== type);
}
- nextValues.push(type);
+ const targetIndex =
+ typeof insertIndex === "number"
+ ? clamp(insertIndex, 0, nextValues.length)
+ : nextValues.length;
+
+ nextValues.splice(targetIndex, 0, type);
const sanitized = sanitizeStructureList(nextValues);
return buildSortableItems(sanitized);
});
@@ -160,69 +205,302 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
[extractValues]
);
- const handleGrant = useCallback((itemId, event) => {
- const rect = containerRef.current?.getBoundingClientRect();
- const scrollY = getScrollY();
- const nativeEvent = event.nativeEvent || {};
- const fallbackY =
- typeof nativeEvent.pageY === "number"
- ? nativeEvent.pageY
- : typeof nativeEvent.clientY === "number"
- ? nativeEvent.clientY + scrollY
- : scrollY;
- const containerTop = rect ? rect.top + scrollY : fallbackY;
- dragStateRef.current = {
- itemId,
- containerTop,
- };
- setActiveItemId(itemId);
- if (typeof document !== "undefined") {
- document.body.style.userSelect = "none";
- }
- }, []);
-
- const handleMove = useCallback((event) => {
- const { itemId, containerTop } = dragStateRef.current;
- if (!itemId) return;
- const nativeEvent = event.nativeEvent || {};
- const scrollY = getScrollY();
- const absoluteY =
- typeof nativeEvent.pageY === "number"
- ? nativeEvent.pageY
- : typeof nativeEvent.clientY === "number"
- ? nativeEvent.clientY + scrollY
- : scrollY;
- const pointerY = absoluteY - containerTop;
-
- setSortableItems((prev) => {
- const currentIndex = prev.findIndex((it) => it.id === itemId);
- if (currentIndex === -1) return prev;
- const targetIndex = clamp(
- Math.floor(pointerY / ROW_HEIGHT),
- 0,
- Math.max(prev.length - 1, 0)
- );
- if (targetIndex === currentIndex) return prev;
- return reorder(prev, currentIndex, targetIndex);
- });
- }, []);
-
- const handleRelease = useCallback(() => {
- setSortableItems((prev) => buildSortableItems(extractValues(prev)));
+ const resetDragState = useCallback(() => {
dragStateRef.current = {
itemId: null,
+ paletteType: null,
+ pointerId: null,
containerTop: 0,
+ containerRect: null,
+ targetIndex: null,
+ isOverContainer: false,
};
- setActiveItemId(null);
+ }, []);
+
+ const cleanupPointerListeners = useCallback(() => {
+ if (typeof window !== "undefined") {
+ const { move, up } = globalListenersRef.current;
+ if (move) {
+ window.removeEventListener("pointermove", move);
+ }
+ if (up) {
+ window.removeEventListener("pointerup", up);
+ window.removeEventListener("pointercancel", up);
+ }
+ globalListenersRef.current = { move: null, up: null };
+ }
if (typeof document !== "undefined") {
document.body.style.userSelect = "";
+ document.body.style.cursor = "";
}
- }, [extractValues]);
+ resetDragState();
+ }, [resetDragState]);
+
+ useEffect(() => {
+ return () => {
+ cleanupPointerListeners();
+ };
+ }, [cleanupPointerListeners]);
+
+ const handleGlobalPointerMove = useCallback(
+ (event) => {
+ const dragState = dragStateRef.current;
+ if (!dragState.itemId && !dragState.paletteType) return;
+ if (
+ dragState.pointerId != null &&
+ event?.pointerId != null &&
+ dragState.pointerId !== event.pointerId
+ ) {
+ return;
+ }
+
+ const scrollY = getScrollY();
+ const absoluteY =
+ typeof event.pageY === "number"
+ ? event.pageY
+ : typeof event.clientY === "number"
+ ? event.clientY + scrollY
+ : scrollY;
+ const pointerY = absoluteY - (dragState.containerTop || 0);
+
+ if (dragState.itemId) {
+ setSortableItems((prev) => {
+ const currentIndex = prev.findIndex(
+ (it) => it.id === dragState.itemId
+ );
+ if (currentIndex === -1) return prev;
+ const maxIndex = Math.max(prev.length - 1, 0);
+ const targetIndex = clamp(
+ Math.floor(pointerY / ROW_HEIGHT),
+ 0,
+ maxIndex
+ );
+ if (targetIndex === currentIndex) return prev;
+ return reorder(prev, currentIndex, targetIndex);
+ });
+ return;
+ }
+
+ const rect = dragState.containerRect;
+ const listLength = sortableItemsRef.current.length;
+ const isOver =
+ rect == null
+ ? true
+ : event.clientX >= rect.left &&
+ event.clientX <= rect.right &&
+ event.clientY >= rect.top &&
+ event.clientY <= rect.bottom;
+ dragStateRef.current.isOverContainer = isOver;
+
+ if (!isOver) {
+ if (insertPreviewIndex !== null) {
+ setInsertPreviewIndex(null);
+ }
+ dragStateRef.current.targetIndex = null;
+ return;
+ }
+
+ const containerHeight = rect?.height ?? listLength * ROW_HEIGHT;
+ let targetIndex = Math.floor(pointerY / ROW_HEIGHT);
+ if (pointerY < 0) targetIndex = 0;
+ if (pointerY > containerHeight) targetIndex = listLength;
+ targetIndex = clamp(targetIndex, 0, listLength);
+
+ if (dragStateRef.current.targetIndex !== targetIndex) {
+ dragStateRef.current.targetIndex = targetIndex;
+ setInsertPreviewIndex(targetIndex);
+ }
+ },
+ [insertPreviewIndex, setSortableItems]
+ );
+
+ const handleGlobalPointerUp = useCallback(
+ (event) => {
+ const dragState = dragStateRef.current;
+ if (!dragState.itemId && !dragState.paletteType) return;
+ if (
+ dragState.pointerId != null &&
+ event?.pointerId != null &&
+ dragState.pointerId !== event.pointerId
+ ) {
+ return;
+ }
+
+ if (dragState.itemId) {
+ setSortableItems((prev) =>
+ buildSortableItems(extractValues(prev))
+ );
+ } else if (dragState.paletteType) {
+ let isOver = dragState.isOverContainer;
+ let targetIndex = dragState.targetIndex;
+ const rect = dragState.containerRect;
+
+ if (rect && (!isOver || typeof targetIndex !== "number")) {
+ const scrollY = getScrollY();
+ const absoluteY =
+ typeof event.pageY === "number"
+ ? event.pageY
+ : typeof event.clientY === "number"
+ ? event.clientY + scrollY
+ : scrollY;
+ const pointerY = absoluteY - (dragState.containerTop || 0);
+ const within =
+ event.clientX >= rect.left &&
+ event.clientX <= rect.right &&
+ event.clientY >= rect.top &&
+ event.clientY <= rect.bottom;
+
+ if (within) {
+ isOver = true;
+ const listLength = sortableItemsRef.current.length;
+ const containerHeight = rect?.height ?? listLength * ROW_HEIGHT;
+ let computedIndex = Math.floor(pointerY / ROW_HEIGHT);
+ if (pointerY < 0) computedIndex = 0;
+ if (pointerY > containerHeight) computedIndex = listLength;
+ targetIndex = clamp(computedIndex, 0, listLength);
+ }
+ }
+
+ if (isOver && typeof targetIndex === "number") {
+ addOptionalSegment(dragState.paletteType, targetIndex);
+ }
+ }
+
+ setActiveItemId(null);
+ setInsertPreviewIndex(null);
+ cleanupPointerListeners();
+ },
+ [addOptionalSegment, cleanupPointerListeners, extractValues]
+ );
+
+ const attachGlobalListeners = useCallback(() => {
+ if (typeof window === "undefined") return;
+ if (globalListenersRef.current.move) return;
+
+ const moveListener = (event) => handleGlobalPointerMove(event);
+ const upListener = (event) => handleGlobalPointerUp(event);
+
+ window.addEventListener("pointermove", moveListener);
+ window.addEventListener("pointerup", upListener);
+ window.addEventListener("pointercancel", upListener);
+
+ globalListenersRef.current = {
+ move: moveListener,
+ up: upListener,
+ };
+ }, [handleGlobalPointerMove, handleGlobalPointerUp]);
+
+ const startItemDrag = useCallback(
+ (itemId, event) => {
+ const nativeEvent = event?.nativeEvent || {};
+ if (typeof nativeEvent.button === "number" && nativeEvent.button !== 0)
+ return;
+ event?.preventDefault?.();
+
+ const rect = containerRef.current?.getBoundingClientRect();
+ const scrollY = getScrollY();
+ const fallbackY =
+ typeof nativeEvent.pageY === "number"
+ ? nativeEvent.pageY
+ : typeof nativeEvent.clientY === "number"
+ ? nativeEvent.clientY + scrollY
+ : scrollY;
+ const containerTop = rect ? rect.top + scrollY : fallbackY;
+
+ dragStateRef.current = {
+ itemId,
+ paletteType: null,
+ pointerId:
+ typeof nativeEvent.pointerId === "number"
+ ? nativeEvent.pointerId
+ : null,
+ containerTop,
+ containerRect: rect
+ ? {
+ top: rect.top,
+ bottom: rect.bottom,
+ left: rect.left,
+ right: rect.right,
+ height: rect.height,
+ }
+ : null,
+ targetIndex: null,
+ isOverContainer: true,
+ };
+
+ setActiveItemId(itemId);
+ setInsertPreviewIndex(null);
+
+ if (typeof document !== "undefined") {
+ document.body.style.userSelect = "none";
+ document.body.style.cursor = "grabbing";
+ }
+
+ attachGlobalListeners();
+ },
+ [attachGlobalListeners]
+ );
+
+ const startPaletteDrag = useCallback(
+ (type, event) => {
+ const nativeEvent = event?.nativeEvent || {};
+ if (typeof nativeEvent.button === "number" && nativeEvent.button !== 0)
+ return;
+ event?.preventDefault?.();
+
+ const rect = containerRef.current?.getBoundingClientRect();
+ const scrollY = getScrollY();
+ const fallbackY =
+ typeof nativeEvent.pageY === "number"
+ ? nativeEvent.pageY
+ : typeof nativeEvent.clientY === "number"
+ ? nativeEvent.clientY + scrollY
+ : scrollY;
+ const containerTop = rect ? rect.top + scrollY : fallbackY;
+
+ dragStateRef.current = {
+ itemId: null,
+ paletteType: type,
+ pointerId:
+ typeof nativeEvent.pointerId === "number"
+ ? nativeEvent.pointerId
+ : null,
+ containerTop,
+ containerRect: rect
+ ? {
+ top: rect.top,
+ bottom: rect.bottom,
+ left: rect.left,
+ right: rect.right,
+ height: rect.height,
+ }
+ : null,
+ targetIndex: null,
+ isOverContainer: false,
+ };
+
+ setActiveItemId(`palette-${type}`);
+ setInsertPreviewIndex(null);
+
+ if (typeof document !== "undefined") {
+ document.body.style.userSelect = "none";
+ document.body.style.cursor = "grabbing";
+ }
+
+ attachGlobalListeners();
+ },
+ [attachGlobalListeners]
+ );
const StructureRow = ({ item }) => {
const baseType = item?.type || item?.value;
const meta = getSegmentMeta(baseType);
const canRemove = meta?.optional === true;
+ const isIntroduction =
+ baseType === "short_intro" ||
+ baseType === "long_intro" ||
+ baseType === "intro" ||
+ baseType === "introduction";
const containerStyle = StyleSheet.flatten([
styles.rowContainer,
activeItemId === item.id ? styles.activeItem : null,
@@ -252,16 +530,14 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
)}
- true}
- onResponderGrant={(event) => handleGrant(item.id, event)}
- onResponderMove={handleMove}
- onResponderRelease={handleRelease}
- onResponderTerminate={handleRelease}
- >
-
-
+ {!isIntroduction && (
+ startItemDrag(item.id, event)}
+ >
+
+
+ )}
@@ -269,74 +545,122 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
};
return (
-
+
-
-
-
- Structure actuelle
-
-
-
- {labeledItems.map((item) => (
-
- ))}
+
+
+
+
+ Structure actuelle
-
-
- Éléments à ajouter
+
+
+ {labeledItems.map((item, index) => (
+
+ {insertPreviewIndex === index && (
+
+ )}
+
+
+ ))}
+ {insertPreviewIndex != null &&
+ insertPreviewIndex >= labeledItems.length && (
+
+ )}
+
+
-
-
- {optionalSegments.map((segment) => {
- const count = segmentCounts[segment.type] || 0;
- const selected = count > 0;
- const blurStyle = StyleSheet.flatten([
- styles.itemBlur,
- selected ? styles.selectedBlur : null,
- ]);
- const badgeStyle = StyleSheet.flatten([
- styles.countBadge,
- selected ? styles.countBadgeActive : null,
- ]);
+
+
+ Éléments à ajouter
+
+
+
+ {paletteSegments.map((segment) => {
+ const count = segmentCounts[segment.type] || 0;
+ const isActive = activeItemId === `palette-${segment.type}`;
+ const blurStyle = StyleSheet.flatten([
+ styles.itemBlur,
+ styles.paletteItemBlur,
+ isActive ? styles.activeBlur : null,
+ ]);
+ const badgeStyle = StyleSheet.flatten([
+ styles.countBadge,
+ count > 0 ? styles.countBadgeActive : null,
+ ]);
- return (
- handleOptionalSegmentPress(segment.type)}
- containerStyle={styles.rowContainer}
- blurViewStyle={blurStyle}
- >
-
- {segment.label}
-
- {count}
-
+ return (
+
+ startPaletteDrag(segment.type, event)
+ }
+ >
+
+
+
+ {segment.label}
+ {segment.description ? (
+
+ {segment.description}
+
+ ) : null}
+
+
+ ev?.stopPropagation?.()}
+ onPressIn={(ev) => ev?.stopPropagation?.()}
+ onPress={(ev) => {
+ ev?.stopPropagation?.();
+ addOptionalSegment(segment.type);
+ setInsertPreviewIndex(null);
+ setActiveItemId(null);
+ }}
+ hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
+ style={({ pressed }) =>
+ StyleSheet.flatten([
+ styles.addButton,
+ pressed ? styles.addButtonPressed : null,
+ ])
+ }
+ >
+
+
+
+ {count}
+
+
+
+
-
- );
- })}
-
-
-
-
+ );
+ })}
+
+
+
+
+
);
};
@@ -344,9 +668,30 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
export default CustomizeSongStructure;
const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ gap: 10,
+ marginTop: 16,
+ },
scrollContent: {
paddingBottom: 24,
- gap: 12,
+ gap: 24,
+ },
+ columns: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: 24,
+ alignItems: "flex-start",
+ },
+ column: {
+ flexGrow: 1,
+ flexBasis: 0,
+ minWidth: 320,
+ gap: 16,
+ },
+ paletteColumn: {
+ flexBasis: 360,
+ maxWidth: 420,
},
sectionTag: {
alignSelf: "flex-start",
@@ -354,10 +699,9 @@ const styles = StyleSheet.create({
paddingVertical: 6,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.12)",
- marginTop: 12,
},
addSectionTag: {
- marginTop: 24,
+ marginTop: 0,
},
sectionTagText: {
fontSize: 12,
@@ -366,12 +710,19 @@ const styles = StyleSheet.create({
letterSpacing: 1,
textTransform: "uppercase",
},
+ blurSection: {
+ width: "100%",
+ },
listContainer: {
gap: 12,
},
- blurSection: {
- marginTop: 12,
- width: "100%",
+ dropPreview: {
+ height: ROW_HEIGHT,
+ borderRadius: 14,
+ borderWidth: 1,
+ borderStyle: "dashed",
+ borderColor: Palette.transparentWhite,
+ backgroundColor: "rgba(255,255,255,0.08)",
},
rowContainer: {
borderRadius: 14,
@@ -387,10 +738,13 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "space-between",
},
- activeBlur: {
- backgroundColor: Palette.transparentWhite,
+ paletteItemBlur: {
+ alignItems: "stretch",
+ justifyContent: "flex-start",
+ flexDirection: "column",
+ paddingVertical: 16,
},
- selectedBlur: {
+ activeBlur: {
backgroundColor: Palette.transparentWhite,
},
row: {
@@ -429,9 +783,46 @@ const styles = StyleSheet.create({
tintColor: Palette.white,
cursor: "grab",
},
- optionsContainer: {
+ paletteList: {
gap: 12,
},
+ paletteCardWrapper: {
+ cursor: "grab",
+ },
+ paletteRow: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ justifyContent: "space-between",
+ gap: 16,
+ },
+ paletteTextWrapper: {
+ flex: 1,
+ gap: 6,
+ },
+ paletteDescription: {
+ fontSize: 14,
+ lineHeight: 20,
+ color: "rgba(255,255,255,0.75)",
+ fontFamily: FONT_FAMILY.InterRegular,
+ },
+ paletteActions: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ },
+ addButton: {
+ padding: 8,
+ borderRadius: 999,
+ backgroundColor: "rgba(255,255,255,0.08)",
+ },
+ addButtonPressed: {
+ backgroundColor: "rgba(255,255,255,0.18)",
+ },
+ addIcon: {
+ width: 18,
+ height: 18,
+ tintColor: Palette.white,
+ },
countBadge: {
minWidth: 28,
paddingHorizontal: 8,