/* * AngularJs Fullcalendar Wrapper for the JQuery FullCalendar * API @ http://arshaw.com/fullcalendar/ * * Angular Calendar Directive that takes in the [eventSources] nested array object as the ng-model and watches it deeply changes. * Can also take in multiple event urls as a source object(s) and feed the events per view. * The calendar will watch any eventSource array and update itself when a change is made. * */ angular.module('ui.calendar', []) .constant('uiCalendarConfig', {calendars: {}}) .controller('uiCalendarCtrl', ['$scope', '$timeout', '$locale', function( $scope, $timeout, $locale){ var sourceSerialId = 1, eventSerialId = 1, sources = $scope.eventSources, extraEventSignature = $scope.calendarWatchEvent ? $scope.calendarWatchEvent : angular.noop, wrapFunctionWithScopeApply = function(functionToWrap){ var wrapper; if (functionToWrap){ wrapper = function(){ // This happens outside of angular context so we need to wrap it in a timeout which has an implied apply. // In this way the function will be safely executed on the next digest. var args = arguments; var _this = this; $timeout(function(){ functionToWrap.apply(_this, args); }); }; } return wrapper; }; this.eventsFingerprint = function(e) { if (!e._id) { e._id = eventSerialId++; } // This extracts all the information we need from the event. http://jsperf.com/angular-calendar-events-fingerprint/3 return "" + e._id + (e.id || '') + (e.title || '') + (e.url || '') + (+e.start || '') + (+e.end || '') + (e.allDay || '') + (e.className || '') + extraEventSignature(e) || ''; }; this.sourcesFingerprint = function(source) { return source.__id || (source.__id = sourceSerialId++); }; this.allEvents = function() { // return sources.flatten(); but we don't have flatten var arraySources = []; for (var i = 0, srcLen = sources.length; i < srcLen; i++) { var source = sources[i]; if (angular.isArray(source)) { // event source as array arraySources.push(source); } else if(angular.isObject(source) && angular.isArray(source.events)){ // event source as object, ie extended form var extEvent = {}; for(var key in source){ if(key !== '_uiCalId' && key !== 'events'){ extEvent[key] = source[key]; } } for(var eI = 0;eI < source.events.length;eI++){ angular.extend(source.events[eI],extEvent); } arraySources.push(source.events); } } return Array.prototype.concat.apply([], arraySources); }; // Track changes in array by assigning id tokens to each element and watching the scope for changes in those tokens // arguments: // arraySource array of function that returns array of objects to watch // tokenFn function(object) that returns the token for a given object this.changeWatcher = function(arraySource, tokenFn) { var self; var getTokens = function() { var array = angular.isFunction(arraySource) ? arraySource() : arraySource; var result = [], token, el; for (var i = 0, n = array.length; i < n; i++) { el = array[i]; token = tokenFn(el); map[token] = el; result.push(token); } return result; }; // returns elements in that are in a but not in b // subtractAsSets([4, 5, 6], [4, 5, 7]) => [6] var subtractAsSets = function(a, b) { var result = [], inB = {}, i, n; for (i = 0, n = b.length; i < n; i++) { inB[b[i]] = true; } for (i = 0, n = a.length; i < n; i++) { if (!inB[a[i]]) { result.push(a[i]); } } return result; }; // Map objects to tokens and vice-versa var map = {}; var applyChanges = function(newTokens, oldTokens) { var i, n, el, token; var replacedTokens = {}; var removedTokens = subtractAsSets(oldTokens, newTokens); for (i = 0, n = removedTokens.length; i < n; i++) { var removedToken = removedTokens[i]; el = map[removedToken]; delete map[removedToken]; var newToken = tokenFn(el); // if the element wasn't removed but simply got a new token, its old token will be different from the current one if (newToken === removedToken) { self.onRemoved(el); } else { replacedTokens[newToken] = removedToken; self.onChanged(el); } } var addedTokens = subtractAsSets(newTokens, oldTokens); for (i = 0, n = addedTokens.length; i < n; i++) { token = addedTokens[i]; el = map[token]; if (!replacedTokens[token]) { self.onAdded(el); } } }; return self = { subscribe: function(scope, onChanged) { scope.$watch(getTokens, function(newTokens, oldTokens) { if (!onChanged || onChanged(newTokens, oldTokens) !== false) { applyChanges(newTokens, oldTokens); } }, true); }, onAdded: angular.noop, onChanged: angular.noop, onRemoved: angular.noop }; }; this.getFullCalendarConfig = function(calendarSettings, uiCalendarConfig){ var config = {}; angular.extend(config, uiCalendarConfig); angular.extend(config, calendarSettings); angular.forEach(config, function(value,key){ if (typeof value === 'function'){ config[key] = wrapFunctionWithScopeApply(config[key]); } }); return config; }; this.getLocaleConfig = function(fullCalendarConfig) { if (!fullCalendarConfig.lang || fullCalendarConfig.useNgLocale) { // Configure to use locale names by default var tValues = function(data) { // convert {0: "Jan", 1: "Feb", ...} to ["Jan", "Feb", ...] var r, k; r = []; for (k in data) { r[k] = data[k]; } return r; }; var dtf = $locale.DATETIME_FORMATS; return { monthNames: tValues(dtf.MONTH), monthNamesShort: tValues(dtf.SHORTMONTH), dayNames: tValues(dtf.DAY), dayNamesShort: tValues(dtf.SHORTDAY) }; } return {}; }; }]) .directive('uiCalendar', ['uiCalendarConfig', function(uiCalendarConfig) { return { restrict: 'A', scope: {eventSources:'=ngModel',calendarWatchEvent: '&'}, controller: 'uiCalendarCtrl', link: function(scope, elm, attrs, controller) { var sources = scope.eventSources, sourcesChanged = false, calendar, eventSourcesWatcher = controller.changeWatcher(sources, controller.sourcesFingerprint), eventsWatcher = controller.changeWatcher(controller.allEvents, controller.eventsFingerprint), options = null; function getOptions(){ var calendarSettings = attrs.uiCalendar ? scope.$parent.$eval(attrs.uiCalendar) : {}, fullCalendarConfig; fullCalendarConfig = controller.getFullCalendarConfig(calendarSettings, uiCalendarConfig); var localeFullCalendarConfig = controller.getLocaleConfig(fullCalendarConfig); angular.extend(localeFullCalendarConfig, fullCalendarConfig); options = { eventSources: sources }; angular.extend(options, localeFullCalendarConfig); //remove calendars from options options.calendars = null; var options2 = {}; for(var o in options){ if(o !== 'eventSources'){ options2[o] = options[o]; } } return JSON.stringify(options2); } scope.destroy = function(){ if(calendar && calendar.fullCalendar){ calendar.fullCalendar('destroy'); } if(attrs.calendar) { calendar = uiCalendarConfig.calendars[attrs.calendar] = $(elm).html(''); } else { calendar = $(elm).html(''); } }; scope.init = function(){ calendar.fullCalendar(options); }; eventSourcesWatcher.onAdded = function(source) { calendar.fullCalendar('addEventSource', source); sourcesChanged = true; }; eventSourcesWatcher.onRemoved = function(source) { calendar.fullCalendar('removeEventSource', source); sourcesChanged = true; }; eventsWatcher.onAdded = function(event) { calendar.fullCalendar('renderEvent', event); }; eventsWatcher.onRemoved = function(event) { calendar.fullCalendar('removeEvents', function(e) { return e._id === event._id; }); }; eventsWatcher.onChanged = function(event) { event._start = $.fullCalendar.moment(event.start); event._end = $.fullCalendar.moment(event.end); calendar.fullCalendar('updateEvent', event); }; eventSourcesWatcher.subscribe(scope); eventsWatcher.subscribe(scope, function(newTokens, oldTokens) { if (sourcesChanged === true) { sourcesChanged = false; // prevent incremental updates in this case return false; } }); scope.$watch(getOptions, function(newO,oldO){ scope.destroy(); scope.init(); }); } }; }]);