feat: add drag and drop errors
This commit is contained in:
@@ -23,6 +23,7 @@ import SongTo from "./SongTo";
|
|||||||
import SpecificityContext from "./SpecificityContext";
|
import SpecificityContext from "./SpecificityContext";
|
||||||
const { width: windowWidth } = Dimensions.get("window");
|
const { width: windowWidth } = Dimensions.get("window");
|
||||||
const MAX_STEP_INDEX = 8;
|
const MAX_STEP_INDEX = 8;
|
||||||
|
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false };
|
||||||
|
|
||||||
const CreateLyricsWithAi = () => {
|
const CreateLyricsWithAi = () => {
|
||||||
const { isWeb } = useLayoutType();
|
const { isWeb } = useLayoutType();
|
||||||
@@ -140,10 +141,10 @@ const CreateLyricsWithAi = () => {
|
|||||||
|
|
||||||
// Structure personnalisée: prioriser selections.customStructure puis config.structure
|
// Structure personnalisée: prioriser selections.customStructure puis config.structure
|
||||||
const savedCustom = Array.isArray(sel.customStructure)
|
const savedCustom = Array.isArray(sel.customStructure)
|
||||||
? sanitizeStructureList(sel.customStructure)
|
? sanitizeStructureList(sel.customStructure, CUSTOM_SANITIZE_OPTIONS)
|
||||||
: null;
|
: null;
|
||||||
const cfgStructure = Array.isArray(cfg.structure)
|
const cfgStructure = Array.isArray(cfg.structure)
|
||||||
? sanitizeStructureList(cfg.structure)
|
? sanitizeStructureList(cfg.structure, CUSTOM_SANITIZE_OPTIONS)
|
||||||
: null;
|
: null;
|
||||||
const fallbackStructure =
|
const fallbackStructure =
|
||||||
savedCustom && savedCustom.length ? savedCustom : cfgStructure;
|
savedCustom && savedCustom.length ? savedCustom : cfgStructure;
|
||||||
@@ -196,7 +197,7 @@ const CreateLyricsWithAi = () => {
|
|||||||
: style || undefined;
|
: style || undefined;
|
||||||
const sanitizedCustom =
|
const sanitizedCustom =
|
||||||
Array.isArray(customStructure) && customStructure.length > 0
|
Array.isArray(customStructure) && customStructure.length > 0
|
||||||
? sanitizeStructureList(customStructure)
|
? sanitizeStructureList(customStructure, CUSTOM_SANITIZE_OPTIONS)
|
||||||
: [];
|
: [];
|
||||||
const sanitizedParsed = Array.isArray(parsedStructure)
|
const sanitizedParsed = Array.isArray(parsedStructure)
|
||||||
? sanitizeStructureList(parsedStructure)
|
? sanitizeStructureList(parsedStructure)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import SongTo from "./SongTo";
|
|||||||
import SpecificityContext from "./SpecificityContext";
|
import SpecificityContext from "./SpecificityContext";
|
||||||
|
|
||||||
const MAX_STEP_INDEX = 8;
|
const MAX_STEP_INDEX = 8;
|
||||||
|
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false };
|
||||||
|
|
||||||
const CreateLyricsWithAi = () => {
|
const CreateLyricsWithAi = () => {
|
||||||
const { selectedProject } = useUser();
|
const { selectedProject } = useUser();
|
||||||
@@ -133,10 +134,10 @@ const CreateLyricsWithAi = () => {
|
|||||||
|
|
||||||
// Structure personnalisée: prioriser selections.customStructure puis config.structure
|
// Structure personnalisée: prioriser selections.customStructure puis config.structure
|
||||||
const savedCustom = Array.isArray(sel.customStructure)
|
const savedCustom = Array.isArray(sel.customStructure)
|
||||||
? sanitizeStructureList(sel.customStructure)
|
? sanitizeStructureList(sel.customStructure, CUSTOM_SANITIZE_OPTIONS)
|
||||||
: null;
|
: null;
|
||||||
const cfgStructure = Array.isArray(cfg.structure)
|
const cfgStructure = Array.isArray(cfg.structure)
|
||||||
? sanitizeStructureList(cfg.structure)
|
? sanitizeStructureList(cfg.structure, CUSTOM_SANITIZE_OPTIONS)
|
||||||
: null;
|
: null;
|
||||||
const fallbackStructure =
|
const fallbackStructure =
|
||||||
savedCustom && savedCustom.length ? savedCustom : cfgStructure;
|
savedCustom && savedCustom.length ? savedCustom : cfgStructure;
|
||||||
@@ -201,7 +202,7 @@ const CreateLyricsWithAi = () => {
|
|||||||
: style || undefined;
|
: style || undefined;
|
||||||
const sanitizedCustom =
|
const sanitizedCustom =
|
||||||
Array.isArray(customStructure) && customStructure.length > 0
|
Array.isArray(customStructure) && customStructure.length > 0
|
||||||
? sanitizeStructureList(customStructure)
|
? sanitizeStructureList(customStructure, CUSTOM_SANITIZE_OPTIONS)
|
||||||
: [];
|
: [];
|
||||||
const sanitizedParsed = Array.isArray(parsedStructure)
|
const sanitizedParsed = Array.isArray(parsedStructure)
|
||||||
? sanitizeStructureList(parsedStructure)
|
? sanitizeStructureList(parsedStructure)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
|
|
||||||
const FAKE_PROGRESS_MAX = 96;
|
const FAKE_PROGRESS_MAX = 96;
|
||||||
const PROGRESS_INTERVAL_MS = 250;
|
const PROGRESS_INTERVAL_MS = 250;
|
||||||
|
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false };
|
||||||
|
|
||||||
const CreatingLyrics = ({ active, config, selections }) => {
|
const CreatingLyrics = ({ active, config, selections }) => {
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
@@ -188,7 +189,8 @@ const CreatingLyrics = ({ active, config, selections }) => {
|
|||||||
if (persistedSelections) {
|
if (persistedSelections) {
|
||||||
if ("customStructure" in persistedSelections) {
|
if ("customStructure" in persistedSelections) {
|
||||||
persistedSelections.customStructure = sanitizeStructureList(
|
persistedSelections.customStructure = sanitizeStructureList(
|
||||||
persistedSelections.customStructure
|
persistedSelections.customStructure,
|
||||||
|
CUSTOM_SANITIZE_OPTIONS
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if ("parsedStructure" in persistedSelections) {
|
if ("parsedStructure" in persistedSelections) {
|
||||||
|
|||||||
@@ -28,16 +28,17 @@ const createSortableItem = (type, index = 0) => ({
|
|||||||
type,
|
type,
|
||||||
value: type,
|
value: type,
|
||||||
});
|
});
|
||||||
|
const SANITIZE_OPTIONS = { autoInjectPreChorus: false };
|
||||||
|
|
||||||
const buildSortableItems = (values = []) =>
|
const buildSortableItems = (values = []) =>
|
||||||
sanitizeStructureList(values).map((type, index) =>
|
sanitizeStructureList(values, SANITIZE_OPTIONS).map((type, index) =>
|
||||||
createSortableItem(type, index)
|
createSortableItem(type, index)
|
||||||
);
|
);
|
||||||
|
|
||||||
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
||||||
const scrollRef = useAnimatedRef();
|
const scrollRef = useAnimatedRef();
|
||||||
const sanitizedBase = useMemo(
|
const sanitizedBase = useMemo(
|
||||||
() => sanitizeStructureList(baseStructure),
|
() => sanitizeStructureList(baseStructure, SANITIZE_OPTIONS),
|
||||||
[baseStructure]
|
[baseStructure]
|
||||||
);
|
);
|
||||||
const extractValues = useCallback(
|
const extractValues = useCallback(
|
||||||
@@ -100,7 +101,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
(itemId) => {
|
(itemId) => {
|
||||||
setSortableItems((prev) => {
|
setSortableItems((prev) => {
|
||||||
const remaining = prev.filter((item) => item.id !== itemId);
|
const remaining = prev.filter((item) => item.id !== itemId);
|
||||||
const sanitized = sanitizeStructureList(extractValues(remaining));
|
const sanitized = sanitizeStructureList(
|
||||||
|
extractValues(remaining),
|
||||||
|
SANITIZE_OPTIONS
|
||||||
|
);
|
||||||
return buildSortableItems(sanitized);
|
return buildSortableItems(sanitized);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -124,12 +128,12 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
const allowsMultiple = meta?.allowMultiple !== false;
|
const allowsMultiple = meta?.allowMultiple !== false;
|
||||||
if (!allowsMultiple && nextValues.includes(type)) {
|
if (!allowsMultiple && nextValues.includes(type)) {
|
||||||
const filtered = nextValues.filter((value) => value !== type);
|
const filtered = nextValues.filter((value) => value !== type);
|
||||||
const sanitized = sanitizeStructureList(filtered);
|
const sanitized = sanitizeStructureList(filtered, SANITIZE_OPTIONS);
|
||||||
return buildSortableItems(sanitized);
|
return buildSortableItems(sanitized);
|
||||||
}
|
}
|
||||||
|
|
||||||
nextValues.push(type);
|
nextValues.push(type);
|
||||||
const sanitized = sanitizeStructureList(nextValues);
|
const sanitized = sanitizeStructureList(nextValues, SANITIZE_OPTIONS);
|
||||||
return buildSortableItems(sanitized);
|
return buildSortableItems(sanitized);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -219,7 +223,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
scrollableRef={scrollRef}
|
scrollableRef={scrollRef}
|
||||||
onDragEnd={({ data: newData }) => {
|
onDragEnd={({ data: newData }) => {
|
||||||
const values = extractValues(newData);
|
const values = extractValues(newData);
|
||||||
const sanitized = sanitizeStructureList(values);
|
const sanitized = sanitizeStructureList(
|
||||||
|
values,
|
||||||
|
SANITIZE_OPTIONS
|
||||||
|
);
|
||||||
setSortableItems(buildSortableItems(sanitized));
|
setSortableItems(buildSortableItems(sanitized));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { useGlobal } from "reactn";
|
||||||
import { icons } from "../../assets";
|
import { icons } from "../../assets";
|
||||||
|
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
||||||
import { Palette } from "../../styles";
|
import { Palette } from "../../styles";
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import {
|
import {
|
||||||
@@ -24,10 +26,21 @@ import {
|
|||||||
OPTIONAL_STRUCTURE_SEGMENTS,
|
OPTIONAL_STRUCTURE_SEGMENTS,
|
||||||
sanitizeStructureList,
|
sanitizeStructureList,
|
||||||
} from "../../utils/songStructure";
|
} from "../../utils/songStructure";
|
||||||
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
|
||||||
import CreateLyricsHeader from "./components/CreateLyricsHeader";
|
import CreateLyricsHeader from "./components/CreateLyricsHeader";
|
||||||
|
|
||||||
const ROW_HEIGHT = 54;
|
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 = [
|
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 randomSuffix = () => Math.random().toString(36).slice(2, 8);
|
||||||
|
|
||||||
const createSortableItem = (type, index = 0) => ({
|
const createSortableItem = (type, occurrence = 1) => ({
|
||||||
id: `${type}-${index}-${randomSuffix()}`,
|
id: `${type}-${occurrence}-${randomSuffix()}`,
|
||||||
type,
|
type,
|
||||||
value: type,
|
value: type,
|
||||||
|
occurrence,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildSortableItems = (values = []) =>
|
const buildSortableItems = (values = []) => {
|
||||||
sanitizeStructureList(values).map((type, index) =>
|
const counts = {};
|
||||||
createSortableItem(type, index)
|
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) => {
|
const reorder = (list, from, to) => {
|
||||||
if (from === to) return list;
|
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 clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||||
|
|
||||||
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
||||||
|
const [, setTooltip] = useGlobal("_tooltip");
|
||||||
const sanitizedBase = useMemo(
|
const sanitizedBase = useMemo(
|
||||||
() => sanitizeStructureList(baseStructure),
|
() => sanitizeStructureList(baseStructure, SANITIZE_OPTIONS),
|
||||||
[baseStructure]
|
[baseStructure]
|
||||||
);
|
);
|
||||||
const [sortableItems, setSortableItems] = useState(() =>
|
const [sortableItems, setSortableItems] = useState(() =>
|
||||||
@@ -97,8 +167,17 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
containerRect: null,
|
containerRect: null,
|
||||||
targetIndex: null,
|
targetIndex: null,
|
||||||
isOverContainer: false,
|
isOverContainer: false,
|
||||||
|
startItems: null,
|
||||||
|
blockedReason: null,
|
||||||
});
|
});
|
||||||
const globalListenersRef = useRef({ move: null, up: null });
|
const globalListenersRef = useRef({ move: null, up: null });
|
||||||
|
const showTooltip = useCallback(
|
||||||
|
(text, type = "error") => {
|
||||||
|
if (!text) return;
|
||||||
|
setTooltip({ text, type });
|
||||||
|
},
|
||||||
|
[setTooltip]
|
||||||
|
);
|
||||||
|
|
||||||
const extractValues = useCallback(
|
const extractValues = useCallback(
|
||||||
(items) =>
|
(items) =>
|
||||||
@@ -121,23 +200,25 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
}, [sortableItems]);
|
}, [sortableItems]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onChange?.(extractValues(sortableItems));
|
const values = extractValues(sortableItems);
|
||||||
|
onChange?.(sanitizeStructureList(values, SANITIZE_OPTIONS));
|
||||||
}, [sortableItems, extractValues, onChange]);
|
}, [sortableItems, extractValues, onChange]);
|
||||||
|
|
||||||
const labeledItems = useMemo(() => {
|
const labeledItems = useMemo(
|
||||||
const counts = {};
|
() =>
|
||||||
return sortableItems.map((item) => {
|
sortableItems.map((item) => {
|
||||||
const baseType = item.type || item.value;
|
const baseType = item.type || item.value;
|
||||||
const nextCount = (counts[baseType] || 0) + 1;
|
const occurrence =
|
||||||
counts[baseType] = nextCount;
|
typeof item.occurrence === "number" ? item.occurrence : 1;
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
type: baseType,
|
type: baseType,
|
||||||
value: baseType,
|
value: baseType,
|
||||||
label: formatStructureLabel(baseType, nextCount),
|
label: formatStructureLabel(baseType, occurrence),
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
}, [sortableItems]);
|
[sortableItems]
|
||||||
|
);
|
||||||
|
|
||||||
const paletteSegments = useMemo(
|
const paletteSegments = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -163,46 +244,95 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
return counts;
|
return counts;
|
||||||
}, [sortableItems, extractValues]);
|
}, [sortableItems, extractValues]);
|
||||||
|
|
||||||
const handleRemoveItem = useCallback(
|
const handleRemoveItem = useCallback((itemId) => {
|
||||||
(itemId) => {
|
setSortableItems((prev) => {
|
||||||
setSortableItems((prev) => {
|
const remaining = prev.filter((item) => item.id !== itemId);
|
||||||
const remaining = prev.filter((item) => item.id !== itemId);
|
return recalculateOccurrences(remaining);
|
||||||
const sanitized = sanitizeStructureList(extractValues(remaining));
|
});
|
||||||
return buildSortableItems(sanitized);
|
}, []);
|
||||||
});
|
|
||||||
},
|
|
||||||
[extractValues]
|
|
||||||
);
|
|
||||||
|
|
||||||
const addOptionalSegment = useCallback(
|
const addOptionalSegment = useCallback(
|
||||||
(type, insertIndex = null) => {
|
(type, insertIndex = null) => {
|
||||||
const meta = getSegmentMeta(type);
|
const meta = getSegmentMeta(type);
|
||||||
setSortableItems((prev) => {
|
if (!meta) {
|
||||||
const currentValues = extractValues(prev);
|
return { success: false, reason: "unknown_segment" };
|
||||||
let nextValues = [...currentValues];
|
}
|
||||||
|
|
||||||
if (meta?.exclusiveGroup) {
|
const currentItems = sortableItemsRef.current || [];
|
||||||
nextValues = nextValues.filter((value) => {
|
let targetIndex =
|
||||||
const itemMeta = getSegmentMeta(value);
|
typeof insertIndex === "number"
|
||||||
return itemMeta?.exclusiveGroup !== meta.exclusiveGroup;
|
? clamp(insertIndex, 0, currentItems.length)
|
||||||
});
|
: currentItems.length;
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meta?.allowMultiple === false) {
|
nextItems.push(item);
|
||||||
nextValues = nextValues.filter((value) => value !== 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(() => {
|
const resetDragState = useCallback(() => {
|
||||||
@@ -214,6 +344,8 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
containerRect: null,
|
containerRect: null,
|
||||||
targetIndex: null,
|
targetIndex: null,
|
||||||
isOverContainer: false,
|
isOverContainer: false,
|
||||||
|
startItems: null,
|
||||||
|
blockedReason: null,
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -264,6 +396,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
const pointerY = absoluteY - (dragState.containerTop || 0);
|
const pointerY = absoluteY - (dragState.containerTop || 0);
|
||||||
|
|
||||||
if (dragState.itemId) {
|
if (dragState.itemId) {
|
||||||
|
dragStateRef.current.blockedReason = null;
|
||||||
setSortableItems((prev) => {
|
setSortableItems((prev) => {
|
||||||
const currentIndex = prev.findIndex(
|
const currentIndex = prev.findIndex(
|
||||||
(it) => it.id === dragState.itemId
|
(it) => it.id === dragState.itemId
|
||||||
@@ -275,8 +408,20 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
0,
|
0,
|
||||||
maxIndex
|
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;
|
if (targetIndex === currentIndex) return prev;
|
||||||
return reorder(prev, currentIndex, targetIndex);
|
const reordered = reorder(prev, currentIndex, targetIndex);
|
||||||
|
return recalculateOccurrences(reordered);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -327,9 +472,20 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dragState.itemId) {
|
if (dragState.itemId) {
|
||||||
setSortableItems((prev) =>
|
if (dragState.blockedReason === "intro_must_be_first") {
|
||||||
buildSortableItems(extractValues(prev))
|
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) {
|
} else if (dragState.paletteType) {
|
||||||
let isOver = dragState.isOverContainer;
|
let isOver = dragState.isOverContainer;
|
||||||
let targetIndex = dragState.targetIndex;
|
let targetIndex = dragState.targetIndex;
|
||||||
@@ -363,6 +519,8 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
|
|
||||||
if (isOver && typeof targetIndex === "number") {
|
if (isOver && typeof targetIndex === "number") {
|
||||||
addOptionalSegment(dragState.paletteType, targetIndex);
|
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);
|
setInsertPreviewIndex(null);
|
||||||
cleanupPointerListeners();
|
cleanupPointerListeners();
|
||||||
},
|
},
|
||||||
[addOptionalSegment, cleanupPointerListeners, extractValues]
|
[addOptionalSegment, cleanupPointerListeners, showTooltip]
|
||||||
);
|
);
|
||||||
|
|
||||||
const attachGlobalListeners = useCallback(() => {
|
const attachGlobalListeners = useCallback(() => {
|
||||||
@@ -406,6 +564,7 @@ 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;
|
||||||
|
const snapshot = sortableItemsRef.current?.map((item) => item) || [];
|
||||||
|
|
||||||
dragStateRef.current = {
|
dragStateRef.current = {
|
||||||
itemId,
|
itemId,
|
||||||
@@ -426,6 +585,8 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
: null,
|
: null,
|
||||||
targetIndex: null,
|
targetIndex: null,
|
||||||
isOverContainer: true,
|
isOverContainer: true,
|
||||||
|
startItems: snapshot,
|
||||||
|
blockedReason: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
setActiveItemId(itemId);
|
setActiveItemId(itemId);
|
||||||
@@ -477,6 +638,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
: null,
|
: null,
|
||||||
targetIndex: null,
|
targetIndex: null,
|
||||||
isOverContainer: false,
|
isOverContainer: false,
|
||||||
|
blockedReason: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
setActiveItemId(`palette-${type}`);
|
setActiveItemId(`palette-${type}`);
|
||||||
@@ -496,11 +658,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
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 =
|
const isIntroduction = INTRO_TYPES.has(baseType);
|
||||||
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,
|
||||||
@@ -509,6 +667,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
styles.itemBlur,
|
styles.itemBlur,
|
||||||
activeItemId === item.id ? styles.activeBlur : null,
|
activeItemId === item.id ? styles.activeBlur : null,
|
||||||
]);
|
]);
|
||||||
|
const rowStyle = StyleSheet.flatten([
|
||||||
|
styles.row,
|
||||||
|
isIntroduction ? styles.rowDisabled : styles.rowDraggable,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateLyricsHeader
|
<CreateLyricsHeader
|
||||||
@@ -518,12 +680,22 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
containerStyle={containerStyle}
|
containerStyle={containerStyle}
|
||||||
blurViewStyle={blurStyle}
|
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>
|
<Text style={styles.rowText}>{item.label}</Text>
|
||||||
<View style={styles.rowActions}>
|
<View style={styles.rowActions}>
|
||||||
{canRemove && (
|
{canRemove && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handleRemoveItem(item.id)}
|
onPressIn={(ev) => ev?.stopPropagation?.()}
|
||||||
|
onPress={(ev) => {
|
||||||
|
ev?.stopPropagation?.();
|
||||||
|
handleRemoveItem(item.id);
|
||||||
|
}}
|
||||||
style={styles.removeButton}
|
style={styles.removeButton}
|
||||||
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
|
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
|
||||||
>
|
>
|
||||||
@@ -533,7 +705,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
{!isIntroduction && (
|
{!isIntroduction && (
|
||||||
<View
|
<View
|
||||||
style={styles.handleWrapper}
|
style={styles.handleWrapper}
|
||||||
onPointerDown={(event) => startItemDrag(item.id, event)}
|
onPointerDown={(event) => {
|
||||||
|
event?.stopPropagation?.();
|
||||||
|
startItemDrag(item.id, event);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Image source={icons.dragDots} style={styles.handleIcon} />
|
<Image source={icons.dragDots} style={styles.handleIcon} />
|
||||||
</View>
|
</View>
|
||||||
@@ -633,9 +808,11 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|||||||
onPressIn={(ev) => ev?.stopPropagation?.()}
|
onPressIn={(ev) => ev?.stopPropagation?.()}
|
||||||
onPress={(ev) => {
|
onPress={(ev) => {
|
||||||
ev?.stopPropagation?.();
|
ev?.stopPropagation?.();
|
||||||
addOptionalSegment(segment.type);
|
const result = addOptionalSegment(segment.type);
|
||||||
setInsertPreviewIndex(null);
|
if (result?.success) {
|
||||||
setActiveItemId(null);
|
setInsertPreviewIndex(null);
|
||||||
|
setActiveItemId(null);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
|
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
|
||||||
style={({ pressed }) =>
|
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>
|
</Pressable>
|
||||||
<View style={badgeStyle}>
|
<View style={badgeStyle}>
|
||||||
<Text style={styles.countBadgeText}>{count}</Text>
|
<Text style={styles.countBadgeText}>{count}</Text>
|
||||||
@@ -753,6 +933,12 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
},
|
},
|
||||||
|
rowDraggable: {
|
||||||
|
cursor: "grab",
|
||||||
|
},
|
||||||
|
rowDisabled: {
|
||||||
|
cursor: "default",
|
||||||
|
},
|
||||||
rowText: {
|
rowText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
|
|||||||
@@ -284,8 +284,9 @@ export const normalizeStructureType = (value) => {
|
|||||||
return sanitized;
|
return sanitized;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sanitizeStructureList = (structure = []) => {
|
export const sanitizeStructureList = (structure = [], options = {}) => {
|
||||||
if (!Array.isArray(structure)) return [];
|
if (!Array.isArray(structure)) return [];
|
||||||
|
const { autoInjectPreChorus = true } = options || {};
|
||||||
const output = [];
|
const output = [];
|
||||||
let shouldInjectInstrumentalPreChorus = false;
|
let shouldInjectInstrumentalPreChorus = false;
|
||||||
|
|
||||||
@@ -311,7 +312,7 @@ export const sanitizeStructureList = (structure = []) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "pre_refrain_instrumental") {
|
if (type === "pre_refrain_instrumental" && autoInjectPreChorus) {
|
||||||
shouldInjectInstrumentalPreChorus = true;
|
shouldInjectInstrumentalPreChorus = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -321,7 +322,7 @@ export const sanitizeStructureList = (structure = []) => {
|
|||||||
|
|
||||||
let result = output;
|
let result = output;
|
||||||
|
|
||||||
if (shouldInjectInstrumentalPreChorus) {
|
if (autoInjectPreChorus && shouldInjectInstrumentalPreChorus) {
|
||||||
const expanded = [];
|
const expanded = [];
|
||||||
let hasRefrain = false;
|
let hasRefrain = false;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user