diff --git a/src/components/focusManager.js b/src/components/focusManager.js index ec23a151c2..56ff324e23 100644 --- a/src/components/focusManager.js +++ b/src/components/focusManager.js @@ -1,4 +1,4 @@ -define(['dom'], function (dom) { +define(['dom', 'scrollManager'], function (dom, scrollManager) { 'use strict'; var scopes = []; @@ -40,7 +40,7 @@ define(['dom'], function (dom) { try { element.focus({ - preventScroll: false + preventScroll: scrollManager.isEnabled() }); } catch (err) { console.log('Error in focusManager.autoFocus: ' + err); diff --git a/src/components/polyfills/focusPreventScroll.js b/src/components/polyfills/focusPreventScroll.js new file mode 100644 index 0000000000..6511c0426c --- /dev/null +++ b/src/components/polyfills/focusPreventScroll.js @@ -0,0 +1,41 @@ +// Polyfill to add support for preventScroll by focus function + +if (HTMLElement.prototype.nativeFocus === undefined) { + (function () { + var supportsPreventScrollOption = false; + try { + var focusElem = document.createElement("div"); + + focusElem.addEventListener("focus", function(event) { + event.preventDefault(); + event.stopPropagation(); + }, true); + + var opts = Object.defineProperty({}, "preventScroll", { + get: function () { + supportsPreventScrollOption = true; + } + }); + + focusElem.focus(opts); + } catch (e) { + console.log("error checking preventScroll support"); + } + + if (!supportsPreventScrollOption) { + HTMLElement.prototype.nativeFocus = HTMLElement.prototype.focus; + + HTMLElement.prototype.focus = function(options) { + var scrollX = window.scrollX; + var scrollY = window.scrollY; + + this.nativeFocus(); + + // Restore window scroll if preventScroll + if (options && options.preventScroll) { + window.scroll(scrollX, scrollY); + } + }; + } + })(); +} diff --git a/src/components/scrollManager.js b/src/components/scrollManager.js new file mode 100644 index 0000000000..9f7035d528 --- /dev/null +++ b/src/components/scrollManager.js @@ -0,0 +1,499 @@ +define(["dom", "browser", "layoutManager"], function (dom, browser, layoutManager) { + "use strict"; + + /** + * Scroll time in ms. + */ + var ScrollTime = 270; + + /** + * Epsilon for comparing values. + */ + var 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() { + var topMenu = document.querySelector(".headerTop"); + if (topMenu) { + return topMenu.clientHeight; + } + return 0; + } + + var supportsSmoothScroll = "scrollBehavior" in document.documentElement.style; + + var supportsScrollToOptions = false; + try { + var elem = document.createElement("div"); + + var opts = Object.defineProperty({}, "behavior", { + get: function () { + supportsScrollToOptions = true; + } + }); + + elem.scrollTo(opts); + } catch (e) { + console.log("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) { + var delta1 = begin1 - begin2; + var 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 + } + + /** + * 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 + */ + function DocumentScroller() { + } + + DocumentScroller.prototype = { + get scrollLeft() { + return window.pageXOffset; + }, + set scrollLeft(val) { + window.scroll(val, window.pageYOffset); + }, + + get scrollTop() { + return window.pageYOffset; + }, + set scrollTop(val) { + window.scroll(window.pageXOffset, val); + }, + + get scrollWidth() { + return Math.max(document.documentElement.scrollWidth, document.body.scrollWidth); + }, + + get scrollHeight() { + return Math.max(document.documentElement.scrollHeight, document.body.scrollHeight); + }, + + get clientWidth() { + return Math.min(document.documentElement.clientWidth, document.body.clientWidth); + }, + + get clientHeight() { + return Math.min(document.documentElement.clientHeight, document.body.clientHeight); + }, + + getBoundingClientRect: function() { + // 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 + }; + }, + + scrollTo: function() { + window.scrollTo.apply(window, arguments); + } + }; + + var documentScroller = new DocumentScroller(); + + /** + * Returns parent element that can be scrolled. If no such, returns documentElement. + * + * @param {HTMLElement} element element for which parent is being searched + * @param {boolean} vertical search for vertical scrollable parent + */ + function getScrollableParent(element, vertical) { + if (element) { + var parent = element.parentElement; + + while (parent) { + if ((!vertical && parent.scrollWidth > parent.clientWidth && parent.classList.contains("scrollX")) || + (vertical && parent.scrollHeight > parent.clientHeight && parent.classList.contains("scrollY"))) { + 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 scroll data for specified orientation. + * + * @param {HTMLElement} scroller scroller + * @param {boolean} vertical vertical scroll data + * @return {ScrollerData} scroll data + */ + function getScrollerData(scroller, vertical) { + var 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) { + var elementRect = element.getBoundingClientRect(); + var 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) { + var maxScroll = scrollerData.scrollSize - scrollerData.clientSize; + + var scroll; + + if (centered) { + scroll = elementPos + (elementSize - scrollerData.clientSize) / 2; + } else { + var 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) { + var scrollX = (options.left !== undefined ? options.left : scroller.scrollLeft); + var 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) { + var 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}); + } + } + + var 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) { + + var ox = xScroller.scrollLeft; + var oy = yScroller.scrollTop; + var dx = scrollX - ox; + var dy = scrollY - oy; + + if (Math.abs(dx) < Epsilon && Math.abs(dy) < Epsilon) { + return; + } + + var start; + + function scrollAnim(currentTimestamp) { + + start = start || currentTimestamp; + + var k = Math.min(1, (currentTimestamp - start) / ScrollTime); + + if (k === 1) { + resetScrollTimer(); + builtinScroll(xScroller, scrollX, yScroller, scrollY, false); + return; + } + + k = ease(k); + + var x = ox + dx*k; + var 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. + */ + var isEnabled = function() { + + if (!layoutManager.tv) { + return false; + } + + if (browser.tizen) { + return true; + } + + if (browser.web0s) { + return true; + } + + return false; + }; + + /** + * Scrolls the document to a given position. + * + * @param {number} scrollX horizontal coordinate + * @param {number} scrollY vertical coordinate + * @param {boolean} [smooth=false] smooth scrolling + */ + var scrollTo = function(scrollX, scrollY, smooth) { + + smooth = !!smooth; + + // Scroller is document itself by default + var scroller = getScrollableParent(null, false); + + var xScrollerData = getScrollerData(scroller, false); + var 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 + */ + var scrollToElement = function(element, smooth) { + + smooth = !!smooth; + + var scrollCenterX = true; + var scrollCenterY = true; + + var offsetParent = element.offsetParent; + + // In Firefox offsetParent.offsetParent is BODY + var 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; + } + + var xScroller = getScrollableParent(element, false); + var yScroller = getScrollableParent(element, true); + + var elementRect = element.getBoundingClientRect(); + + var xScrollerData = getScrollerData(xScroller, false); + var yScrollerData = getScrollerData(yScroller, true); + + var xPos = getScrollerChildPos(xScroller, element, false); + var yPos = getScrollerChildPos(yScroller, element, true); + + var scrollX = calcScroll(xScrollerData, xPos, elementRect.width, scrollCenterX); + var 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}); + } + + return { + isEnabled: isEnabled, + scrollTo: scrollTo, + scrollToElement: scrollToElement + }; +}); diff --git a/src/scripts/site.js b/src/scripts/site.js index 840d9e1b48..828b882e7e 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -477,6 +477,7 @@ var AppInfo = {}; require(["keyboardnavigation"], function(keyboardnavigation) { keyboardnavigation.enable(); }); + require(["focusPreventScroll"]); require(["autoFocuser"], function(autoFocuser) { autoFocuser.enable(); }); @@ -842,6 +843,7 @@ var AppInfo = {}; }); define("slideshow", [componentsPath + "/slideshow/slideshow"], returnFirstDependency); define("objectassign", [componentsPath + "/polyfills/objectassign"], returnFirstDependency); + define("focusPreventScroll", [componentsPath + "/polyfills/focusPreventScroll"], returnFirstDependency); define("userdataButtons", [componentsPath + "/userdatabuttons/userdatabuttons"], returnFirstDependency); define("emby-playstatebutton", [componentsPath + "/userdatabuttons/emby-playstatebutton"], returnFirstDependency); define("emby-ratingbutton", [componentsPath + "/userdatabuttons/emby-ratingbutton"], returnFirstDependency); @@ -866,6 +868,7 @@ var AppInfo = {}; define("serverNotifications", [componentsPath + "/serverNotifications/serverNotifications"], returnFirstDependency); define("skinManager", [componentsPath + "/skinManager"], returnFirstDependency); define("keyboardnavigation", [componentsPath + "/keyboardnavigation"], returnFirstDependency); + define("scrollManager", [componentsPath + "/scrollManager"], returnFirstDependency); define("autoFocuser", [componentsPath + "/autoFocuser"], returnFirstDependency); define("connectionManager", [], function () { return ConnectionManager;