/* 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; } }