feat: fix drag and drop

This commit is contained in:
2025-11-17 14:07:37 +01:00
parent 0dd9dbcfae
commit 97321cdbda
4 changed files with 320 additions and 68 deletions
+9 -10
View File
@@ -21,6 +21,7 @@ import { gutters, size } from "../../styles/Style";
import { FONT_FAMILY } from "../../styles/Fonts";
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import CreditAmount from "../../components/CreditAmount";
const MUSIC_GENERATION_COIN_COST = 8;
const SONG_OPTIONS_PER_GENERATION = 2;
@@ -65,7 +66,7 @@ const SongReady = () => {
} catch (error) {
console.log("SongReady pause error", error?.message);
}
},
}
);
await Promise.all(tasks);
}, []);
@@ -93,7 +94,7 @@ const SongReady = () => {
console.log("SongReady play error", error?.message);
}
},
[pauseAllExcept],
[pauseAllExcept]
);
// Sync URLs from provider's selectedProject
@@ -110,8 +111,6 @@ const SongReady = () => {
});
}, [selectedProject?.musicUrls]);
const validateSelection = async () => {
try {
const url = musicUrls[selectedIndex];
@@ -150,7 +149,7 @@ const SongReady = () => {
return () => {
pauseAllPlayers();
};
}, [pauseAllPlayers]),
}, [pauseAllPlayers])
);
useEffect(() => {
@@ -177,7 +176,7 @@ const SongReady = () => {
},
},
],
{ cancelable: false },
{ cancelable: false }
);
return;
}
@@ -208,7 +207,7 @@ const SongReady = () => {
onPress: () => handleConfirmRegenerate(),
},
],
{ cancelable: true },
{ cancelable: true }
);
return;
}
@@ -234,7 +233,7 @@ const SongReady = () => {
marginBottom: responsiveHeight(2),
}}
/>
<View style={{ gap: 16 }}>
<View style={{ gap: 16, marginTop: isWeb ? gutters * 3 : 0 }}>
{musicUrls.map((url, idx) => (
<SongOptionCard
key={`${url || "song"}-${idx}`}
@@ -341,11 +340,11 @@ const SongOptionCard = ({
const id = setInterval(() => {
const durationMs = Math.max(
0,
Math.round((Number(player.duration) || 0) * 1000),
Math.round((Number(player.duration) || 0) * 1000)
);
const positionMs = Math.max(
0,
Math.round((Number(player.currentTime) || 0) * 1000),
Math.round((Number(player.currentTime) || 0) * 1000)
);
setProgressInfo((prev) => {
if (
+1 -4
View File
@@ -317,10 +317,7 @@ const CreateLyricsWithAi = () => {
};
const onPressBack = () => {
// If currently on SongStructure, go back to previous screen instead of previous step
if (selectedIndex === 5) {
goBack();
} else if (selectedIndex > 0) {
if (selectedIndex > 0) {
setSelectedIndex((idx) => Math.max(0, idx - 1));
} else {
goBack();
@@ -319,10 +319,7 @@ const CreateLyricsWithAi = () => {
};
const onPressBack = () => {
// If currently on SongStructure, go back to previous screen instead of previous step
if (selectedIndex === 5) {
goBack();
} else if (selectedIndex > 0) {
if (selectedIndex > 0) {
setSelectedIndex((idx) => Math.max(0, idx - 1));
} else {
goBack();
+309 -50
View File
@@ -65,6 +65,17 @@ const PALETTE_SEGMENT_OVERRIDES = [
},
];
const getPaletteSegmentInfo = (type) => {
const meta = getSegmentMeta(type);
const override = PALETTE_SEGMENT_OVERRIDES.find(
(segment) => segment.type === type
);
return {
label: meta?.label || formatStructureLabel(type, 1),
description: override?.description || "",
};
};
const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0);
const randomSuffix = () => Math.random().toString(36).slice(2, 8);
@@ -163,6 +174,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
);
const [activeItemId, setActiveItemId] = useState(null);
const [insertPreviewIndex, setInsertPreviewIndex] = useState(null);
const [dragOverlay, setDragOverlay] = useState(null);
const containerRef = useRef(null);
const sortableItemsRef = useRef(sortableItems);
const dragStateRef = useRef({
@@ -175,8 +187,36 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
isOverContainer: false,
startItems: null,
blockedReason: null,
startIndex: null,
slotIndex: null,
grabOffsetX: null,
grabOffsetY: null,
});
const globalListenersRef = useRef({ move: null, up: null });
const clearDragOverlay = useCallback(() => {
setDragOverlay(null);
}, []);
const updateDragOverlayPosition = useCallback((clientX, clientY) => {
setDragOverlay((prev) => {
if (!prev) return prev;
const offsetX =
typeof dragStateRef.current?.grabOffsetX === "number"
? dragStateRef.current.grabOffsetX
: prev.width / 2;
const offsetY =
typeof dragStateRef.current?.grabOffsetY === "number"
? dragStateRef.current.grabOffsetY
: prev.height / 2;
const left =
typeof clientX === "number" ? clientX - offsetX : prev.left;
const top =
typeof clientY === "number" ? clientY - offsetY : prev.top;
if (left === prev.left && top === prev.top) {
return prev;
}
return { ...prev, left, top };
});
}, []);
const showTooltip = useCallback(
(text, type = "error") => {
if (!text) return;
@@ -229,14 +269,11 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
const paletteSegments = useMemo(
() =>
OPTIONAL_STRUCTURE_SEGMENTS.map((type) => {
const meta = getSegmentMeta(type);
const override = PALETTE_SEGMENT_OVERRIDES.find(
(segment) => segment.type === type
);
const info = getPaletteSegmentInfo(type);
return {
type,
label: meta?.label || formatStructureLabel(type, 1),
description: override?.description || "",
label: info.label,
description: info.description,
};
}),
[]
@@ -352,6 +389,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
isOverContainer: false,
startItems: null,
blockedReason: null,
startIndex: null,
slotIndex: null,
grabOffsetX: null,
grabOffsetY: null,
};
}, []);
@@ -401,34 +442,77 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
: scrollY;
const pointerY = absoluteY - (dragState.containerTop || 0);
updateDragOverlayPosition(event?.clientX, event?.clientY);
if (dragState.itemId) {
dragStateRef.current.blockedReason = null;
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
);
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;
const rect = dragState.containerRect;
const listLength = sortableItemsRef.current.length;
const isOver =
rect == null
? true
: event.clientX >= rect.left && event.clientX <= rect.right;
dragStateRef.current.isOverContainer = isOver;
if (!isOver) {
dragStateRef.current.targetIndex = null;
dragStateRef.current.slotIndex = null;
if (insertPreviewIndex !== null) {
setInsertPreviewIndex(null);
}
if (targetIndex === currentIndex) return prev;
const reordered = reorder(prev, currentIndex, targetIndex);
return recalculateOccurrences(reordered);
});
return;
}
const containerHeight = rect?.height ?? listLength * ROW_HEIGHT;
let slotIndex = Math.round(pointerY / ROW_HEIGHT);
if (pointerY < 0) slotIndex = 0;
if (pointerY > containerHeight) slotIndex = listLength;
slotIndex = clamp(slotIndex, 0, listLength);
const snapshot = dragState.startItems || sortableItemsRef.current;
const movingIndex =
typeof dragState.startIndex === "number"
? dragState.startIndex
: snapshot.findIndex((item) => item.id === dragState.itemId);
dragStateRef.current.startIndex = movingIndex;
if (movingIndex < 0) {
dragStateRef.current.slotIndex = null;
return;
}
const movingItem = snapshot[movingIndex];
const movingType = movingItem?.type || movingItem?.value;
const topType = snapshot[0]?.type || snapshot[0]?.value;
if (
slotIndex === 0 &&
INTRO_TYPES.has(topType) &&
!INTRO_TYPES.has(movingType)
) {
dragStateRef.current.blockedReason = "intro_must_be_first";
return;
}
dragStateRef.current.blockedReason = null;
const shouldPreview =
typeof movingIndex === "number" &&
slotIndex !== movingIndex &&
slotIndex !== movingIndex + 1;
if (shouldPreview) {
if (dragStateRef.current.slotIndex !== slotIndex) {
dragStateRef.current.slotIndex = slotIndex;
setInsertPreviewIndex(slotIndex);
}
} else if (insertPreviewIndex !== null) {
dragStateRef.current.slotIndex = null;
setInsertPreviewIndex(null);
}
let targetIndex = slotIndex;
if (typeof movingIndex === "number" && slotIndex > movingIndex) {
targetIndex -= 1;
}
targetIndex = clamp(targetIndex, 0, Math.max(listLength - 1, 0));
dragStateRef.current.targetIndex = targetIndex;
return;
}
@@ -462,7 +546,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
setInsertPreviewIndex(targetIndex);
}
},
[insertPreviewIndex, setSortableItems]
[insertPreviewIndex, setInsertPreviewIndex, updateDragOverlayPosition]
);
const handleGlobalPointerUp = useCallback(
@@ -480,17 +564,36 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
if (dragState.itemId) {
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.isOverContainer &&
typeof dragState.targetIndex === "number"
) {
const desiredIndex = dragState.targetIndex;
setSortableItems((prev) => {
const currentIndex = prev.findIndex(
(it) => it.id === dragState.itemId
);
if (currentIndex === -1) return prev;
const targetIndex = clamp(
desiredIndex,
0,
Math.max(prev.length, 0)
);
if (targetIndex === currentIndex) {
return prev;
}
const reordered = reorder(prev, currentIndex, targetIndex);
const recalculated = recalculateOccurrences(reordered);
const validation = validateStructureRules(recalculated);
if (!validation.valid) {
const fallbackItems =
dragState.startItems?.map((item) => item) ||
prev;
showTooltip(validation.message);
return recalculateOccurrences(fallbackItems);
}
return recalculated;
});
}
} else if (dragState.paletteType) {
let isOver = dragState.isOverContainer;
@@ -532,9 +635,16 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
setActiveItemId(null);
setInsertPreviewIndex(null);
clearDragOverlay();
cleanupPointerListeners();
},
[addOptionalSegment, cleanupPointerListeners, showTooltip]
[
addOptionalSegment,
cleanupPointerListeners,
clearDragOverlay,
showTooltip,
setSortableItems,
]
);
const attachGlobalListeners = useCallback(() => {
@@ -555,7 +665,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
}, [handleGlobalPointerMove, handleGlobalPointerUp]);
const startItemDrag = useCallback(
(itemId, event) => {
(itemId, event, dragElement = null) => {
const nativeEvent = event?.nativeEvent || {};
if (typeof nativeEvent.button === "number" && nativeEvent.button !== 0)
return;
@@ -571,6 +681,32 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
: scrollY;
const containerTop = rect ? rect.top + scrollY : fallbackY;
const snapshot = sortableItemsRef.current?.map((item) => item) || [];
const startIndex = snapshot.findIndex((item) => item.id === itemId);
const movingItem = startIndex >= 0 ? snapshot[startIndex] : null;
const baseType = movingItem?.type || movingItem?.value;
const occurrence =
typeof movingItem?.occurrence === "number" ? movingItem.occurrence : 1;
const label =
baseType != null ? formatStructureLabel(baseType, occurrence) : "";
const pointerClientX =
typeof nativeEvent.clientX === "number" ? nativeEvent.clientX : 0;
const pointerClientY =
typeof nativeEvent.clientY === "number" ? nativeEvent.clientY : 0;
const getRect = (node) =>
typeof node?.getBoundingClientRect === "function"
? node.getBoundingClientRect()
: null;
const itemRect = getRect(dragElement) || getRect(event?.currentTarget);
const overlayWidth = itemRect?.width || rect?.width || 280;
const overlayHeight = itemRect?.height || ROW_HEIGHT;
const grabOffsetX = itemRect
? pointerClientX - itemRect.left
: overlayWidth / 2;
const grabOffsetY = itemRect
? pointerClientY - itemRect.top
: overlayHeight / 2;
const overlayLeft = pointerClientX - grabOffsetX;
const overlayTop = pointerClientY - grabOffsetY;
dragStateRef.current = {
itemId,
@@ -593,10 +729,23 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
isOverContainer: true,
startItems: snapshot,
blockedReason: null,
startIndex,
slotIndex: null,
grabOffsetX,
grabOffsetY,
};
setActiveItemId(itemId);
setInsertPreviewIndex(null);
setDragOverlay({
kind: "structure",
label,
description: "",
width: overlayWidth,
height: overlayHeight,
left: overlayLeft,
top: overlayTop,
});
if (typeof document !== "undefined") {
document.body.style.userSelect = "none";
@@ -624,6 +773,25 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
? nativeEvent.clientY + scrollY
: scrollY;
const containerTop = rect ? rect.top + scrollY : fallbackY;
const pointerClientX =
typeof nativeEvent.clientX === "number" ? nativeEvent.clientX : 0;
const pointerClientY =
typeof nativeEvent.clientY === "number" ? nativeEvent.clientY : 0;
const cardRect =
typeof event?.currentTarget?.getBoundingClientRect === "function"
? event.currentTarget.getBoundingClientRect()
: null;
const overlayWidth = cardRect?.width || 320;
const overlayHeight = cardRect?.height || ROW_HEIGHT;
const grabOffsetX = cardRect
? pointerClientX - cardRect.left
: overlayWidth / 2;
const grabOffsetY = cardRect
? pointerClientY - cardRect.top
: overlayHeight / 2;
const overlayLeft = pointerClientX - grabOffsetX;
const overlayTop = pointerClientY - grabOffsetY;
const segmentInfo = getPaletteSegmentInfo(type);
dragStateRef.current = {
itemId: null,
@@ -645,10 +813,23 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
targetIndex: null,
isOverContainer: false,
blockedReason: null,
startIndex: null,
slotIndex: null,
grabOffsetX,
grabOffsetY,
};
setActiveItemId(`palette-${type}`);
setInsertPreviewIndex(null);
setDragOverlay({
kind: "palette",
label: segmentInfo.label,
description: segmentInfo.description,
width: overlayWidth,
height: overlayHeight,
left: overlayLeft,
top: overlayTop,
});
if (typeof document !== "undefined") {
document.body.style.userSelect = "none";
@@ -665,17 +846,22 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
const meta = getSegmentMeta(baseType);
const canRemove = meta?.optional === true;
const isIntroduction = INTRO_TYPES.has(baseType);
const rowRef = useRef(null);
const isActive = activeItemId === item.id;
const containerStyle = StyleSheet.flatten([
styles.rowContainer,
activeItemId === item.id ? styles.activeItem : null,
isActive ? styles.activeItem : null,
isActive ? styles.draggingContainer : null,
]);
const blurStyle = StyleSheet.flatten([
styles.itemBlur,
activeItemId === item.id ? styles.activeBlur : null,
isActive ? styles.activeBlur : null,
isActive ? styles.draggingBlur : null,
]);
const rowStyle = StyleSheet.flatten([
styles.row,
isIntroduction ? styles.rowDisabled : styles.rowDraggable,
isActive ? styles.rowHidden : null,
]);
return (
@@ -687,10 +873,11 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
blurViewStyle={blurStyle}
>
<View
ref={rowRef}
style={rowStyle}
onPointerDown={(event) => {
if (isIntroduction) return;
startItemDrag(item.id, event);
startItemDrag(item.id, event, rowRef.current);
}}
>
<Text style={styles.rowText}>{item.label}</Text>
@@ -714,7 +901,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
style={styles.handleWrapper}
onPointerDown={(event) => {
event?.stopPropagation?.();
startItemDrag(item.id, event);
startItemDrag(item.id, event, rowRef.current);
}}
>
<Image source={icons.dragDots} style={styles.handleIcon} />
@@ -848,6 +1035,52 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
</View>
</View>
</ScrollView>
{dragOverlay ? (
<View
pointerEvents="none"
style={StyleSheet.flatten([
styles.dragOverlay,
{
width: dragOverlay.width || 320,
height: dragOverlay.height || ROW_HEIGHT,
left: dragOverlay.left || 0,
top: dragOverlay.top || 0,
},
])}
>
<CreateLyricsHeader
tint="dark"
intensity={40}
showBorder={false}
containerStyle={StyleSheet.flatten([
styles.rowContainer,
styles.dragOverlayContainer,
])}
blurViewStyle={StyleSheet.flatten([
styles.itemBlur,
dragOverlay.kind === "palette" ? styles.paletteItemBlur : null,
styles.dragOverlayBlur,
])}
>
{dragOverlay.kind === "palette" ? (
<View style={styles.paletteRow}>
<View style={styles.paletteTextWrapper}>
<Text style={styles.rowText}>{dragOverlay.label}</Text>
{dragOverlay.description ? (
<Text style={styles.paletteDescription}>
{dragOverlay.description}
</Text>
) : null}
</View>
</View>
) : (
<View style={styles.row}>
<Text style={styles.rowText}>{dragOverlay.label}</Text>
</View>
)}
</CreateLyricsHeader>
</View>
) : null}
</View>
);
};
@@ -918,6 +1151,9 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: Palette.white,
},
draggingContainer: {
opacity: 0,
},
itemBlur: {
minHeight: ROW_HEIGHT,
paddingHorizontal: 16,
@@ -934,6 +1170,9 @@ const styles = StyleSheet.create({
activeBlur: {
backgroundColor: Palette.transparentWhite,
},
draggingBlur: {
opacity: 0,
},
row: {
flex: 1,
flexDirection: "row",
@@ -946,6 +1185,9 @@ const styles = StyleSheet.create({
rowDisabled: {
cursor: "default",
},
rowHidden: {
opacity: 0,
},
rowText: {
fontSize: 16,
color: Palette.white,
@@ -1033,4 +1275,21 @@ const styles = StyleSheet.create({
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
},
dragOverlay: {
position: "fixed",
zIndex: 999,
pointerEvents: "none",
},
dragOverlayContainer: {
width: "100%",
},
dragOverlayBlur: {
cursor: "grabbing",
opacity: 1,
shadowColor: "#000",
shadowOpacity: 0.35,
shadowRadius: 12,
shadowOffset: { width: 0, height: 8 },
elevation: 6,
},
});