/* eslint-disable indent */ /** * Module for controlling scroll behavior. * @module components/scrollManager */ import dom from '../scripts/dom'; import browser from '../scripts/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) { if (value <= min) { return min; } else if (value >= max) { return max; } return 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 attribute value. * @param {string} attributeName - Attibute name. * @return {string} Attibute value. */ getAttribute(attributeName) { return document.body.getAttribute(attributeName); } /** * 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(); const scrollerHints = { x: { nameScroll: 'scrollWidth', nameClient: 'clientWidth', nameStyle: 'overflowX', nameScrollMode: 'data-scroll-mode-x' }, y: { nameScroll: 'scrollHeight', nameClient: 'clientHeight', nameStyle: 'overflowY', nameScrollMode: 'data-scroll-mode-y' } }; /** * 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) { const scrollerHint = vertical ? scrollerHints.y : scrollerHints.x; let parent = element.parentElement; while (parent && parent !== document.body) { const scrollMode = parent.getAttribute(scrollerHint.nameScrollMode); // Stop on self-scrolled containers if (scrollMode === 'custom') { return parent; } const styles = window.getComputedStyle(parent); // Stop on fixed parent if (styles.position === 'fixed') { return parent; } const overflow = styles[scrollerHint.nameStyle]; if (overflow === 'scroll' || overflow === 'auto' && parent[scrollerHint.nameScroll] > parent[scrollerHint.nameClient]) { 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. * @property {string} mode - Scrolling mode. * @property {boolean} custom - Custom scrolling mode. */ /** * Returns scroller data for specified orientation. * * @param {HTMLElement} scroller - Scroller. * @param {boolean} vertical - Vertical scroller data. * @return {ScrollerData} Scroller data. */ function getScrollerData(scroller, vertical) { const data = {}; if (!vertical) { data.scrollPos = scroller.scrollLeft; data.scrollSize = scroller.scrollWidth; data.clientSize = scroller.clientWidth; data.mode = scroller.getAttribute(scrollerHints.x.nameScrollMode); } else { data.scrollPos = scroller.scrollTop; data.scrollSize = scroller.scrollHeight; data.clientSize = scroller.clientHeight; data.mode = scroller.getAttribute(scrollerHints.y.nameScrollMode); } data.custom = data.mode === 'custom'; 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) { if (xScroller) { scrollToHelper(xScroller, { left: scrollX, behavior: scrollBehavior }); } if (yScroller) { scrollToHelper(yScroller, { top: scrollY, behavior: scrollBehavior }); } } else if (xScroller) { 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 ? xScroller.scrollLeft : scrollX; const oy = yScroller ? yScroller.scrollTop : scrollY; 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() { return !!browser.tizen; } /** * 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; } let xScroller = getScrollableParent(element, false); let yScroller = getScrollableParent(element, true); const xScrollerData = getScrollerData(xScroller, false); const yScrollerData = getScrollerData(yScroller, true); // Exit, since we have no control over scrolling in this container if (xScroller === yScroller && (xScrollerData.custom || yScrollerData.custom)) { return; } // Exit, since we have no control over scrolling in these containers if (xScrollerData.custom && yScrollerData.custom) { return; } const elementRect = element.getBoundingClientRect(); let scrollX = 0; let scrollY = 0; if (!xScrollerData.custom) { const xPos = getScrollerChildPos(xScroller, element, false); scrollX = calcScroll(xScrollerData, xPos, elementRect.width, scrollCenterX); } else { xScroller = null; } if (!yScrollerData.custom) { const yPos = getScrollerChildPos(yScroller, element, true); 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; } } else { yScroller = null; } 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 };