1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00
jellyfin-web/src/components/scrollManager.js

610 lines
19 KiB
JavaScript
Raw Normal View History

2020-03-28 19:42:18 +03:00
/* eslint-disable indent */
/**
* Module for controlling scroll behavior.
* @module components/scrollManager
*/
2020-08-14 08:46:34 +02:00
import dom from '../scripts/dom';
import browser from '../scripts/browser';
import layoutManager from './layoutManager';
/**
* Scroll time in ms.
*/
2020-03-28 19:42:18 +03:00
const ScrollTime = 270;
/**
* Epsilon for comparing values.
*/
2020-03-28 19:42:18 +03:00
const Epsilon = 1e-6;
2019-10-20 14:53:12 +03:00
// 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.
*
2020-03-31 18:59:12 +03:00
* @return {number} Minimum vertical scroll.
*/
function minimumScrollY() {
2020-05-04 12:44:12 +02:00
const topMenu = document.querySelector('.headerTop');
2019-11-27 14:30:56 +03:00
if (topMenu) {
return topMenu.clientHeight;
}
2019-11-27 14:30:56 +03:00
return 0;
}
2020-05-04 12:44:12 +02:00
const supportsSmoothScroll = 'scrollBehavior' in document.documentElement.style;
2020-03-28 19:42:18 +03:00
let supportsScrollToOptions = false;
try {
2020-05-04 12:44:12 +02:00
const elem = document.createElement('div');
2020-05-04 12:44:12 +02:00
const opts = Object.defineProperty({}, 'behavior', {
// eslint-disable-next-line getter-return
get: function () {
supportsScrollToOptions = true;
}
});
elem.scrollTo(opts);
2019-11-27 14:13:16 +03:00
} catch (e) {
2020-05-04 12:44:12 +02:00
console.error('error checking ScrollToOptions support');
2019-11-27 14:13:16 +03:00
}
/**
* Returns value clamped by range [min, max].
*
2020-03-31 18:59:12 +03:00
* @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.
*
2020-03-31 18:59:12 +03:00
* @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) {
2020-03-28 19:42:18 +03:00
const delta1 = begin1 - begin2;
const delta2 = end2 - end1;
if (delta1 < 0 && delta1 < delta2) {
return -delta1;
} else if (delta2 < 0) {
return delta2;
}
return 0;
2019-11-27 14:13:16 +03:00
}
/**
* Ease value.
*
2020-03-31 18:59:12 +03:00
* @param {number} t - Value in range [0, 1].
* @return {number} Eased value in range [0, 1].
*/
function ease(t) {
2020-05-17 18:17:47 +02:00
return t * (2 - t); // easeOutQuad === ease-out
}
2020-03-31 18:59:12 +03:00
/**
* @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
*/
2020-03-28 19:42:18 +03:00
class DocumentScroller {
2020-03-31 18:59:12 +03:00
/**
* Horizontal scroll position.
* @type {number}
*/
get scrollLeft() {
return window.pageXOffset;
2020-03-28 19:42:18 +03:00
}
set scrollLeft(val) {
window.scroll(val, window.pageYOffset);
2020-03-28 19:42:18 +03:00
}
2020-03-31 18:59:12 +03:00
/**
* Vertical scroll position.
* @type {number}
*/
get scrollTop() {
return window.pageYOffset;
2020-03-28 19:42:18 +03:00
}
set scrollTop(val) {
window.scroll(window.pageXOffset, val);
2020-03-28 19:42:18 +03:00
}
2020-03-31 18:59:12 +03:00
/**
* Horizontal scroll size (scroll width).
* @type {number}
*/
get scrollWidth() {
return Math.max(document.documentElement.scrollWidth, document.body.scrollWidth);
2020-03-28 19:42:18 +03:00
}
2020-03-31 18:59:12 +03:00
/**
* Vertical scroll size (scroll height).
* @type {number}
*/
get scrollHeight() {
return Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
2020-03-28 19:42:18 +03:00
}
2020-03-31 18:59:12 +03:00
/**
* Horizontal client size (client width).
* @type {number}
*/
get clientWidth() {
return Math.min(document.documentElement.clientWidth, document.body.clientWidth);
2020-03-28 19:42:18 +03:00
}
2020-03-31 18:59:12 +03:00
/**
* Vertical client size (client height).
* @type {number}
*/
get clientHeight() {
return Math.min(document.documentElement.clientHeight, document.body.clientHeight);
2020-03-28 19:42:18 +03:00
}
2021-04-24 21:30:40 +03:00
/**
* Returns attribute value.
* @param {string} attributeName - Attibute name.
* @return {string} Attibute value.
*/
getAttribute(attributeName) {
return document.body.getAttribute(attributeName);
}
2020-03-31 18:59:12 +03:00
/**
* Returns bounding client rect.
* @return {Rect} Bounding client rect.
*/
2020-03-28 19:42:18 +03:00
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
};
2020-03-28 19:42:18 +03:00
}
2020-03-31 18:59:12 +03:00
/**
* Scrolls window.
* @param {...mixed} args See window.scrollTo.
*/
2020-03-28 19:42:18 +03:00
scrollTo() {
window.scrollTo.apply(window, arguments);
}
2020-03-28 19:42:18 +03:00
}
2020-03-28 19:42:18 +03:00
/**
* Default (document) scroller.
*/
const documentScroller = new DocumentScroller();
const scrollerHints = {
x: {
nameScroll: 'scrollWidth',
nameClient: 'clientWidth',
2021-04-24 21:30:40 +03:00
nameStyle: 'overflowX',
nameScrollMode: 'data-scroll-mode-x'
},
y: {
nameScroll: 'scrollHeight',
nameClient: 'clientHeight',
2021-04-24 21:30:40 +03:00
nameStyle: 'overflowY',
nameScrollMode: 'data-scroll-mode-y'
}
};
/**
2020-03-31 18:59:12 +03:00
* Returns parent element that can be scrolled. If no such, returns document scroller.
*
2020-03-31 18:59:12 +03:00
* @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;
2020-02-16 22:06:37 +03:00
2020-03-28 19:42:18 +03:00
let parent = element.parentElement;
while (parent && parent !== document.body) {
2021-04-24 21:30:40 +03:00
const scrollMode = parent.getAttribute(scrollerHint.nameScrollMode);
// Stop on self-scrolled containers
if (scrollMode === 'custom') {
return parent;
}
const styles = window.getComputedStyle(parent);
2021-04-24 21:30:40 +03:00
// Stop on fixed parent
if (styles.position === 'fixed') {
return parent;
}
2021-04-24 21:30:40 +03:00
const overflow = styles[scrollerHint.nameStyle];
2021-04-24 21:30:40 +03:00
if (overflow === 'scroll' || overflow === 'auto' && parent[scrollerHint.nameScroll] > parent[scrollerHint.nameClient]) {
return parent;
}
parent = parent.parentElement;
}
}
return documentScroller;
}
/**
* @typedef {Object} ScrollerData
2020-03-31 18:59:12 +03:00
* @property {number} scrollPos - Current scroll position.
* @property {number} scrollSize - Scroll size.
* @property {number} clientSize - Client size.
2021-04-24 21:30:40 +03:00
* @property {string} mode - Scrolling mode.
* @property {boolean} custom - Custom scrolling mode.
*/
/**
2020-03-31 18:59:12 +03:00
* Returns scroller data for specified orientation.
*
2020-03-31 18:59:12 +03:00
* @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;
2021-04-24 21:30:40 +03:00
data.mode = scroller.getAttribute(scrollerHints.x.nameScrollMode);
} else {
data.scrollPos = scroller.scrollTop;
data.scrollSize = scroller.scrollHeight;
data.clientSize = scroller.clientHeight;
2021-04-24 21:30:40 +03:00
data.mode = scroller.getAttribute(scrollerHints.y.nameScrollMode);
}
2021-04-24 21:30:40 +03:00
data.custom = data.mode === 'custom';
return data;
}
/**
* Returns position of child of scroller for specified orientation.
*
2020-03-31 18:59:12 +03:00
* @param {HTMLElement} scroller - Scroller.
* @param {HTMLElement} element - Child of scroller.
* @param {boolean} vertical - Vertical scroll.
* @return {number} Child position.
*/
function getScrollerChildPos(scroller, element, vertical) {
2020-03-28 19:42:18 +03:00
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.
*
2020-03-31 18:59:12 +03:00
* @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) {
2020-03-28 19:42:18 +03:00
const maxScroll = scrollerData.scrollSize - scrollerData.clientSize;
2020-03-28 19:42:18 +03:00
let scroll;
if (centered) {
scroll = elementPos + (elementSize - scrollerData.clientSize) / 2;
} else {
2020-03-28 19:42:18 +03:00
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.
*
2020-03-31 18:59:12 +03:00
* @param {HTMLElement} scroller - Scroller.
* @param {ScrollToOptions} options - Scroll options.
*/
function scrollToHelper(scroller, options) {
2020-05-04 12:44:12 +02:00
if ('scrollTo' in scroller) {
if (!supportsScrollToOptions) {
2020-03-28 19:42:18 +03:00
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);
}
2020-05-04 12:44:12 +02:00
} 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.
*
2020-03-31 18:59:12 +03:00
* @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) {
2020-05-04 12:44:12 +02:00
const scrollBehavior = smooth ? 'smooth' : 'instant';
if (xScroller !== yScroller) {
2021-04-24 21:30:40 +03:00
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});
}
}
2020-03-31 18:59:12 +03:00
/**
* Requested frame for animated scroll.
*/
2020-03-28 19:42:18 +03:00
let scrollTimer;
2019-10-20 14:53:12 +03:00
/**
* Resets scroll timer to stop scrolling.
*/
function resetScrollTimer() {
cancelAnimationFrame(scrollTimer);
scrollTimer = undefined;
}
/**
* Performs animated scroll.
*
2020-03-31 18:59:12 +03:00
* @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) {
2021-04-24 21:30:40 +03:00
const ox = xScroller ? xScroller.scrollLeft : scrollX;
const oy = yScroller ? yScroller.scrollTop : scrollY;
2020-03-28 19:42:18 +03:00
const dx = scrollX - ox;
const dy = scrollY - oy;
if (Math.abs(dx) < Epsilon && Math.abs(dy) < Epsilon) {
return;
}
2020-03-28 19:42:18 +03:00
let start;
function scrollAnim(currentTimestamp) {
start = start || currentTimestamp;
2019-10-20 14:53:12 +03:00
2020-03-28 19:42:18 +03:00
let k = Math.min(1, (currentTimestamp - start) / ScrollTime);
2019-10-20 14:53:12 +03:00
if (k === 1) {
resetScrollTimer();
2019-11-27 14:30:56 +03:00
builtinScroll(xScroller, scrollX, yScroller, scrollY, false);
return;
}
2019-10-20 14:53:12 +03:00
k = ease(k);
2019-10-20 14:53:12 +03:00
2020-05-17 18:17:47 +02:00
const x = ox + dx * k;
const y = oy + dy * k;
2019-10-20 14:53:12 +03:00
builtinScroll(xScroller, x, yScroller, y, false);
scrollTimer = requestAnimationFrame(scrollAnim);
2019-11-27 14:13:16 +03:00
}
2019-10-20 14:53:12 +03:00
scrollTimer = requestAnimationFrame(scrollAnim);
}
2019-10-20 14:53:12 +03:00
/**
* Performs scroll.
*
2020-03-31 18:59:12 +03:00
* @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;
2019-11-27 14:13:16 +03:00
}
2019-10-20 14:53:12 +03:00
/**
* 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;
2019-11-27 14:13:16 +03:00
}
2019-10-20 14:53:12 +03:00
/**
* Returns true if scroll manager is enabled.
*/
2020-03-28 19:42:18 +03:00
export function isEnabled() {
2020-01-09 22:24:45 +01:00
return layoutManager.tv;
2020-03-28 19:42:18 +03:00
}
/**
2019-10-22 23:46:21 +03:00
* Scrolls the document to a given position.
*
2020-03-31 18:59:12 +03:00
* @param {number} scrollX - Horizontal coordinate.
* @param {number} scrollY - Vertical coordinate.
* @param {boolean} [smooth=false] - Smooth scrolling.
*/
2020-03-28 19:42:18 +03:00
export function scrollTo(scrollX, scrollY, smooth) {
2019-10-22 23:46:21 +03:00
smooth = !!smooth;
// Scroller is document itself by default
2020-03-28 19:42:18 +03:00
const scroller = getScrollableParent(null, false);
2020-03-28 19:42:18 +03:00
const xScrollerData = getScrollerData(scroller, false);
const yScrollerData = getScrollerData(scroller, true);
2019-10-22 23:46:21 +03:00
scrollX = clamp(Math.round(scrollX), 0, xScrollerData.scrollSize - xScrollerData.clientSize);
scrollY = clamp(Math.round(scrollY), 0, yScrollerData.scrollSize - yScrollerData.clientSize);
2019-10-22 23:46:21 +03:00
doScroll(scroller, scrollX, scroller, scrollY, smooth);
}
2019-10-22 23:46:21 +03:00
/**
* Scrolls the document to a given element.
*
2020-03-31 18:59:12 +03:00
* @param {HTMLElement} element - Target element of scroll task.
* @param {boolean} [smooth=false] - Smooth scrolling.
2019-10-22 23:46:21 +03:00
*/
2020-03-28 19:42:18 +03:00
export function scrollToElement(element, smooth) {
2019-10-22 23:46:21 +03:00
smooth = !!smooth;
2020-03-28 19:42:18 +03:00
let scrollCenterX = true;
let scrollCenterY = true;
2020-03-28 19:42:18 +03:00
const offsetParent = element.offsetParent;
2019-10-23 22:38:01 +03:00
// In Firefox offsetParent.offsetParent is BODY
2020-05-04 12:44:12 +02:00
const isFixed = offsetParent && (!offsetParent.offsetParent || window.getComputedStyle(offsetParent).position === 'fixed');
2019-10-22 23:46:21 +03:00
// Scroll fixed elements to nearest edge (or do not scroll at all)
if (isFixed) {
scrollCenterX = scrollCenterY = false;
}
2021-04-24 21:30:40 +03:00
let xScroller = getScrollableParent(element, false);
let yScroller = getScrollableParent(element, true);
2020-03-28 19:42:18 +03:00
const xScrollerData = getScrollerData(xScroller, false);
const yScrollerData = getScrollerData(yScroller, true);
2019-10-22 23:46:21 +03:00
2021-04-24 21:30:40 +03:00
// 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();
2019-10-22 23:46:21 +03:00
2021-04-24 21:30:40 +03:00
let scrollX = 0;
let scrollY = 0;
2019-10-22 23:46:21 +03:00
2021-04-24 21:30:40 +03:00
if (!xScrollerData.custom) {
const xPos = getScrollerChildPos(xScroller, element, false);
scrollX = calcScroll(xScrollerData, xPos, elementRect.width, scrollCenterX);
} else {
xScroller = null;
2019-10-22 23:46:21 +03:00
}
2021-04-24 21:30:40 +03:00
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()) {
2020-05-04 12:44:12 +02:00
dom.addEventListener(window, 'focusin', function(e) {
setTimeout(function() {
2019-10-22 23:46:21 +03:00
scrollToElement(e.target, useSmoothScroll());
}, 0);
}, {capture: true});
}
2020-04-02 23:45:45 +02:00
/* eslint-enable indent */
export default {
isEnabled: isEnabled,
scrollTo: scrollTo,
scrollToElement: scrollToElement
};