feat: 2 columns web for structure

This commit is contained in:
2025-11-03 14:27:09 +01:00
parent a7b88f1a52
commit 68c2b14557
+459 -68
View File
@@ -7,6 +7,7 @@ import React, {
} from "react"; } from "react";
import { import {
Image, Image,
Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
@@ -28,6 +29,29 @@ import CreateLyricsHeader from "./components/CreateLyricsHeader";
const ROW_HEIGHT = 54; const ROW_HEIGHT = 54;
const PALETTE_SEGMENT_OVERRIDES = [
{
type: "pont",
description:
"Transition contrastée qui relance l’énergie avant dentrer dans la dernière partie.",
},
{
type: "interlude_melodique",
description:
"Respiration instrumentale qui met la mélodie à lhonneur 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 getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0);
const randomSuffix = () => Math.random().toString(36).slice(2, 8); const randomSuffix = () => Math.random().toString(36).slice(2, 8);
@@ -62,11 +86,19 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
buildSortableItems(sanitizedBase) buildSortableItems(sanitizedBase)
); );
const [activeItemId, setActiveItemId] = useState(null); const [activeItemId, setActiveItemId] = useState(null);
const [insertPreviewIndex, setInsertPreviewIndex] = useState(null);
const containerRef = useRef(null); const containerRef = useRef(null);
const sortableItemsRef = useRef(sortableItems);
const dragStateRef = useRef({ const dragStateRef = useRef({
itemId: null, itemId: null,
paletteType: null,
pointerId: null,
containerTop: 0, containerTop: 0,
containerRect: null,
targetIndex: null,
isOverContainer: false,
}); });
const globalListenersRef = useRef({ move: null, up: null });
const extractValues = useCallback( const extractValues = useCallback(
(items) => (items) =>
@@ -84,6 +116,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
}); });
}, [sanitizedBase, extractValues]); }, [sanitizedBase, extractValues]);
useEffect(() => {
sortableItemsRef.current = sortableItems;
}, [sortableItems]);
useEffect(() => { useEffect(() => {
onChange?.(extractValues(sortableItems)); onChange?.(extractValues(sortableItems));
}, [sortableItems, extractValues, onChange]); }, [sortableItems, extractValues, onChange]);
@@ -103,11 +139,18 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
}); });
}, [sortableItems]); }, [sortableItems]);
const optionalSegments = useMemo( const paletteSegments = useMemo(
() => () =>
OPTIONAL_STRUCTURE_SEGMENTS.map((type) => { OPTIONAL_STRUCTURE_SEGMENTS.map((type) => {
const meta = getSegmentMeta(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] [extractValues]
); );
const handleOptionalSegmentPress = useCallback( const addOptionalSegment = useCallback(
(type) => { (type, insertIndex = null) => {
const meta = getSegmentMeta(type); const meta = getSegmentMeta(type);
setSortableItems((prev) => { setSortableItems((prev) => {
const currentValues = extractValues(prev); const currentValues = extractValues(prev);
@@ -145,14 +188,16 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
}); });
} }
const allowsMultiple = meta?.allowMultiple !== false; if (meta?.allowMultiple === false) {
if (!allowsMultiple && nextValues.includes(type)) { nextValues = nextValues.filter((value) => value !== type);
const filtered = nextValues.filter((value) => value !== type);
const sanitized = sanitizeStructureList(filtered);
return buildSortableItems(sanitized);
} }
nextValues.push(type); const targetIndex =
typeof insertIndex === "number"
? clamp(insertIndex, 0, nextValues.length)
: nextValues.length;
nextValues.splice(targetIndex, 0, type);
const sanitized = sanitizeStructureList(nextValues); const sanitized = sanitizeStructureList(nextValues);
return buildSortableItems(sanitized); return buildSortableItems(sanitized);
}); });
@@ -160,10 +205,200 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
[extractValues] [extractValues]
); );
const handleGrant = useCallback((itemId, event) => { const resetDragState = useCallback(() => {
dragStateRef.current = {
itemId: null,
paletteType: null,
pointerId: null,
containerTop: 0,
containerRect: null,
targetIndex: null,
isOverContainer: false,
};
}, []);
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 = "";
}
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 rect = containerRef.current?.getBoundingClientRect();
const scrollY = getScrollY(); const scrollY = getScrollY();
const nativeEvent = event.nativeEvent || {};
const fallbackY = const fallbackY =
typeof nativeEvent.pageY === "number" typeof nativeEvent.pageY === "number"
? nativeEvent.pageY ? nativeEvent.pageY
@@ -171,58 +406,101 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
? nativeEvent.clientY + scrollY ? nativeEvent.clientY + scrollY
: scrollY; : scrollY;
const containerTop = rect ? rect.top + scrollY : fallbackY; const containerTop = rect ? rect.top + scrollY : fallbackY;
dragStateRef.current = { dragStateRef.current = {
itemId, itemId,
paletteType: null,
pointerId:
typeof nativeEvent.pointerId === "number"
? nativeEvent.pointerId
: null,
containerTop, 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); setActiveItemId(itemId);
setInsertPreviewIndex(null);
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
document.body.style.userSelect = "none"; document.body.style.userSelect = "none";
document.body.style.cursor = "grabbing";
} }
}, []);
const handleMove = useCallback((event) => { attachGlobalListeners();
const { itemId, containerTop } = dragStateRef.current; },
if (!itemId) return; [attachGlobalListeners]
const nativeEvent = event.nativeEvent || {}; );
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 scrollY = getScrollY();
const absoluteY = const fallbackY =
typeof nativeEvent.pageY === "number" typeof nativeEvent.pageY === "number"
? nativeEvent.pageY ? nativeEvent.pageY
: typeof nativeEvent.clientY === "number" : typeof nativeEvent.clientY === "number"
? nativeEvent.clientY + scrollY ? nativeEvent.clientY + scrollY
: scrollY; : scrollY;
const pointerY = absoluteY - containerTop; const containerTop = rect ? rect.top + scrollY : fallbackY;
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)));
dragStateRef.current = { dragStateRef.current = {
itemId: null, itemId: null,
containerTop: 0, paletteType: type,
}; pointerId:
setActiveItemId(null); typeof nativeEvent.pointerId === "number"
if (typeof document !== "undefined") { ? nativeEvent.pointerId
document.body.style.userSelect = ""; : null,
containerTop,
containerRect: rect
? {
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
height: rect.height,
} }
}, [extractValues]); : 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 StructureRow = ({ item }) => {
const baseType = item?.type || item?.value; const baseType = item?.type || item?.value;
const meta = getSegmentMeta(baseType); const meta = getSegmentMeta(baseType);
const canRemove = meta?.optional === true; const canRemove = meta?.optional === true;
const isIntroduction =
baseType === "short_intro" ||
baseType === "long_intro" ||
baseType === "intro" ||
baseType === "introduction";
const containerStyle = StyleSheet.flatten([ const containerStyle = StyleSheet.flatten([
styles.rowContainer, styles.rowContainer,
activeItemId === item.id ? styles.activeItem : null, activeItemId === item.id ? styles.activeItem : null,
@@ -252,16 +530,14 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
<Image source={icons.close} style={styles.removeIcon} /> <Image source={icons.close} style={styles.removeIcon} />
</TouchableOpacity> </TouchableOpacity>
)} )}
{!isIntroduction && (
<View <View
style={styles.handleWrapper} style={styles.handleWrapper}
onStartShouldSetResponder={() => true} onPointerDown={(event) => startItemDrag(item.id, event)}
onResponderGrant={(event) => handleGrant(item.id, event)}
onResponderMove={handleMove}
onResponderRelease={handleRelease}
onResponderTerminate={handleRelease}
> >
<Image source={icons.dragDots} style={styles.handleIcon} /> <Image source={icons.dragDots} style={styles.handleIcon} />
</View> </View>
)}
</View> </View>
</View> </View>
</CreateLyricsHeader> </CreateLyricsHeader>
@@ -269,16 +545,17 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
}; };
return ( return (
<View style={{ flex: 1, gap: 10, marginTop: 16 }}> <View style={styles.container}>
<CreateLyricsHeader <CreateLyricsHeader
title="Personnalise la structure de ta chanson" title="Personnalise la structure de ta chanson"
subTitle="Réorganise par glisser-déposer" subTitle="Réorganise par glisser-déposer"
/> />
<View style={{ flex: 1 }}>
<ScrollView <ScrollView
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<View style={styles.columns}>
<View style={styles.column}>
<View style={styles.sectionTag}> <View style={styles.sectionTag}>
<Text style={styles.sectionTagText}>Structure actuelle</Text> <Text style={styles.sectionTagText}>Structure actuelle</Text>
</View> </View>
@@ -288,11 +565,22 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
style={styles.blurSection} style={styles.blurSection}
> >
<View style={styles.listContainer} ref={containerRef}> <View style={styles.listContainer} ref={containerRef}>
{labeledItems.map((item) => ( {labeledItems.map((item, index) => (
<StructureRow key={item.id} item={item} /> <React.Fragment key={item.id}>
{insertPreviewIndex === index && (
<View style={styles.dropPreview} />
)}
<StructureRow item={item} />
</React.Fragment>
))} ))}
{insertPreviewIndex != null &&
insertPreviewIndex >= labeledItems.length && (
<View style={styles.dropPreview} />
)}
</View> </View>
</ItemContainer> </ItemContainer>
</View>
<View style={[styles.column, styles.paletteColumn]}>
<View style={[styles.sectionTag, styles.addSectionTag]}> <View style={[styles.sectionTag, styles.addSectionTag]}>
<Text style={styles.sectionTagText}>Éléments à ajouter</Text> <Text style={styles.sectionTagText}>Éléments à ajouter</Text>
</View> </View>
@@ -301,52 +589,109 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
disableKeyboardHeight disableKeyboardHeight
style={styles.blurSection} style={styles.blurSection}
> >
<View style={styles.optionsContainer}> <View style={styles.paletteList}>
{optionalSegments.map((segment) => { {paletteSegments.map((segment) => {
const count = segmentCounts[segment.type] || 0; const count = segmentCounts[segment.type] || 0;
const selected = count > 0; const isActive = activeItemId === `palette-${segment.type}`;
const blurStyle = StyleSheet.flatten([ const blurStyle = StyleSheet.flatten([
styles.itemBlur, styles.itemBlur,
selected ? styles.selectedBlur : null, styles.paletteItemBlur,
isActive ? styles.activeBlur : null,
]); ]);
const badgeStyle = StyleSheet.flatten([ const badgeStyle = StyleSheet.flatten([
styles.countBadge, styles.countBadge,
selected ? styles.countBadgeActive : null, count > 0 ? styles.countBadgeActive : null,
]); ]);
return ( return (
<CreateLyricsHeader <View
key={segment.type} key={segment.type}
style={styles.paletteCardWrapper}
onPointerDown={(event) =>
startPaletteDrag(segment.type, event)
}
>
<CreateLyricsHeader
tint="dark" tint="dark"
intensity={40} intensity={40}
showBorder={false} showBorder={false}
onPress={() => handleOptionalSegmentPress(segment.type)}
containerStyle={styles.rowContainer} containerStyle={styles.rowContainer}
blurViewStyle={blurStyle} blurViewStyle={blurStyle}
> >
<View style={styles.row}> <View style={styles.paletteRow}>
<View style={styles.paletteTextWrapper}>
<Text style={styles.rowText}>{segment.label}</Text> <Text style={styles.rowText}>{segment.label}</Text>
{segment.description ? (
<Text style={styles.paletteDescription}>
{segment.description}
</Text>
) : null}
</View>
<View style={styles.paletteActions}>
<Pressable
onPointerDown={(ev) => 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,
])
}
>
<Image source={icons.add} style={styles.addIcon} />
</Pressable>
<View style={badgeStyle}> <View style={badgeStyle}>
<Text style={styles.countBadgeText}>{count}</Text> <Text style={styles.countBadgeText}>{count}</Text>
</View> </View>
</View> </View>
</View>
</CreateLyricsHeader> </CreateLyricsHeader>
</View>
); );
})} })}
</View> </View>
</ItemContainer> </ItemContainer>
</ScrollView>
</View> </View>
</View> </View>
</ScrollView>
</View>
); );
}; };
export default CustomizeSongStructure; export default CustomizeSongStructure;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: {
flex: 1,
gap: 10,
marginTop: 16,
},
scrollContent: { scrollContent: {
paddingBottom: 24, 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: { sectionTag: {
alignSelf: "flex-start", alignSelf: "flex-start",
@@ -354,10 +699,9 @@ const styles = StyleSheet.create({
paddingVertical: 6, paddingVertical: 6,
borderRadius: 999, borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.12)", backgroundColor: "rgba(255,255,255,0.12)",
marginTop: 12,
}, },
addSectionTag: { addSectionTag: {
marginTop: 24, marginTop: 0,
}, },
sectionTagText: { sectionTagText: {
fontSize: 12, fontSize: 12,
@@ -366,12 +710,19 @@ const styles = StyleSheet.create({
letterSpacing: 1, letterSpacing: 1,
textTransform: "uppercase", textTransform: "uppercase",
}, },
blurSection: {
width: "100%",
},
listContainer: { listContainer: {
gap: 12, gap: 12,
}, },
blurSection: { dropPreview: {
marginTop: 12, height: ROW_HEIGHT,
width: "100%", borderRadius: 14,
borderWidth: 1,
borderStyle: "dashed",
borderColor: Palette.transparentWhite,
backgroundColor: "rgba(255,255,255,0.08)",
}, },
rowContainer: { rowContainer: {
borderRadius: 14, borderRadius: 14,
@@ -387,10 +738,13 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
}, },
activeBlur: { paletteItemBlur: {
backgroundColor: Palette.transparentWhite, alignItems: "stretch",
justifyContent: "flex-start",
flexDirection: "column",
paddingVertical: 16,
}, },
selectedBlur: { activeBlur: {
backgroundColor: Palette.transparentWhite, backgroundColor: Palette.transparentWhite,
}, },
row: { row: {
@@ -429,9 +783,46 @@ const styles = StyleSheet.create({
tintColor: Palette.white, tintColor: Palette.white,
cursor: "grab", cursor: "grab",
}, },
optionsContainer: { paletteList: {
gap: 12, 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: { countBadge: {
minWidth: 28, minWidth: 28,
paddingHorizontal: 8, paddingHorizontal: 8,