Web   ·   Wiki   ·   Activities   ·   Blog   ·   Lists   ·   Chat   ·   Meeting   ·   Bugs   ·   Git   ·   Translate   ·   Archive   ·   People   ·   Donate
summaryrefslogtreecommitdiffstats
path: root/shared/js/visibility_monitor.js
blob: 5a8ba1f11c49b009fe91e44c0a0089150885e41f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
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;
  }
}