lib/amd/src/scroll_manager.js

// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Scroll manager is a class that help with saving the scroll positing when you
 * click on an action icon, and then when the page is reloaded after processing
 * the action, it scrolls you to exactly where you were. This is much nicer for
 * the user.
 *
 * To use this in your code, you need to ensure that:
 * 1. The button that triggers the action has to have a click event handler that
 *    calls saveScrollPos()
 * 2. After doing the processing, the redirect() function will add 'mdlscrollto'
 *    parameter into the redirect url automatically.
 * 3. Finally, on the page that is reloaded (which should be the same as the one
 *    the user started on) you need to call scrollToSavedPosition()
 *    on page load.
 *
 * @module     core/scroll_manager
 * @copyright  2021 The Open University
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

/** @property {HTMLElement} scrollingElement the current scrolling element. */
let scrollingElement = null;

/**
 * Is the element scrollable?
 *
 * @param {HTMLElement} element Element.
 * @returns {boolean}
 */
const isScrollable = (element) => {
    // Check if the element has scrollable content.
    const hasScrollableContent = element.scrollHeight > element.clientHeight;

    // If 'overflow-y' is set to hidden, the scroll bar is't show.
    const elementOverflow = window.getComputedStyle(element).overflowY;
    const isOverflowHidden = elementOverflow.indexOf('hidden') !== -1;

    return hasScrollableContent && !isOverflowHidden;
};

/**
 * Get the scrolling element.
 *
 * @returns {HTMLElement}
 */
const getScrollingElement = () => {
    if (scrollingElement === null) {
        const page = document.getElementById('page');
        if (isScrollable(page)) {
            scrollingElement = page;
        } else {
            scrollingElement = document.scrollingElement;
        }
    }

    return scrollingElement;
};

/**
 * Get current scroll position.
 *
 * @returns {Number} Scroll position.
 */
const getScrollPos = () => {
    const scrollingElement = getScrollingElement();

    return scrollingElement.scrollTop;
};

/**
 * Get the scroll position for this form.
 *
 * @param {HTMLFormElement} form
 * @returns {HTMLInputElement}
 */
const getScrollPositionElement = (form) => {
    const element = form.querySelector('input[name=mdlscrollto]');
    if (element) {
        return element;
    }

    const scrollPos = document.createElement('input');
    scrollPos.type = 'hidden';
    scrollPos.name = 'mdlscrollto';
    form.appendChild(scrollPos);

    return scrollPos;
};

/**
 * In the form that contains the element, set the value of the form field with
 * name mdlscrollto to the current scroll position. If there is no element with
 * that name, it creates a hidden form field with that name within the form.
 *
 * @param {string} elementId The element in the form.
 */
export const saveScrollPos = (elementId) => {
    const element = document.getElementById(elementId);
    const form = element.closest('form');
    if (!form) {
        return;
    }

    saveScrollPositionToForm(form);
};

/**
 * Init event handlers for all links with data-savescrollposition=true.
 * Set the value to the closest form.
 */
export const watchScrollButtonSaves = () => {
    document.addEventListener('click', (e) => {
        const button = e.target.closest('[data-savescrollposition="true"]');
        if (button) {
            saveScrollPositionToForm(button.form);
        }
    });
};

/**
 * Save the position to form.
 *
 * @param {Object} form The form is saved scroll position.
 */
export const saveScrollPositionToForm = (form) => {
    getScrollPositionElement(form).value = getScrollPos();
};

/**
 * Init event handlers for all links with data-save-scroll=true.
 * Handle to add mdlscrollto parameter to link using js when we click on the link.
 *
 */
export const initLinksScrollPos = () => {
    document.addEventListener('click', (e) => {
        const link = e.target.closest('a[data-save-scroll=true]');
        if (!link) {
            return;
        }

        e.preventDefault();
        const url = new URL(e.target.href);
        url.searchParams.set('mdlscrollto', getScrollPos());
        window.location = url;
    });
};

/**
 * If there is a parameter like mdlscrollto=123 in the URL, scroll to that saved position.
 */
export const scrollToSavedPosition = () => {
    const url = new URL(window.location.href);
    if (!url.searchParams.has('mdlscrollto')) {
        return;
    }

    const scrollPosition = url.searchParams.get('mdlscrollto');

    // Event onDOMReady is the effective one here. I am leaving the immediate call to
    // window.scrollTo in case it reduces flicker.
    const scrollingElement = getScrollingElement();
    scrollingElement.scrollTo(0, scrollPosition);
    document.addEventListener('DOMContentLoaded', () => {
        scrollingElement.scrollTo(0, scrollPosition);
    });
};