Corp/backend/static/js/components/autoScrollerHandler.js
Игорь Надеин 286ce43d9d debug: добавлено детальное логирование автоскроллера
- Логи до и после клонирования элементов
- Логи размеров: scrollWidth, clientWidth, copiesNeeded
- Логи количества элементов до и после клонирования
- Добавлена защита от застревания (прыжок при достижении конца)
- Логи прыжков с maxScrollLeft для диагностики
- Исправлена логика вычисления blockScrollWidth = rail.scrollWidth / totalCopies
2025-12-05 11:08:32 +03:00

360 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @module autoScrollerHandler
* @description Автоматический горизонтальный скроллер для галереи элементов.
* Поддерживает плавную непрерывную прокрутку, паузу при наведении, центрирование элемента.
*
* Пример HTML:
* <div class="js-join-scroller overflow-hidden">
* <div class="join-rail d-flex gap-3">
* <div class="join-item">...</div>
* <div class="join-item">...</div>
* </div>
* </div>
*
* Использование:
* 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;
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);
/**
* Один кадр анимации - плавный скролл.
*/
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 >= blockScrollWidth || wrapper.scrollLeft >= maxScrollLeft - 5) {
const oldScrollLeft = wrapper.scrollLeft;
wrapper.scrollLeft = 0;
accumulatedScroll = 0;
console.log('animate: SEAMLESS LOOP! jumped from', oldScrollLeft, 'to 0, blockScrollWidth =', blockScrollWidth, '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;
}