'use client'; import * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; import { useConfigDirection } from '../../hooks/useConfigDirection'; import { useExternRef } from '../../hooks/useExternRef'; import { useMutationObserver } from '../../hooks/useMutationObserver'; import { useResizeObserver } from '../../hooks/useResizeObserver'; import { useDOM } from '../../lib/dom'; import { mergeCalls } from '../../lib/mergeCalls'; import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect'; import { warnOnce } from '../../lib/warnOnce'; import { useHover } from '../Clickable/useState'; import { RootComponent } from '../RootComponent/RootComponent'; import { type CustomTouchEvent } from '../Touch/Touch'; import { Bullets } from './Bullets'; import { CarouselViewPort } from './CarouselViewPort'; import { ScrollArrows } from './ScrollArrows'; import { CONTROL_ELEMENTS_STATE, DEFAULT_ANIMATION_DURATION, SLIDE_THRESHOLD, SLIDES_MANAGER_STATE, } from './constants'; import { calcMax, calcMin, calculateIndent, getLoopPoints, getTargetIndex, isBigger, isBiggerOrEqual, isLowerOrEqual, revertRtlValue, validateIndent, } from './helpers'; import { useSlideAnimation } from './hooks'; import { type BaseGalleryProps, type ControlElementsState, type GallerySlidesState, type SlidesManagerState, } from './types'; import styles from './CarouselBase.module.css'; const warn = warnOnce('Gallery'); export const CarouselBase = ({ bullets = false, getRootRef, children, slideWidth = '100%', slideIndex = 0, dragDisabled = false, resizeSource = 'window', onDragStart, onDragEnd, onChange, onPrevClick, onNextClick, onPointerEnter, onPointerLeave, align = 'left', showArrows, getRef, arrowSize, arrowAreaHeight, slideTestId, bulletTestId, nextArrowTestId, prevArrowTestId, looped = false, animationDuration = DEFAULT_ANIMATION_DURATION, animationEasing = 'ease', // a11y 'aria-roledescription': ariaRoleDescription = 'Карусель', arrowNextLabel = 'Следующий слайд', arrowPrevLabel = 'Предыдущий слайд', slideLabel, slideRoleDescription, ...restProps }: BaseGalleryProps): React.ReactNode => { const slidesStore = React.useRef>({}); const slidesManager = React.useRef(SLIDES_MANAGER_STATE); const textDirection = useConfigDirection(); const isRtl = textDirection === 'rtl'; const rootRef = useExternRef(getRootRef); const viewportRef = useExternRef(getRef); const layerRef = React.useRef(null); const animationFrameRef = React.useRef | null>(null); const shiftXCurrentRef = React.useRef(0); const shiftXDeltaRef = React.useRef(0); const initialized = React.useRef(false); const { animationInQueue, addToAnimationQueue, getAnimateFunction, startAnimation, getAnimationEasing, } = useSlideAnimation(animationDuration, animationEasing); const isDragging = React.useRef(false); const [controlElementsState, setControlElementsState] = React.useState(CONTROL_ELEMENTS_STATE); const slidesContainerId = React.useId(); const isCenterAlign = align === 'center'; const calculateCanSlideLeft = () => { if (looped) { return !slidesManager.current.isFullyVisible; } const isStartShiftX = isBiggerOrEqual(shiftXCurrentRef.current, 0, isRtl); return !slidesManager.current.isFullyVisible && !isStartShiftX; }; const calculateCanSlideRight = () => { if (looped) { return !slidesManager.current.isFullyVisible; } return ( !slidesManager.current.isFullyVisible && // we can't move right when gallery layer fully scrolled right, if gallery aligned by left side ((align === 'left' && slidesManager.current.containerWidth - revertRtlValue(shiftXCurrentRef.current, isRtl) < (slidesManager.current.layerWidth ?? 0)) || // otherwise we need to check current slide index (align = right or align = center) (align !== 'left' && slideIndex < slidesManager.current.slides.length - 1)) ); }; const transformCssStyles = (shiftX: number, animation = false) => { shiftX = Math.round(shiftX); if (looped) { slidesManager.current.loopPoints.forEach((loopPoint) => { const { target, index } = loopPoint; const slide = slidesStore.current[index]; if (slide) { slide.style.transform = `translate3d(${target(shiftX)}px, 0, 0)`; } }); } else { Object.values(slidesStore.current).forEach((slide) => { if (slide) { slide.style.transform = ''; } }); } if (layerRef.current) { const indent = isDragging.current && !looped ? validateIndent( slidesManager.current, shiftXCurrentRef.current + shiftXDeltaRef.current, isRtl, false, ) : shiftX; layerRef.current.style.transform = `translate3d(${indent}px, 0, 0)`; layerRef.current.style.transition = animation ? `transform ${animationDuration}ms ${getAnimationEasing()}` : ''; } }; const checkShiftOutOfBoundsFromStart = (shiftX: number, snaps: number[]) => isBigger(shiftX, snaps[0], isRtl); const checkShiftOutOfBoundsFromEnd = (shiftX: number, slides: GallerySlidesState[]) => { /** * Поскольку при `align="center"` слайды сдвинуты, прежде чем рассчитать крайнюю правую точку, * нужно вычесть сдвиг слайдов. */ const firstSlideShift = align === 'center' ? (slidesManager.current.containerWidth - slidesManager.current.slides[0].width) / 2 : 0; const lastPoint = slides[slides.length - 1].width + slides[slides.length - 1].coordX - firstSlideShift; return isRtl ? shiftX >= lastPoint : shiftX <= -lastPoint; }; const requestTransform = (shiftX: number, animation = false) => { const { snaps, contentSize, slides } = slidesManager.current; if (animationFrameRef.current !== null) { cancelAnimationFrame(animationFrameRef.current); } animationFrameRef.current = requestAnimationFrame(() => { /** * Для бесконечной галереи проверяем, что при dnd мы прокрутили левее, чем первый слайд, * чтобы сбросить `shiftXCurrentRef`. */ if (looped && checkShiftOutOfBoundsFromStart(shiftX, snaps)) { const firstSnap = revertRtlValue(snaps[0], isRtl); shiftXCurrentRef.current = revertRtlValue(-contentSize + firstSnap, isRtl); shiftX = shiftXCurrentRef.current + shiftXDeltaRef.current; } /** * Для бесконечной галереи проверяем, что при dnd мы прокрутили правее, чем последний слайд, * чтобы правильно пересчитать `shiftXCurrentRef`. */ if (looped && checkShiftOutOfBoundsFromEnd(shiftX, slides)) { shiftXCurrentRef.current = Math.abs(shiftXDeltaRef.current) + snaps[0]; } transformCssStyles(shiftX, animation); animationFrameRef.current = null; }); }; const initializeSlides = () => { if (!rootRef.current || !viewportRef.current || !layerRef.current) { return; } const layerOffsetWidth = layerRef.current.offsetWidth; const calcRtlCoord = (element: HTMLDivElement) => { const offsetLeft = element.offsetLeft; const offsetWidth = element.offsetWidth; return layerOffsetWidth - offsetLeft - offsetWidth; }; let localSlides = React.Children.map(children, (_item, i): GallerySlidesState => { const elem = slidesStore.current[i]; if (!elem) { return { coordX: 0, width: 0 }; } const coordX = isRtl ? calcRtlCoord(elem) : elem.offsetLeft; return { coordX, width: elem.offsetWidth }; }) || []; if (localSlides.length === 0) { initialized.current = false; return; } const containerWidth = rootRef.current.offsetWidth; const viewportOffsetWidth = viewportRef.current.offsetWidth; const layerWidth = localSlides.reduce((val, slide) => slide.width + val, 0); if (process.env.NODE_ENV === 'development' && looped) { let remainingWidth = containerWidth; let slideIndex = 0; while (remainingWidth > 0 && slideIndex < localSlides.length) { remainingWidth -= localSlides[slideIndex].width; slideIndex++; } if (remainingWidth <= 0 && slideIndex === localSlides.length) { warn( 'Ширины слайдов недостаточно для корректной работы свойства "looped". Пожалуйста, сделайте её больше.', ); } } const currentSlideOffsetOnCenterAlignment = (containerWidth - (localSlides[slideIndex]?.width ?? 0)) / 2; const isFullyVisible = align === 'center' ? layerWidth + currentSlideOffsetOnCenterAlignment <= containerWidth : layerWidth <= containerWidth; const onlyOneSlide = localSlides.length === 1; slidesManager.current = { ...slidesManager.current, layerWidth, containerWidth, viewportOffsetWidth, slides: localSlides, isFullyVisible, max: looped || onlyOneSlide ? null : calcMax({ slides: localSlides, containerWidth, isCenterAlign, isRtl, }), min: looped || onlyOneSlide ? null : calcMin({ containerWidth, layerWidth, slides: localSlides, viewportOffsetWidth, isFullyVisible, align, isRtl, }), }; const snaps = localSlides.map((_, index) => calculateIndent({ targetIndex: index, slidesManager: slidesManager.current, isCenter: isCenterAlign, looped, isRtl, }), ); let contentSize = Math.abs(snaps[snaps.length - 1]) + localSlides[localSlides.length - 1].width; if (align === 'center') { contentSize += revertRtlValue(snaps[0], isRtl); } slidesManager.current.snaps = snaps; slidesManager.current.contentSize = contentSize; // Если галерея не зациклена и слайд всего один, то рассчитывать loopPoints тоже не надо if (looped && !onlyOneSlide && !isFullyVisible) { slidesManager.current.loopPoints = getLoopPoints( slidesManager.current, containerWidth, isRtl, ); } const isAnimationInProgress = animationInQueue() || animationFrameRef.current !== null; if (isAnimationInProgress) { return; } shiftXCurrentRef.current = snaps[slideIndex]; initialized.current = true; setControlElementsState({ canSlideLeft: calculateCanSlideLeft(), canSlideRight: calculateCanSlideRight(), isDraggable: !(dragDisabled || slidesManager.current.isFullyVisible), }); requestTransform(shiftXCurrentRef.current); }; const onResize = () => { if (initialized.current) { initializeSlides(); } }; const { window } = useDOM(); useResizeObserver(resizeSource === 'element' ? rootRef : window, onResize); const loopedSlideChangePerform = () => { const { snaps, slides } = slidesManager.current; const indent = snaps[slideIndex]; let startPoint = shiftXCurrentRef.current; const fromLastToFirst = isLowerOrEqual( shiftXCurrentRef.current, snaps[snaps.length - 1], isRtl, ); /** * Переключаемся с последнего элемента на первый * Для корректной анимации мы прокручиваем последний слайд на всю длину (shiftX) "вперед" * В конце анимации при отрисовке следующего кадра задаем всем слайдам начальные значения. */ if (indent === snaps[0] && fromLastToFirst) { const endEdge = revertRtlValue( Math.abs(snaps[snaps.length - 1]) + slides[slides.length - 1].width, isRtl, ); const distance = endEdge + startPoint; addToAnimationQueue( getAnimateFunction((progress) => { const shiftX = startPoint + progress * distance * -1; transformCssStyles(shiftX); if (shiftX <= snaps[snaps.length - 1] - slides[slides.length - 1].width) { requestAnimationFrame(() => { shiftXCurrentRef.current = indent; transformCssStyles(snaps[0]); }); } }), ); /** * Переключаемся с первого слайда на последний * Для корректной анимации сначала задаем первым видимым слайдам смещение * В следующем кадре начинаем анимация прокрутки "назад". */ } else if (indent === snaps[snaps.length - 1] && shiftXCurrentRef.current === snaps[0]) { startPoint = indent - revertRtlValue(slides[slides.length - 1].width, isRtl); addToAnimationQueue(() => { requestAnimationFrame(() => { const shiftX = indent - revertRtlValue(slides[slides.length - 1].width, isRtl); transformCssStyles(shiftX); getAnimateFunction((progress) => { const diff = revertRtlValue(progress * slides[slides.length - 1].width, isRtl); transformCssStyles(startPoint + diff); })(); }); }); /** * Если не обработаны `corner`-кейсы выше, то просто проигрываем анимацию смещения. */ } else { addToAnimationQueue(() => { const distance = Math.abs(indent - startPoint); let direction = startPoint <= indent ? 1 : -1; getAnimateFunction((progress) => { const shiftX = startPoint + progress * distance * direction; transformCssStyles(shiftX); })(); }); } }; const simpleSlideChangePerform = () => { const { snaps } = slidesManager.current; requestTransform(snaps[slideIndex], true); }; useIsomorphicLayoutEffect( function performSlideChange() { if (!initialized.current) { return; } const { snaps } = slidesManager.current; const indent = snaps[slideIndex]; if (looped) { loopedSlideChangePerform(); } else { simpleSlideChangePerform(); } startAnimation(); shiftXCurrentRef.current = indent; setControlElementsState((v) => ({ ...v, canSlideLeft: calculateCanSlideLeft(), canSlideRight: calculateCanSlideRight(), })); }, [slideIndex], ); useIsomorphicLayoutEffect( function updateIsDraggable() { setControlElementsState((v) => ({ ...v, isDraggable: !(dragDisabled || slidesManager.current.isFullyVisible), })); }, [dragDisabled], ); useMutationObserver(layerRef, initializeSlides); useIsomorphicLayoutEffect(initializeSlides, [align, slideWidth, looped, isRtl]); const calculateMinDeltaXToSlide = () => { return slidesManager.current.slides[slideIndex].width * SLIDE_THRESHOLD; }; const slideLeft = (event: React.MouseEvent) => { if (slideIndex > 0) { shiftXCurrentRef.current += revertRtlValue(calculateMinDeltaXToSlide(), isRtl); } onChange?.( (slideIndex - 1 + slidesManager.current.slides.length) % slidesManager.current.slides.length, ); onPrevClick?.(event); }; const slideRight = (event: React.MouseEvent) => { if (slideIndex < slidesManager.current.slides.length - 1) { shiftXCurrentRef.current -= revertRtlValue(calculateMinDeltaXToSlide(), isRtl); } onChange?.((slideIndex + 1) % slidesManager.current.slides.length); onNextClick?.(event); }; const onStart = (e: CustomTouchEvent) => { e.originalEvent.stopPropagation(); if (controlElementsState.isDraggable) { onDragStart?.(e); shiftXCurrentRef.current = slidesManager.current.snaps[slideIndex]; shiftXDeltaRef.current = 0; } }; const onMoveX = (e: CustomTouchEvent) => { if (controlElementsState.isDraggable) { e.originalEvent.preventDefault(); if (e.isSlideX) { isDragging.current = true; if (shiftXDeltaRef.current !== e.shiftX) { shiftXDeltaRef.current = e.shiftX; requestTransform(shiftXCurrentRef.current + shiftXDeltaRef.current); } } } }; const onEnd = (e: CustomTouchEvent) => { if (controlElementsState.isDraggable) { isDragging.current = false; let targetIndex = slideIndex; if (e.isSlide) { targetIndex = getTargetIndex({ slides: slidesManager.current.slides, slideIndex, currentShiftX: shiftXCurrentRef.current, currentShiftXDelta: shiftXDeltaRef.current, max: slidesManager.current.max, looped, isRtl, }); } onDragEnd?.(e, targetIndex); if (targetIndex !== slideIndex) { shiftXCurrentRef.current = shiftXCurrentRef.current + shiftXDeltaRef.current; onChange?.(targetIndex); } else { const initialShiftX = slidesManager.current.snaps[targetIndex]; requestTransform(initialShiftX, true); } } }; const setSlideRef = (slideRef: HTMLDivElement | null, slideIndex: number) => { slidesStore.current[slideIndex] = slideRef; }; const { isDraggable, canSlideRight, canSlideLeft } = controlElementsState; const handleScrollForFixVoiceOverBehavior = (event: React.UIEvent) => { restProps.onScroll?.(event); if (rootRef.current) { event.currentTarget.scrollLeft = 0; } }; const { isHovered, ...hoverHandlers } = useHover(); const handlers = mergeCalls(hoverHandlers, { onPointerEnter, onPointerLeave }); return ( {children} {bullets && ( )} ); };