Web   ·   Wiki   ·   Activities   ·   Blog   ·   Lists   ·   Chat   ·   Meeting   ·   Bugs   ·   Git   ·   Translate   ·   Archive   ·   People   ·   Donate
summaryrefslogtreecommitdiffstats
path: root/shared/js/visibility_monitor.js
diff options
context:
space:
mode:
Diffstat (limited to 'shared/js/visibility_monitor.js')
-rw-r--r--shared/js/visibility_monitor.js494
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;
+ }
+}