-
Notifications
You must be signed in to change notification settings - Fork 2
/
index.js
522 lines (467 loc) · 19 KB
/
index.js
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
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
(function ( root, factory ) {
if ( typeof define === "function" && define.amd ) {
define( [], factory );
} else if ( typeof exports === "object" ) {
module.exports = factory();
} else {
root.Postpone = factory();
}
}(this, function() {
"use strict";
/**
* Creates a new Postpone instance.
* @constructor
* @param {number|string} [threshold] - The distance from an edge at which an
* element should be considered to be at the edge.
*/
var Postpone = function( threshold ) {
if ( !( this instanceof Postpone ) ) return new Postpone( threshold );
/**
* The init method for Postpone gets the object running. It runs
* postpone.postpone() to attach scroll event handlers and check if any
* elements are already visible. Then, init will start the watch process.
* @returns this
*/
this.init = function( threshold ) {
/**
* @property {string} tags - A list of all the tags for which postpone
* will work;
*/
this.tags = "audio, embed, iframe, img, image, object, picture, use, video, tref";
/**
* @property {array} elements - An array of all the postponed elements in the document.
*/
this.elements = [];
/**
* @property {array} visible - An array of all the non-hidden postponed
* elements in the document.
*/
this.elements.visible = [];
/**
* @property {object} scrollElements - A variable to keep track of the
* elements with respoect to which the postponed elements scroll.
*/
this.scrollElements = [];
this.setThreshold( threshold );
/** Call method to start looking for postponed media. */
return this.start();
};
return this.init( threshold );
};
/**
* The main postpone method. This method iterates over all the elements with
* a `postpone` attribute and links them to a scroll event so that they are not
* loaded until they become visible.
* @returns this
*/
Postpone.prototype.postpone = function() {
/**
* Remove any previous event handlers so they can be reattached for new
* postponed elements without duplicating old ones. This must be done
* before updating the scroll elements so the references to the event
* callbacks still exist.
*/
this.unbindEvents();
/**
* Update the elements and scroll elements to properly load or postpone
* them.
*/
this.getElements();
this.getScrollElements();
/**
* If any of the postponed elements should be visible to begin with,
* load them.
*/
for ( var id in this.scrollElements ) {
for ( var i = 0, element = {}; i < this.scrollElements[ id ].length; i++ ) {
element = this.scrollElements[ id ][ i ];
if ( this.isInViewport( element, this.scrollElements[ id ].element ) ) {
this.load( element );
}
}
}
if ( this.elements.length ) {
/** Attach scroll event listeners. */
this.bindEvents();
}
return this;
};
/**
* A helper method to unbind the scroll event callbacks.
* @returns this
*/
Postpone.prototype.unbindEvents = function() {
for ( var id in this.scrollElements ) {
this._removeEventListener( id === "window" ? window : this.scrollElements[ id ].element, this.scrollElements[ id ].callback );
}
};
/**
* A helper method to bind the scroll event callbacks.
* @returns this
*/
Postpone.prototype.bindEvents = function() {
for ( var id in this.scrollElements ) {
this.scrollElements[ id ].callback = Function.prototype.bind ? this.scrollHandler.bind( this ) : function( _this ) { return function() { return _this.scrollHandler.apply( _this, arguments ); }; }( this );
this._addEventListener( id === "window" ? window : this.scrollElements[ id ].element, this.scrollElements[ id ].callback );
}
return this;
};
/**
* A helper method to find all of the elements with a postponed attribute.
* @returns {Boolean} A boolean stating whether new postpone elements have been
* found.
*/
Postpone.prototype.getElements = function() {
var elements = [],
visible = [],
matches = this._slice( document.querySelectorAll( this.tags ) ),
postpone = null,
change = false;
for ( var i = 0; i < matches.length; i++ ) {
postpone = matches[ i ].getAttribute( "postpone" );
if ( typeof postpone === "string" && postpone !== "false" ) {
elements.push( matches[ i ] );
if ( this.isVisible( matches[ i ] ) ) {
visible.push( matches[ i ] );
/** Check if this element is not already postponed. */
if ( matches[ i ] !== this.elements.visible[ visible.length - 1 ] ) {
change = true;
}
}
}
}
/** Check if old postponed elements are no longer on the page. */
if ( this.elements.visible.length !== visible.length ) {
change = true;
}
this.elements = elements;
this.elements.visible = visible;
return change;
};
/**
* A helper method to find all of the elements with respect to which
* postponed elements scroll. The elements are stored with a unique ID as
* their key.
* @returns {object} A hash with arrays of postponed elements associated with
* IDs of their scroll elements.
*/
Postpone.prototype.getScrollElements = function() {
this.scrollElements = {};
var id = "",
element = {},
scrollElement = {};
for ( var i = 0; i < this.elements.visible.length; i++ ) {
element = this.elements.visible[ i ];
/**
* Find the element relative to which the postponed element's
* position should be calculated.
*/
if ( element.getAttribute( "data-scroll-element" ) ) {
scrollElement = document.querySelector( element.getAttribute( "data-scroll-element" ) );
/**
* If the scroll element does not have an ID, generate one and
* assign it as a data attribute.
*/
id = scrollElement.getAttribute( "data-id" );
if ( !id ) {
scrollElement.setAttribute( "data-id", id = new Date().getTime() );
}
/**
* If the element does not have a scroll element specified then
* assume its position should be calculated relative to the window.
*/
} else {
scrollElement = "window";
id = "window";
}
/**
* If the array already has this id as a key, then add the current
* element to the array in its value, otherwise create a new key.
*/
if ( this.scrollElements[ id ] ) {
this.scrollElements[ id ].push( element );
} else {
this.scrollElements[ id ] = [ element ];
this.scrollElements[ id ].element = scrollElement;
}
}
return this.scrollElements;
};
/**
* Method to watch the document for new postponed elements.
* @returns this
*/
Postpone.prototype.watch = function() {
/**
* Refresh the array of postponed elements, this.elements. If the postponed
* elements have changed, then process them.
*/
if ( this.getElements() ) {
this.postpone();
}
/**
* This timeout calls the watch method every 500ms. In other words,
* postpone will look for new postponed elements twice a second.
* @property {number} timeout - The ID for the current timeout.
*/
this.timeout = window.setTimeout( (function( _this ) {
return function() {
return _this.watch();
};
})( this ), 500);
return this;
};
/**
* Method to start watching for elements that should postponed.
* @returns this
*/
Postpone.prototype.start = function() {
/** Ensure that watching has stopped before starting to watch. */
if ( this.timeout ) this.stop();
/**
* Call `postpone` to ensure events are bound and items in view are
* loaded.
*/
this.postpone();
/** Start watching. */
this.watch();
return this;
};
/**
* Method to stop watching for elements that should postponed and unbind events
* associated with postponed elements.
* @returns this
*/
Postpone.prototype.stop = function() {
if ( this.timeout ) window.clearTimeout( this.timeout );
/* Unbind the scroll events associated with postponed elements. */
this.unbindEvents();
return this;
};
/**
* This method defines the scroll event handler used to test if postponed
* elementes are visible.
* @param {object} e - Event object.
* @returns this
*/
Postpone.prototype.scrollHandler = function( e ) {
var scrollElement = e.srcElement || e.target || window.document,
elements = this.scrollElements[ scrollElement === window.document ? scrollElement = "window" : scrollElement.getAttribute( "data-id" ) ],
element = {},
scrolledIntoView = false;
for ( var i = 0; i < elements.length; i++ ) {
element = elements[ i ];
/**
* If an element is visible then we no longer need to postpone it
* and can download it.
*/
if ( this.isInViewport( element, scrollElement ) ) {
this.load( element );
}
}
return this;
};
/**
* A convenience method to easily set the threshold property of postpone.
* @param {number|string} threshold - The distance from an edge at which an
* element should be considered to be at the edge.
* @returns this
*/
Postpone.prototype.setThreshold = function( threshold ) {
threshold = threshold ? threshold : 0;
/**
* @property {object} threshold - A hash containing the value and unit of
* measurement of the desired postpone threshold.
*/
this.threshold = {};
/**
* @property {number} value - The number of units from an edge at
* which an element should be considered to be at the edge. This is
* useful to start loading images or other resources before they scroll
* into view to prevent flash of content.
*/
this.threshold.value = parseInt( threshold, 10 );
/**
* @property {string} unit - The unit of measurement for the threshold
* value. Currently, only `vh` and `px` are supported. By default, the unit
* is `vh`.
*/
this.threshold.unit = ( typeof threshold === "number" ) ? "vh" : ( threshold.match(/[a-zA-Z]+/)[ 0 ] || "vh" );
return this;
};
/**
* Small helper method to find the total vertical offset of an element.
* @param {object} el - The element we wish to locate.
* @returns {number} The total vertical offset of the element.
*/
Postpone.prototype.offsetTop = function( el ) {
var temp = el,
o = 0;
/** Iterate over all parents of el up to body to find the vertical offset. */
while ( temp && temp.tagName.toLowerCase() !== "body" && temp.tagName.toLowerCase() !== "html" ) {
o += temp.offsetTop;
temp = temp.offsetParent;
}
return o;
};
/**
* Small helper method to determine if an element is visually hidden or not.
* This method check if the element provided, or any of its parents have the
* style `display: none;`.
* @param {object} el - The element we wish to locate.
* @returns {boolean} Returns true if the element is visible and false if it is
* hidden.
*/
Postpone.prototype.isVisible = function( el ) {
var temp = el,
isVisible = true;
/**
* Iterate over all parents of el up to HTML to find if el or a parent is
* hidden.
*/
while ( temp && temp.parentElement && isVisible ) {
isVisible = temp.currentStyle ? temp.currentStyle.display !== "none" : document.defaultView.getComputedStyle( temp ).getPropertyValue( "display" ) !== "none";
temp = temp.parentElement;
}
return isVisible;
};
/**
* Helper method to determine if an element is in the browser's viewport.
* @param {object} el - The element we wish to test.
* @param {object} [scrollElement] - The element with respect to which `el` scrolls.
* @returns {boolean} Return true if the `el` is in view and false if it is not.
*/
Postpone.prototype.isInViewport = function( el, scrollElement ) {
/** If no scroll element is specified, then assume the scroll element is the window. */
scrollElement = scrollElement ? scrollElement : "window";
if ( scrollElement === "window" ) {
scrollElement = document.documentElement.scrollTop ? document.documentElement : document.body;
}
/** Use clientHeight instead of window.innerHeight for compatability with ie8. */
var viewPortHeight = document.documentElement.clientHeight,
top = this.offsetTop( el ),
scrollHeight = scrollElement.scrollTop + this.offsetTop( scrollElement ),
isHighEnough = false,
isLowEnough = false,
threshold = 0;
if ( this.threshold.unit === "vh" ) {
threshold = viewPortHeight * this.threshold.value / 100;
} else if ( this.threshold.unit === "px" ) {
threshold = this.threshold.value;
}
/** Check if element is above bottom of screen. */
isHighEnough = viewPortHeight + scrollHeight + threshold >= top;
/** Check if element is below top of screen. */
isLowEnough = ( el.height || 0 ) + top + threshold >= scrollHeight;
return isHighEnough && isLowEnough;
};
/**
* This method takes care of loading the media that should no longer be
* postponed.
* @param {object} el - The element that should be loaded.
* @returns {object} The element that was loaded.
*/
Postpone.prototype.load = function( el ) {
var child = {},
i = 0;
el.removeAttribute( "postpone" );
/** If the element has a `data-src` attribute then copy it to `src`. */
if ( ~"audio, embed, iframe, img, picture, video".indexOf( el.tagName.toLowerCase() ) && el.getAttribute( "data-src" ) ) {
el.setAttribute( "src", el.getAttribute( "data-src" ) );
}
if ( ~"image, tref, use".indexOf( el.tagName.toLowerCase() ) && el.getAttribute( "data-xlink:href" ) ) {
el.setAttribute( "xlink:href", el.getAttribute( "data-xlink:href" ) );
}
else if ( ~"audio, video".indexOf( el.tagName.toLowerCase() ) && el.children.length ) {
for ( i = 0; i < el.children.length; i++ ) {
child = el.children[ i ];
if ( child.tagName.toLowerCase() === "source" && child.getAttribute( "data-src" ) ) {
child.setAttribute( "src", child.getAttribute( "data-src" ) );
}
}
}
else if ( el.tagName.toLowerCase() === "picture" && el.children.length ) {
for ( i = 0; i < el.children.length; i++ ) {
child = el.children[ i ];
if ( child.tagName.toLowerCase() === "source" ) {
if ( child.getAttribute( "data-src" ) ) {
child.setAttribute( "src", child.getAttribute( "data-src" ) );
}
if ( child.getAttribute( "data-srcset" ) ) {
child.setAttribute( "srcset", child.getAttribute( "data-srcset" ) );
}
}
}
}
else if ( el.tagName.toLowerCase() === "object" && el.getAttribute( "data-data" ) ) {
el.setAttribute( "data", el.getAttribute( "data-data" ) );
/**
* This is necessary to make Safari 7 refresh the object's new content.
*/
var activeElement = document.activeElement;
el.focus();
if ( activeElement ) {
activeElement.focus();
}
}
return el;
};
/**
* A helper method to convert array-like objects into arrays.
* @param {object} arr - The object to be converted.
* @returns {array} An array representation of the supplied object.
* @api private
*/
Postpone.prototype._slice = function( object ) {
/** Try to use `slice` to convert the object. */
try {
return Array.prototype.slice.call( object );
/**
* If that doesn't work, manually iterate over the object and convert
* it to an array.
*/
} catch(e) {
var array = [];
for ( var i = 0; i < object.length; i++ ) {
array.push( object[ i ] );
}
return array;
}
};
/**
* A helper method to abstract event listener creation.
* @param {object} el - The element to which the event should be added.
* @param {function} callback - The callback to be executed when the event
* is fired.
* @returns undefined
* @api private
*/
Postpone.prototype._addEventListener = function( el, callback ) {
/** Try to add the event using `addEventListener`. */
try {
return el.addEventListener( "scroll", callback );
/** If that doesn't work, add the event using `attachEvent`. */
} catch(e) {
return el.attachEvent( "onscroll", callback );
}
};
/**
* A helper method to abstract event listener removal.
* @param {object} el - The element from which the event should be removed.
* @param {function} callback - The callback to be executed when the event
* is fired.
* @returns undefined
* @api private
*/
Postpone.prototype._removeEventListener = function( el, callback ) {
/** Try to remove the event using `removeEventListener`. */
try {
return el.removeEventListener( "scroll", callback );
/** If that doesn't work, remove the event using `detachEvent`. */
} catch(e) {
return el.detachEvent( "onscroll", callback );
}
};
/** Expose `Postpone`. */
return Postpone;
}));