diff options
Diffstat (limited to 'shared/js/visibility_monitor.js')
-rw-r--r-- | shared/js/visibility_monitor.js | 494 |
1 files changed, 494 insertions, 0 deletions
diff --git a/shared/js/visibility_monitor.js b/shared/js/visibility_monitor.js new file mode 100644 index 0000000..5a8ba1f --- /dev/null +++ b/shared/js/visibility_monitor.js @@ -0,0 +1,494 @@ +/* + * 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. + * + * 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, + 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; + + // 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(); + + for (var i = 0; i < mutations.length; i++) { + var mutation = mutations[i]; + if (mutation.addedNodes) { + for (var j = 0; j < mutation.addedNodes.length; j++) { + var child = mutation.addedNodes[j]; + if (child.nodeType === Node.ELEMENT_NODE) + childAdded(child); + } + } + + if (mutation.removedNodes) { + for (var j = 0; j < mutation.removedNodes.length; j++) { + var 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 + adjustBounds(); + + // 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); + + // If the first element is onscreen, see if there are earlier ones + if (toppos === ON) { + var 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); + if (bottompos === ON) { + // If the last element is onscreen, see if there are more below it. + var 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; + var 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; + } +} |