351 lines
9.0 KiB
JavaScript
351 lines
9.0 KiB
JavaScript
import React, {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import {
|
||
FlatList,
|
||
Platform,
|
||
StyleSheet,
|
||
View,
|
||
useWindowDimensions,
|
||
} from "react-native";
|
||
import PersonaCard from "../cards/PersonaCard/PersonaCard";
|
||
import { ai } from "../../assets";
|
||
import { getCreationStageStates } from "../../utils/projectStages";
|
||
|
||
const STAGE_CARD_CONTENT = [
|
||
{
|
||
key: "songwriter",
|
||
title: "Nathalie",
|
||
description: "Let’s write lyrics together !",
|
||
image: ai.leftIcon,
|
||
},
|
||
{
|
||
key: "beatmaker",
|
||
title: "Theo",
|
||
description: "Come back, when you'll have lyrics!",
|
||
image: ai.rightIcon,
|
||
},
|
||
{
|
||
key: "designer",
|
||
title: "Bena",
|
||
description: "Come back when you want to generate cover!",
|
||
image: ai.leftIcon,
|
||
},
|
||
{
|
||
key: "director",
|
||
title: "John",
|
||
description: "Come back when your audio is ready!",
|
||
image: ai.rightIcon,
|
||
},
|
||
];
|
||
|
||
const FeatureCarousel = ({
|
||
style,
|
||
selectedProject,
|
||
stageStates: stageStatesProp,
|
||
activeIndex,
|
||
onActiveIndexChange,
|
||
}) => {
|
||
const stageStates = useMemo(() => {
|
||
if (stageStatesProp) {
|
||
return stageStatesProp;
|
||
}
|
||
return getCreationStageStates(selectedProject);
|
||
}, [stageStatesProp, selectedProject]);
|
||
|
||
const carouselItems = useMemo(
|
||
() =>
|
||
STAGE_CARD_CONTENT.map((item, index) => ({
|
||
...item,
|
||
isLocked: stageStates[index]?.isLocked ?? true,
|
||
})),
|
||
[stageStates],
|
||
);
|
||
|
||
const { height: windowHeight } = useWindowDimensions();
|
||
const isWeb = Platform.OS === "web";
|
||
|
||
const [viewportHeight, setViewportHeight] = useState(() =>
|
||
Math.max(windowHeight, 1),
|
||
);
|
||
|
||
const updateSnapHeight = useCallback((height) => {
|
||
if (!height || Number.isNaN(height)) {
|
||
return;
|
||
}
|
||
setViewportHeight((prev) => {
|
||
if (prev == null || Math.abs(prev - height) > 0.5) {
|
||
return height;
|
||
}
|
||
return prev;
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
updateSnapHeight(Math.max(windowHeight, 1));
|
||
}, [updateSnapHeight, windowHeight]);
|
||
|
||
const itemHeight = Math.max(viewportHeight, 1);
|
||
|
||
const listRef = useRef(null);
|
||
const pendingScrollRef = useRef(false);
|
||
const alignTimeoutRef = useRef(null);
|
||
const activeIndexRef = useRef(
|
||
typeof activeIndex === "number" ? activeIndex : 0,
|
||
);
|
||
const onActiveIndexChangeRef = useRef(onActiveIndexChange);
|
||
|
||
useEffect(() => {
|
||
onActiveIndexChangeRef.current = onActiveIndexChange;
|
||
}, [onActiveIndexChange]);
|
||
|
||
useEffect(() => {
|
||
if (typeof activeIndex === "number") {
|
||
activeIndexRef.current = activeIndex;
|
||
}
|
||
}, [activeIndex]);
|
||
|
||
const clampIndex = useCallback(
|
||
(index) => {
|
||
if (!carouselItems.length) {
|
||
return 0;
|
||
}
|
||
if (index < 0) {
|
||
return 0;
|
||
}
|
||
if (index >= carouselItems.length) {
|
||
return carouselItems.length - 1;
|
||
}
|
||
return index;
|
||
},
|
||
[carouselItems.length],
|
||
);
|
||
|
||
const clearPendingAlignment = useCallback(() => {
|
||
if (alignTimeoutRef.current != null) {
|
||
clearTimeout(alignTimeoutRef.current);
|
||
alignTimeoutRef.current = null;
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment]);
|
||
|
||
const scrollToIndex = useCallback(
|
||
(index, animated = true, heightOverride) => {
|
||
const ref = listRef.current;
|
||
if (!ref) {
|
||
return;
|
||
}
|
||
|
||
const clamped = clampIndex(index);
|
||
const height =
|
||
heightOverride && heightOverride > 0 ? heightOverride : itemHeight;
|
||
|
||
if (isWeb) {
|
||
if (!height) {
|
||
return;
|
||
}
|
||
updateSnapHeight(height);
|
||
try {
|
||
ref.scrollToOffset({ offset: clamped * height, animated: false });
|
||
} catch (_error) {
|
||
// Ignore scroll errors when list is not ready yet.
|
||
}
|
||
activeIndexRef.current = clamped;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
pendingScrollRef.current = !!animated;
|
||
ref.scrollToIndex({ index: clamped, animated });
|
||
activeIndexRef.current = clamped;
|
||
} catch (_error) {
|
||
pendingScrollRef.current = false;
|
||
}
|
||
},
|
||
[clampIndex, isWeb, itemHeight, updateSnapHeight],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
listRef.current == null ||
|
||
typeof activeIndex !== "number" ||
|
||
activeIndex < 0 ||
|
||
activeIndex >= carouselItems.length ||
|
||
(isWeb && !itemHeight)
|
||
) {
|
||
return;
|
||
}
|
||
scrollToIndex(activeIndex);
|
||
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
||
|
||
useEffect(() => {
|
||
if (listRef.current == null || (isWeb && !itemHeight)) {
|
||
return;
|
||
}
|
||
scrollToIndex(activeIndexRef.current, false);
|
||
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
||
|
||
const alignToOffset = useCallback(
|
||
(offset, layoutHeight) => {
|
||
const height =
|
||
layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight;
|
||
if (!height) {
|
||
return;
|
||
}
|
||
|
||
updateSnapHeight(height);
|
||
|
||
const nextIndex = clampIndex(Math.round(offset / height));
|
||
const hasChanged = nextIndex !== activeIndexRef.current;
|
||
|
||
if (hasChanged) {
|
||
activeIndexRef.current = nextIndex;
|
||
const callback = onActiveIndexChangeRef.current;
|
||
if (callback) {
|
||
callback(nextIndex);
|
||
}
|
||
}
|
||
|
||
if (isWeb || hasChanged) {
|
||
scrollToIndex(nextIndex, !isWeb, height);
|
||
}
|
||
},
|
||
[clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight],
|
||
);
|
||
|
||
const handleScrollEnd = useCallback(
|
||
(event) => {
|
||
clearPendingAlignment();
|
||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
|
||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
|
||
alignToOffset(offsetY, layoutHeight);
|
||
},
|
||
[alignToOffset, clearPendingAlignment],
|
||
);
|
||
|
||
const handleScroll = useCallback(
|
||
(event) => {
|
||
if (!isWeb) {
|
||
return;
|
||
}
|
||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
|
||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
|
||
clearPendingAlignment();
|
||
alignTimeoutRef.current = setTimeout(() => {
|
||
alignToOffset(offsetY, layoutHeight);
|
||
alignTimeoutRef.current = null;
|
||
}, 80);
|
||
},
|
||
[alignToOffset, clearPendingAlignment, isWeb],
|
||
);
|
||
|
||
const handleLayout = useCallback(
|
||
(event) => {
|
||
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0;
|
||
if (layoutHeight > 0) {
|
||
updateSnapHeight(layoutHeight);
|
||
}
|
||
},
|
||
[updateSnapHeight],
|
||
);
|
||
|
||
const keyExtractor = useCallback((item) => item.key, []);
|
||
|
||
const renderItem = useCallback(
|
||
({ item, index }) => (
|
||
<PersonaCard
|
||
item={item}
|
||
index={index}
|
||
isLock={item.isLocked}
|
||
height={itemHeight}
|
||
/>
|
||
),
|
||
[itemHeight],
|
||
);
|
||
|
||
const getItemLayout = useCallback(
|
||
(_data, index) => ({
|
||
length: itemHeight,
|
||
offset: itemHeight * index,
|
||
index,
|
||
}),
|
||
[itemHeight],
|
||
);
|
||
|
||
const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 });
|
||
|
||
const handleViewableItemsChangedRef = useRef();
|
||
if (!handleViewableItemsChangedRef.current) {
|
||
handleViewableItemsChangedRef.current = ({ viewableItems }) => {
|
||
if (!viewableItems?.length) return;
|
||
const firstVisible = viewableItems.find((item) => item?.isViewable);
|
||
if (!firstVisible || firstVisible.index == null) return;
|
||
if (pendingScrollRef.current) {
|
||
if (firstVisible.index === activeIndexRef.current) {
|
||
pendingScrollRef.current = false;
|
||
}
|
||
return;
|
||
}
|
||
const callback = onActiveIndexChangeRef.current;
|
||
if (callback && firstVisible.index !== activeIndexRef.current) {
|
||
callback(firstVisible.index);
|
||
}
|
||
};
|
||
}
|
||
|
||
const snapOffsets = useMemo(() => {
|
||
if (!itemHeight || !isWeb) {
|
||
return undefined;
|
||
}
|
||
return carouselItems.map((_, index) => index * itemHeight);
|
||
}, [carouselItems.length, isWeb, itemHeight]);
|
||
|
||
return (
|
||
<View style={[styles.container, style]} onLayout={handleLayout}>
|
||
<FlatList
|
||
ref={listRef}
|
||
data={carouselItems}
|
||
keyExtractor={keyExtractor}
|
||
renderItem={renderItem}
|
||
onViewableItemsChanged={handleViewableItemsChangedRef.current}
|
||
viewabilityConfig={viewabilityConfig.current}
|
||
getItemLayout={getItemLayout}
|
||
showsVerticalScrollIndicator={false}
|
||
pagingEnabled={!isWeb}
|
||
bounces={false}
|
||
overScrollMode="never"
|
||
initialNumToRender={1}
|
||
maxToRenderPerBatch={2}
|
||
windowSize={3}
|
||
scrollEventThrottle={16}
|
||
snapToAlignment={isWeb ? undefined : "start"}
|
||
snapToInterval={!isWeb && itemHeight ? itemHeight : undefined}
|
||
snapToOffsets={snapOffsets}
|
||
disableIntervalMomentum={!isWeb}
|
||
decelerationRate={!isWeb ? "fast" : undefined}
|
||
style={styles.list}
|
||
onScroll={isWeb ? handleScroll : undefined}
|
||
onMomentumScrollEnd={handleScrollEnd}
|
||
onScrollEndDrag={isWeb ? handleScrollEnd : undefined}
|
||
/>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
export default FeatureCarousel;
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
width: "100%",
|
||
},
|
||
list: {
|
||
flex: 1,
|
||
},
|
||
});
|