GraphEditor/plugins/slider/itc-slider.js
2025-08-16 08:37:11 +00:00

589 lines
21 KiB
JavaScript
Raw Permalink 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.

/**
* @class ItcSlider
* @version 1.0.1
* @author https://github.com/itchief
* @copyright Alexander Maltsev 2020 - 2023
* @license MIT (https://github.com/itchief/ui-components/blob/master/LICENSE)
* @tutorial https://itchief.ru/javascript/slider
*/
class ItcSlider {
static #EL_WRAPPER = 'wrapper';
static #EL_ITEMS = 'items';
static #EL_ITEM = 'item';
static #EL_ITEM_ACTIVE = 'item-active';
static #EL_INDICATOR = 'indicator';
static #EL_INDICATOR_ACTIVE = 'indicator-active';
static #BTN_PREV = 'btn-prev';
static #BTN_NEXT = 'btn-next';
static #BTN_HIDE = 'btn-hide';
static #TRANSITION_NONE = 'transition-none';
static #SWIPE_THRESHOLD = 20;
static #instances = [];
static checkSupportPassiveEvents() {
let passiveSupported = false;
try {
const options = Object.defineProperty({}, 'passive', {
get() {
passiveSupported = true;
},
});
window.addEventListener('testPassiveListener', null, options);
window.removeEventListener('testPassiveListener', null, options);
} catch (error) {
passiveSupported = false;
}
return passiveSupported;
}
#config;
#state;
#resizeObserver;
/**
* @param {HTMLElement} el
* @param {Object} config
* @param {String} prefix
*/
constructor(el, config = {}, prefix = 'itc-slider-') {
this.#state = {
prefix, // префикс для классов
el, // элемент который нужно активировать как ItcSlider
elWrapper: el.querySelector(`.${prefix}${this.constructor.#EL_WRAPPER}`), // элемент с #CLASS_WRAPPER
elItems: el.querySelector(`.${prefix}${this.constructor.#EL_ITEMS}`), // элемент, в котором находятся слайды
elListItem: el.querySelectorAll(`.${prefix}${this.constructor.#EL_ITEM}`), // список элементов, являющиеся слайдами
btnPrev: el.querySelector(`.${prefix}${this.constructor.#BTN_PREV}`), // кнопка, для перехода к предыдущему слайду
btnNext: el.querySelector(`.${prefix}${this.constructor.#BTN_NEXT}`), // кнопка, для перехода к следующему слайду
btnClassHide: prefix + this.constructor.#BTN_HIDE, // класс для скрытия кнопки
exOrderMin: 0,
exOrderMax: 0,
exItemMin: null,
exItemMax: null,
exTranslateMin: 0,
exTranslateMax: 0,
direction: 'next', // направление смены слайдов
intervalId: null, // id таймера
isSwiping: false,
swipeX: 0,
swipeY: 0,
};
this.#resizeObserver = null;
this.#config = {
loop: true, direction: 'next', autoplay: false, interval: 5000, refresh: true, swipe: true, ...config
};
this.#init();
this.#attachEvents();
}
/**
* Статический метод, который возвращает экземпляр ItcSlider, связанный с DOM-элементом
* @param {HTMLElement} elSlider
* @returns {?ItcSlider}
*/
static getInstance(elSlider) {
const found = this.#instances.find((el) => el.target === elSlider);
if (found) {
return found.instance;
}
return null;
}
/**
* @param {String|HTMLElement} target
* @param {Object} config
* @param {String} prefix
*/
static getOrCreateInstance(target, config = {}, prefix = 'itc-slider-') {
const elSlider = typeof target === 'string' ? document.querySelector(target) : target;
const result = this.getInstance(elSlider);
if (result) {
return result;
}
const slider = new this(elSlider, config, prefix);
this.#instances.push({ target: elSlider, instance: slider });
return slider;
}
// статический метод для активирования элементов как ItcSlider на основе data-атрибутов
static createInstances() {
document.querySelectorAll('[data-slider="itc-slider"]').forEach((el) => {
const { dataset } = el;
const params = {};
Object.keys(dataset).forEach((key) => {
if (key === 'slider') {
return;
}
let value = dataset[key];
value = Number.isNaN(Number(value)) ? value : Number(value);
value = value === 'true' ? true : value;
value = value === 'false' ? false : value;
params[key] = value;
});
this.getOrCreateInstance(el, params);
});
}
slideNext() {
this.#state.direction = 'next';
this.#move();
}
slidePrev() {
this.#state.direction = 'prev';
this.#move();
}
slideTo(index) {
this.#moveTo(index);
}
reset() {
this.#reset();
}
get autoplay() {
return {
// Start autoplay
start: () => {
this.#config.autoplay = true;
this.#autoplay();
},
// Stop autoplay
stop: () => {
this.#autoplay('stop');
this.#config.autoplay = false;
}
};
}
dispose() {
this.#detachEvents();
const transitionNoneClass = this.#state.prefix + this.constructor.#TRANSITION_NONE;
const activeClass = this.#state.prefix + this.constructor.#EL_ITEM_ACTIVE;
this.#autoplay('stop');
this.#state.elItems.classList.add(transitionNoneClass);
this.#state.elItems.style.transform = '';
this.#state.elListItem.forEach((el) => {
el.style.transform = '';
el.classList.remove(activeClass);
});
const selIndicators = `${this.#state.prefix}${this.constructor.#EL_INDICATOR_ACTIVE}`;
document.querySelectorAll(`.${selIndicators}`).forEach((el) => {
el.classList.remove(selIndicators);
});
this.#state.elItems.offsetHeight;
this.#state.elItems.classList.remove(transitionNoneClass);
const index = this.constructor.#instances.findIndex((el) => el.target === this.#state.el);
this.constructor.#instances.splice(index, 1);
}
#onClick(e) {
if (this.#state.isMoving) {
e.preventDefault();
}
if (!(e.target.closest('.itc-slider-btn') || e.target.closest('.itc-slider-indicators'))) {
return;
}
const classBtnPrev = this.#state.prefix + this.constructor.#BTN_PREV;
const classBtnNext = this.#state.prefix + this.constructor.#BTN_NEXT;
this.#autoplay('stop');
if (e.target.closest(`.${classBtnPrev}`) || e.target.closest(`.${classBtnNext}`)) {
this.#state.direction = e.target.closest(`.${classBtnPrev}`) ? 'prev' : 'next';
this.#move();
} else if (e.target.dataset.slideTo) {
const index = parseInt(e.target.dataset.slideTo, 10);
this.#moveTo(index);
}
this.#config.loop ? this.#autoplay() : null;
}
#onMouseEnter() {
this.#autoplay('stop');
}
#onMouseLeave() {
this.#autoplay();
}
#onTransitionStart() {
if (this.#config.loop) {
if (this.#state.isBalancing) {
return;
}
this.#state.isBalancing = true;
window.requestAnimationFrame(() => {
this.#balanceItems(false);
});
}
}
#onTransitionEnd() {
if (this.#config.loop) {
this.#state.isBalancing = false;
}
}
#onDragStart(e) {
e.preventDefault();
}
#onVisibilityChange() {
if (document.visibilityState === 'hidden') {
this.#autoplay('stop');
} else if (document.visibilityState === 'visible' && this.#config.loop) {
this.#autoplay();
}
}
#touchStart(e) {
this.#state.isMoving = false;
this.#autoplay('stop');
const event = e.type.search('touch') === 0 ? e.touches[0] : e;
this.#state.swipeX = event.clientX;
this.#state.swipeY = event.clientY;
this.#state.isSwiping = true;
this.#state.isTouchMoving = false;
}
#touchEnd(e) {
if (!this.#state.isSwiping) {
return;
}
const event = e.type.search('touch') === 0 ? e.changedTouches[0] : e;
const wrapperRect = this.#state.elWrapper.getBoundingClientRect();
let clientX = event.clientX < wrapperRect.left ? wrapperRect.left : event.clientX;
clientX = clientX > wrapperRect.right ? wrapperRect.right : clientX;
let diffPosX = this.#state.swipeX - clientX;
if (diffPosX === 0) {
this.#state.isSwiping = false;
return;
}
if (!this.#config.loop) {
const isNotMoveFirst = this.#state.activeItems[0] === 1 && diffPosX <= 0;
const isNotMoveLast = this.#state.activeItems[this.#state.activeItems.length - 1] && diffPosX >= 0;
if (isNotMoveFirst || isNotMoveLast) {
diffPosX = 0;
}
}
const value = (diffPosX / this.#state.width) * 100;
const transitionNoneClass = this.#state.prefix + this.constructor.#TRANSITION_NONE;
this.#state.elItems.classList.remove(transitionNoneClass);
if (value > this.constructor.#SWIPE_THRESHOLD) {
this.#state.direction = 'next';
let count = 0;
while (count <= Math.floor(Math.abs(value) - this.constructor.#SWIPE_THRESHOLD) / 100) {
this.#move();
count += 1;
}
} else if (value < -this.constructor.#SWIPE_THRESHOLD) {
this.#state.direction = 'prev';
let count = 0;
while (count <= Math.floor(Math.abs(value) - this.constructor.#SWIPE_THRESHOLD) / 100) {
this.#move();
count += 1;
}
} else {
this.#state.direction = 'none';
this.#move();
}
this.#state.isSwiping = false;
if (this.#config.loop) {
this.#autoplay();
}
this.#state.isBalancing = false;
}
#touchMove(e) {
if (!this.#state.isSwiping) {
return;
}
this.#state.isMoving = true;
const event = e.type.search('touch') === 0 ? e.changedTouches[0] : e;
let diffPosX = this.#state.swipeX - event.clientX;
const diffPosY = this.#state.swipeY - event.clientY;
const prevPosX = this.#state.prevPosX ? this.#state.prevPosX : event.clientX;
const direction = prevPosX > event.clientX ? 'next' : 'prev';
this.#state.prevPosX = event.clientX;
if (!this.#state.isTouchMoving) {
if (Math.abs(diffPosY) > Math.abs(diffPosX) || Math.abs(diffPosX) === 0) {
this.#state.isSwiping = false;
return;
}
this.#state.isTouchMoving = true;
}
e.preventDefault();
if (!this.#config.loop) {
const isNotMoveFirst = this.#state.activeItems[0] === 1 && diffPosX <= 0;
const isNotMoveLast = this.#state.activeItems[this.#state.activeItems.length - 1] && diffPosX >= 0;
if (isNotMoveFirst || isNotMoveLast) {
diffPosX /= 4;
}
}
const transitionNoneClass = this.#state.prefix + this.constructor.#TRANSITION_NONE;
this.#state.elItems.classList.add(transitionNoneClass);
const translate = this.#state.translate - diffPosX;
this.#state.elItems.style.transform = `translate3D(${translate}px, 0px, 0.1px)`;
if (this.#config.loop) {
this.#state.direction = diffPosX > 0 ? 'next' : 'prev';
this.#state.direction = direction;
window.requestAnimationFrame(() => {
this.#balanceItems(true);
});
}
}
#attachEvents() {
this.#state.events = {
click: [this.#state.el, this.#onClick.bind(this), true],
mouseenter: [this.#state.el, this.#onMouseEnter.bind(this), true],
mouseleave: [this.#state.el, this.#onMouseLeave.bind(this), true],
transitionstart: [this.#state.elItems, this.#onTransitionStart.bind(this), this.#config.loop],
transitionend: [this.#state.elItems, this.#onTransitionEnd.bind(this), this.#config.loop],
touchstart: [this.#state.el, this.#touchStart.bind(this), this.#config.swipe],
mousedown: [this.#state.el, this.#touchStart.bind(this), this.#config.swipe],
touchend: [document, this.#touchEnd.bind(this), this.#config.swipe],
mouseup: [document, this.#touchEnd.bind(this), this.#config.swipe],
touchmove: [this.#state.el, this.#touchMove.bind(this), this.#config.swipe],
mousemove: [this.#state.el, this.#touchMove.bind(this), this.#config.swipe],
dragstart: [this.#state.el, this.#onDragStart.bind(this), true],
visibilitychange: [document, this.#onVisibilityChange.bind(this), true]
};
Object.keys(this.#state.events).forEach((type) => {
if (this.#state.events[type][2]) {
const el = this.#state.events[type][0];
const fn = this.#state.events[type][1];
if (type === 'touchstart' || type === 'touchmove') {
const options = this.constructor.checkSupportPassiveEvents() ? { passive: false } : false;
el.addEventListener(type, fn, options);
} else {
el.addEventListener(type, fn);
}
}
});
this.#resizeObserver = new ResizeObserver((entries) => {
window.requestAnimationFrame(this.#reset.bind(this));
});
this.#resizeObserver.observe(this.#state.elWrapper);
}
#detachEvents() {
Object.keys(this.#state.events).forEach((type) => {
if (this.#state.events[type][2]) {
const el = this.#state.events[type][0];
const fn = this.#state.events[type][1];
el.removeEventListener(type, fn);
this.#resizeObserver.disconnect();
}
});
}
#autoplay(action) {
if (!this.#config.autoplay) {
return;
}
if (action === 'stop') {
clearInterval(this.#state.intervalId);
this.#state.intervalId = null;
return;
}
if (this.#state.intervalId === null) {
this.#state.intervalId = setInterval(() => {
this.#state.direction = this.#config.direction === 'prev' ? 'prev' : 'next';
this.#move();
}, this.#config.interval);
}
}
#balanceItems(once = false) {
if (!this.#state.isBalancing && !once) {
return;
}
const wrapperRect = this.#state.elWrapper.getBoundingClientRect();
const targetWidth = wrapperRect.width / this.#state.countActiveItems / 2;
const countItems = this.#state.elListItem.length;
if (this.#state.direction === 'next') {
const exItemRectRight = this.#state.exItemMin.getBoundingClientRect().right;
if (exItemRectRight < wrapperRect.left - targetWidth) {
const elFound = this.#state.els.find((item) => item.el === this.#state.exItemMin);
elFound.order = this.#state.exOrderMin + countItems;
const translate = this.#state.exTranslateMin + countItems * this.#state.width;
elFound.translate = translate;
this.#state.exItemMin.style.transform = `translate3D(${translate}px, 0px, 0.1px)`;
this.#updateExProperties();
}
} else {
const exItemRectLeft = this.#state.exItemMax.getBoundingClientRect().left;
if (exItemRectLeft > wrapperRect.right + targetWidth) {
const elFound = this.#state.els.find((item) => item.el === this.#state.exItemMax);
elFound.order = this.#state.exOrderMax - countItems;
const translate = this.#state.exTranslateMax - countItems * this.#state.width;
elFound.translate = translate;
this.#state.exItemMax.style.transform = `translate3D(${translate}px, 0px, 0.1px)`;
this.#updateExProperties();
}
}
if (!once) {
window.requestAnimationFrame(() => {
this.#balanceItems(false);
});
}
}
#updateClasses() {
const activeClass = this.#state.prefix + this.constructor.#EL_ITEM_ACTIVE;
this.#state.activeItems.forEach((item, index) => {
if (item) {
this.#state.elListItem[index].classList.add(activeClass);
} else {
this.#state.elListItem[index].classList.remove(activeClass);
}
const elListIndicators = this.#state.el.querySelectorAll(`.${this.#state.prefix}${this.constructor.#EL_INDICATOR}`);
if (elListIndicators.length && item) {
elListIndicators[index].classList.add(`${this.#state.prefix}${this.constructor.#EL_INDICATOR_ACTIVE}`);
} else if (elListIndicators.length && !item) {
elListIndicators[index].classList.remove(`${this.#state.prefix}${this.constructor.#EL_INDICATOR_ACTIVE}`);
}
});
}
#move() {
if (this.#state.direction === 'none') {
const transform = this.#state.translate;
this.#state.elItems.style.transform = `translate3D(${transform}px, 0px, 0.1px)`;
return;
}
const widthItem = this.#state.direction === 'next' ? -this.#state.width : this.#state.width;
const transform = this.#state.translate + widthItem;
if (!this.#config.loop) {
const limit = this.#state.width * (this.#state.elListItem.length - this.#state.countActiveItems);
if (transform < -limit || transform > 0) {
return;
}
if (this.#state.btnPrev) {
this.#state.btnPrev.classList.remove(this.#state.btnClassHide);
this.#state.btnNext.classList.remove(this.#state.btnClassHide);
}
if (this.#state.btnPrev && transform === -limit) {
this.#state.btnNext.classList.add(this.#state.btnClassHide);
} else if (this.#state.btnPrev && transform === 0) {
this.#state.btnPrev.classList.add(this.#state.btnClassHide);
}
}
if (this.#state.direction === 'next') {
this.#state.activeItems = [...this.#state.activeItems.slice(-1), ...this.#state.activeItems.slice(0, -1)];
} else {
this.#state.activeItems = [...this.#state.activeItems.slice(1), ...this.#state.activeItems.slice(0, 1)];
}
this.#updateClasses();
this.#state.translate = transform;
this.#state.elItems.style.transform = `translate3D(${transform}px, 0px, 0.1px)`;
}
#moveTo(index) {
const delta = this.#state.activeItems.reduce((acc, current, currentIndex) => {
const diff = current ? index - currentIndex : acc;
return Math.abs(diff) < Math.abs(acc) ? diff : acc;
}, this.#state.activeItems.length);
if (delta !== 0) {
this.#state.direction = delta > 0 ? 'next' : 'prev';
for (let i = 0; i < Math.abs(delta); i++) {
this.#move();
}
}
}
// приватный метод для выполнения первичной инициализации
#init() {
// состояние элементов
this.#state.els = [];
// текущее значение translate
this.#state.translate = 0;
// позиции активных элементов
this.#state.activeItems = [];
// состояние элементов
this.#state.isBalancing = false;
// получаем gap между слайдами
const gap = parseFloat(getComputedStyle(this.#state.elItems).gap) || 0;
// ширина одного слайда
this.#state.width = this.#state.elListItem[0].getBoundingClientRect().width + gap;
// ширина #EL_WRAPPER
const widthWrapper = this.#state.elWrapper.getBoundingClientRect().width;
// количество активных элементов
this.#state.countActiveItems = Math.round(widthWrapper / this.#state.width);
this.#state.elListItem.forEach((el, index) => {
el.style.transform = '';
this.#state.activeItems.push(index < this.#state.countActiveItems ? 1 : 0);
this.#state.els.push({
el, index, order: index, translate: 0
});
});
if (this.#state.countActiveItems === this.#state.elListItem.length) {
if (this.#state.btnPrev) {
this.#state.btnPrev.classList.add(this.#state.btnClassHide);
}
if (this.#state.btnNext) {
this.#state.btnNext.classList.add(this.#state.btnClassHide);
}
} else {
if (this.#state.btnPrev) {
this.#state.btnPrev.classList.remove(this.#state.btnClassHide);
}
if (this.#state.btnNext) {
this.#state.btnNext.classList.remove(this.#state.btnClassHide);
}
}
if (this.#config.loop) {
const lastIndex = this.#state.elListItem.length - 1;
const translate = -(lastIndex + 1) * this.#state.width;
this.#state.elListItem[lastIndex].style.transform = `translate3D(${translate}px, 0px, 0.1px)`;
this.#state.els[lastIndex].order = -1;
this.#state.els[lastIndex].translate = translate;
this.#updateExProperties();
} else if (this.#state.btnPrev) {
this.#state.btnPrev.classList.add(this.#state.btnClassHide);
}
this.#updateClasses();
this.#autoplay();
}
#reset() {
const transitionNoneClass = this.#state.prefix + this.constructor.#TRANSITION_NONE;
// получаем gap между слайдами
const gap = parseFloat(getComputedStyle(this.#state.elItems).gap) || 0;
// ширина одного слайда
const widthItem = this.#state.elListItem[0].getBoundingClientRect().width + gap;
const widthWrapper = this.#state.elWrapper.getBoundingClientRect().width;
const countActiveEls = Math.round(widthWrapper / widthItem);
if (widthItem === this.#state.width && countActiveEls === this.#state.countActiveItems) {
return;
}
this.#autoplay('stop');
this.#state.elItems.classList.add(transitionNoneClass);
this.#state.elItems.style.transform = 'translate3D(0px, 0px, 0.1px)';
this.#init();
window.requestAnimationFrame(() => {
this.#state.elItems.classList.remove(transitionNoneClass);
});
}
#updateExProperties() {
const els = this.#state.els.map((item) => item.el);
const orders = this.#state.els.map((item) => item.order);
this.#state.exOrderMin = Math.min(...orders);
this.#state.exOrderMax = Math.max(...orders);
const min = orders.indexOf(this.#state.exOrderMin);
const max = orders.indexOf(this.#state.exOrderMax);
this.#state.exItemMin = els[min];
this.#state.exItemMax = els[max];
this.#state.exTranslateMin = this.#state.els[min].translate;
this.#state.exTranslateMax = this.#state.els[max].translate;
}
}
ItcSlider.createInstances();