feat: 2 columns web for structure

This commit is contained in:
2025-11-03 14:27:09 +01:00
parent a7b88f1a52
commit 68c2b14557
+532 -141
View File
@@ -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 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 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 }) => {
<Image source={icons.close} style={styles.removeIcon} />
</TouchableOpacity>
)}
<View
style={styles.handleWrapper}
onStartShouldSetResponder={() => true}
onResponderGrant={(event) => handleGrant(item.id, event)}
onResponderMove={handleMove}
onResponderRelease={handleRelease}
onResponderTerminate={handleRelease}
>
<Image source={icons.dragDots} style={styles.handleIcon} />
</View>
{!isIntroduction && (
<View
style={styles.handleWrapper}
onPointerDown={(event) => startItemDrag(item.id, event)}
>
<Image source={icons.dragDots} style={styles.handleIcon} />
</View>
)}
</View>
</View>
</CreateLyricsHeader>
@@ -269,74 +545,122 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
};
return (
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
<View style={styles.container}>
<CreateLyricsHeader
title="Personnalise la structure de ta chanson"
subTitle="Réorganise par glisser-déposer"
/>
<View style={{ flex: 1 }}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.sectionTag}>
<Text style={styles.sectionTagText}>Structure actuelle</Text>
</View>
<ItemContainer
height="auto"
disableKeyboardHeight
style={styles.blurSection}
>
<View style={styles.listContainer} ref={containerRef}>
{labeledItems.map((item) => (
<StructureRow key={item.id} item={item} />
))}
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.columns}>
<View style={styles.column}>
<View style={styles.sectionTag}>
<Text style={styles.sectionTagText}>Structure actuelle</Text>
</View>
</ItemContainer>
<View style={[styles.sectionTag, styles.addSectionTag]}>
<Text style={styles.sectionTagText}>Éléments à ajouter</Text>
<ItemContainer
height="auto"
disableKeyboardHeight
style={styles.blurSection}
>
<View style={styles.listContainer} ref={containerRef}>
{labeledItems.map((item, index) => (
<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>
</ItemContainer>
</View>
<ItemContainer
height="auto"
disableKeyboardHeight
style={styles.blurSection}
>
<View style={styles.optionsContainer}>
{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,
]);
<View style={[styles.column, styles.paletteColumn]}>
<View style={[styles.sectionTag, styles.addSectionTag]}>
<Text style={styles.sectionTagText}>Éléments à ajouter</Text>
</View>
<ItemContainer
height="auto"
disableKeyboardHeight
style={styles.blurSection}
>
<View style={styles.paletteList}>
{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 (
<CreateLyricsHeader
key={segment.type}
tint="dark"
intensity={40}
showBorder={false}
onPress={() => handleOptionalSegmentPress(segment.type)}
containerStyle={styles.rowContainer}
blurViewStyle={blurStyle}
>
<View style={styles.row}>
<Text style={styles.rowText}>{segment.label}</Text>
<View style={badgeStyle}>
<Text style={styles.countBadgeText}>{count}</Text>
</View>
return (
<View
key={segment.type}
style={styles.paletteCardWrapper}
onPointerDown={(event) =>
startPaletteDrag(segment.type, event)
}
>
<CreateLyricsHeader
tint="dark"
intensity={40}
showBorder={false}
containerStyle={styles.rowContainer}
blurViewStyle={blurStyle}
>
<View style={styles.paletteRow}>
<View style={styles.paletteTextWrapper}>
<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}>
<Text style={styles.countBadgeText}>{count}</Text>
</View>
</View>
</View>
</CreateLyricsHeader>
</View>
</CreateLyricsHeader>
);
})}
</View>
</ItemContainer>
</ScrollView>
</View>
);
})}
</View>
</ItemContainer>
</View>
</View>
</ScrollView>
</View>
);
};
@@ -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,