/* eslint-disable indent */ /** * Module for controlling scroll behavior. * @module components/scrollManager */ import dom from "dom"; import browser from "browser"; import layoutManager from "layoutManager"; /** * Scroll time in ms. */ const ScrollTime = 270; /** * Epsilon for comparing values. */ const Epsilon = 1e-6; // FIXME: Need to scroll to top of page to fully show the top menu. This can be solved by some marker of top most elements or their containers /** * Returns minimum vertical scroll. * Scroll less than that value will be zeroed. * * @return {number} Minimum vertical scroll. */ function minimumScrollY() { const topMenu = document.querySelector(".headerTop"); if (topMenu) { return topMenu.clientHeight; } return 0; } const supportsSmoothScroll = "scrollBehavior" in document.documentElement.style; let supportsScrollToOptions = false; try { const elem = document.createElement("div"); const opts = Object.defineProperty({}, "behavior", { // eslint-disable-next-line getter-return get: function () { supportsScrollToOptions = true; } }); elem.scrollTo(opts); } catch (e) { console.error("error checking ScrollToOptions support"); } /** * Returns value clamped by range [min, max]. * * @param {number} value - Clamped value. * @param {number} min - Begining of range. * @param {number} max - Ending of range. * @return {number} Clamped value. */ function clamp(value, min, max) { return value <= min ? min : value >= max ? max : value; } /** * Returns the required delta to fit range 1 into range 2. * In case of range 1 is bigger than range 2 returns delta to fit most out of range part. * * @param {number} begin1 - Begining of range 1. * @param {number} end1 - Ending of range 1. * @param {number} begin2 - Begining of range 2. * @param {number} end2 - Ending of range 2. * @return {number} Delta: <0 move range1 to the left, >0 - to the right. */ function fitRange(begin1, end1, begin2, end2) { const delta1 = begin1 - begin2; const delta2 = end2 - end1; if (delta1 < 0 && delta1 < delta2) { return -delta1; } else if (delta2 < 0) { return delta2; } return 0; } /** * Ease value. * * @param {number} t - Value in range [0, 1]. * @return {number} Eased value in range [0, 1]. */ function ease(t) { return t*(2 - t); // easeOutQuad === ease-out } /** * @typedef {Object} Rect * @property {number} left - X coordinate of top-left corner. * @property {number} top - Y coordinate of top-left corner. * @property {number} width - Width. * @property {number} height - Height. */ /** * Document scroll wrapper helps to unify scrolling and fix issues of some browsers. * * webOS 2 Browser: scrolls documentElement (and window), but body has a scroll size * * webOS 3 Browser: scrolls body (and window) * * webOS 4 Native: scrolls body (and window); has a document.scrollingElement * * Tizen 4 Browser/Native: scrolls body (and window); has a document.scrollingElement * * Tizen 5 Browser/Native: scrolls documentElement (and window); has a document.scrollingElement */ class DocumentScroller { /** * Horizontal scroll position. * @type {number} */ get scrollLeft() { return window.pageXOffset; } set scrollLeft(val) { window.scroll(val, window.pageYOffset); } /** * Vertical scroll position. * @type {number} */ get scrollTop() { return window.pageYOffset; } set scrollTop(val) { window.scroll(window.pageXOffset, val); } /** * Horizontal scroll size (scroll width). * @type {number} */ get scrollWidth() { return Math.max(document.documentElement.scrollWidth, document.body.scrollWidth); } /** * Vertical scroll size (scroll height). * @type {number} */ get scrollHeight() { return Math.max(document.documentElement.scrollHeight, document.body.scrollHeight); } /** * Horizontal client size (client width). * @type {number} */ get clientWidth() { return Math.min(document.documentElement.clientWidth, document.body.clientWidth); } /** * Vertical client size (client height). * @type {number} */ get clientHeight() { return Math.min(document.documentElement.clientHeight, document.body.clientHeight); } /** * Returns bounding client rect. * @return {Rect} Bounding client rect. */ getBoundingClientRect() { // Make valid viewport coordinates: documentElement.getBoundingClientRect returns rect of entire document relative to viewport return { left: 0, top: 0, width: this.clientWidth, height: this.clientHeight }; } /** * Scrolls window. * @param {...mixed} args See window.scrollTo. */ scrollTo() { window.scrollTo.apply(window, arguments); } } /** * Default (document) scroller. */ const documentScroller = new DocumentScroller(); /** * Returns parent element that can be scrolled. If no such, returns document scroller. * * @param {HTMLElement} element - Element for which parent is being searched. * @param {boolean} vertical - Search for vertical scrollable parent. * @param {HTMLElement|DocumentScroller} Parent element that can be scrolled or document scroller. */ function getScrollableParent(element, vertical) { if (element) { let nameScroll = "scrollWidth"; let nameClient = "clientWidth"; let nameClass = "scrollX"; if (vertical) { nameScroll = "scrollHeight"; nameClient = "clientHeight"; nameClass = "scrollY"; } let parent = element.parentElement; while (parent) { // Skip 'emby-scroller' because it scrolls by itself if (!parent.classList.contains("emby-scroller") && parent[nameScroll] > parent[nameClient] && parent.classList.contains(nameClass)) { return parent; } parent = parent.parentElement; } } return documentScroller; } /** * @typedef {Object} ScrollerData * @property {number} scrollPos - Current scroll position. * @property {number} scrollSize - Scroll size. * @property {number} clientSize - Client size. */ /** * Returns scroller data for specified orientation. * * @param {HTMLElement} scroller - Scroller. * @param {boolean} vertical - Vertical scroller data. * @return {ScrollerData} Scroller data. */ function getScrollerData(scroller, vertical) { let data = {}; if (!vertical) { data.scrollPos = scroller.scrollLeft; data.scrollSize = scroller.scrollWidth; data.clientSize = scroller.clientWidth; } else { data.scrollPos = scroller.scrollTop; data.scrollSize = scroller.scrollHeight; data.clientSize = scroller.clientHeight; } return data; } /** * Returns position of child of scroller for specified orientation. * * @param {HTMLElement} scroller - Scroller. * @param {HTMLElement} element - Child of scroller. * @param {boolean} vertical - Vertical scroll. * @return {number} Child position. */ function getScrollerChildPos(scroller, element, vertical) { const elementRect = element.getBoundingClientRect(); const scrollerRect = scroller.getBoundingClientRect(); if (!vertical) { return scroller.scrollLeft + elementRect.left - scrollerRect.left; } else { return scroller.scrollTop + elementRect.top - scrollerRect.top; } } /** * Returns scroll position for element. * * @param {ScrollerData} scrollerData - Scroller data. * @param {number} elementPos - Child element position. * @param {number} elementSize - Child element size. * @param {boolean} centered - Scroll to center. * @return {number} Scroll position. */ function calcScroll(scrollerData, elementPos, elementSize, centered) { const maxScroll = scrollerData.scrollSize - scrollerData.clientSize; let scroll; if (centered) { scroll = elementPos + (elementSize - scrollerData.clientSize) / 2; } else { const delta = fitRange(elementPos, elementPos + elementSize - 1, scrollerData.scrollPos, scrollerData.scrollPos + scrollerData.clientSize - 1); scroll = scrollerData.scrollPos - delta; } return clamp(Math.round(scroll), 0, maxScroll); } /** * Calls scrollTo function in proper way. * * @param {HTMLElement} scroller - Scroller. * @param {ScrollToOptions} options - Scroll options. */ function scrollToHelper(scroller, options) { if ("scrollTo" in scroller) { if (!supportsScrollToOptions) { const scrollX = (options.left !== undefined ? options.left : scroller.scrollLeft); const scrollY = (options.top !== undefined ? options.top : scroller.scrollTop); scroller.scrollTo(scrollX, scrollY); } else { scroller.scrollTo(options); } } else if ("scrollLeft" in scroller) { if (options.left !== undefined) { scroller.scrollLeft = options.left; } if (options.top !== undefined) { scroller.scrollTop = options.top; } } } /** * Performs built-in scroll. * * @param {HTMLElement} xScroller - Horizontal scroller. * @param {number} scrollX - Horizontal coordinate. * @param {HTMLElement} yScroller - Vertical scroller. * @param {number} scrollY - Vertical coordinate. * @param {boolean} smooth - Smooth scrolling. */ function builtinScroll(xScroller, scrollX, yScroller, scrollY, smooth) { const scrollBehavior = smooth ? "smooth" : "instant"; if (xScroller !== yScroller) { scrollToHelper(xScroller, {left: scrollX, behavior: scrollBehavior}); scrollToHelper(yScroller, {top: scrollY, behavior: scrollBehavior}); } else { scrollToHelper(xScroller, {left: scrollX, top: scrollY, behavior: scrollBehavior}); } } /** * Requested frame for animated scroll. */ let scrollTimer; /** * Resets scroll timer to stop scrolling. */ function resetScrollTimer() { cancelAnimationFrame(scrollTimer); scrollTimer = undefined; } /** * Performs animated scroll. * * @param {HTMLElement} xScroller - Horizontal scroller. * @param {number} scrollX - Horizontal coordinate. * @param {HTMLElement} yScroller - Vertical scroller. * @param {number} scrollY - Vertical coordinate. */ function animateScroll(xScroller, scrollX, yScroller, scrollY) { const ox = xScroller.scrollLeft; const oy = yScroller.scrollTop; const dx = scrollX - ox; const dy = scrollY - oy; if (Math.abs(dx) < Epsilon && Math.abs(dy) < Epsilon) { return; } let start; function scrollAnim(currentTimestamp) { start = start || currentTimestamp; let k = Math.min(1, (currentTimestamp - start) / ScrollTime); if (k === 1) { resetScrollTimer(); builtinScroll(xScroller, scrollX, yScroller, scrollY, false); return; } k = ease(k); const x = ox + dx*k; const y = oy + dy*k; builtinScroll(xScroller, x, yScroller, y, false); scrollTimer = requestAnimationFrame(scrollAnim); } scrollTimer = requestAnimationFrame(scrollAnim); } /** * Performs scroll. * * @param {HTMLElement} xScroller - Horizontal scroller. * @param {number} scrollX - Horizontal coordinate. * @param {HTMLElement} yScroller - Vertical scroller. * @param {number} scrollY - Vertical coordinate. * @param {boolean} smooth - Smooth scrolling. */ function doScroll(xScroller, scrollX, yScroller, scrollY, smooth) { resetScrollTimer(); if (smooth && useAnimatedScroll()) { animateScroll(xScroller, scrollX, yScroller, scrollY); } else { builtinScroll(xScroller, scrollX, yScroller, scrollY, smooth); } } /** * Returns true if smooth scroll must be used. */ function useSmoothScroll() { if (browser.tizen) { return true; } return false; } /** * Returns true if animated implementation of smooth scroll must be used. */ function useAnimatedScroll() { // Add block to force using (or not) of animated implementation return !supportsSmoothScroll; } /** * Returns true if scroll manager is enabled. */ export function isEnabled() { return layoutManager.tv; } /** * Scrolls the document to a given position. * * @param {number} scrollX - Horizontal coordinate. * @param {number} scrollY - Vertical coordinate. * @param {boolean} [smooth=false] - Smooth scrolling. */ export function scrollTo(scrollX, scrollY, smooth) { smooth = !!smooth; // Scroller is document itself by default const scroller = getScrollableParent(null, false); const xScrollerData = getScrollerData(scroller, false); const yScrollerData = getScrollerData(scroller, true); scrollX = clamp(Math.round(scrollX), 0, xScrollerData.scrollSize - xScrollerData.clientSize); scrollY = clamp(Math.round(scrollY), 0, yScrollerData.scrollSize - yScrollerData.clientSize); doScroll(scroller, scrollX, scroller, scrollY, smooth); } /** * Scrolls the document to a given element. * * @param {HTMLElement} element - Target element of scroll task. * @param {boolean} [smooth=false] - Smooth scrolling. */ export function scrollToElement(element, smooth) { smooth = !!smooth; let scrollCenterX = true; let scrollCenterY = true; const offsetParent = element.offsetParent; // In Firefox offsetParent.offsetParent is BODY const isFixed = offsetParent && (!offsetParent.offsetParent || window.getComputedStyle(offsetParent).position === "fixed"); // Scroll fixed elements to nearest edge (or do not scroll at all) if (isFixed) { scrollCenterX = scrollCenterY = false; } const xScroller = getScrollableParent(element, false); const yScroller = getScrollableParent(element, true); const elementRect = element.getBoundingClientRect(); const xScrollerData = getScrollerData(xScroller, false); const yScrollerData = getScrollerData(yScroller, true); const xPos = getScrollerChildPos(xScroller, element, false); const yPos = getScrollerChildPos(yScroller, element, true); const scrollX = calcScroll(xScrollerData, xPos, elementRect.width, scrollCenterX); let scrollY = calcScroll(yScrollerData, yPos, elementRect.height, scrollCenterY); // HACK: Scroll to top for top menu because it is hidden // FIXME: Need a marker to scroll top/bottom if (isFixed && elementRect.bottom < 0) { scrollY = 0; } // HACK: Ensure we are at the top // FIXME: Need a marker to scroll top/bottom if (scrollY < minimumScrollY() && yScroller === documentScroller) { scrollY = 0; } doScroll(xScroller, scrollX, yScroller, scrollY, smooth); } if (isEnabled()) { dom.addEventListener(window, "focusin", function(e) { setTimeout(function() { scrollToElement(e.target, useSmoothScroll()); }, 0); }, {capture: true}); } /* eslint-enable indent */ export default { isEnabled: isEnabled, scrollTo: scrollTo, scrollToElement: scrollToElement };