|
@@ -0,0 +1,388 @@
|
|
|
+// # Angular-Inview
|
|
|
+// - Author: [Nicola Peduzzi](https://github.com/thenikso)
|
|
|
+// - Repository: https://github.com/thenikso/angular-inview
|
|
|
+// - Install with: `npm install angular-inview`
|
|
|
+// - Version: **2.2.0**
|
|
|
+(function() {
|
|
|
+'use strict';
|
|
|
+
|
|
|
+// An [angular.js](https://angularjs.org) directive to evaluate an expression if
|
|
|
+// a DOM element is or not in the current visible browser viewport.
|
|
|
+// Use it in your AngularJS app by including the javascript and requireing it:
|
|
|
+//
|
|
|
+// `angular.module('myApp', ['angular-inview'])`
|
|
|
+var angularInviewModule = angular.module('angular-inview', [])
|
|
|
+
|
|
|
+// ## in-view directive
|
|
|
+//
|
|
|
+// ### Usage
|
|
|
+// ```html
|
|
|
+// <any in-view="{expression}" [in-view-options="{object}"]></any>
|
|
|
+// ```
|
|
|
+.directive('inView', ['$parse', inViewDirective])
|
|
|
+
|
|
|
+// ## in-view-container directive
|
|
|
+.directive('inViewContainer', inViewContainerDirective);
|
|
|
+
|
|
|
+// ## Implementation
|
|
|
+function inViewDirective ($parse) {
|
|
|
+ return {
|
|
|
+ // Evaluate the expression passet to the attribute `in-view` when the DOM
|
|
|
+ // element is visible in the viewport.
|
|
|
+ restrict: 'A',
|
|
|
+ require: '?^^inViewContainer',
|
|
|
+ link: function inViewDirectiveLink (scope, element, attrs, container) {
|
|
|
+ // in-view-options attribute can be specified with an object expression
|
|
|
+ // containing:
|
|
|
+ // - `offset`: An array of values to offset the element position.
|
|
|
+ // Offsets are expressed as arrays of 4 numbers [top, right, bottom, left].
|
|
|
+ // Like CSS, you can also specify only 2 numbers [top/bottom, left/right].
|
|
|
+ // Instead of numbers, some array elements can be a string with a percentage.
|
|
|
+ // Positive numbers are offsets outside the element rectangle and
|
|
|
+ // negative numbers are offsets to the inside.
|
|
|
+ // - `viewportOffset`: Like the element offset but appied to the viewport.
|
|
|
+ // - `generateDirection`: Indicate if the `direction` information should
|
|
|
+ // be included in `$inviewInfo` (default false).
|
|
|
+ // - `generateParts`: Indicate if the `parts` information should
|
|
|
+ // be included in `$inviewInfo` (default false).
|
|
|
+ // - `throttle`: Specify a number of milliseconds by which to limit the
|
|
|
+ // number of incoming events.
|
|
|
+ var options = {};
|
|
|
+ if (attrs.inViewOptions) {
|
|
|
+ options = scope.$eval(attrs.inViewOptions);
|
|
|
+ }
|
|
|
+ if (options.offset) {
|
|
|
+ options.offset = normalizeOffset(options.offset);
|
|
|
+ }
|
|
|
+ if (options.viewportOffset) {
|
|
|
+ options.viewportOffset = normalizeOffset(options.viewportOffset);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Build reactive chain from an initial event
|
|
|
+ var viewportEventSignal = signalSingle({ type: 'initial' })
|
|
|
+
|
|
|
+ // Merged with the window events
|
|
|
+ .merge(signalFromEvent(window, 'checkInView click ready wheel mousewheel DomMouseScroll MozMousePixelScroll resize scroll touchmove mouseup keydown'));
|
|
|
+
|
|
|
+ // Merged with the page visibility events
|
|
|
+ if (options.considerPageVisibility) {
|
|
|
+ viewportEventSignal = viewportEventSignal.merge(signalFromEvent(document, 'visibilitychange'));
|
|
|
+ }
|
|
|
+
|
|
|
+ // Merge with container's events signal
|
|
|
+ if (container) {
|
|
|
+ viewportEventSignal = viewportEventSignal.merge(container.eventsSignal);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Throttle if option specified
|
|
|
+ if (options.throttle) {
|
|
|
+ viewportEventSignal = viewportEventSignal.throttle(options.throttle);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Map to viewport intersection and in-view informations
|
|
|
+ var inviewInfoSignal = viewportEventSignal
|
|
|
+
|
|
|
+ // Inview information structure contains:
|
|
|
+ // - `inView`: a boolean value indicating if the element is
|
|
|
+ // visible in the viewport;
|
|
|
+ // - `changed`: a boolean value indicating if the inview status
|
|
|
+ // changed after the last event;
|
|
|
+ // - `event`: the event that initiated the in-view check;
|
|
|
+ .map(function(event) {
|
|
|
+ var viewportRect;
|
|
|
+ if (container) {
|
|
|
+ viewportRect = container.getViewportRect();
|
|
|
+ // TODO merge with actual window!
|
|
|
+ } else {
|
|
|
+ viewportRect = getViewportRect();
|
|
|
+ }
|
|
|
+ viewportRect = offsetRect(viewportRect, options.viewportOffset);
|
|
|
+ var elementRect = offsetRect(element[0].getBoundingClientRect(), options.offset);
|
|
|
+ var isVisible = !!(element[0].offsetWidth || element[0].offsetHeight || element[0].getClientRects().length);
|
|
|
+ var documentVisible = !options.considerPageVisibility || document.visibilityState === 'visible' || document.hidden === false;
|
|
|
+ var info = {
|
|
|
+ inView: documentVisible && isVisible && intersectRect(elementRect, viewportRect),
|
|
|
+ event: event,
|
|
|
+ element: element,
|
|
|
+ elementRect: elementRect,
|
|
|
+ viewportRect: viewportRect
|
|
|
+ };
|
|
|
+ // Add inview parts
|
|
|
+ if (options.generateParts && info.inView) {
|
|
|
+ info.parts = {};
|
|
|
+ info.parts.top = elementRect.top >= viewportRect.top;
|
|
|
+ info.parts.left = elementRect.left >= viewportRect.left;
|
|
|
+ info.parts.bottom = elementRect.bottom <= viewportRect.bottom;
|
|
|
+ info.parts.right = elementRect.right <= viewportRect.right;
|
|
|
+ }
|
|
|
+ return info;
|
|
|
+ })
|
|
|
+
|
|
|
+ // Add the changed information to the inview structure.
|
|
|
+ .scan({}, function (lastInfo, newInfo) {
|
|
|
+ // Add inview direction info
|
|
|
+ if (options.generateDirection && newInfo.inView && lastInfo.elementRect) {
|
|
|
+ newInfo.direction = {
|
|
|
+ horizontal: newInfo.elementRect.left - lastInfo.elementRect.left,
|
|
|
+ vertical: newInfo.elementRect.top - lastInfo.elementRect.top
|
|
|
+ };
|
|
|
+ }
|
|
|
+ // Calculate changed flag
|
|
|
+ newInfo.changed =
|
|
|
+ newInfo.inView !== lastInfo.inView ||
|
|
|
+ !angular.equals(newInfo.parts, lastInfo.parts) ||
|
|
|
+ !angular.equals(newInfo.direction, lastInfo.direction);
|
|
|
+ return newInfo;
|
|
|
+ })
|
|
|
+
|
|
|
+ // Filters only informations that should be forwarded to the callback
|
|
|
+ .filter(function (info) {
|
|
|
+ // Don't forward if no relevant infomation changed
|
|
|
+ if (!info.changed) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ // Don't forward if not initially in-view
|
|
|
+ if (info.event.type === 'initial' && !info.inView) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+
|
|
|
+ // Execute in-view callback
|
|
|
+ var inViewExpression = $parse(attrs.inView);
|
|
|
+ var dispose = inviewInfoSignal.subscribe(function (info) {
|
|
|
+ scope.$applyAsync(function () {
|
|
|
+ inViewExpression(scope, {
|
|
|
+ '$inview': info.inView,
|
|
|
+ '$inviewInfo': info
|
|
|
+ });
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ // Dispose of reactive chain
|
|
|
+ scope.$on('$destroy', dispose);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function inViewContainerDirective () {
|
|
|
+ return {
|
|
|
+ restrict: 'A',
|
|
|
+ controller: ['$element', function ($element) {
|
|
|
+ this.element = $element;
|
|
|
+ this.eventsSignal = signalFromEvent($element, 'scroll');
|
|
|
+ this.getViewportRect = function () {
|
|
|
+ return $element[0].getBoundingClientRect();
|
|
|
+ };
|
|
|
+ }]
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ## Utilities
|
|
|
+
|
|
|
+function getViewportRect () {
|
|
|
+ var result = {
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ width: window.innerWidth,
|
|
|
+ right: window.innerWidth,
|
|
|
+ height: window.innerHeight,
|
|
|
+ bottom: window.innerHeight
|
|
|
+ };
|
|
|
+ if (result.height) {
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ var mode = document.compatMode;
|
|
|
+ if (mode === 'CSS1Compat') {
|
|
|
+ result.width = result.right = document.documentElement.clientWidth;
|
|
|
+ result.height = result.bottom = document.documentElement.clientHeight;
|
|
|
+ } else {
|
|
|
+ result.width = result.right = document.body.clientWidth;
|
|
|
+ result.height = result.bottom = document.body.clientHeight;
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+}
|
|
|
+
|
|
|
+function intersectRect (r1, r2) {
|
|
|
+ return !(r2.left > r1.right ||
|
|
|
+ r2.right < r1.left ||
|
|
|
+ r2.top > r1.bottom ||
|
|
|
+ r2.bottom < r1.top);
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeOffset (offset) {
|
|
|
+ if (!angular.isArray(offset)) {
|
|
|
+ return [offset, offset, offset, offset];
|
|
|
+ }
|
|
|
+ if (offset.length == 2) {
|
|
|
+ return offset.concat(offset);
|
|
|
+ }
|
|
|
+ else if (offset.length == 3) {
|
|
|
+ return offset.concat([offset[1]]);
|
|
|
+ }
|
|
|
+ return offset;
|
|
|
+}
|
|
|
+
|
|
|
+function offsetRect (rect, offset) {
|
|
|
+ if (!offset) {
|
|
|
+ return rect;
|
|
|
+ }
|
|
|
+ var offsetObject = {
|
|
|
+ top: isPercent(offset[0]) ? (parseFloat(offset[0]) * rect.height) : offset[0],
|
|
|
+ right: isPercent(offset[1]) ? (parseFloat(offset[1]) * rect.width) : offset[1],
|
|
|
+ bottom: isPercent(offset[2]) ? (parseFloat(offset[2]) * rect.height) : offset[2],
|
|
|
+ left: isPercent(offset[3]) ? (parseFloat(offset[3]) * rect.width) : offset[3]
|
|
|
+ };
|
|
|
+ // Note: ClientRect object does not allow its properties to be written to therefore a new object has to be created.
|
|
|
+ return {
|
|
|
+ top: rect.top - offsetObject.top,
|
|
|
+ left: rect.left - offsetObject.left,
|
|
|
+ bottom: rect.bottom + offsetObject.bottom,
|
|
|
+ right: rect.right + offsetObject.right,
|
|
|
+ height: rect.height + offsetObject.top + offsetObject.bottom,
|
|
|
+ width: rect.width + offsetObject.left + offsetObject.right
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function isPercent (n) {
|
|
|
+ return angular.isString(n) && n.indexOf('%') > 0;
|
|
|
+}
|
|
|
+
|
|
|
+// ## QuickSignal FRP
|
|
|
+// A quick and dirty implementation of Rx to have a streamlined code in the
|
|
|
+// directives.
|
|
|
+
|
|
|
+// ### QuickSignal
|
|
|
+//
|
|
|
+// - `didSubscribeFunc`: a function receiving a `subscriber` as described below
|
|
|
+//
|
|
|
+// Usage:
|
|
|
+// var mySignal = new QuickSignal(function(subscriber) { ... })
|
|
|
+function QuickSignal (didSubscribeFunc) {
|
|
|
+ this.didSubscribeFunc = didSubscribeFunc;
|
|
|
+}
|
|
|
+
|
|
|
+// Subscribe to a signal and consume the steam of data.
|
|
|
+//
|
|
|
+// Returns a function that can be called to stop the signal stream of data and
|
|
|
+// perform cleanup.
|
|
|
+//
|
|
|
+// A `subscriber` is a function that will be called when a new value arrives.
|
|
|
+// a `subscriber.$dispose` property can be set to a function to be called uppon
|
|
|
+// disposal. When setting the `$dispose` function, the previously set function
|
|
|
+// should be chained.
|
|
|
+QuickSignal.prototype.subscribe = function (subscriber) {
|
|
|
+ this.didSubscribeFunc(subscriber);
|
|
|
+ var dispose = function () {
|
|
|
+ if (subscriber.$dispose) {
|
|
|
+ subscriber.$dispose();
|
|
|
+ subscriber.$dispose = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return dispose;
|
|
|
+}
|
|
|
+
|
|
|
+QuickSignal.prototype.map = function (f) {
|
|
|
+ var s = this;
|
|
|
+ return new QuickSignal(function (subscriber) {
|
|
|
+ subscriber.$dispose = s.subscribe(function (nextValue) {
|
|
|
+ subscriber(f(nextValue));
|
|
|
+ });
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+QuickSignal.prototype.filter = function (f) {
|
|
|
+ var s = this;
|
|
|
+ return new QuickSignal(function (subscriber) {
|
|
|
+ subscriber.$dispose = s.subscribe(function (nextValue) {
|
|
|
+ if (f(nextValue)) {
|
|
|
+ subscriber(nextValue);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+QuickSignal.prototype.scan = function (initial, scanFunc) {
|
|
|
+ var s = this;
|
|
|
+ return new QuickSignal(function (subscriber) {
|
|
|
+ var last = initial;
|
|
|
+ subscriber.$dispose = s.subscribe(function (nextValue) {
|
|
|
+ last = scanFunc(last, nextValue);
|
|
|
+ subscriber(last);
|
|
|
+ });
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+QuickSignal.prototype.merge = function (signal) {
|
|
|
+ return signalMerge(this, signal);
|
|
|
+};
|
|
|
+
|
|
|
+QuickSignal.prototype.throttle = function (threshhold) {
|
|
|
+ var s = this, last, deferTimer;
|
|
|
+ return new QuickSignal(function (subscriber) {
|
|
|
+ var chainDisposable = s.subscribe(function () {
|
|
|
+ var now = +new Date,
|
|
|
+ args = arguments;
|
|
|
+ if (last && now < last + threshhold) {
|
|
|
+ clearTimeout(deferTimer);
|
|
|
+ deferTimer = setTimeout(function () {
|
|
|
+ last = now;
|
|
|
+ subscriber.apply(null, args);
|
|
|
+ }, threshhold);
|
|
|
+ } else {
|
|
|
+ last = now;
|
|
|
+ subscriber.apply(null, args);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ subscriber.$dispose = function () {
|
|
|
+ clearTimeout(deferTimer);
|
|
|
+ if (chainDisposable) chainDisposable();
|
|
|
+ };
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+function signalMerge () {
|
|
|
+ var signals = arguments;
|
|
|
+ return new QuickSignal(function (subscriber) {
|
|
|
+ var disposables = [];
|
|
|
+ for (var i = signals.length - 1; i >= 0; i--) {
|
|
|
+ disposables.push(signals[i].subscribe(function () {
|
|
|
+ subscriber.apply(null, arguments);
|
|
|
+ }));
|
|
|
+ }
|
|
|
+ subscriber.$dispose = function () {
|
|
|
+ for (var i = disposables.length - 1; i >= 0; i--) {
|
|
|
+ if (disposables[i]) disposables[i]();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// Returns a signal from DOM events of a target.
|
|
|
+function signalFromEvent (target, event) {
|
|
|
+ return new QuickSignal(function (subscriber) {
|
|
|
+ var handler = function (e) {
|
|
|
+ subscriber(e);
|
|
|
+ };
|
|
|
+ var el = angular.element(target);
|
|
|
+ el.on(event, handler);
|
|
|
+ subscriber.$dispose = function () {
|
|
|
+ el.off(event, handler);
|
|
|
+ };
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function signalSingle (value) {
|
|
|
+ return new QuickSignal(function (subscriber) {
|
|
|
+ setTimeout(function() { subscriber(value); });
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// Module loaders exports
|
|
|
+if (typeof define === 'function' && define.amd) {
|
|
|
+ define(['angular'], angularInviewModule);
|
|
|
+} else if (typeof module !== 'undefined' && module && module.exports) {
|
|
|
+ module.exports = angularInviewModule;
|
|
|
+}
|
|
|
+
|
|
|
+})();
|