VulpesOS/gaia/shared/js/visibility_monitor.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

537 lines
19 KiB
JavaScript
Executable file

/* exported monitorChildVisibility */
/*
* visibility_monitor.js
*
* Given a scrolling container element (with overflow-y: scroll set,
* e.g.), monitorChildVisibility() listens for scroll events in order to
* determine which child elements are visible within the element and
* which are not (assuming that the element itself is visible).
*
* When a child scrolls onscreen, it is passed to the onscreen callback.
*
* When a child scrolls offscreen, it is passed to the offscreen callback.
*
* This class also listens for DOM modification events so that it can handle
* children being added to or removed from the scrolling element. It also
* handles resize events.
*
* Note that this class only pays attention to the direct children of
* the container element, not all ancestors.
*
* When you insert a new child into the container, you should create it in
* its offscreen state. If it is inserted offscreen nothing will happen.
* If you insert it onscreen, it will immediately be passed to the onscreen
* callback function
*
* The scrollmargin argument specifies a number of pixels. Elements
* that are within this many pixels of being onscreen are considered
* onscreen.
*
* The scrolldelta parameter is also a number of pixels. The user
* must scroll this distance before any visibility recomputation is
* done by this code. This parameter can be used to "batch" up work
* into larger chunks.
*
* By specifing proper onscreen and offscreen functions you can use this
* class to (for example) remove the background-image style of elements
* that are not visible, allowing gecko to free up image memory.
* In that sense, this class can be used to workaround
* https://bugzilla.mozilla.org/show_bug.cgi?id=689623
*
* The return value of this function is an object that has a stop() method.
* calling the stop method stops visiblity monitoring. If you want to restart
* call monitorChildVisiblity() again.
*
* monitorChildVisiblity() makes the following assumptions. If your program
* violates them, the function may not work correctly:
*
* Child elements of the container element flow left to right and
* top to bottom. I.e. the nextSibling of a child element never has a
* smaller clientTop value. They are not absolutely positioned and don't
* move on their own.
*
* The children of the container element are themselves all elements;
* there are no text nodes or comments cluttering things up.
*
* Children don't change size, either spontaneously or in response to
* onscreen and offscreen callbacks. Don't set display:none on an element
* when it goes offscreen, for example.
*
* Children aren't added or removed to the container while the container
* or any of its ancestors is hidden with display:none or is removed from
* the tree. The mutation observer that responds to additions and deletions
* needs the container and its children to have valid layout data in order
* to figure out what is onscreen and what is offscreen. Use visiblity:hidden
* instead of display:none if you need to add or remove children while
* the container is hidden.
*
* DocumentFragments are not used to add multiple children at once to
* the container, and multiple children are not deleted at once by
* setting innerHTML or innerText to ''.
*
* The container element only changes size when there is a resize event
* on the window.
*/
'use strict';
function monitorChildVisibility(container,
scrollmargin, scrolldelta,
onscreenCallback, offscreenCallback)
{
// The onscreen region is represented by these two elements
var firstOnscreen = null, lastOnscreen = null;
// This is the last onscreen region that we have notified the client about
var firstNotifiedOnscreen = null, lastNotifiedOnscreen = null;
// The timer used by deferCallbacks()
var pendingCallbacks = null;
// The scrolltop on |container| our scroll handler saw before we
// last recomputed visibility.
var lastScrollTop = -1;
// Update the onscreen region whenever we scroll
container.addEventListener('scroll', scrollHandler);
// Update the onscreen region when the window changes size
window.addEventListener('resize', resizeHandler);
// Update the onscreen region when children are added or removed
var observer = new MutationObserver(mutationHandler);
observer.observe(container, { childList: true });
// Now determine the initial onscreen region
adjustBounds();
// Call the onscreenCallback for the initial onscreen elements
callCallbacks();
// Return an object that allows the caller to stop monitoring
return {
stop: function stop() {
// Unregister our event handlers and stop the mutation observer.
container.removeEventListener('scroll', scrollHandler);
window.removeEventListener('resize', resizeHandler);
observer.disconnect();
}
};
// Adjust the onscreen element range and synchronously call onscreen
// and offscreen callbacks as needed.
function resizeHandler() {
// If we are triggered with 0 height, ignore the event. If this happens
// we don't have any layout data and we'll end up thinking that all
// of the children are onscreen. Better to do nothing at all here and
// just wait until the container becomes visible again.
if (container.clientHeight === 0) {
return;
}
adjustBounds();
callCallbacks();
}
// This gets called when children are added or removed from the container.
// Adding and removing nodes can change the position of other elements
// so changes may extend beyond just the ones added or removed
function mutationHandler(mutations) {
// Ignore any mutations while we are not displayed because
// none of our calculations will be right
if (container.clientHeight === 0) {
return;
}
// If there are any pending callbacks, call them now before handling
// the mutations so that we start off in sync, with the onscreen range
// equal to the notified range.
if (pendingCallbacks) {
callCallbacks();
}
var j, child;
for (var i = 0; i < mutations.length; i++) {
var mutation = mutations[i];
if (mutation.addedNodes) {
for (j = 0; j < mutation.addedNodes.length; j++) {
child = mutation.addedNodes[j];
if (child.nodeType === Node.ELEMENT_NODE) {
childAdded(child);
}
}
}
if (mutation.removedNodes) {
for (j = 0; j < mutation.removedNodes.length; j++) {
child = mutation.removedNodes[j];
if (child.nodeType === Node.ELEMENT_NODE) {
childRemoved(child,
mutation.previousSibling,
mutation.nextSibling);
}
}
}
}
}
// If the new child is onscreen, call the onscreen callback for it.
// Adjust the onscreen element range and synchronously call
// onscreen and offscreen callbacks as needed.
function childAdded(child) {
// If the added child is after the last onscreen child, and we're
// not filling in the first page of content then this insertion
// doesn't affect us at all.
if (lastOnscreen &&
after(child, lastOnscreen) &&
child.offsetTop > container.clientHeight + scrollmargin) {
return;
}
// Otherwise, if this is the first element added or if it is after
// the first onscreen element, then it is onscreen and we need to
// call the onscreen callback for it.
if (!firstOnscreen || after(child, firstOnscreen)) {
// Invoke the onscreen callback for this child
try {
onscreenCallback(child);
}
catch (e) {
console.warn('monitorChildVisiblity: Exception in onscreenCallback:',
e, e.stack);
}
}
// Now adjust the first and last onscreen element and
// send a synchronous notification
adjustBounds();
callCallbacks();
}
// If the removed element was after the last onscreen element just return.
// Otherwise adjust the onscreen element range and synchronously call
// onscreen and offscreen callbacks as needed. Note, however that there
// are some special cases when the last element is deleted or when the
// first or last onscreen element is deleted.
function childRemoved(child, previous, next) {
// If there aren't any elements left revert back to initial state
if (container.firstElementChild === null) {
firstOnscreen = lastOnscreen = null;
firstNotifiedOnscreen = lastNotifiedOnscreen = null;
}
else {
// If the removed child was after the last onscreen child, then
// this removal doesn't affect us at all.
if (previous !== null && after(previous, lastOnscreen)) {
return;
}
// If the first onscreen element was the one removed
// use the next or previous element as a starting point instead.
// We know that there is at least one element left, so one of these
// two must be defined.
if (child === firstOnscreen) {
firstOnscreen = firstNotifiedOnscreen = next || previous;
}
// And similarly for the last onscreen element
if (child === lastOnscreen) {
lastOnscreen = lastNotifiedOnscreen = previous || next;
}
// Find the new bounds after the deletion
adjustBounds();
}
// Synchronously call the callbacks
callCallbacks();
}
// Adjust the onscreen element range and asynchronously call
// onscreen and offscreen callbacks as needed. We do this
// asynchronously so that if we get lots of scroll events in
// rapid succession and can't keep up, we can skip some of
// the notifications.
function scrollHandler() {
// Ignore scrolls while we are not displayed because
// none of our calculations will be right
if (container.clientHeight === 0) {
return;
}
// Adjust the first and last onscreen element if we've panned
// beyond the scrolldelta margin.
var scrollTop = container.scrollTop;
if (Math.abs(scrollTop - lastScrollTop) < scrolldelta) {
return;
}
lastScrollTop = scrollTop;
adjustBounds();
if (scrolldelta > 1) {
// Assume that clients are using the scrolldelta to batch work,
// and that they want finer control over scheduling. Recompute
// visibility immediately.
callCallbacks();
} else {
// Assume that clients are relying on us to throttle work while
// the user is busy.
//
// We may get a lot of scroll events in quick succession, so
// don't call the callbacks synchronously. Instead defer so that
// we can handle any other queued scroll events.
deferCallbacks();
}
}
// Return true if node a is before node b and false otherwise
function before(a, b) {
return !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
}
// Return true if node a is after node b and false otherwise
function after(a, b) {
return !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_PRECEDING);
}
// This function recomputes the range of onscreen elements. Normally it
// just needs to do small amounts of nextElementSibling
// or previousElementSibling iteration to find the range. But it can also
// start from an unknown state and search the entire container to find
// the range of child elements that are onscreen.
function adjustBounds() {
// If the container has no children, the bounds are null
if (container.firstElementChild === null) {
firstOnscreen = lastOnscreen = null;
return;
}
// Compute the visible region of the screen, including scroll margin
var scrollTop = container.scrollTop;
var screenTop = scrollTop - scrollmargin;
var screenBottom = scrollTop + container.clientHeight + scrollmargin;
// This utility function returns ON if the child is onscreen,
// BEFORE if it offscreen before the visible elements and AFTER if
// it is offscreen aafter the visible elements
var BEFORE = -1, ON = 0, AFTER = 1;
function position(child) {
var childTop = child.offsetTop;
var childBottom = childTop + child.offsetHeight;
if (childBottom < screenTop) {
return BEFORE;
}
if (childTop > screenBottom) {
return AFTER;
}
return ON;
}
// If we don't have a first onscreen element yet, start with the first.
if (!firstOnscreen) {
firstOnscreen = container.firstElementChild;
}
// Check the position of the top
var toppos = position(firstOnscreen);
var prev;
// If the first element is onscreen, see if there are earlier ones
if (toppos === ON) {
prev = firstOnscreen.previousElementSibling;
while (prev && position(prev) === ON) {
firstOnscreen = prev;
prev = prev.previousElementSibling;
}
}
else if (toppos === BEFORE) {
// The screen is below us, so find the next element that is visible.
var e = firstOnscreen.nextElementSibling;
while (e && position(e) !== ON) {
e = e.nextElementSibling;
}
firstOnscreen = e;
}
else {
// We've scrolled a lot or things have moved so much that the
// entire visible region is now above the first element.
// So scan backwards to find the new lastOnscreen and firstOnscreen
// elements. Note that if we get here, we can return since we
// will have updated both bounds
// Loop until we find an onscreen element
lastOnscreen = firstOnscreen.previousElementSibling;
while (lastOnscreen && position(lastOnscreen) !== ON) {
lastOnscreen = lastOnscreen.previousElementSibling;
}
// Now loop from there to find the first onscreen element
firstOnscreen = lastOnscreen;
prev = firstOnscreen.previousElementSibling;
while (prev && position(prev) === ON) {
firstOnscreen = prev;
prev = prev.previousElementSibling;
}
return;
}
// Now make the same adjustment on the bottom of the onscreen region
// If we don't have a lastOnscreen value to start with, use the newly
// computed firstOnscreen value.
if (lastOnscreen === null) {
lastOnscreen = firstOnscreen;
}
var bottompos = position(lastOnscreen);
var next;
if (bottompos === ON) {
// If the last element is onscreen, see if there are more below it.
next = lastOnscreen.nextElementSibling;
while (next && position(next) === ON) {
lastOnscreen = next;
next = next.nextElementSibling;
}
}
else if (bottompos === AFTER) {
// the last element is now below the visible part of the screen
lastOnscreen = lastOnscreen.previousElementSibling;
while (position(lastOnscreen) !== ON) {
lastOnscreen = lastOnscreen.previousElementSibling;
}
}
else {
// First and last are now both above the visible portion of the screen
// So loop down to find their new positions
firstOnscreen = lastOnscreen.nextElementSibling;
while (firstOnscreen && position(firstOnscreen) !== ON) {
firstOnscreen = firstOnscreen.nextElementSibling;
}
lastOnscreen = firstOnscreen;
next = lastOnscreen.nextElementSibling;
while (next && position(next) === ON) {
lastOnscreen = next;
next = next.nextElementSibling;
}
}
}
// Call the callCallbacks() function after any pending events are processed
// We use this for asynchronous notification after scroll events.
function deferCallbacks() {
if (pendingCallbacks) {
// XXX: or we could just return here, which would defer for less time.
clearTimeout(pendingCallbacks);
}
pendingCallbacks = setTimeout(callCallbacks, 0);
}
// Synchronously call the callbacks to notify the client of the new set
// of onscreen elements. This only calls the onscreen and offscreen
// callbacks for elements that have come onscreen or gone offscreen since
// the last time it was called.
function callCallbacks() {
// If there is a pending call to this function (or if this was the pending
// call) clear it now, since we are sending the callbacks
if (pendingCallbacks) {
clearTimeout(pendingCallbacks);
pendingCallbacks = null;
}
// Call the onscreen callback for element from and its siblings
// up to, but not including to.
function onscreen(from, to) {
var e = from;
while (e && e !== to) {
try {
onscreenCallback(e);
}
catch (ex) {
console.warn('monitorChildVisibility: Exception in onscreenCallback:',
ex, ex.stack);
}
e = e.nextElementSibling;
}
}
// Call the offscreen callback for element from and its siblings
// up to, but not including to.
function offscreen(from, to) {
var e = from;
while (e && e !== to) {
try {
offscreenCallback(e);
}
catch (ex) {
console.warn('monitorChildVisibility: ' +
'Exception in offscreenCallback:',
ex, ex.stack);
}
e = e.nextElementSibling;
}
}
// If the two ranges are the same, return immediately
if (firstOnscreen === firstNotifiedOnscreen &&
lastOnscreen === lastNotifiedOnscreen) {
return;
}
// If the last notified range is null, then we just add the new range
if (firstNotifiedOnscreen === null) {
onscreen(firstOnscreen, lastOnscreen.nextElementSibling);
}
// If the new range is null, this means elements have been removed.
// We don't need to call offscreen for elements that are not in the
// container anymore, so we don't do anything in this case
else if (firstOnscreen === null) {
// Nothing to do here
}
// If the new range and the old range are disjoint, call the onscreen
// callback for the new range first and then call the offscreen callback
// for the old.
else if (before(lastOnscreen, firstNotifiedOnscreen) ||
after(firstOnscreen, lastNotifiedOnscreen)) {
// Mark the new ones onscreen
onscreen(firstOnscreen, lastOnscreen.nextElementSibling);
// Mark the old range offscreen
offscreen(firstNotifiedOnscreen,
lastNotifiedOnscreen.nextElementSibling);
}
// Otherwise if new elements are visible at the top, call those callbacks
// If new elements are visible at the bottom, call those.
// If elements have gone offscreen at the top, call those callbacks
// If elements have gone offscreen at the bottom, call those.
else {
// Are there new onscreen elements at the top?
if (before(firstOnscreen, firstNotifiedOnscreen)) {
onscreen(firstOnscreen, firstNotifiedOnscreen);
}
// Are there new onscreen elements at the bottom?
if (after(lastOnscreen, lastNotifiedOnscreen)) {
onscreen(lastNotifiedOnscreen.nextElementSibling,
lastOnscreen.nextElementSibling);
}
// Have elements gone offscreen at the top?
if (after(firstOnscreen, firstNotifiedOnscreen)) {
offscreen(firstNotifiedOnscreen, firstOnscreen);
}
// Have elements gone offscreen at the bottom?
if (before(lastOnscreen, lastNotifiedOnscreen)) {
offscreen(lastOnscreen.nextElementSibling,
lastNotifiedOnscreen.nextElementSibling);
}
}
// Now the notified onscreen range is in sync with the actual
// onscreen range.
firstNotifiedOnscreen = firstOnscreen;
lastNotifiedOnscreen = lastOnscreen;
}
}