mirror of
https://github.com/jellyfin/jellyfin-web.git
synced 2024-11-20 04:18:19 -07:00
554 lines
17 KiB
JavaScript
554 lines
17 KiB
JavaScript
/* 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
|
|
};
|