/** * @module autoScrollerHandler * @description Автоматический горизонтальный скроллер для галереи элементов. * Поддерживает плавную непрерывную прокрутку, паузу при наведении, центрирование элемента. * * Пример HTML: *
*
*
...
*
...
*
*
* * Использование: * import { initAutoScroller } from './autoScrollerHandler.js'; * initAutoScroller({ selector: '.js-join-scroller', speed: 0.5 }); */ const DEFAULT_SPEED = 0.5; // пикселей за кадр (примерно 30px/сек при 60fps) const SCROLL_BEHAVIOR = 'smooth'; /** * Центрирует элемент в видимой области контейнера. * @param {HTMLElement} container - Контейнер со скроллом * @param {HTMLElement} item - Элемент для центрирования */ function centerItemInView(container, item) { const itemRect = item.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const itemCenter = itemRect.left + itemRect.width / 2; const containerCenter = containerRect.left + containerRect.width / 2; const delta = itemCenter - containerCenter; container.scrollTo({ left: container.scrollLeft + delta, behavior: SCROLL_BEHAVIOR }); } /** * Вычисляет шаг скролла (ширина элемента + gap). * @param {HTMLElement} rail - Элемент с flex-контейнером * @returns {number} Размер шага в пикселях */ function computeScrollStep(rail) { const firstChild = rail.firstElementChild; if (!firstChild) return 0; const rect = firstChild.getBoundingClientRect(); const gap = parseFloat(getComputedStyle(rail).gap || '0') || 0; return Math.round(rect.width + gap); } /** * Инициализирует автоскроллер для одного контейнера. * @param {HTMLElement} wrapper - Обёртка со скроллом * @param {Object} options - Опции * @param {number} [options.speed=0.5] - Скорость прокрутки в пикселях за кадр * @returns {Object} API с методами start, stop, destroy */ function initSingleScroller(wrapper, options = {}) { const { speed = DEFAULT_SPEED } = options; console.log('initSingleScroller: starting, speed =', speed); const rail = wrapper.querySelector('.join-rail'); if (!rail) { console.warn('initAutoScroller: .join-rail не найден'); return { start: () => {}, stop: () => {}, destroy: () => {} }; } // Проверяем, нужен ли скролл (контент переполняет контейнер) const hasOverflow = rail.scrollWidth > wrapper.clientWidth + 4; console.log('initSingleScroller: hasOverflow =', hasOverflow, 'scrollWidth =', rail.scrollWidth, 'clientWidth =', wrapper.clientWidth); if (!hasOverflow) { console.log('initSingleScroller: no overflow, skipping'); return { start: () => {}, stop: () => {}, destroy: () => {} }; } // Дублируем элементы для бесшовного цикла // Создаем несколько копий для более плавной бесконечной прокрутки const originalChildren = Array.from(rail.children); const originalScrollWidth = rail.scrollWidth; const minScrollWidth = wrapper.clientWidth * 3; // Минимум 3 экрана контента console.log('initSingleScroller: BEFORE cloning - originalChildren.length =', originalChildren.length); console.log('initSingleScroller: BEFORE cloning - originalScrollWidth =', originalScrollWidth); console.log('initSingleScroller: BEFORE cloning - wrapper.clientWidth =', wrapper.clientWidth); console.log('initSingleScroller: BEFORE cloning - minScrollWidth =', minScrollWidth); // Вычисляем сколько копий нужно создать let copiesNeeded = Math.ceil(minScrollWidth / originalScrollWidth); copiesNeeded = Math.max(2, copiesNeeded); // Минимум 2 копии (оригинал + 1 клон) console.log('initSingleScroller: copiesNeeded =', copiesNeeded); // Создаем копии const allClones = []; for (let i = 0; i < copiesNeeded; i++) { const clones = originalChildren.map(node => node.cloneNode(true)); rail.append(...clones); allClones.push(...clones); } console.log('initSingleScroller: created', copiesNeeded, 'copies,', allClones.length, 'total cloned items'); console.log('initSingleScroller: AFTER cloning - rail.children.length =', rail.children.length); console.log('initSingleScroller: AFTER cloning - rail.scrollWidth =', rail.scrollWidth); // Пересчитываем overflow после клонирования const hasOverflowAfterClone = rail.scrollWidth > wrapper.clientWidth + 4; console.log('initSingleScroller: hasOverflowAfterClone =', hasOverflowAfterClone, 'scrollWidth =', rail.scrollWidth); if (!hasOverflowAfterClone) { // Удаляем клоны если даже с ними нет переполнения allClones.forEach(clone => clone.remove()); console.log('initSingleScroller: no overflow after clone, removing clones'); return { start: () => {}, stop: () => {}, destroy: () => {} }; } // Состояние let scrollStep = computeScrollStep(rail); const originalCount = originalChildren.length; const totalCopies = copiesNeeded + 1; // +1 потому что есть оригиналы + копии // Вычисляем точную ширину блока после рендеринга const blockScrollWidth = rail.scrollWidth / totalCopies; // Точка прыжка - в середине всего контента (после половины копий) // Это даст максимально плавный переход const loopPoint = blockScrollWidth * Math.floor(totalCopies / 2); let animationFrame = null; let paused = false; let resizeFrame = null; console.log('initSingleScroller: scrollStep =', scrollStep, 'originalCount =', originalCount, 'totalCopies =', totalCopies); console.log('initSingleScroller: blockScrollWidth =', blockScrollWidth, 'totalScrollWidth =', rail.scrollWidth); console.log('initSingleScroller: loopPoint =', loopPoint, '(will jump back at this position)'); /** * Один кадр анимации - плавный скролл. */ let frameCount = 0; let accumulatedScroll = 0; // Накопленные дробные пиксели function animate() { if (paused) { animationFrame = requestAnimationFrame(animate); return; } frameCount++; // Максимальный scrollLeft (конец контента) const maxScrollLeft = rail.scrollWidth - wrapper.clientWidth; // Прыгаем в середине контента для максимально незаметного перехода // ИЛИ когда достигли конца контейнера (защита от застревания) if (wrapper.scrollLeft >= loopPoint || wrapper.scrollLeft >= maxScrollLeft - 5) { const oldScrollLeft = wrapper.scrollLeft; wrapper.scrollLeft = 0; accumulatedScroll = 0; console.log('animate: SEAMLESS LOOP! jumped from', oldScrollLeft, 'to 0, loopPoint =', loopPoint, 'maxScrollLeft =', maxScrollLeft); } // Накапливаем дробные пиксели accumulatedScroll += speed; // Скроллим только когда накопился хотя бы 1 пиксель if (accumulatedScroll >= 1) { const scrollAmount = Math.floor(accumulatedScroll); accumulatedScroll -= scrollAmount; // Оставляем дробную часть wrapper.scrollBy({ left: scrollAmount, behavior: 'auto' }); if (frameCount % 60 === 0) { console.log('animate: frame', frameCount, 'scrollLeft =', wrapper.scrollLeft, 'scrollAmount =', scrollAmount); } } animationFrame = requestAnimationFrame(animate); } /** * Запускает автоматическую прокрутку. */ function start() { console.log('start: starting animation'); if (!animationFrame) { animationFrame = requestAnimationFrame(animate); } } /** * Останавливает автоматическую прокрутку. */ function stop() { if (animationFrame) { cancelAnimationFrame(animationFrame); animationFrame = null; } } /** * Обработчик наведения на элемент. */ function handleItemMouseEnter(event) { paused = true; centerItemInView(wrapper, event.currentTarget); } /** * Обработчик ухода курсора с элемента. */ function handleItemMouseLeave() { paused = false; } /** * Обработчик нажатия кнопки мыши на элементе. */ function handleItemMouseDown(event) { // Отменяем поведение по умолчанию чтобы не начиналось перетаскивание event.preventDefault(); // Сразу снимаем паузу и убираем увеличение const item = event.currentTarget; paused = false; // Добавляем класс для отключения hover item.classList.add('no-hover'); // Добавляем глобальный обработчик отпускания кнопки function handleGlobalMouseUp() { item.classList.remove('no-hover'); document.removeEventListener('mouseup', handleGlobalMouseUp); } document.addEventListener('mouseup', handleGlobalMouseUp); } /** * Обработчик получения фокуса элементом. */ function handleItemFocusIn(event) { paused = true; centerItemInView(wrapper, event.currentTarget); } /** * Обработчик потери фокуса элементом. */ function handleItemFocusOut() { paused = false; } /** * Обработчик изменения размера окна. */ function handleResize() { cancelAnimationFrame(resizeFrame); resizeFrame = requestAnimationFrame(() => { scrollStep = computeScrollStep(rail); }); } /** * Обработчик изменения видимости вкладки. */ function handleVisibilityChange() { if (document.hidden) { stop(); } else { start(); } } // Устанавливаем обработчики на все элементы const items = rail.querySelectorAll('.join-item'); items.forEach(item => { item.addEventListener('mouseenter', handleItemMouseEnter); item.addEventListener('mouseleave', handleItemMouseLeave); item.addEventListener('mousedown', handleItemMouseDown); item.addEventListener('focusin', handleItemFocusIn); item.addEventListener('focusout', handleItemFocusOut); }); // Глобальные обработчики window.addEventListener('resize', handleResize); document.addEventListener('visibilitychange', handleVisibilityChange); // Проверяем prefers-reduced-motion const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; console.log('initSingleScroller: prefersReducedMotion =', prefersReducedMotion); if (!prefersReducedMotion) { start(); } else { console.log('initSingleScroller: skipping animation due to prefers-reduced-motion'); } /** * Очистка всех обработчиков и таймеров. */ function destroy() { stop(); items.forEach(item => { item.removeEventListener('mouseenter', handleItemMouseEnter); item.removeEventListener('mouseleave', handleItemMouseLeave); item.removeEventListener('mousedown', handleItemMouseDown); item.removeEventListener('focusin', handleItemFocusIn); item.removeEventListener('focusout', handleItemFocusOut); }); window.removeEventListener('resize', handleResize); document.removeEventListener('visibilitychange', handleVisibilityChange); cancelAnimationFrame(resizeFrame); // Удаляем клонированные элементы clones.forEach(clone => clone.remove()); } return { start, stop, destroy }; } /** * Инициализирует автоскроллеры для всех элементов по селектору. * @param {Object} options - Опции * @param {string} [options.selector='.js-join-scroller'] - CSS-селектор контейнеров * @param {number} [options.speed=0.5] - Скорость прокрутки в пикселях за кадр (примерно 30px/сек при 60fps) * @returns {Object} API с методом destroy */ export function initAutoScroller(options = {}) { const { selector = '.js-join-scroller', speed = DEFAULT_SPEED } = options; const scrollers = []; document.querySelectorAll(selector).forEach(wrapper => { const scroller = initSingleScroller(wrapper, { speed }); scrollers.push(scroller); }); /** * Останавливает и очищает все скроллеры. */ function destroy() { scrollers.forEach(scroller => scroller.destroy()); scrollers.length = 0; } return { destroy }; } // Экспорт для совместимости с неModular кодом if (typeof window !== 'undefined') { window.initAutoScroller = initAutoScroller; }