feat: add drag and drop errors

This commit is contained in:
2025-11-03 15:26:09 +01:00
parent 74053b695a
commit c361505175
6 changed files with 283 additions and 85 deletions
+4 -3
View File
@@ -23,6 +23,7 @@ import SongTo from "./SongTo";
import SpecificityContext from "./SpecificityContext";
const { width: windowWidth } = Dimensions.get("window");
const MAX_STEP_INDEX = 8;
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false };
const CreateLyricsWithAi = () => {
const { isWeb } = useLayoutType();
@@ -140,10 +141,10 @@ const CreateLyricsWithAi = () => {
// Structure personnalisée: prioriser selections.customStructure puis config.structure
const savedCustom = Array.isArray(sel.customStructure)
? sanitizeStructureList(sel.customStructure)
? sanitizeStructureList(sel.customStructure, CUSTOM_SANITIZE_OPTIONS)
: null;
const cfgStructure = Array.isArray(cfg.structure)
? sanitizeStructureList(cfg.structure)
? sanitizeStructureList(cfg.structure, CUSTOM_SANITIZE_OPTIONS)
: null;
const fallbackStructure =
savedCustom && savedCustom.length ? savedCustom : cfgStructure;
@@ -196,7 +197,7 @@ const CreateLyricsWithAi = () => {
: style || undefined;
const sanitizedCustom =
Array.isArray(customStructure) && customStructure.length > 0
? sanitizeStructureList(customStructure)
? sanitizeStructureList(customStructure, CUSTOM_SANITIZE_OPTIONS)
: [];
const sanitizedParsed = Array.isArray(parsedStructure)
? sanitizeStructureList(parsedStructure)
@@ -21,6 +21,7 @@ import SongTo from "./SongTo";
import SpecificityContext from "./SpecificityContext";
const MAX_STEP_INDEX = 8;
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false };
const CreateLyricsWithAi = () => {
const { selectedProject } = useUser();
@@ -133,10 +134,10 @@ const CreateLyricsWithAi = () => {
// Structure personnalisée: prioriser selections.customStructure puis config.structure
const savedCustom = Array.isArray(sel.customStructure)
? sanitizeStructureList(sel.customStructure)
? sanitizeStructureList(sel.customStructure, CUSTOM_SANITIZE_OPTIONS)
: null;
const cfgStructure = Array.isArray(cfg.structure)
? sanitizeStructureList(cfg.structure)
? sanitizeStructureList(cfg.structure, CUSTOM_SANITIZE_OPTIONS)
: null;
const fallbackStructure =
savedCustom && savedCustom.length ? savedCustom : cfgStructure;
@@ -201,7 +202,7 @@ const CreateLyricsWithAi = () => {
: style || undefined;
const sanitizedCustom =
Array.isArray(customStructure) && customStructure.length > 0
? sanitizeStructureList(customStructure)
? sanitizeStructureList(customStructure, CUSTOM_SANITIZE_OPTIONS)
: [];
const sanitizedParsed = Array.isArray(parsedStructure)
? sanitizeStructureList(parsedStructure)
+3 -1
View File
@@ -20,6 +20,7 @@ import {
const FAKE_PROGRESS_MAX = 96;
const PROGRESS_INTERVAL_MS = 250;
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false };
const CreatingLyrics = ({ active, config, selections }) => {
const [progress, setProgress] = useState(0);
@@ -188,7 +189,8 @@ const CreatingLyrics = ({ active, config, selections }) => {
if (persistedSelections) {
if ("customStructure" in persistedSelections) {
persistedSelections.customStructure = sanitizeStructureList(
persistedSelections.customStructure
persistedSelections.customStructure,
CUSTOM_SANITIZE_OPTIONS
);
}
if ("parsedStructure" in persistedSelections) {
+13 -6
View File
@@ -28,16 +28,17 @@ const createSortableItem = (type, index = 0) => ({
type,
value: type,
});
const SANITIZE_OPTIONS = { autoInjectPreChorus: false };
const buildSortableItems = (values = []) =>
sanitizeStructureList(values).map((type, index) =>
sanitizeStructureList(values, SANITIZE_OPTIONS).map((type, index) =>
createSortableItem(type, index)
);
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
const scrollRef = useAnimatedRef();
const sanitizedBase = useMemo(
() => sanitizeStructureList(baseStructure),
() => sanitizeStructureList(baseStructure, SANITIZE_OPTIONS),
[baseStructure]
);
const extractValues = useCallback(
@@ -100,7 +101,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
(itemId) => {
setSortableItems((prev) => {
const remaining = prev.filter((item) => item.id !== itemId);
const sanitized = sanitizeStructureList(extractValues(remaining));
const sanitized = sanitizeStructureList(
extractValues(remaining),
SANITIZE_OPTIONS
);
return buildSortableItems(sanitized);
});
},
@@ -124,12 +128,12 @@ 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);
const sanitized = sanitizeStructureList(filtered, SANITIZE_OPTIONS);
return buildSortableItems(sanitized);
}
nextValues.push(type);
const sanitized = sanitizeStructureList(nextValues);
const sanitized = sanitizeStructureList(nextValues, SANITIZE_OPTIONS);
return buildSortableItems(sanitized);
});
},
@@ -219,7 +223,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
scrollableRef={scrollRef}
onDragEnd={({ data: newData }) => {
const values = extractValues(newData);
const sanitized = sanitizeStructureList(values);
const sanitized = sanitizeStructureList(
values,
SANITIZE_OPTIONS
);
setSortableItems(buildSortableItems(sanitized));
}}
/>
+243 -57
View File
@@ -14,7 +14,9 @@ import {
TouchableOpacity,
View,
} from "react-native";
import { useGlobal } from "reactn";
import { icons } from "../../assets";
import ItemContainer from "../../components/ItemContainer/ItemContainer";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import {
@@ -24,10 +26,21 @@ import {
OPTIONAL_STRUCTURE_SEGMENTS,
sanitizeStructureList,
} from "../../utils/songStructure";
import ItemContainer from "../../components/ItemContainer/ItemContainer";
import CreateLyricsHeader from "./components/CreateLyricsHeader";
const ROW_HEIGHT = 54;
const PRE_REFRAIN_TYPE = "pre_refrain_instrumental";
const INTRO_TYPES = new Set([
"short_intro",
"long_intro",
"intro",
"introduction",
]);
const PRE_REFRAIN_ERROR_MESSAGE =
"Impossible de positionner un pré-refrain ici. Place-le juste avant un refrain.";
const INTRO_ERROR_MESSAGE =
"L'introduction musicale doit rester en première position.";
const SANITIZE_OPTIONS = { autoInjectPreChorus: false };
const PALETTE_SEGMENT_OVERRIDES = [
{
@@ -56,16 +69,72 @@ const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0);
const randomSuffix = () => Math.random().toString(36).slice(2, 8);
const createSortableItem = (type, index = 0) => ({
id: `${type}-${index}-${randomSuffix()}`,
const createSortableItem = (type, occurrence = 1) => ({
id: `${type}-${occurrence}-${randomSuffix()}`,
type,
value: type,
occurrence,
});
const buildSortableItems = (values = []) =>
sanitizeStructureList(values).map((type, index) =>
createSortableItem(type, index)
const buildSortableItems = (values = []) => {
const counts = {};
return sanitizeStructureList(values, SANITIZE_OPTIONS).map((type) => {
const occurrence = (counts[type] || 0) + 1;
counts[type] = occurrence;
return createSortableItem(type, occurrence);
});
};
const getMaxOccurrence = (items = [], type) =>
items.reduce((max, item) => {
if (item?.type !== type) return max;
const value = typeof item?.occurrence === "number" ? item.occurrence : 0;
return Math.max(max, value);
}, 0);
const recalculateOccurrences = (items = []) => {
const counts = {};
return items.map((item) => {
const type = item?.type || item?.value;
const occurrence = (counts[type] || 0) + 1;
counts[type] = occurrence;
if (item?.occurrence === occurrence) {
return item;
}
return { ...item, occurrence };
});
};
const validateStructureRules = (items = []) => {
for (let index = 0; index < items.length; index += 1) {
const current = items[index];
const type = current?.type || current?.value;
if (type === PRE_REFRAIN_TYPE) {
const next = items[index + 1];
const nextType = next?.type || next?.value;
if (nextType !== "refrain") {
return {
valid: false,
reason: "pre_refrain_invalid_position",
message: PRE_REFRAIN_ERROR_MESSAGE,
};
}
}
}
const introIndex = items.findIndex((item) =>
INTRO_TYPES.has(item?.type || item?.value)
);
if (introIndex > 0) {
return {
valid: false,
reason: "intro_must_be_first",
message: INTRO_ERROR_MESSAGE,
};
}
return { valid: true };
};
const reorder = (list, from, to) => {
if (from === to) return list;
@@ -78,8 +147,9 @@ const reorder = (list, from, to) => {
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
const [, setTooltip] = useGlobal("_tooltip");
const sanitizedBase = useMemo(
() => sanitizeStructureList(baseStructure),
() => sanitizeStructureList(baseStructure, SANITIZE_OPTIONS),
[baseStructure]
);
const [sortableItems, setSortableItems] = useState(() =>
@@ -97,8 +167,17 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
containerRect: null,
targetIndex: null,
isOverContainer: false,
startItems: null,
blockedReason: null,
});
const globalListenersRef = useRef({ move: null, up: null });
const showTooltip = useCallback(
(text, type = "error") => {
if (!text) return;
setTooltip({ text, type });
},
[setTooltip]
);
const extractValues = useCallback(
(items) =>
@@ -121,23 +200,25 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
}, [sortableItems]);
useEffect(() => {
onChange?.(extractValues(sortableItems));
const values = extractValues(sortableItems);
onChange?.(sanitizeStructureList(values, SANITIZE_OPTIONS));
}, [sortableItems, extractValues, onChange]);
const labeledItems = useMemo(() => {
const counts = {};
return sortableItems.map((item) => {
const labeledItems = useMemo(
() =>
sortableItems.map((item) => {
const baseType = item.type || item.value;
const nextCount = (counts[baseType] || 0) + 1;
counts[baseType] = nextCount;
const occurrence =
typeof item.occurrence === "number" ? item.occurrence : 1;
return {
...item,
type: baseType,
value: baseType,
label: formatStructureLabel(baseType, nextCount),
label: formatStructureLabel(baseType, occurrence),
};
});
}, [sortableItems]);
}),
[sortableItems]
);
const paletteSegments = useMemo(
() =>
@@ -163,46 +244,95 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
return counts;
}, [sortableItems, extractValues]);
const handleRemoveItem = useCallback(
(itemId) => {
const handleRemoveItem = useCallback((itemId) => {
setSortableItems((prev) => {
const remaining = prev.filter((item) => item.id !== itemId);
const sanitized = sanitizeStructureList(extractValues(remaining));
return buildSortableItems(sanitized);
return recalculateOccurrences(remaining);
});
},
[extractValues]
);
}, []);
const addOptionalSegment = useCallback(
(type, insertIndex = null) => {
const meta = getSegmentMeta(type);
setSortableItems((prev) => {
const currentValues = extractValues(prev);
let nextValues = [...currentValues];
if (meta?.exclusiveGroup) {
nextValues = nextValues.filter((value) => {
const itemMeta = getSegmentMeta(value);
return itemMeta?.exclusiveGroup !== meta.exclusiveGroup;
});
if (!meta) {
return { success: false, reason: "unknown_segment" };
}
if (meta?.allowMultiple === false) {
nextValues = nextValues.filter((value) => value !== type);
}
const targetIndex =
const currentItems = sortableItemsRef.current || [];
let targetIndex =
typeof insertIndex === "number"
? clamp(insertIndex, 0, nextValues.length)
: nextValues.length;
? clamp(insertIndex, 0, currentItems.length)
: currentItems.length;
nextValues.splice(targetIndex, 0, type);
const sanitized = sanitizeStructureList(nextValues);
return buildSortableItems(sanitized);
const nextItems = [];
let reusedOccurrence = null;
currentItems.forEach((item, index) => {
const itemMeta = getSegmentMeta(item?.type);
const conflictsExclusive =
meta?.exclusiveGroup &&
itemMeta?.exclusiveGroup === meta.exclusiveGroup;
const conflictsSameType =
meta?.allowMultiple === false && item?.type === type;
if (conflictsExclusive || conflictsSameType) {
if (index < targetIndex) {
targetIndex -= 1;
}
if (item?.type === type && reusedOccurrence == null) {
reusedOccurrence =
typeof item?.occurrence === "number" ? item.occurrence : 1;
}
return;
}
nextItems.push(item);
});
if (
type === PRE_REFRAIN_TYPE &&
insertIndex == null &&
nextItems.length > 0
) {
const firstRefrainIndex = nextItems.findIndex(
(item) => item?.type === "refrain"
);
if (firstRefrainIndex !== -1) {
targetIndex = firstRefrainIndex;
}
}
if (meta?.exclusiveGroup === "intro") {
targetIndex = 0;
} else if (meta?.exclusiveGroup === "outro") {
targetIndex = nextItems.length;
}
const occurrence =
meta?.allowMultiple === false
? reusedOccurrence ?? 1
: getMaxOccurrence(nextItems, type) + 1;
const insertionIndex = clamp(targetIndex, 0, nextItems.length);
const draftedItems = [...nextItems];
draftedItems.splice(
insertionIndex,
0,
createSortableItem(type, occurrence)
);
const recalculatedDraft = recalculateOccurrences(draftedItems);
const validation = validateStructureRules(recalculatedDraft);
if (!validation.valid) {
showTooltip(validation.message);
return { success: false, reason: validation.reason };
}
setSortableItems(recalculatedDraft);
return { success: true };
},
[extractValues]
[showTooltip]
);
const resetDragState = useCallback(() => {
@@ -214,6 +344,8 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
containerRect: null,
targetIndex: null,
isOverContainer: false,
startItems: null,
blockedReason: null,
};
}, []);
@@ -264,6 +396,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
const pointerY = absoluteY - (dragState.containerTop || 0);
if (dragState.itemId) {
dragStateRef.current.blockedReason = null;
setSortableItems((prev) => {
const currentIndex = prev.findIndex(
(it) => it.id === dragState.itemId
@@ -275,8 +408,20 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
0,
maxIndex
);
const movingItem = prev[currentIndex];
const movingType = movingItem?.type || movingItem?.value;
const topType = prev[0]?.type || prev[0]?.value;
if (
targetIndex === 0 &&
INTRO_TYPES.has(topType) &&
!INTRO_TYPES.has(movingType)
) {
dragStateRef.current.blockedReason = "intro_must_be_first";
return prev;
}
if (targetIndex === currentIndex) return prev;
return reorder(prev, currentIndex, targetIndex);
const reordered = reorder(prev, currentIndex, targetIndex);
return recalculateOccurrences(reordered);
});
return;
}
@@ -327,9 +472,20 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
}
if (dragState.itemId) {
setSortableItems((prev) =>
buildSortableItems(extractValues(prev))
if (dragState.blockedReason === "intro_must_be_first") {
showTooltip(INTRO_ERROR_MESSAGE);
}
const validation = validateStructureRules(
sortableItemsRef.current || []
);
if (!validation.valid) {
const fallbackItems =
dragState.startItems?.map((item) => item) ||
sortableItemsRef.current?.map((item) => item) ||
[];
setSortableItems(fallbackItems);
showTooltip(validation.message);
}
} else if (dragState.paletteType) {
let isOver = dragState.isOverContainer;
let targetIndex = dragState.targetIndex;
@@ -363,6 +519,8 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
if (isOver && typeof targetIndex === "number") {
addOptionalSegment(dragState.paletteType, targetIndex);
} else if (dragState.paletteType === PRE_REFRAIN_TYPE) {
showTooltip(PRE_REFRAIN_ERROR_MESSAGE);
}
}
@@ -370,7 +528,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
setInsertPreviewIndex(null);
cleanupPointerListeners();
},
[addOptionalSegment, cleanupPointerListeners, extractValues]
[addOptionalSegment, cleanupPointerListeners, showTooltip]
);
const attachGlobalListeners = useCallback(() => {
@@ -406,6 +564,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
? nativeEvent.clientY + scrollY
: scrollY;
const containerTop = rect ? rect.top + scrollY : fallbackY;
const snapshot = sortableItemsRef.current?.map((item) => item) || [];
dragStateRef.current = {
itemId,
@@ -426,6 +585,8 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
: null,
targetIndex: null,
isOverContainer: true,
startItems: snapshot,
blockedReason: null,
};
setActiveItemId(itemId);
@@ -477,6 +638,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
: null,
targetIndex: null,
isOverContainer: false,
blockedReason: null,
};
setActiveItemId(`palette-${type}`);
@@ -496,11 +658,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
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 isIntroduction = INTRO_TYPES.has(baseType);
const containerStyle = StyleSheet.flatten([
styles.rowContainer,
activeItemId === item.id ? styles.activeItem : null,
@@ -509,6 +667,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
styles.itemBlur,
activeItemId === item.id ? styles.activeBlur : null,
]);
const rowStyle = StyleSheet.flatten([
styles.row,
isIntroduction ? styles.rowDisabled : styles.rowDraggable,
]);
return (
<CreateLyricsHeader
@@ -518,12 +680,22 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
containerStyle={containerStyle}
blurViewStyle={blurStyle}
>
<View style={styles.row}>
<View
style={rowStyle}
onPointerDown={(event) => {
if (isIntroduction) return;
startItemDrag(item.id, event);
}}
>
<Text style={styles.rowText}>{item.label}</Text>
<View style={styles.rowActions}>
{canRemove && (
<TouchableOpacity
onPress={() => handleRemoveItem(item.id)}
onPressIn={(ev) => ev?.stopPropagation?.()}
onPress={(ev) => {
ev?.stopPropagation?.();
handleRemoveItem(item.id);
}}
style={styles.removeButton}
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
>
@@ -533,7 +705,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
{!isIntroduction && (
<View
style={styles.handleWrapper}
onPointerDown={(event) => startItemDrag(item.id, event)}
onPointerDown={(event) => {
event?.stopPropagation?.();
startItemDrag(item.id, event);
}}
>
<Image source={icons.dragDots} style={styles.handleIcon} />
</View>
@@ -633,9 +808,11 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
onPressIn={(ev) => ev?.stopPropagation?.()}
onPress={(ev) => {
ev?.stopPropagation?.();
addOptionalSegment(segment.type);
const result = addOptionalSegment(segment.type);
if (result?.success) {
setInsertPreviewIndex(null);
setActiveItemId(null);
}
}}
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
style={({ pressed }) =>
@@ -645,7 +822,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
])
}
>
<Image source={icons.add} style={styles.addIcon} />
<Image
source={icons.add}
style={styles.addIcon}
/>
</Pressable>
<View style={badgeStyle}>
<Text style={styles.countBadgeText}>{count}</Text>
@@ -753,6 +933,12 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "space-between",
},
rowDraggable: {
cursor: "grab",
},
rowDisabled: {
cursor: "default",
},
rowText: {
fontSize: 16,
color: Palette.white,
+4 -3
View File
@@ -284,8 +284,9 @@ export const normalizeStructureType = (value) => {
return sanitized;
};
export const sanitizeStructureList = (structure = []) => {
export const sanitizeStructureList = (structure = [], options = {}) => {
if (!Array.isArray(structure)) return [];
const { autoInjectPreChorus = true } = options || {};
const output = [];
let shouldInjectInstrumentalPreChorus = false;
@@ -311,7 +312,7 @@ export const sanitizeStructureList = (structure = []) => {
}
}
if (type === "pre_refrain_instrumental") {
if (type === "pre_refrain_instrumental" && autoInjectPreChorus) {
shouldInjectInstrumentalPreChorus = true;
return;
}
@@ -321,7 +322,7 @@ export const sanitizeStructureList = (structure = []) => {
let result = output;
if (shouldInjectInstrumentalPreChorus) {
if (autoInjectPreChorus && shouldInjectInstrumentalPreChorus) {
const expanded = [];
let hasRefrain = false;