import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useStatefulRef } from "@bedrock-layout/use-stateful-ref"; import { useForceUpdate } from "../../utils"; import type { PanInfo } from "framer-motion"; const clamp = (value: number, min: number, max: number): number => { return Math.floor(Math.min(Math.max(value, min), max)); }; // This is needed to make sure the animated value changes when trying to move the grid outside the bounds const forceGridXMovement = 0.001; // eslint-disable-next-line max-lines-per-function, max-statements export const useCarousel = ({ children, columns, rows, stepAmount = 1, step, clampStep, onStepChange, gridProps, }: { children: Array; columns: number; rows: number; stepAmount?: number; step?: number; clampStep?: boolean; onStepChange?: (currentStep: number) => void; gridProps: unknown; // too much to type, we don't care for the type, it's just for dep array }) => { const [localCurrentStep, setLocalCurrentStep] = useState(0); const forceUpdate = useForceUpdate(); const prevGridX = useRef(0); const hasStartedDrag = useRef(false); const firstElementRef = useStatefulRef(null); const firstElement = firstElementRef.current; const stepSizeRef = useRef(0); const viewWindowDimensionRef = useRef<[width: number, height: number]>([0, 0]); const gridWidthRef = useRef(0); const gridElement = (firstElement?.closest("[data-grid]") ?? null) as HTMLDivElement | null; const numberOfOverflowingCols = Math.ceil(children.length / rows - columns); const maxSteps = Math.max(numberOfOverflowingCols, 0); const currentStep = step != null ? (clampStep ? clamp(step, 0, maxSteps) : step) : localCurrentStep; const disablePrevious = currentStep <= 0; const disableNext = currentStep >= maxSteps; const desiredGridX = currentStep * -stepSizeRef.current; const gridX = desiredGridX === prevGridX.current ? prevGridX.current + forceGridXMovement : desiredGridX; prevGridX.current = gridX; const calculateDimensions = useCallback(() => { const firstElementOffsetWidth = firstElement?.offsetWidth ?? 0; const firstElementOffsetHeight = firstElement?.offsetHeight ?? 0; const style = gridElement && getComputedStyle(gridElement); const columnGapSize = parseInt(style?.columnGap ?? "0") || 0; const rowGapSize = parseInt(style?.rowGap ?? "0") || 0; const stepSize = firstElementOffsetWidth + columnGapSize; stepSizeRef.current = stepSize; gridWidthRef.current = Math.ceil(children.length / rows) * stepSize - columnGapSize; viewWindowDimensionRef.current = [ columns * firstElementOffsetWidth + (columns - 1) * columnGapSize, rows * firstElementOffsetHeight + (rows - 1) * rowGapSize, ]; }, [children.length, columns, firstElement, rows, gridElement]); useMemo(calculateDimensions, [calculateDimensions]); // run the function immediately if it changes // Grid props can contain stuff like: `{ css: { width: "100%" } }` // We don't want to force users of the component to memoize stuff like that, so we use JSON.stringify to keep // the identity of the props if they are "the same". // Not the perfect solution performance-wise, but we don't expect heavy props const gridPropsStringified = JSON.stringify(gridProps); useEffect(() => { calculateDimensions(); forceUpdate(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [gridPropsStringified, gridWidthRef.current, calculateDimensions, forceUpdate]); const handleSetCurrentLocalStep = useCallback( (calculateNewStep: (prev: number) => number) => { if (typeof step !== "undefined") { const newStep = clamp(calculateNewStep(currentStep), 0, maxSteps); forceUpdate(); onStepChange?.(newStep); } else { setLocalCurrentStep((prev) => { const newStep = clamp(calculateNewStep(prev), 0, maxSteps); onStepChange?.(newStep); return newStep; }); forceUpdate(); } }, [step, currentStep, maxSteps, forceUpdate, onStepChange] ); useEffect(() => { handleSetCurrentLocalStep((prev) => prev); }, [columns, handleSetCurrentLocalStep]); useEffect(() => { // track the first element resizes and re-calculate the dimensions: if (!firstElement) return; const observer = new ResizeObserver(() => { calculateDimensions(); forceUpdate(); }); observer.observe(firstElement); return () => observer.disconnect(); }, [calculateDimensions, forceUpdate, firstElement]); const onItemFocus = useCallback( (index: number) => { if (hasStartedDrag.current) return; const childCol = parseInt((index / rows).toString()); if (childCol > currentStep + (columns - 1)) { handleSetCurrentLocalStep(() => childCol); } else if (childCol < currentStep) { handleSetCurrentLocalStep(() => childCol - (columns - 1)); } }, [rows, columns, currentStep, handleSetCurrentLocalStep] ); const handleDragEnd = useCallback( (e: MouseEvent | TouchEvent | PointerEvent, { offset }: PanInfo) => { const dragX = -offset.x; const steps = dragX < 0 ? Math.floor(dragX / stepSizeRef.current) : Math.ceil(dragX / stepSizeRef.current); hasStartedDrag.current = false; handleSetCurrentLocalStep((prev) => { const newStep = prev + steps; return newStep; }); }, [stepSizeRef, handleSetCurrentLocalStep] ); const onMouseDown: () => void = useCallback(() => { hasStartedDrag.current = true; }, []); const onMouseUp: () => void = useCallback(() => { hasStartedDrag.current = false; }, []); const increment = useCallback(() => { handleSetCurrentLocalStep((prev) => prev + stepAmount); }, [stepAmount, handleSetCurrentLocalStep]); const decrement = useCallback(() => { handleSetCurrentLocalStep((prev) => prev - stepAmount); }, [stepAmount, handleSetCurrentLocalStep]); return { gridX, viewWindowWidth: viewWindowDimensionRef.current[0], viewWindowHeight: viewWindowDimensionRef.current[1], gridWidth: gridWidthRef.current, disablePrevious, disableNext, firstElementRef, increment, decrement, handleDragEnd, onItemFocus, onMouseDown, onMouseUp, }; };