// # 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'));
      }

      // Merged with the page focus/blur events
      if (options.considerPageFocus) {
        viewportEventSignal = viewportEventSignal.merge(signalFromEvent(window, 'focus blur'));
      }

      // 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 documentFocussed = !options.considerPageFocus || document.hasFocus();
        var info = {
          inView: documentVisible && documentFocussed && 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;
}

})();
