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,