/** * NOTE: This file should not be modified. * It is a legacy library that should be replaced at some point. */ import browser from '../../scripts/browser'; import layoutManager from '../../components/layoutManager'; import dom from '../../scripts/dom'; import focusManager from '../../components/focusManager'; import ResizeObserver from 'resize-observer-polyfill'; import '../../styles/scrollstyles.scss'; import globalize from '../globalize'; /** * Return type of the value. * * @param {Mixed} value * * @return {String} */ function type(value) { if (value == null) { return String(value); } if (typeof value === 'object' || typeof value === 'function') { return Object.prototype.toString.call(value).match(/\s([a-z]+)/i)[1].toLowerCase() || 'object'; } return typeof value; } /** * Disables an event it was triggered on and unbinds itself. * * @param {Event} event * * @return {Void} */ function disableOneEvent(event) { /*jshint validthis:true */ event.preventDefault(); event.stopPropagation(); this.removeEventListener(event.type, disableOneEvent); } /** * Make sure that number is within the limits. * * @param {Number} number * @param {Number} min * @param {Number} max * * @return {Number} */ function within(number, num1, num2) { if (num2 === undefined && globalize.getIsRTL()) { return number > num1 ? num1 : number; } else if (num2 === undefined) { return number < num1 ? num1 : number; } const min = Math.min(num1, num2); const max = Math.max(num1, num2); if (number < min) { return min; } else if (number > max) { return max; } return number; } // Other global values const dragMouseEvents = ['mousemove', 'mouseup']; const dragTouchEvents = ['touchmove', 'touchend']; const wheelEvent = (document.implementation.hasFeature('Event.wheel', '3.0') ? 'wheel' : 'mousewheel'); const interactiveElements = ['INPUT', 'SELECT', 'TEXTAREA']; const scrollerFactory = function (frame, options) { // Extend options const o = Object.assign({}, { slidee: null, // Selector, DOM element, or jQuery object with DOM element representing SLIDEE. horizontal: false, // Switch to horizontal mode. // Scrolling mouseWheel: true, scrollBy: 0, // Pixels or items to move per one mouse scroll. 0 to disable scrolling // Dragging dragSource: null, // Selector or DOM element for catching dragging events. Default is FRAME. mouseDragging: 1, // Enable navigation by dragging the SLIDEE with mouse cursor. touchDragging: 1, // Enable navigation by dragging the SLIDEE with touch events. dragThreshold: 3, // Distance in pixels before Sly recognizes dragging. intervactive: null, // Selector for special interactive elements. // Mixed options speed: 0 // Animations speed in milliseconds. 0 to disable animations. }, options); const isSmoothScrollSupported = 'scrollBehavior' in document.documentElement.style; // native scroll is a must with touch input // also use native scroll when scrolling vertically in desktop mode - excluding horizontal because the mouse wheel support is choppy at the moment // in cases with firefox, if the smooth scroll api is supported then use that because their implementation is very good if (options.allowNativeScroll === false) { options.enableNativeScroll = false; } else if (isSmoothScrollSupported && ((browser.firefox && !layoutManager.tv) || options.allowNativeSmoothScroll)) { // native smooth scroll options.enableNativeScroll = true; } else if (options.requireAnimation && (browser.animate || browser.supportsCssAnimation())) { // transform is the only way to guarantee animation options.enableNativeScroll = false; } else if (!layoutManager.tv || !browser.animate) { options.enableNativeScroll = true; } // Need this for the magic wheel. With the animated scroll the magic wheel will run off of the screen if (browser.web0s) { options.enableNativeScroll = true; } // Private variables const self = this; self.options = o; // Frame const slideeElement = o.slidee ? o.slidee : sibling(frame.firstChild)[0]; self._pos = { start: 0, center: 0, end: 0, cur: 0, dest: 0 }; const transform = !options.enableNativeScroll; // Miscellaneous const scrollSource = frame; const dragSourceElement = o.dragSource ? o.dragSource : frame; const dragging = { released: 1 }; const scrolling = { last: 0, delta: 0, resetTime: 200 }; // Expose properties self.initialized = 0; self.slidee = slideeElement; self.options = o; self.dragging = dragging; const nativeScrollElement = frame; function sibling(n, elem) { const matched = []; for (; n; n = n.nextSibling) { if (n.nodeType === 1 && n !== elem) { matched.push(n); } } return matched; } let requiresReflow = true; let frameSize = 0; let slideeSize = 0; function ensureSizeInfo() { if (requiresReflow) { requiresReflow = false; // Reset global variables frameSize = slideeElement[o.horizontal ? 'clientWidth' : 'clientHeight']; slideeSize = o.scrollWidth || Math.max(slideeElement[o.horizontal ? 'offsetWidth' : 'offsetHeight'], slideeElement[o.horizontal ? 'scrollWidth' : 'scrollHeight']); // Set position limits & relatives self._pos.end = Math.max(slideeSize - frameSize, 0); if (globalize.getIsRTL()) { self._pos.end *= -1; } } } /** * Loading function. * * Populate arrays, set sizes, bind events, ... * * @param {Boolean} [isInit] Whether load is called from within self.init(). * @return {Void} */ function load(isInit) { requiresReflow = true; if (!isInit) { ensureSizeInfo(); // Fix possible overflowing const pos = self._pos; self.slideTo(within(pos.dest, pos.start, pos.end)); } } function initFrameResizeObserver() { const observerOptions = {}; self.frameResizeObserver = new ResizeObserver(onResize, observerOptions); self.frameResizeObserver.observe(frame); } self.reload = function () { load(); }; self.getScrollEventName = function () { return transform ? 'scrollanimate' : 'scroll'; }; self.getScrollSlider = function () { return slideeElement; }; self.getScrollFrame = function () { return frame; }; function nativeScrollTo(container, pos, immediate) { if (container.scroll) { if (o.horizontal) { container.scroll({ left: pos, behavior: immediate ? 'instant' : 'smooth' }); } else { container.scroll({ top: pos, behavior: immediate ? 'instant' : 'smooth' }); } } else if (!immediate && container.scrollTo) { if (o.horizontal) { container.scrollTo(Math.round(pos), 0); } else { container.scrollTo(0, Math.round(pos)); } } else if (o.horizontal) { container.scrollLeft = Math.round(pos); } else { container.scrollTop = Math.round(pos); } } let lastAnimate; /** * Animate to a position. * * @param {Int} newPos New position. * @param {Bool} immediate Reposition immediately without an animation. * * @return {Void} */ self.slideTo = function (newPos, immediate, fullItemPos) { ensureSizeInfo(); const pos = self._pos; if (layoutManager.tv && globalize.getIsRTL()) { newPos = within(-newPos, pos.start); } else if (layoutManager.tv) { newPos = within(newPos, pos.start); } else { newPos = within(newPos, pos.start, pos.end); } if (!transform) { nativeScrollTo(nativeScrollElement, newPos, immediate); return; } // Update the animation object const from = pos.cur; immediate = immediate || dragging.init || !o.speed; const now = new Date().getTime(); if (o.autoImmediate && !immediate && (now - (lastAnimate || 0)) <= 50) { immediate = true; } if (!immediate && o.skipSlideToWhenVisible && fullItemPos?.isVisible) { return; } // Start animation rendering // NOTE the dependency was modified here to fix a scrollbutton issue pos.dest = newPos; renderAnimateWithTransform(from, newPos, immediate); lastAnimate = now; }; function setStyleProperty(elem, name, value, speed, resetTransition) { const style = elem.style; if (resetTransition || browser.edge) { style.transition = 'none'; void elem.offsetWidth; } style.transition = 'transform ' + speed + 'ms ease-out'; style[name] = value; } function dispatchScrollEventIfNeeded() { if (o.dispatchScrollEvent) { frame.dispatchEvent(new CustomEvent(self.getScrollEventName(), { bubbles: true, cancelable: false })); } } function renderAnimateWithTransform(fromPosition, toPosition, immediate) { let speed = o.speed; if (immediate) { speed = o.immediateSpeed || 50; } if (o.horizontal) { setStyleProperty(slideeElement, 'transform', 'translateX(' + (-Math.round(toPosition)) + 'px)', speed); } else { setStyleProperty(slideeElement, 'transform', 'translateY(' + (-Math.round(toPosition)) + 'px)', speed); } self._pos.cur = toPosition; dispatchScrollEventIfNeeded(); } function getBoundingClientRect(elem) { // Support: BlackBerry 5, iOS 3 (original iPhone) // If we don't have gBCR, just use 0,0 rather than error if (elem.getBoundingClientRect) { return elem.getBoundingClientRect(); } else { return { top: 0, left: 0 }; } } /** * Returns the position object. * * @param {Mixed} item * * @return {Object} */ self.getPos = function (item) { const scrollElement = transform ? slideeElement : nativeScrollElement; const slideeOffset = getBoundingClientRect(scrollElement); const itemOffset = getBoundingClientRect(item); let horizontalOffset = itemOffset.left - slideeOffset.left; if (globalize.getIsRTL()) { horizontalOffset = slideeOffset.right - itemOffset.right; } let offset = o.horizontal ? horizontalOffset : itemOffset.top - slideeOffset.top; let size = o.horizontal ? itemOffset.width : itemOffset.height; if (!size && size !== 0) { size = item[o.horizontal ? 'offsetWidth' : 'offsetHeight']; } let centerOffset = o.centerOffset || 0; if (!transform) { centerOffset = 0; if (o.horizontal) { offset += nativeScrollElement.scrollLeft; } else { offset += nativeScrollElement.scrollTop; } } ensureSizeInfo(); const currentStart = self._pos.cur; let currentEnd = currentStart + frameSize; if (globalize.getIsRTL()) { currentEnd = currentStart - frameSize; } console.debug('offset:' + offset + ' currentStart:' + currentStart + ' currentEnd:' + currentEnd); const isVisible = offset >= Math.min(currentStart, currentEnd) && (globalize.getIsRTL() ? (offset - size) : (offset + size)) <= Math.max(currentStart, currentEnd); return { start: offset, center: offset + centerOffset - (frameSize / 2) + (size / 2), end: offset - frameSize + size, size, isVisible }; }; self.getCenterPosition = function (item) { ensureSizeInfo(); const pos = self.getPos(item); return within(pos.center, pos.start, pos.end); }; function dragInitSlidee(event) { const isTouch = event.type === 'touchstart'; // Ignore when already in progress, or interactive element in non-touch navivagion if (dragging.init || !isTouch && isInteractive(event.target)) { return; } // SLIDEE dragging conditions if (!(isTouch ? o.touchDragging : o.mouseDragging && event.which < 2)) { return; } if (!isTouch) { // prevents native image dragging in Firefox event.preventDefault(); } // Reset dragging object dragging.released = 0; // Properties used in dragHandler dragging.init = 0; dragging.source = event.target; dragging.touch = isTouch; const pointer = isTouch ? event.touches[0] : event; dragging.initX = pointer.pageX; dragging.initY = pointer.pageY; dragging.initPos = self._pos.cur; dragging.start = +new Date(); dragging.time = 0; dragging.path = 0; dragging.delta = 0; dragging.locked = 0; dragging.pathToLock = isTouch ? 30 : 10; // Bind dragging events if (transform) { if (isTouch) { dragTouchEvents.forEach(function (eventName) { dom.addEventListener(document, eventName, dragHandler, { passive: true }); }); } else { dragMouseEvents.forEach(function (eventName) { dom.addEventListener(document, eventName, dragHandler, { passive: true }); }); } } } /** * Handler for dragging scrollbar handle or SLIDEE. * * @param {Event} event * * @return {Void} */ function dragHandler(event) { dragging.released = event.type === 'mouseup' || event.type === 'touchend'; const eventName = dragging.released ? 'changedTouches' : 'touches'; const pointer = dragging.touch ? event[eventName][0] : event; dragging.pathX = pointer.pageX - dragging.initX; dragging.pathY = pointer.pageY - dragging.initY; dragging.path = Math.sqrt(Math.pow(dragging.pathX, 2) + Math.pow(dragging.pathY, 2)); dragging.delta = o.horizontal ? dragging.pathX : dragging.pathY; if (!dragging.released && dragging.path < 1) { return; } // We haven't decided whether this is a drag or not... if (!dragging.init) { // If the drag path was very short, maybe it's not a drag? if (dragging.path < o.dragThreshold) { // If the pointer was released, the path will not become longer and it's // definitely not a drag. If not released yet, decide on next iteration return dragging.released ? dragEnd() : undefined; } else if (o.horizontal ? Math.abs(dragging.pathX) > Math.abs(dragging.pathY) : Math.abs(dragging.pathX) < Math.abs(dragging.pathY)) { // If dragging path is sufficiently long we can confidently start a drag // if drag is in different direction than scroll, ignore it dragging.init = 1; } else { return dragEnd(); } } // Disable click on a source element, as it is unwelcome when dragging if (!dragging.locked && dragging.path > dragging.pathToLock) { dragging.locked = 1; dragging.source.addEventListener('click', disableOneEvent); } // Cancel dragging on release if (dragging.released) { dragEnd(); } self.slideTo(Math.round(dragging.initPos - dragging.delta)); } /** * Stops dragging and cleans up after it. * * @return {Void} */ function dragEnd() { dragging.released = true; dragTouchEvents.forEach(function (eventName) { dom.removeEventListener(document, eventName, dragHandler, { passive: true }); }); dragMouseEvents.forEach(function (eventName) { dom.removeEventListener(document, eventName, dragHandler, { passive: true }); }); // Make sure that disableOneEvent is not active in next tick. setTimeout(function () { dragging.source.removeEventListener('click', disableOneEvent); }); dragging.init = 0; } /** * Check whether element is interactive. * * @return {Boolean} */ function isInteractive(element) { while (element) { if (interactiveElements.indexOf(element.tagName) !== -1) { return true; } element = element.parentNode; } return false; } /** * Mouse wheel delta normalization. * * @param {Event} event * * @return {Int} */ function normalizeWheelDelta(event) { // JELLYFIN MOD: Only use deltaX for horizontal scroll and remove IE8 support scrolling.curDelta = o.horizontal ? event.deltaX : event.deltaY; // END JELLYFIN MOD if (transform) { scrolling.curDelta /= event.deltaMode === 1 ? 3 : 100; } return scrolling.curDelta; } /** * Mouse scrolling handler. * * @param {Event} event * * @return {Void} */ function scrollHandler(event) { ensureSizeInfo(); const pos = self._pos; // Ignore if there is no scrolling to be done if (!o.scrollBy || pos.start === pos.end) { return; } let delta = normalizeWheelDelta(event); if (transform) { self.slideBy(o.scrollBy * delta); } else { if (isSmoothScrollSupported) { delta *= 12; } if (o.horizontal) { nativeScrollElement.scrollLeft += delta; } else { nativeScrollElement.scrollTop += delta; } } } /** * Destroys instance and everything it created. * * @return {Void} */ self.destroy = function () { if (self.frameResizeObserver) { self.frameResizeObserver.disconnect(); self.frameResizeObserver = null; } // Reset native FRAME element scroll dom.removeEventListener(frame, 'scroll', resetScroll, { passive: true }); dom.removeEventListener(scrollSource, wheelEvent, scrollHandler, { passive: true }); dom.removeEventListener(dragSourceElement, 'touchstart', dragInitSlidee, { passive: true }); dom.removeEventListener(frame, 'click', onFrameClick, { passive: true, capture: true }); dom.removeEventListener(dragSourceElement, 'mousedown', dragInitSlidee, { //passive: true }); scrollSource.removeAttribute(`data-scroll-mode-${o.horizontal ? 'x' : 'y'}`); // Reset initialized status and return the instance self.initialized = 0; return self; }; let contentRect = {}; function onResize(entries) { const entry = entries[0]; if (entry) { const newRect = entry.contentRect; // handle element being hidden if (newRect.width === 0 || newRect.height === 0) { return; } if (newRect.width !== contentRect.width || newRect.height !== contentRect.height) { contentRect = newRect; load(false); } } } function resetScroll() { if (o.horizontal) { this.scrollLeft = 0; } else { this.scrollTop = 0; } } function onFrameClick(e) { if (e.which === 1) { const focusableParent = focusManager.focusableParent(e.target); if (focusableParent && focusableParent !== document.activeElement) { focusableParent.focus(); } } } self.getScrollPosition = function () { if (transform) { return self._pos.cur; } if (o.horizontal) { return nativeScrollElement.scrollLeft; } else { return nativeScrollElement.scrollTop; } }; self.getScrollSize = function () { if (transform) { return slideeSize; } if (o.horizontal) { return nativeScrollElement.scrollWidth; } else { return nativeScrollElement.scrollHeight; } }; /** * Initialize. * * @return {Object} */ self.init = function () { if (self.initialized) { return; } if (!transform) { if (o.horizontal) { if (layoutManager.desktop && !o.hideScrollbar) { nativeScrollElement.classList.add('scrollX'); } else { nativeScrollElement.classList.add('scrollX'); nativeScrollElement.classList.add('hiddenScrollX'); if (layoutManager.tv && o.allowNativeSmoothScroll !== false) { nativeScrollElement.classList.add('smoothScrollX'); } } if (o.forceHideScrollbars) { nativeScrollElement.classList.add('hiddenScrollX-forced'); } } else { if (layoutManager.desktop && !o.hideScrollbar) { nativeScrollElement.classList.add('scrollY'); } else { nativeScrollElement.classList.add('scrollY'); nativeScrollElement.classList.add('hiddenScrollY'); if (layoutManager.tv && o.allowNativeSmoothScroll !== false) { nativeScrollElement.classList.add('smoothScrollY'); } } if (o.forceHideScrollbars) { nativeScrollElement.classList.add('hiddenScrollY-forced'); } } } else { frame.style.overflow = 'hidden'; slideeElement.style['will-change'] = 'transform'; slideeElement.style.transition = 'transform ' + o.speed + 'ms ease-out'; if (o.horizontal) { slideeElement.classList.add('animatedScrollX'); } else { slideeElement.classList.add('animatedScrollY'); } } scrollSource.setAttribute(`data-scroll-mode-${o.horizontal ? 'x' : 'y'}`, 'custom'); if (transform || layoutManager.tv) { // This can prevent others from being able to listen to mouse events dom.addEventListener(dragSourceElement, 'mousedown', dragInitSlidee, { //passive: true }); } initFrameResizeObserver(); if (transform) { dom.addEventListener(dragSourceElement, 'touchstart', dragInitSlidee, { passive: true }); if (!o.horizontal) { dom.addEventListener(frame, 'scroll', resetScroll, { passive: true }); } if (o.mouseWheel) { // Scrolling navigation dom.addEventListener(scrollSource, wheelEvent, scrollHandler, { passive: true }); } } else if (o.horizontal && o.mouseWheel) { // Don't bind to mouse events with vertical scroll since the mouse wheel can handle this natively // Scrolling navigation dom.addEventListener(scrollSource, wheelEvent, scrollHandler, { passive: true }); } dom.addEventListener(frame, 'click', onFrameClick, { passive: true, capture: true }); // Mark instance as initialized self.initialized = 1; // Load load(true); // Return instance return self; }; }; /** * Slide SLIDEE by amount of pixels. * * @param {Int} delta Pixels/Items. Positive means forward, negative means backward. * @param {Bool} immediate Reposition immediately without an animation. * * @return {Void} */ scrollerFactory.prototype.slideBy = function (delta, immediate) { if (!delta) { return; } this.slideTo(this._pos.dest + delta, immediate); }; /** * Core method for handling `toLocation` methods. * * @param {String} location * @param {Mixed} item * @param {Bool} immediate * * @return {Void} */ scrollerFactory.prototype.to = function (location, item, immediate) { // Optional arguments logic if (type(item) === 'boolean') { immediate = item; item = undefined; } if (item === undefined) { this.slideTo(this._pos[location], immediate); } else { const itemPos = this.getPos(item); if (itemPos) { this.slideTo(itemPos[location], immediate, itemPos); } } }; /** * Animate element or the whole SLIDEE to the start of the frame. * * @param {Mixed} item Item DOM element, or index starting at 0. Omitting will animate SLIDEE. * @param {Bool} immediate Reposition immediately without an animation. * * @return {Void} */ scrollerFactory.prototype.toStart = function (item, immediate) { this.to('start', item, immediate); }; /** * Animate element or the whole SLIDEE to the end of the frame. * * @param {Mixed} item Item DOM element, or index starting at 0. Omitting will animate SLIDEE. * @param {Bool} immediate Reposition immediately without an animation. * * @return {Void} */ scrollerFactory.prototype.toEnd = function (item, immediate) { this.to('end', item, immediate); }; /** * Animate element or the whole SLIDEE to the center of the frame. * * @param {Mixed} item Item DOM element, or index starting at 0. Omitting will animate SLIDEE. * @param {Bool} immediate Reposition immediately without an animation. * * @return {Void} */ scrollerFactory.prototype.toCenter = function (item, immediate) { this.to('center', item, immediate); }; scrollerFactory.create = function (frame, options) { // eslint-disable-next-line new-cap const instance = new scrollerFactory(frame, options); return Promise.resolve(instance); }; export default scrollerFactory;