From 03cf7c38882c8b034e49719d3360dd5556ca8bc3 Mon Sep 17 00:00:00 2001 From: swarn Date: Wed, 14 Jun 2017 11:28:08 +0530 Subject: [PATCH] minor changes due to ui updation and integrated schedular --- app/css/custom.css | 4 +- app/index.html | 11 +- app/js/app.js | 2 +- app/js/fullcalender.js | 10795 ++++++++++++++++++- app/partials/scheduler/scheduler.controller.js | 117 +- app/partials/scheduler/scheduler.html | 27 +- .../updateFuelManager.controller.js | 6 +- .../updateFuelManager/updateFuelManager.html | 6 +- bower.json | 3 +- 9 files changed, 10869 insertions(+), 102 deletions(-) diff --git a/app/css/custom.css b/app/css/custom.css index 4ec2b1c..b3c9c67 100644 --- a/app/css/custom.css +++ b/app/css/custom.css @@ -185,7 +185,6 @@ font-size: 10px !important; } .customAccordianHeader > span{ font-size: 15px; - margin-right: 20px; line-height: 22px; float: left; } @@ -200,7 +199,6 @@ font-size: 10px !important; width: 150px; height: 26px; padding: 0 6px; - margin-right: 20px; float: left; } .customAccordianHeader > select.form-control[disabled]{ @@ -213,7 +211,7 @@ font-size: 10px !important; width: auto; } .customAccordianHeader > input.form-control{ - width: 80px; + width: 70px; height: 26px; padding: 0 6px; float: left; diff --git a/app/index.html b/app/index.html index a1e83c5..ff98ed6 100644 --- a/app/index.html +++ b/app/index.html @@ -54,7 +54,7 @@ --> - + @@ -94,9 +94,12 @@ + - + + + + + \ No newline at end of file diff --git a/app/js/app.js b/app/js/app.js index e356dab..1a37e11 100644 --- a/app/js/app.js +++ b/app/js/app.js @@ -1,7 +1,7 @@ 'use strict'; - angular.module('acufuel', ['ngCookies', 'ngResource', 'ui.router', 'ngAnimate', 'ui.bootstrap', 'xeditable', 'ui.toggle', 'ngTable', 'ui.select2', 'ckeditor']) + angular.module('acufuel', ['ngCookies', 'ngResource', 'ui.router', 'ngAnimate', 'ui.bootstrap', 'xeditable', 'ui.toggle', 'ngTable', 'ui.select2', 'ckeditor', 'ui.calendar', 'ngDragDrop']) .config(['$httpProvider', function($httpProvider) { $httpProvider.defaults.withCredentials = true; diff --git a/app/js/fullcalender.js b/app/js/fullcalender.js index 075aa0c..2e0618e 100644 --- a/app/js/fullcalender.js +++ b/app/js/fullcalender.js @@ -1,10 +1,10789 @@ /*! - * FullCalendar v3.4.0 - * Docs & License: https://fullcalendar.io/ - * (c) 2017 Adam Shaw + * FullCalendar v2.3.1 + * Docs & License: http://fullcalendar.io/ + * (c) 2015 Adam Shaw */ -!function(t){"function"==typeof define&&define.amd?define(["jquery","moment"],t):"object"==typeof exports?module.exports=t(require("jquery"),require("moment")):t(jQuery,moment)}(function(t,e){function n(t){return it(t,Qt)}function i(t,e){e.left&&t.css({"border-left-width":1,"margin-left":e.left-1}),e.right&&t.css({"border-right-width":1,"margin-right":e.right-1})}function r(t){t.css({"margin-left":"","margin-right":"","border-left-width":"","border-right-width":""})}function s(){t("body").addClass("fc-not-allowed")}function o(){t("body").removeClass("fc-not-allowed")}function a(e,n,i){var r=Math.floor(n/e.length),s=Math.floor(n-r*(e.length-1)),o=[],a=[],u=[],h=0;l(e),e.each(function(n,i){var l=n===e.length-1?s:r,c=t(i).outerHeight(!0);c *").each(function(e,i){var r=t(i).outerWidth();r>n&&(n=r)}),n++,e.width(n),n}function h(t,e){var n,i=t.add(e);return i.css({position:"relative",left:-1}),n=t.outerHeight()-e.outerHeight(),i.css({position:"",left:""}),n}function c(e){var n=e.css("position"),i=e.parents().filter(function(){var e=t(this);return/(auto|scroll)/.test(e.css("overflow")+e.css("overflow-y")+e.css("overflow-x"))}).eq(0);return"fixed"!==n&&i.length?i:t(e[0].ownerDocument||document)}function d(t,e){var n=t.offset(),i=n.left-(e?e.left:0),r=n.top-(e?e.top:0);return{left:i,right:i+t.outerWidth(),top:r,bottom:r+t.outerHeight()}}function f(t,e){var n=t.offset(),i=p(t),r=n.left+w(t,"border-left-width")+i.left-(e?e.left:0),s=n.top+w(t,"border-top-width")+i.top-(e?e.top:0);return{left:r,right:r+t[0].clientWidth,top:s,bottom:s+t[0].clientHeight}}function g(t,e){var n=t.offset(),i=n.left+w(t,"border-left-width")+w(t,"padding-left")-(e?e.left:0),r=n.top+w(t,"border-top-width")+w(t,"padding-top")-(e?e.top:0);return{left:i,right:i+t.width(),top:r,bottom:r+t.height()}}function p(t){var e,n=t[0].offsetWidth-t[0].clientWidth,i=t[0].offsetHeight-t[0].clientHeight;return n=v(n),i=v(i),e={left:0,right:0,top:0,bottom:i},m()&&"rtl"==t.css("direction")?e.left=n:e.right=n,e}function v(t){return t=Math.max(0,t),t=Math.round(t)}function m(){return null===Xt&&(Xt=y()),Xt}function y(){var e=t("
").css({position:"absolute",top:-1e3,left:0,border:0,padding:0,overflow:"scroll",direction:"rtl"}).appendTo("body"),n=e.children(),i=n.offset().left>e.offset().left;return e.remove(),i}function w(t,e){return parseFloat(t.css(e))||0}function S(t){return 1==t.which&&!t.ctrlKey}function b(t){var e=t.originalEvent.touches;return e&&e.length?e[0].pageX:t.pageX}function E(t){var e=t.originalEvent.touches;return e&&e.length?e[0].pageY:t.pageY}function D(t){return/^touch/.test(t.type)}function T(t){t.addClass("fc-unselectable").on("selectstart",H)}function C(t){t.removeClass("fc-unselectable").off("selectstart",H)}function H(t){t.preventDefault()}function R(t,e){var n={left:Math.max(t.left,e.left),right:Math.min(t.right,e.right),top:Math.max(t.top,e.top),bottom:Math.min(t.bottom,e.bottom)};return n.leftl&&o=l?(n=o.clone(),r=!0):(n=l.clone(),r=!1),a<=u?(i=a.clone(),s=!0):(i=u.clone(),s=!1),{start:n,end:i,isStart:r,isEnd:s}}function F(t,n){return e.duration({days:t.clone().stripTime().diff(n.clone().stripTime(),"days"),ms:t.time()-n.time()})}function A(t,n){return e.duration({days:t.clone().stripTime().diff(n.clone().stripTime(),"days")})}function G(t,n,i){return e.duration(Math.round(t.diff(n,i,!0)),i)}function V(t,e){var n,i,r;for(n=0;n=1&&vt(r)));n++);return i}function O(t,e){var n=V(t);return"week"===n&&"object"==typeof e&&e.days&&(n="day"),n}function P(t,n,i){return null!=i?i.diff(n,t,!0):e.isDuration(n)?n.as(t):n.end.diff(n.start,t,!0)}function _(t,e,n){var i;return tt(n)?(e-t)/n:(i=n.asMonths(),Math.abs(i)>=1&&vt(i)?e.diff(t,"months",!0)/i:e.diff(t,"days",!0)/n.asDays())}function W(t,e){var n,i;return tt(t)||tt(e)?t/e:(n=t.asMonths(),i=e.asMonths(),Math.abs(n)>=1&&vt(n)&&Math.abs(i)>=1&&vt(i)?n/i:t.asDays()/e.asDays())}function Y(t,n){var i;return tt(t)?e.duration(t*n):(i=t.asMonths(),Math.abs(i)>=1&&vt(i)?e.duration({months:i*n}):e.duration({days:t.asDays()*n}))}function q(t){return{start:t.start.clone(),end:t.end.clone()}}function U(t,e){return t=q(t),e.start&&(t.start=j(t.start,e)),e.end&&(t.end=K(t.end,e.end)),t}function j(t,e){return t=t.clone(),e.start&&(t=J(t,e.start)),e.end&&t>=e.end&&(t=e.end.clone().subtract(1)),t}function Z(t,e){return(!e.start||t>=e.start)&&(!e.end||t=e.start)&&(!e.end||t.start=e.start)&&(!e.end||t.end<=e.end)}function X(t,e){return(t.start&&e.start&&t.start.isSame(e.start)||!t.start&&!e.start)&&(t.end&&e.end&&t.end.isSame(e.end)||!t.end&&!e.end)}function K(t,e){return(t.isBefore(e)?t:e).clone()}function J(t,e){return(t.isAfter(e)?t:e).clone()}function tt(t){return Boolean(t.hours()||t.minutes()||t.seconds()||t.milliseconds())}function et(t){return"[object Date]"===Object.prototype.toString.call(t)||t instanceof Date}function nt(t){return/^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(t)}function it(t,e){var n,i,r,s,o,a,l={};if(e)for(n=0;n=0;s--)if("object"==typeof(o=t[s][i]))r.unshift(o);else if(void 0!==o){l[i]=o;break}r.length&&(l[i]=it(r))}for(n=t.length-1;n>=0;n--){a=t[n];for(i in a)i in l||(l[i]=a[i])}return l}function rt(t){var e=function(){};return e.prototype=t,new e}function st(t,e){for(var n in t)ot(t,n)&&(e[n]=t[n])}function ot(t,e){return te.call(t,e)}function at(e){return/undefined|null|boolean|number|string/.test(t.type(e))}function lt(e,n,i){if(t.isFunction(e)&&(e=[e]),e){var r,s;for(r=0;r/g,">").replace(/'/g,"'").replace(/"/g,""").replace(/\n/g,"
")}function ct(t){return t.replace(/&.*?;/g,"")}function dt(e){var n=[];return t.each(e,function(t,e){null!=e&&n.push(t+":"+e)}),n.join(";")}function ft(e){var n=[];return t.each(e,function(t,e){null!=e&&n.push(t+'="'+ht(e)+'"')}),n.join(" ")}function gt(t){return t.charAt(0).toUpperCase()+t.slice(1)}function pt(t,e){return t-e}function vt(t){return t%1==0}function mt(t,e){var n=t[e];return function(){return n.apply(t,arguments)}}function yt(t,e,n){var i,r,s,o,a,l=function(){var u=+new Date-o;u=t.leftCol)return!0;return!1}function Ft(t,e){return t.leftCol-e.leftCol}function At(t){var e,n,i,r=[];for(e=0;ee.top&&t.top"),g.append(o("left")).append(o("right")).append(o("center")).append('
')):s()}function s(){g&&(g.remove(),g=f.el=null)}function o(i){var r=t('
'),s=n.layout[i],o=e.opt("customButtons")||{},a=e.opt("buttonText")||{};return s&&t.each(s.split(" "),function(n){var i,s=t(),l=!0;t.each(this.split(","),function(n,i){var r,u,h,c,d,f,g,m,y,w;"title"==i?(s=s.add(t("

 

")),l=!1):((r=o[i])?(h=function(t){r.click&&r.click.call(w[0],t)},c="",d=r.text):(u=e.getViewSpec(i))?(h=function(){e.changeView(i)},v.push(i),c=u.buttonTextOverride,d=u.buttonTextDefault):e[i]&&(h=function(){e[i]()},c=(e.overrides.buttonText||{})[i],d=a[i]),h&&(f=r?r.themeIcon:e.opt("themeButtonIcons")[i],g=r?r.icon:e.opt("buttonIcons")[i],m=c?ht(c):f&&e.opt("theme")?"":g&&!e.opt("theme")?"":ht(d),y=["fc-"+i+"-button",p+"-button",p+"-state-default"],w=t('").click(function(t){w.hasClass(p+"-state-disabled")||(h(t),(w.hasClass(p+"-state-active")||w.hasClass(p+"-state-disabled"))&&w.removeClass(p+"-state-hover"))}).mousedown(function(){w.not("."+p+"-state-active").not("."+p+"-state-disabled").addClass(p+"-state-down")}).mouseup(function(){w.removeClass(p+"-state-down")}).hover(function(){w.not("."+p+"-state-active").not("."+p+"-state-disabled").addClass(p+"-state-hover")},function(){w.removeClass(p+"-state-hover").removeClass(p+"-state-down")}),s=s.add(w)))}),l&&s.first().addClass(p+"-corner-left").end().last().addClass(p+"-corner-right").end(),s.length>1?(i=t("
"),l&&i.addClass("fc-button-group"),i.append(s),r.append(i)):r.append(s)}),r}function a(t){g&&g.find("h2").text(t)}function l(t){g&&g.find(".fc-"+t+"-button").addClass(p+"-state-active")}function u(t){g&&g.find(".fc-"+t+"-button").removeClass(p+"-state-active")}function h(t){g&&g.find(".fc-"+t+"-button").prop("disabled",!0).addClass(p+"-state-disabled")}function c(t){g&&g.find(".fc-"+t+"-button").prop("disabled",!1).removeClass(p+"-state-disabled")}function d(){return v}var f=this;f.setToolbarOptions=i,f.render=r,f.removeElement=s,f.updateTitle=a,f.activateButton=l,f.deactivateButton=u,f.disableButton=h,f.enableButton=c,f.getViewsWithButtons=d,f.el=null;var g,p,v=[]}function Yt(e){t.each(Me,function(t,n){null==e[t]&&(e[t]=n(e))})}function qt(t){return e.localeData(t)||e.localeData("en")}function Ut(){function n(t,e){return!q.opt("lazyFetching")||s(t,e)?o(t,e):he.resolve(Z)}function i(){Z=r(K),q.trigger("eventsReset",Z)}function r(t){var e,n,i=[];for(e=0;eU&&i.push(n);return i}function s(t,e){return!U||tj}function o(t,e){return U=t,j=e,a()}function a(){return u(Q,"reset")}function l(t){return u(b(t))}function u(t,e){var n,i;for("reset"===e?K=[]:"add"!==e&&(K=C(K,t)),n=0;ns&&(!l[o]||u.isSame(h,l[o]))&&(o-1!==s||"."!==f[o]);o--)v=f[o]+v;for(a=s;a<=o;a++)m+=f[a],y+=g[a];return(m||y)&&(w=r?y+i+m:m+i+y),d(p+w+v)}function r(t){return S[t]||(S[t]=s(t))}function s(t){var e=o(t);return{fakeFormatString:l(e),sameUnits:u(e)}}function o(t){for(var e,n=[],i=/\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g;e=i.exec(t);)e[1]?n.push.apply(n,a(e[1])):e[2]?n.push({maybe:o(e[2])}):e[3]?n.push({token:e[3]}):e[5]&&n.push.apply(n,a(e[5]));return n}function a(t){return". "===t?["."," "]:[t]}function l(t){var e,n,i=[];for(e=0;er.value)&&(r=i);return r?r.unit:null}Zt.formatDate=t,Zt.formatRange=n,Zt.oldMomentFormat=e,Zt.queryMostGranularFormatUnit=f;var g="\v",p="",v="",m=new RegExp(v+"([^"+v+"]*)"+v,"g"),y={t:function(t){return e(t,"a").charAt(0)},T:function(t){return e(t,"A").charAt(0)}},w={Y:{value:1,unit:"year"},M:{value:2,unit:"month"},W:{value:3,unit:"week"},w:{value:3,unit:"week"},D:{value:4,unit:"day"},d:{value:4,unit:"day"}},S={}}();var oe=Zt.formatDate,ae=Zt.formatRange,le=Zt.oldMomentFormat;Zt.Class=bt,bt.extend=function(){var t,e,n=arguments.length;for(t=0;t=0;e--)n=i[e],n.namespace!==t.namespace||"add"!==n.type&&"remove"!==n.type||i.splice(e,1);"destroy"===t.type?i.length&&(n=i[i.length-1],n.namespace===t.namespace&&("init"===n.type?(r=!1,i.pop()):"destroy"===n.type&&(r=!1))):"init"===t.type&&i.length&&(n=i[i.length-1],n.namespace===t.namespace&&"init"===n.type&&i.pop())}return r&&i.push(t),r}});Zt.RenderQueue=de;var fe=Zt.EmitterMixin={on:function(e,n){return t(this).on(e,this._prepareIntercept(n)),this},one:function(e,n){return t(this).one(e,this._prepareIntercept(n)),this},_prepareIntercept:function(e){var n=function(t,n){return e.apply(n.context||this,n.args||[])};return e.guid||(e.guid=t.guid++),n.guid=e.guid,n}, -off:function(e,n){return t(this).off(e,n),this},trigger:function(e){var n=Array.prototype.slice.call(arguments,1);return t(this).triggerHandler(e,{args:n}),this},triggerWith:function(e,n,i){return t(this).triggerHandler(e,{context:n,args:i}),this}},ge=Zt.ListenerMixin=function(){var e=0;return{listenerId:null,listenTo:function(e,n,i){if("object"==typeof n)for(var r in n)n.hasOwnProperty(r)&&this.listenTo(e,r,n[r]);else"string"==typeof n&&e.on(n+"."+this.getListenerNamespace(),t.proxy(i,this))},stopListeningTo:function(t,e){t.off((e||"")+"."+this.getListenerNamespace())},getListenerNamespace:function(){return null==this.listenerId&&(this.listenerId=e++),"_listener"+this.listenerId}}}(),pe=bt.extend(ge,{isHidden:!0,options:null,el:null,margin:10,constructor:function(t){this.options=t||{}},show:function(){this.isHidden&&(this.el||this.render(),this.el.show(),this.position(),this.isHidden=!1,this.trigger("show"))},hide:function(){this.isHidden||(this.el.hide(),this.isHidden=!0,this.trigger("hide"))},render:function(){var e=this,n=this.options;this.el=t('
').addClass(n.className||"").css({top:0,left:0}).append(n.content).appendTo(n.parentEl),this.el.on("click",".fc-close",function(){e.hide()}),n.autoHide&&this.listenTo(t(document),"mousedown",this.documentMousedown)},documentMousedown:function(e){this.el&&!t(e.target).closest(this.el).length&&this.hide()},removeElement:function(){this.hide(),this.el&&(this.el.remove(),this.el=null),this.stopListeningTo(t(document),"mousedown")},position:function(){var e,n,i,r,s,o=this.options,a=this.el.offsetParent().offset(),l=this.el.outerWidth(),u=this.el.outerHeight(),h=t(window),d=c(this.el);r=o.top||0,s=void 0!==o.left?o.left:void 0!==o.right?o.right-l:0,d.is(window)||d.is(document)?(d=h,e=0,n=0):(i=d.offset(),e=i.top,n=i.left),e+=h.scrollTop(),n+=h.scrollLeft(),!1!==o.viewportConstrain&&(r=Math.min(r,e+d.outerHeight()-u-this.margin),r=Math.max(r,e+this.margin),s=Math.min(s,n+d.outerWidth()-l-this.margin),s=Math.max(s,n+this.margin)),this.el.css({top:r-a.top,left:s-a.left})},trigger:function(t){this.options[t]&&this.options[t].apply(this,Array.prototype.slice.call(arguments,1))}}),ve=Zt.CoordCache=bt.extend({els:null,forcedOffsetParentEl:null,origin:null,boundingRect:null,isHorizontal:!1,isVertical:!1,lefts:null,rights:null,tops:null,bottoms:null,constructor:function(e){this.els=t(e.els),this.isHorizontal=e.isHorizontal,this.isVertical=e.isVertical,this.forcedOffsetParentEl=e.offsetParent?t(e.offsetParent):null},build:function(){var t=this.forcedOffsetParentEl;!t&&this.els.length>0&&(t=this.els.eq(0).offsetParent()),this.origin=t?t.offset():null,this.boundingRect=this.queryBoundingRect(),this.isHorizontal&&this.buildElHorizontals(),this.isVertical&&this.buildElVerticals()},clear:function(){this.origin=null,this.boundingRect=null,this.lefts=null,this.rights=null,this.tops=null,this.bottoms=null},ensureBuilt:function(){this.origin||this.build()},buildElHorizontals:function(){var e=[],n=[];this.els.each(function(i,r){var s=t(r),o=s.offset().left,a=s.outerWidth();e.push(o),n.push(o+a)}),this.lefts=e,this.rights=n},buildElVerticals:function(){var e=[],n=[];this.els.each(function(i,r){var s=t(r),o=s.offset().top,a=s.outerHeight();e.push(o),n.push(o+a)}),this.tops=e,this.bottoms=n},getHorizontalIndex:function(t){this.ensureBuilt();var e,n=this.lefts,i=this.rights,r=n.length;for(e=0;e=n[e]&&t=n[e]&&t0&&(t=c(this.els.eq(0)),!t.is(document))?f(t):null},isPointInBounds:function(t,e){return this.isLeftInBounds(t)&&this.isTopInBounds(e)},isLeftInBounds:function(t){return!this.boundingRect||t>=this.boundingRect.left&&t=this.boundingRect.top&&t=i*i&&this.handleDistanceSurpassed(t),this.isDragging&&this.handleDrag(e,n,t)},handleDrag:function(t,e,n){this.trigger("drag",t,e,n),this.updateAutoScroll(n)},endDrag:function(t){this.isDragging&&(this.isDragging=!1,this.handleDragEnd(t))},handleDragEnd:function(t){this.trigger("dragEnd",t)},startDelay:function(t){var e=this;this.delay?this.delayTimeoutId=setTimeout(function(){e.handleDelayEnd(t)},this.delay):this.handleDelayEnd(t)},handleDelayEnd:function(t){this.isDelayEnded=!0,this.isDistanceSurpassed&&this.startDrag(t)},handleDistanceSurpassed:function(t){this.isDistanceSurpassed=!0,this.isDelayEnded&&this.startDrag(t)},handleTouchMove:function(t){this.isDragging&&this.shouldCancelTouchScroll&&t.preventDefault(),this.handleMove(t)},handleMouseMove:function(t){this.handleMove(t)},handleTouchScroll:function(t){this.isDragging&&!this.scrollAlwaysKills||this.endInteraction(t,!0)},trigger:function(t){this.options[t]&&this.options[t].apply(this,Array.prototype.slice.call(arguments,1)),this["_"+t]&&this["_"+t].apply(this,Array.prototype.slice.call(arguments,1))}});me.mixin({isAutoScroll:!1,scrollBounds:null,scrollTopVel:null,scrollLeftVel:null,scrollIntervalId:null,scrollSensitivity:30,scrollSpeed:200,scrollIntervalMs:50,initAutoScroll:function(){var t=this.scrollEl;this.isAutoScroll=this.options.scroll&&t&&!t.is(window)&&!t.is(document),this.isAutoScroll&&this.listenTo(t,"scroll",yt(this.handleDebouncedScroll,100))},destroyAutoScroll:function(){this.endAutoScroll(),this.isAutoScroll&&this.stopListeningTo(this.scrollEl,"scroll")},computeScrollBounds:function(){this.isAutoScroll&&(this.scrollBounds=d(this.scrollEl))},updateAutoScroll:function(t){var e,n,i,r,s=this.scrollSensitivity,o=this.scrollBounds,a=0,l=0;o&&(e=(s-(E(t)-o.top))/s,n=(s-(o.bottom-E(t)))/s,i=(s-(b(t)-o.left))/s,r=(s-(o.right-b(t)))/s,e>=0&&e<=1?a=e*this.scrollSpeed*-1:n>=0&&n<=1&&(a=n*this.scrollSpeed),i>=0&&i<=1?l=i*this.scrollSpeed*-1:r>=0&&r<=1&&(l=r*this.scrollSpeed)),this.setScrollVel(a,l)},setScrollVel:function(t,e){this.scrollTopVel=t,this.scrollLeftVel=e,this.constrainScrollVel(),!this.scrollTopVel&&!this.scrollLeftVel||this.scrollIntervalId||(this.scrollIntervalId=setInterval(mt(this,"scrollIntervalFunc"),this.scrollIntervalMs))},constrainScrollVel:function(){var t=this.scrollEl;this.scrollTopVel<0?t.scrollTop()<=0&&(this.scrollTopVel=0):this.scrollTopVel>0&&t.scrollTop()+t[0].clientHeight>=t[0].scrollHeight&&(this.scrollTopVel=0),this.scrollLeftVel<0?t.scrollLeft()<=0&&(this.scrollLeftVel=0):this.scrollLeftVel>0&&t.scrollLeft()+t[0].clientWidth>=t[0].scrollWidth&&(this.scrollLeftVel=0)},scrollIntervalFunc:function(){var t=this.scrollEl,e=this.scrollIntervalMs/1e3;this.scrollTopVel&&t.scrollTop(t.scrollTop()+this.scrollTopVel*e),this.scrollLeftVel&&t.scrollLeft(t.scrollLeft()+this.scrollLeftVel*e),this.constrainScrollVel(),this.scrollTopVel||this.scrollLeftVel||this.endAutoScroll()},endAutoScroll:function(){this.scrollIntervalId&&(clearInterval(this.scrollIntervalId),this.scrollIntervalId=null,this.handleScrollEnd())},handleDebouncedScroll:function(){this.scrollIntervalId||this.handleScrollEnd()},handleScrollEnd:function(){}});var ye=me.extend({component:null,origHit:null,hit:null,coordAdjust:null,constructor:function(t,e){me.call(this,e),this.component=t},handleInteractionStart:function(t){var e,n,i,r=this.subjectEl;this.component.hitsNeeded(),this.computeScrollBounds(),t?(n={left:b(t),top:E(t)},i=n,r&&(e=d(r),i=x(i,e)),this.origHit=this.queryHit(i.left,i.top),r&&this.options.subjectCenter&&(this.origHit&&(e=R(this.origHit,e)||e),i=I(e)),this.coordAdjust=k(i,n)):(this.origHit=null,this.coordAdjust=null),me.prototype.handleInteractionStart.apply(this,arguments)},handleDragStart:function(t){var e;me.prototype.handleDragStart.apply(this,arguments),(e=this.queryHit(b(t),E(t)))&&this.handleHitOver(e)},handleDrag:function(t,e,n){var i;me.prototype.handleDrag.apply(this,arguments),i=this.queryHit(b(n),E(n)),Ht(i,this.hit)||(this.hit&&this.handleHitOut(),i&&this.handleHitOver(i))},handleDragEnd:function(){this.handleHitDone(),me.prototype.handleDragEnd.apply(this,arguments)},handleHitOver:function(t){var e=Ht(t,this.origHit);this.hit=t,this.trigger("hitOver",this.hit,e,this.origHit)},handleHitOut:function(){this.hit&&(this.trigger("hitOut",this.hit),this.handleHitDone(),this.hit=null)},handleHitDone:function(){this.hit&&this.trigger("hitDone",this.hit)},handleInteractionEnd:function(){me.prototype.handleInteractionEnd.apply(this,arguments),this.origHit=null,this.hit=null,this.component.hitsNotNeeded()},handleScrollEnd:function(){me.prototype.handleScrollEnd.apply(this,arguments),this.isDragging&&(this.component.releaseHits(),this.component.prepareHits())},queryHit:function(t,e){return this.coordAdjust&&(t+=this.coordAdjust.left,e+=this.coordAdjust.top),this.component.queryHit(t,e)}});Zt.touchMouseIgnoreWait=500;var we=bt.extend(ge,fe,{isTouching:!1,mouseIgnoreDepth:0,handleScrollProxy:null,bind:function(){var e=this;this.listenTo(t(document),{touchstart:this.handleTouchStart,touchcancel:this.handleTouchCancel,touchend:this.handleTouchEnd,mousedown:this.handleMouseDown,mousemove:this.handleMouseMove,mouseup:this.handleMouseUp,click:this.handleClick,selectstart:this.handleSelectStart,contextmenu:this.handleContextMenu}),window.addEventListener("touchmove",this.handleTouchMoveProxy=function(n){e.handleTouchMove(t.Event(n))},{passive:!1}),window.addEventListener("scroll",this.handleScrollProxy=function(n){e.handleScroll(t.Event(n))},!0)},unbind:function(){this.stopListeningTo(t(document)),window.removeEventListener("touchmove",this.handleTouchMoveProxy),window.removeEventListener("scroll",this.handleScrollProxy,!0)},handleTouchStart:function(t){this.stopTouch(t,!0),this.isTouching=!0,this.trigger("touchstart",t)},handleTouchMove:function(t){this.isTouching&&this.trigger("touchmove",t)},handleTouchCancel:function(t){this.isTouching&&(this.trigger("touchcancel",t),this.stopTouch(t))},handleTouchEnd:function(t){this.stopTouch(t)},handleMouseDown:function(t){this.shouldIgnoreMouse()||this.trigger("mousedown",t)},handleMouseMove:function(t){this.shouldIgnoreMouse()||this.trigger("mousemove",t)},handleMouseUp:function(t){this.shouldIgnoreMouse()||this.trigger("mouseup",t)},handleClick:function(t){this.shouldIgnoreMouse()||this.trigger("click",t)},handleSelectStart:function(t){this.trigger("selectstart",t)},handleContextMenu:function(t){this.trigger("contextmenu",t)},handleScroll:function(t){this.trigger("scroll",t)},stopTouch:function(t,e){this.isTouching&&(this.isTouching=!1,this.trigger("touchend",t),e||this.startTouchMouseIgnore())},startTouchMouseIgnore:function(){var t=this,e=Zt.touchMouseIgnoreWait;e&&(this.mouseIgnoreDepth++,setTimeout(function(){t.mouseIgnoreDepth--},e))},shouldIgnoreMouse:function(){return this.isTouching||Boolean(this.mouseIgnoreDepth)}});!function(){var t=null,e=0;we.get=function(){return t||(t=new we,t.bind()),t},we.needed=function(){we.get(),e++},we.unneeded=function(){--e||(t.unbind(),t=null)}}();var Se=bt.extend(ge,{options:null,sourceEl:null,el:null,parentEl:null,top0:null,left0:null,y0:null,x0:null,topDelta:null,leftDelta:null,isFollowing:!1,isHidden:!1,isAnimating:!1,constructor:function(e,n){this.options=n=n||{},this.sourceEl=e,this.parentEl=n.parentEl?t(n.parentEl):e.parent()},start:function(e){this.isFollowing||(this.isFollowing=!0,this.y0=E(e),this.x0=b(e),this.topDelta=0,this.leftDelta=0,this.isHidden||this.updatePosition(),D(e)?this.listenTo(t(document),"touchmove",this.handleMove):this.listenTo(t(document),"mousemove",this.handleMove))},stop:function(e,n){function i(){r.isAnimating=!1,r.removeElement(),r.top0=r.left0=null,n&&n()}var r=this,s=this.options.revertDuration;this.isFollowing&&!this.isAnimating&&(this.isFollowing=!1,this.stopListeningTo(t(document)),e&&s&&!this.isHidden?(this.isAnimating=!0,this.el.animate({top:this.top0,left:this.left0},{duration:s,complete:i})):i())},getEl:function(){var t=this.el;return t||(t=this.el=this.sourceEl.clone().addClass(this.options.additionalClass||"").css({position:"absolute",visibility:"",display:this.isHidden?"none":"",margin:0,right:"auto",bottom:"auto",width:this.sourceEl.width(),height:this.sourceEl.height(),opacity:this.options.opacity||"",zIndex:this.options.zIndex}),t.addClass("fc-unselectable"),t.appendTo(this.parentEl)),t},removeElement:function(){this.el&&(this.el.remove(),this.el=null)},updatePosition:function(){var t,e;this.getEl(),null===this.top0&&(t=this.sourceEl.offset(),e=this.el.offsetParent().offset(),this.top0=t.top-e.top,this.left0=t.left-e.left),this.el.css({top:this.top0+this.topDelta,left:this.left0+this.leftDelta})},handleMove:function(t){this.topDelta=E(t)-this.y0,this.leftDelta=b(t)-this.x0,this.isHidden||this.updatePosition()},hide:function(){this.isHidden||(this.isHidden=!0,this.el&&this.el.hide())},show:function(){this.isHidden&&(this.isHidden=!1,this.updatePosition(),this.getEl().show())}}),be=Zt.Grid=bt.extend(ge,{hasDayInteractions:!0,view:null,isRTL:null,start:null,end:null,el:null,elsByFill:null,eventTimeFormat:null,displayEventTime:null,displayEventEnd:null,minResizeDuration:null,largeUnit:null,dayClickListener:null,daySelectListener:null,segDragListener:null,segResizeListener:null,externalDragListener:null,constructor:function(t){this.view=t,this.isRTL=t.opt("isRTL"),this.elsByFill={},this.dayClickListener=this.buildDayClickListener(),this.daySelectListener=this.buildDaySelectListener()},computeEventTimeFormat:function(){return this.view.opt("smallTimeFormat")},computeDisplayEventTime:function(){return!0},computeDisplayEventEnd:function(){return!0},setRange:function(t){this.start=t.start.clone(),this.end=t.end.clone(),this.rangeUpdated(),this.processRangeOptions()},rangeUpdated:function(){},processRangeOptions:function(){var t,e,n=this.view;this.eventTimeFormat=n.opt("eventTimeFormat")||n.opt("timeFormat")||this.computeEventTimeFormat(),t=n.opt("displayEventTime"),null==t&&(t=this.computeDisplayEventTime()),e=n.opt("displayEventEnd"),null==e&&(e=this.computeDisplayEventEnd()),this.displayEventTime=t,this.displayEventEnd=e},spanToSegs:function(t){},diffDates:function(t,e){return this.largeUnit?G(t,e,this.largeUnit):F(t,e)},hitsNeededDepth:0,hitsNeeded:function(){this.hitsNeededDepth++||this.prepareHits()},hitsNotNeeded:function(){this.hitsNeededDepth&&!--this.hitsNeededDepth&&this.releaseHits()},prepareHits:function(){},releaseHits:function(){},queryHit:function(t,e){},getSafeHitSpan:function(t){var e=this.getHitSpan(t);return Q(e,this.view.activeRange)?e:null},getHitSpan:function(t){},getHitEl:function(t){},setElement:function(t){this.el=t,this.hasDayInteractions&&(T(t),this.bindDayHandler("touchstart",this.dayTouchStart),this.bindDayHandler("mousedown",this.dayMousedown)),this.bindSegHandlers(),this.bindGlobalHandlers()},bindDayHandler:function(e,n){var i=this;this.el.on(e,function(e){if(!t(e.target).is(i.segSelector+","+i.segSelector+" *,.fc-more,a[data-goto]"))return n.call(i,e)})},removeElement:function(){this.unbindGlobalHandlers(),this.clearDragListeners(),this.el.remove()},renderSkeleton:function(){},renderDates:function(){},unrenderDates:function(){},bindGlobalHandlers:function(){this.listenTo(t(document),{dragstart:this.externalDragStart,sortstart:this.externalDragStart})},unbindGlobalHandlers:function(){this.stopListeningTo(t(document))},dayMousedown:function(t){var e=this.view;we.get().shouldIgnoreMouse()||(this.dayClickListener.startInteraction(t),e.opt("selectable")&&this.daySelectListener.startInteraction(t,{distance:e.opt("selectMinDistance")}))},dayTouchStart:function(t){var e,n=this.view;n.isSelected||n.selectedEvent||(e=n.opt("selectLongPressDelay"),null==e&&(e=n.opt("longPressDelay")),this.dayClickListener.startInteraction(t),n.opt("selectable")&&this.daySelectListener.startInteraction(t,{delay:e}))},buildDayClickListener:function(){var t,e=this,n=this.view,i=new ye(this,{scroll:n.opt("dragScroll"),interactionStart:function(){t=i.origHit},hitOver:function(e,n,i){n||(t=null)},hitOut:function(){t=null},interactionEnd:function(i,r){var s;!r&&t&&(s=e.getSafeHitSpan(t))&&n.triggerDayClick(s,e.getHitEl(t),i)}});return i.shouldCancelTouchScroll=!1,i.scrollAlwaysKills=!0,i},buildDaySelectListener:function(){var t,e=this,n=this.view;return new ye(this,{scroll:n.opt("dragScroll"),interactionStart:function(){t=null},dragStart:function(){n.unselect()},hitOver:function(n,i,r){var o,a;r&&(o=e.getSafeHitSpan(r),a=e.getSafeHitSpan(n),t=o&&a?e.computeSelection(o,a):null,t?e.renderSelection(t):!1===t&&s())},hitOut:function(){t=null,e.unrenderSelection()},hitDone:function(){o()},interactionEnd:function(e,i){!i&&t&&n.reportSelection(t,e)}})},clearDragListeners:function(){this.dayClickListener.endInteraction(),this.daySelectListener.endInteraction(),this.segDragListener&&this.segDragListener.endInteraction(),this.segResizeListener&&this.segResizeListener.endInteraction(),this.externalDragListener&&this.externalDragListener.endInteraction()},renderEventLocationHelper:function(t,e){var n=this.fabricateHelperEvent(t,e);return this.renderHelper(n,e)},fabricateHelperEvent:function(t,e){var n=e?rt(e.event):{};return n.start=t.start.clone(),n.end=t.end?t.end.clone():null,n.allDay=null,this.view.calendar.normalizeEventDates(n),n.className=(n.className||[]).concat("fc-helper"),e||(n.editable=!1),n},renderHelper:function(t,e){},unrenderHelper:function(){},renderSelection:function(t){this.renderHighlight(t)},unrenderSelection:function(){this.unrenderHighlight()},computeSelection:function(t,e){var n=this.computeSelectionSpan(t,e);return!(n&&!this.view.calendar.isSelectionSpanAllowed(n))&&n},computeSelectionSpan:function(t,e){var n=[t.start,t.end,e.start,e.end];return n.sort(pt),{start:n[0].clone(),end:n[3].clone()}},renderHighlight:function(t){this.renderFill("highlight",this.spanToSegs(t))},unrenderHighlight:function(){this.unrenderFill("highlight")},highlightSegClasses:function(){return["fc-highlight"]},renderBusinessHours:function(){},unrenderBusinessHours:function(){},getNowIndicatorUnit:function(){},renderNowIndicator:function(t){},unrenderNowIndicator:function(){},renderFill:function(t,e){},unrenderFill:function(t){var e=this.elsByFill[t];e&&(e.remove(),delete this.elsByFill[t])},renderFillSegEls:function(e,n){var i,r=this,s=this[e+"SegEl"],o="",a=[];if(n.length){for(i=0;i"},getDayClasses:function(t,e){var n,i=this.view,r=[];return Z(t,i.activeRange)?(r.push("fc-"+Kt[t.day()]),1==i.currentRangeAs("months")&&t.month()!=i.currentRange.start.month()&&r.push("fc-other-month"),n=i.calendar.getNow(),t.isSame(n,"day")?(r.push("fc-today"),!0!==e&&r.push(i.highlightStateClass)):t *",mousedOverSeg:null,isDraggingSeg:!1,isResizingSeg:!1,isDraggingExternal:!1,segs:null,renderEvents:function(t){var e,n=[],i=[];for(e=0;ea&&o.push({start:a,end:n.start}),n.end>a&&(a=n.end);return a=e.length?e[e.length-1]+1:e[n]},computeColHeadFormat:function(){return this.rowCnt>1||this.colCnt>10?"ddd":this.colCnt>1?this.view.opt("dayOfMonthFormat"):"dddd"},sliceRangeByRow:function(t){var e,n,i,r,s,o=this.daysPerRow,a=this.view.computeDayRange(t),l=this.getDateDayIndex(a.start),u=this.getDateDayIndex(a.end.clone().subtract(1,"days")),h=[];for(e=0;e'+this.renderHeadTrHtml()+"
"},renderHeadIntroHtml:function(){return this.renderIntroHtml()},renderHeadTrHtml:function(){return""+(this.isRTL?"":this.renderHeadIntroHtml())+this.renderHeadDateCellsHtml()+(this.isRTL?this.renderHeadIntroHtml():"")+""},renderHeadDateCellsHtml:function(){var t,e,n=[];for(t=0;t1?' colspan="'+e+'"':"")+(n?" "+n:"")+">"+(r?i.buildGotoAnchorHtml({date:t,forceOff:this.rowCnt>1||1===this.colCnt},o):o)+""},renderBgTrHtml:function(t){return""+(this.isRTL?"":this.renderBgIntroHtml(t))+this.renderBgCellsHtml(t)+(this.isRTL?this.renderBgIntroHtml(t):"")+""},renderBgIntroHtml:function(t){return this.renderIntroHtml()},renderBgCellsHtml:function(t){var e,n,i=[];for(e=0;e"},renderIntroHtml:function(){},bookendCells:function(t){var e=this.renderIntroHtml();e&&(this.isRTL?t.append(e):t.prepend(e))}},De=Zt.DayGrid=be.extend(Ee,{numbersVisible:!1,bottomCoordPadding:0,rowEls:null,cellEls:null,helperEls:null,rowCoordCache:null,colCoordCache:null,renderDates:function(t){var e,n,i=this.view,r=this.rowCnt,s=this.colCnt,o="";for(e=0;e
'+this.renderBgTrHtml(t)+'
'+(this.numbersVisible?""+this.renderNumberTrHtml(t)+"":"")+"
"},renderNumberTrHtml:function(t){return""+(this.isRTL?"":this.renderNumberIntroHtml(t))+this.renderNumberCellsHtml(t)+(this.isRTL?this.renderNumberIntroHtml(t):"")+""},renderNumberIntroHtml:function(t){return this.renderIntroHtml()},renderNumberCellsHtml:function(t){var e,n,i=[];for(e=0;e",i.cellWeekNumbersVisible&&t.day()==n&&(r+=i.buildGotoAnchorHtml({date:t,type:"week"},{class:"fc-week-number"},t.format("w"))),o&&(r+=i.buildGotoAnchorHtml(t,{class:"fc-day-number"},t.date())),r+=""):""},computeEventTimeFormat:function(){return this.view.opt("extraSmallTimeFormat")},computeDisplayEventEnd:function(){return 1==this.colCnt},rangeUpdated:function(){this.updateDayTable()},spanToSegs:function(t){var e,n,i=this.sliceRangeByRow(t);for(e=0;e');o=n&&n.row===e?n.el.position().top:a.find(".fc-content-skeleton tbody").position().top,l.css("top",o).find("table").append(i[e].tbodyEl),a.append(l),r.push(l[0])}),this.helperEls=t(r)},unrenderHelper:function(){this.helperEls&&(this.helperEls.remove(),this.helperEls=null)},fillSegTag:"td",renderFill:function(e,n,i){var r,s,o,a=[];for(n=this.renderFillSegEls(e,n),r=0;r
'),s=r.find("tr"),a>0&&s.append(''),s.append(n.el.attr("colspan",l-a)),l'),this.bookendCells(s),r}});De.mixin({rowStructs:null,unrenderEvents:function(){this.removeSegPopover(),be.prototype.unrenderEvents.apply(this,arguments)},getEventSegs:function(){return be.prototype.getEventSegs.call(this).concat(this.popoverSegs||[])},renderBgSegs:function(e){var n=t.grep(e,function(t){return t.event.allDay});return be.prototype.renderBgSegs.call(this,n)},renderFgSegs:function(e){var n;return e=this.renderFgSegEls(e),n=this.rowStructs=this.renderSegRows(e),this.rowEls.each(function(e,i){t(i).find(".fc-content-skeleton > table").append(n[e].tbodyEl)}),e},unrenderFgSegs:function(){for(var t,e=this.rowStructs||[];t=e.pop();)t.tbodyEl.remove();this.rowStructs=null},renderSegRows:function(t){var e,n,i=[];for(e=this.groupSegRows(t),n=0;n'+ht(n)+""),i=''+(ht(s.title||"")||" ")+"",'
'+(this.isRTL?i+" "+c:c+" "+i)+"
"+(a?'
':"")+(l?'
':"")+""},renderSegRow:function(e,n){function i(e){for(;o"),a.append(h)),v[r][o]=h,m[r][o]=h,o++}var r,s,o,a,l,u,h,c=this.colCnt,d=this.buildSegLevels(n),f=Math.max(1,d.length),g=t(""),p=[],v=[],m=[];for(r=0;r"),p.push([]),v.push([]),m.push([]),s)for(l=0;l').append(u.el),u.leftCol!=u.rightCol?h.attr("colspan",u.rightCol-u.leftCol+1):m[r][o]=h;o<=u.rightCol;)v[r][o]=h,p[r][o]=u,o++;a.append(h)}i(c),this.bookendCells(a),g.append(a)}return{row:e,tbodyEl:g,cellMatrix:v,segMatrix:p,segLevels:d,segs:n}},buildSegLevels:function(t){var e,n,i,r=[];for(this.sortEventSegs(t),e=0;e td > :first-child").each(n),r.position().top+s>a)return i;return!1},limitRow:function(e,n){function i(i){for(;E").append(y),d.append(m),b.push(m[0])),E++}var r,s,o,a,l,u,h,c,d,f,g,p,v,m,y,w=this,S=this.rowStructs[e],b=[],E=0;if(n&&n').attr("rowspan",f),u=c[p],y=this.renderMoreLink(e,l.leftCol+p,[l].concat(u)),m=t("
").append(y),v.append(m),g.push(v[0]),b.push(v[0]);d.addClass("fc-limited").after(t(g)),o.push(d[0])}}i(this.colCnt),S.moreEls=t(b),S.limitedEls=t(o)}},unlimitRow:function(t){var e=this.rowStructs[t];e.moreEls&&(e.moreEls.remove(),e.moreEls=null),e.limitedEls&&(e.limitedEls.removeClass("fc-limited"),e.limitedEls=null)},renderMoreLink:function(e,n,i){var r=this,s=this.view;return t('').text(this.getMoreLinkText(i.length)).on("click",function(o){var a=s.opt("eventLimitClick"),l=r.getCellDate(e,n),u=t(this),h=r.getCellEl(e,n),c=r.getCellSegs(e,n),d=r.resliceDaySegs(c,l),f=r.resliceDaySegs(i,l);"function"==typeof a&&(a=s.publiclyTrigger("eventLimitClick",null,{date:l,dayEl:h,moreEl:u,segs:d,hiddenSegs:f},o)),"popover"===a?r.showSegPopover(e,n,u,d):"string"==typeof a&&s.calendar.zoomTo(l,a)})},showSegPopover:function(t,e,n,i){var r,s,o=this,a=this.view,l=n.parent();r=1==this.rowCnt?a.el:this.rowEls.eq(t),s={className:"fc-more-popover",content:this.renderSegPopoverContent(t,e,i),parentEl:this.view.el,top:r.offset().top,autoHide:!0,viewportConstrain:a.opt("popoverViewportConstrain"),hide:function(){if(o.popoverSegs)for(var t,e=0;e'+ht(a)+'
'),u=l.find(".fc-event-container");for(i=this.renderFgSegEls(i,!0),this.popoverSegs=i,r=0;r'+this.renderBgTrHtml(0)+'
"},renderSlatRowHtml:function(){for(var t,n,i,r=this.view,s=this.isRTL,o="",a=e.duration(+this.view.minTime);a"+(n?""+ht(t.format(this.labelFormat))+"":"")+"",o+='"+(s?"":i)+''+(s?i:"")+"",a.add(this.slotDuration);return o},processOptions:function(){var n,i=this.view,r=i.opt("slotDuration"),s=i.opt("snapDuration");r=e.duration(r),s=s?e.duration(s):r,this.slotDuration=r,this.snapDuration=s,this.snapsPerSlot=r/s,this.minResizeDuration=s,n=i.opt("slotLabelFormat"),t.isArray(n)&&(n=n[n.length-1]),this.labelFormat=n||i.opt("smallTimeFormat"),n=i.opt("slotLabelInterval"),this.labelInterval=n?e.duration(n):this.computeLabelInterval(r)},computeLabelInterval:function(t){var n,i,r;for(n=_e.length-1;n>=0;n--)if(i=e.duration(_e[n]),r=W(i,t),vt(r)&&r>1)return i;return e.duration(t)},computeEventTimeFormat:function(){return this.view.opt("noMeridiemTimeFormat")},computeDisplayEventEnd:function(){return!0},prepareHits:function(){this.colCoordCache.build(),this.slatCoordCache.build()},releaseHits:function(){this.colCoordCache.clear()},queryHit:function(t,e){var n=this.snapsPerSlot,i=this.colCoordCache,r=this.slatCoordCache;if(i.isLeftInBounds(t)&&r.isTopInBounds(e)){var s=i.getHorizontalIndex(t),o=r.getVerticalIndex(e);if(null!=s&&null!=o){var a=r.getTopOffset(o),l=r.getHeight(o),u=(e-a)/l,h=Math.floor(u*n),c=o*n+h,d=a+h/n*l,f=a+(h+1)/n*l;return{col:s,snap:c,component:this,left:i.getLeftOffset(s),right:i.getRightOffset(s),top:d,bottom:f}}}},getHitSpan:function(t){var e,n=this.getCellDate(0,t.col),i=this.computeSnapTime(t.snap);return n.time(i),e=n.clone().add(this.snapDuration),{start:n,end:e}},getHitEl:function(t){return this.colEls.eq(t.col)},rangeUpdated:function(){this.updateDayTable()},computeSnapTime:function(t){return e.duration(this.view.minTime+this.snapDuration*t)},spanToSegs:function(t){var e,n=this.sliceRangeByTimes(t);for(e=0;e
').css("top",r).appendTo(this.colContainerEls.eq(i[n].col))[0]);i.length>0&&s.push(t('
').css("top",r).appendTo(this.el.find(".fc-content-skeleton"))[0]),this.nowIndicatorEls=t(s)},unrenderNowIndicator:function(){this.nowIndicatorEls&&(this.nowIndicatorEls.remove(),this.nowIndicatorEls=null)},renderSelection:function(t){this.view.opt("selectHelper")?this.renderEventLocationHelper(t):this.renderHighlight(t)},unrenderSelection:function(){this.unrenderHelper(),this.unrenderHighlight()},renderHighlight:function(t){this.renderHighlightSegs(this.spanToSegs(t))},unrenderHighlight:function(){this.unrenderHighlightSegs()}});Te.mixin({colContainerEls:null,fgContainerEls:null,bgContainerEls:null,helperContainerEls:null,highlightContainerEls:null,businessContainerEls:null,fgSegs:null,bgSegs:null,helperSegs:null,highlightSegs:null,businessSegs:null,renderContentSkeleton:function(){var e,n,i="";for(e=0;e
';n=t('
'+i+"
"),this.colContainerEls=n.find(".fc-content-col"),this.helperContainerEls=n.find(".fc-helper-container"),this.fgContainerEls=n.find(".fc-event-container:not(.fc-helper-container)"),this.bgContainerEls=n.find(".fc-bgevent-container"),this.highlightContainerEls=n.find(".fc-highlight-container"),this.businessContainerEls=n.find(".fc-business-container"),this.bookendCells(n.find("tr")),this.el.append(n)},renderFgSegs:function(t){return t=this.renderFgSegsIntoContainers(t,this.fgContainerEls),this.fgSegs=t,t},unrenderFgSegs:function(){this.unrenderNamedSegs("fgSegs")},renderHelperSegs:function(e,n){var i,r,s,o=[];for(e=this.renderFgSegsIntoContainers(e,this.helperContainerEls),i=0;i
'+(n?'
'+ht(n)+"
":"")+(o.title?'
'+ht(o.title)+"
":"")+'
'+(u?'
':"")+""},updateSegVerticals:function(t){this.computeSegVerticals(t),this.assignSegVerticals(t)},computeSegVerticals:function(t){var e,n,i;for(e=0;e1?"ll":"LL"},formatRange:function(t,e,n){var i=t.end;return i.hasTime()||(i=i.clone().subtract(1)),ae(t.start,i,e,n,this.opt("isRTL"))},getAllDayHtml:function(){return this.opt("allDayHtml")||ht(this.opt("allDayText"))},buildGotoAnchorHtml:function(e,n,i){var r,s,o,a;return t.isPlainObject(e)?(r=e.date,s=e.type,o=e.forceOff):r=e,r=Zt.moment(r),a={date:r.format("YYYY-MM-DD"),type:s||"day"},"string"==typeof n&&(i=n,n=null),n=n?" "+ft(n):"",i=i||"",!o&&this.opt("navLinks")?"'+i+"":""+i+""},setElement:function(t){this.el=t,this.bindGlobalHandlers(),this.bindBaseRenderHandlers(),this.renderSkeleton()}, -removeElement:function(){this.unsetDate(),this.unrenderSkeleton(),this.unbindGlobalHandlers(),this.unbindBaseRenderHandlers(),this.el.remove()},renderSkeleton:function(){},unrenderSkeleton:function(){},setDate:function(t){var e=this.get("dateProfile"),n=this.buildDateProfile(t,null,!0);return e&&X(e.activeRange,n.activeRange)||this.set("dateProfile",n),n.date},unsetDate:function(){this.unset("dateProfile")},requestDateRender:function(t){var e=this;this.renderQueue.queue(function(){e.executeDateRender(t)},"date","init")},requestDateUnrender:function(){var t=this;this.renderQueue.queue(function(){t.executeDateUnrender()},"date","destroy")},fetchInitialEvents:function(t){return this.calendar.requestEvents(t.activeRange.start,t.activeRange.end)},bindEventChanges:function(){this.listenTo(this.calendar,"eventsReset",this.resetEvents)},unbindEventChanges:function(){this.stopListeningTo(this.calendar,"eventsReset")},setEvents:function(t){this.set("currentEvents",t),this.set("hasEvents",!0)},unsetEvents:function(){this.unset("currentEvents"),this.unset("hasEvents")},resetEvents:function(t){this.startBatchRender(),this.unsetEvents(),this.setEvents(t),this.stopBatchRender()},requestEventsRender:function(t){var e=this;this.renderQueue.queue(function(){e.executeEventsRender(t)},"event","init")},requestEventsUnrender:function(){var t=this;this.renderQueue.queue(function(){t.executeEventsUnrender()},"event","destroy")},executeDateRender:function(t,e){this.setDateProfileForRendering(t),this.updateTitle(),this.calendar.updateToolbarButtons(),this.render&&this.render(),this.renderDates(),this.updateSize(),this.renderBusinessHours(),this.startNowIndicator(),e||this.addScroll(this.computeInitialDateScroll()),this.isDatesRendered=!0,this.trigger("datesRendered")},executeDateUnrender:function(){this.unselect(),this.stopNowIndicator(),this.trigger("before:datesUnrendered"),this.unrenderBusinessHours(),this.unrenderDates(),this.destroy&&this.destroy(),this.isDatesRendered=!1},renderDates:function(){},unrenderDates:function(){},bindBaseRenderHandlers:function(){var t=this;this.on("datesRendered.baseHandler",function(){t.onBaseRender()}),this.on("before:datesUnrendered.baseHandler",function(){t.onBeforeBaseUnrender()})},unbindBaseRenderHandlers:function(){this.off(".baseHandler")},onBaseRender:function(){this.applyScreenState(),this.publiclyTrigger("viewRender",this,this,this.el)},onBeforeBaseUnrender:function(){this.applyScreenState(),this.publiclyTrigger("viewDestroy",this,this,this.el)},bindGlobalHandlers:function(){this.listenTo(we.get(),{touchstart:this.processUnselect,mousedown:this.handleDocumentMousedown})},unbindGlobalHandlers:function(){this.stopListeningTo(we.get())},initThemingProps:function(){var t=this.opt("theme")?"ui":"fc";this.widgetHeaderClass=t+"-widget-header",this.widgetContentClass=t+"-widget-content",this.highlightStateClass=t+"-state-highlight"},renderBusinessHours:function(){},unrenderBusinessHours:function(){},startNowIndicator:function(){var t,n,i,r=this;this.opt("nowIndicator")&&(t=this.getNowIndicatorUnit())&&(n=mt(this,"updateNowIndicator"),this.initialNowDate=this.calendar.getNow(),this.initialNowQueriedMs=+new Date,this.renderNowIndicator(this.initialNowDate),this.isNowIndicatorRendered=!0,i=this.initialNowDate.clone().startOf(t).add(1,t)-this.initialNowDate,this.nowIndicatorTimeoutID=setTimeout(function(){r.nowIndicatorTimeoutID=null,n(),i=+e.duration(1,t),i=Math.max(100,i),r.nowIndicatorIntervalID=setInterval(n,i)},i))},updateNowIndicator:function(){this.isNowIndicatorRendered&&(this.unrenderNowIndicator(),this.renderNowIndicator(this.initialNowDate.clone().add(new Date-this.initialNowQueriedMs)))},stopNowIndicator:function(){this.isNowIndicatorRendered&&(this.nowIndicatorTimeoutID&&(clearTimeout(this.nowIndicatorTimeoutID),this.nowIndicatorTimeoutID=null),this.nowIndicatorIntervalID&&(clearTimeout(this.nowIndicatorIntervalID),this.nowIndicatorIntervalID=null),this.unrenderNowIndicator(),this.isNowIndicatorRendered=!1)},getNowIndicatorUnit:function(){},renderNowIndicator:function(t){},unrenderNowIndicator:function(){},updateSize:function(t){var e;t&&(e=this.queryScroll()),this.updateHeight(t),this.updateWidth(t),this.updateNowIndicator(),t&&this.applyScroll(e)},updateWidth:function(t){},updateHeight:function(t){var e=this.calendar;this.setHeight(e.getSuggestedViewHeight(),e.isHeightAuto())},setHeight:function(t,e){},addForcedScroll:function(e){this.addScroll(t.extend(e,{isForced:!0}))},addScroll:function(e){var n=this.queuedScroll||(this.queuedScroll={});n.isForced||t.extend(n,e)},popScroll:function(){this.applyQueuedScroll(),this.queuedScroll=null},applyQueuedScroll:function(){this.queuedScroll&&this.applyScroll(this.queuedScroll)},queryScroll:function(){var e={};return this.isDatesRendered&&t.extend(e,this.queryDateScroll()),e},applyScroll:function(t){this.isDatesRendered&&this.applyDateScroll(t)},computeInitialDateScroll:function(){return{}},queryDateScroll:function(){return{}},applyDateScroll:function(t){},freezeHeight:function(){this.calendar.freezeContentHeight()},thawHeight:function(){this.calendar.thawContentHeight()},executeEventsRender:function(t){this.renderEvents(t),this.isEventsRendered=!0,this.onEventsRender()},executeEventsUnrender:function(){this.onBeforeEventsUnrender(),this.destroyEvents&&this.destroyEvents(),this.unrenderEvents(),this.isEventsRendered=!1},onEventsRender:function(){this.applyScreenState(),this.renderedEventSegEach(function(t){this.publiclyTrigger("eventAfterRender",t.event,t.event,t.el)}),this.publiclyTrigger("eventAfterAllRender")},onBeforeEventsUnrender:function(){this.applyScreenState(),this.renderedEventSegEach(function(t){this.publiclyTrigger("eventDestroy",t.event,t.event,t.el)})},applyScreenState:function(){this.thawHeight(),this.freezeHeight(),this.applyQueuedScroll()},renderEvents:function(t){},unrenderEvents:function(){},resolveEventEl:function(e,n){var i=this.publiclyTrigger("eventRender",e,e,n);return!1===i?n=null:i&&!0!==i&&(n=t(i)),n},showEvent:function(t){this.renderedEventSegEach(function(t){t.el.css("visibility","")},t)},hideEvent:function(t){this.renderedEventSegEach(function(t){t.el.css("visibility","hidden")},t)},renderedEventSegEach:function(t,e){var n,i=this.getEventSegs();for(n=0;n=this.nextDayThreshold&&r.add(1,"days")),(!i||r<=n)&&(r=n.clone().add(1,"days")),{start:n,end:r}},isMultiDayEvent:function(t){var e=this.computeDayRange(t);return e.end.diff(e.start,"days")>1}});Ce.watch("displayingDates",["dateProfile"],function(t){this.requestDateRender(t.dateProfile)},function(){this.requestDateUnrender()}),Ce.watch("initialEvents",["dateProfile"],function(t){return this.fetchInitialEvents(t.dateProfile)}),Ce.watch("bindingEvents",["initialEvents"],function(t){this.setEvents(t.initialEvents),this.bindEventChanges()},function(){this.unbindEventChanges(),this.unsetEvents()}),Ce.watch("displayingEvents",["displayingDates","hasEvents"],function(){this.requestEventsRender(this.get("currentEvents"))},function(){this.requestEventsUnrender()}),Ce.mixin({currentRange:null,currentRangeUnit:null,renderRange:null,activeRange:null,validRange:null,dateIncrement:null,minTime:null,maxTime:null,usesMinMaxTime:!1,start:null,end:null,intervalStart:null,intervalEnd:null,setDateProfileForRendering:function(t){this.currentRange=t.currentRange,this.currentRangeUnit=t.currentRangeUnit,this.renderRange=t.renderRange,this.activeRange=t.activeRange,this.validRange=t.validRange,this.dateIncrement=t.dateIncrement,this.minTime=t.minTime,this.maxTime=t.maxTime,this.start=t.activeRange.start,this.end=t.activeRange.end,this.intervalStart=t.currentRange.start,this.intervalEnd=t.currentRange.end},buildPrevDateProfile:function(t){var e=t.clone().startOf(this.currentRangeUnit).subtract(this.dateIncrement);return this.buildDateProfile(e,-1)},buildNextDateProfile:function(t){var e=t.clone().startOf(this.currentRangeUnit).add(this.dateIncrement);return this.buildDateProfile(e,1)},buildDateProfile:function(t,n,i){var r,s,o,a,l=this.buildValidRange(),u=null,h=null;return i&&(t=j(t,l)),r=this.buildCurrentRangeInfo(t,n),s=this.buildRenderRange(r.range,r.unit),o=q(s),this.opt("showNonCurrentDates")||(o=U(o,r.range)),u=e.duration(this.opt("minTime")),h=e.duration(this.opt("maxTime")),this.adjustActiveRange(o,u,h),o=U(o,l),t=j(t,o),a=$(r.range,l),{validRange:l,currentRange:r.range,currentRangeUnit:r.unit,activeRange:o,renderRange:s,minTime:u,maxTime:h,isValid:a,date:t,dateIncrement:this.buildDateIncrement(r.duration)}},buildValidRange:function(){return this.getRangeOption("validRange",this.calendar.getNow())||{}},buildCurrentRangeInfo:function(t,e){var n,i=null,r=null,s=null;return this.viewSpec.duration?(i=this.viewSpec.duration,r=this.viewSpec.durationUnit,s=this.buildRangeFromDuration(t,e,i,r)):(n=this.opt("dayCount"))?(r="day",s=this.buildRangeFromDayCount(t,e,n)):(s=this.buildCustomVisibleRange(t))?r=V(s.start,s.end):(i=this.getFallbackDuration(),r=V(i),s=this.buildRangeFromDuration(t,e,i,r)),this.normalizeCurrentRange(s,r),{duration:i,unit:r,range:s}},getFallbackDuration:function(){return e.duration({days:1})},normalizeCurrentRange:function(t,e){/^(year|month|week|day)$/.test(e)?(t.start.stripTime(),t.end.stripTime()):(t.start.hasTime()||t.start.time(0),t.end.hasTime()||t.end.time(0))},adjustActiveRange:function(t,e,n){var i=!1;this.usesMinMaxTime&&(e<0&&(t.start.time(0).add(e),i=!0),n>864e5&&(t.end.time(n-864e5),i=!0),i&&(t.start.hasTime()||t.start.time(0),t.end.hasTime()||t.end.time(0)))},buildRangeFromDuration:function(t,n,i,r){var s,o,a,l=this.opt("dateAlignment"),u=t.clone();return i.as("days")<=1&&this.isHiddenDay(u)&&(u=this.skipHiddenDays(u,n),u.startOf("day")),l||(o=this.opt("dateIncrement"),o?(a=e.duration(o),l=a
')},clear:function(){this.setHeight("auto"),this.applyOverflow()},destroy:function(){this.el.remove()},applyOverflow:function(){this.scrollEl.css({"overflow-x":this.overflowX,"overflow-y":this.overflowY})},lockOverflow:function(t){var e=this.overflowX,n=this.overflowY;t=t||this.getScrollbarWidths(),"auto"===e&&(e=t.top||t.bottom||this.scrollEl[0].scrollWidth-1>this.scrollEl[0].clientWidth?"scroll":"hidden"),"auto"===n&&(n=t.left||t.right||this.scrollEl[0].scrollHeight-1>this.scrollEl[0].clientHeight?"scroll":"hidden"),this.scrollEl.css({"overflow-x":e,"overflow-y":n})},setHeight:function(t){this.scrollEl.height(t)},getScrollTop:function(){return this.scrollEl.scrollTop()},setScrollTop:function(t){this.scrollEl.scrollTop(t)},getClientWidth:function(){return this.scrollEl[0].clientWidth},getClientHeight:function(){return this.scrollEl[0].clientHeight},getScrollbarWidths:function(){return p(this.scrollEl)}});_t.prototype.proxyCall=function(t){var e=Array.prototype.slice.call(arguments,1),n=[];return this.items.forEach(function(i){n.push(i[t].apply(i,e))}),n};var Re=Zt.Calendar=bt.extend(fe,{view:null,viewsByType:null,currentDate:null,loadingLevel:0,constructor:function(t,e){we.needed(),this.el=t,this.viewsByType={},this.viewSpecCache={},this.initOptionsInternals(e),this.initMomentInternals(),this.initCurrentDate(),Ut.call(this),this.initialize()},initialize:function(){},getCalendar:function(){return this},getView:function(){return this.view},publiclyTrigger:function(t,e){var n=Array.prototype.slice.call(arguments,2),i=this.opt(t);if(e=e||this.el[0],this.triggerWith(t,e,n),i)return i.apply(e,n)},instantiateView:function(t){var e=this.getViewSpec(t);return new e.class(this,e)},isValidViewType:function(t){return Boolean(this.getViewSpec(t))},changeView:function(t,e){e&&(e.start&&e.end?this.recordOptionOverrides({visibleRange:e}):this.currentDate=this.moment(e).stripZone()),this.renderView(t)},zoomTo:function(t,e){var n;e=e||"day",n=this.getViewSpec(e)||this.getUnitViewSpec(e),this.currentDate=t.clone(),this.renderView(n?n.type:null)},initCurrentDate:function(){var t=this.opt("defaultDate");this.currentDate=null!=t?this.moment(t).stripZone():this.getNow()},prev:function(){var t=this.view.buildPrevDateProfile(this.currentDate);t.isValid&&(this.currentDate=t.date,this.renderView())},next:function(){var t=this.view.buildNextDateProfile(this.currentDate);t.isValid&&(this.currentDate=t.date,this.renderView())},prevYear:function(){this.currentDate.add(-1,"years"),this.renderView()},nextYear:function(){this.currentDate.add(1,"years"),this.renderView()},today:function(){this.currentDate=this.getNow(),this.renderView()},gotoDate:function(t){this.currentDate=this.moment(t).stripZone(),this.renderView()},incrementDate:function(t){this.currentDate.add(e.duration(t)),this.renderView()},getDate:function(){return this.applyTimezone(this.currentDate)},pushLoading:function(){this.loadingLevel++||this.publiclyTrigger("loading",null,!0,this.view)},popLoading:function(){--this.loadingLevel||this.publiclyTrigger("loading",null,!1,this.view)},select:function(t,e){this.view.select(this.buildSelectSpan.apply(this,arguments))},unselect:function(){this.view&&this.view.unselect()},buildSelectSpan:function(t,e){var n,i=this.moment(t).stripZone();return n=e?this.moment(e).stripZone():i.hasTime()?i.clone().add(this.defaultTimedEventDuration):i.clone().add(this.defaultAllDayEventDuration),{start:i,end:n}},parseRange:function(t){var e=null,n=null;return t.start&&(e=this.moment(t.start).stripZone()),t.end&&(n=this.moment(t.end).stripZone()),e||n?e&&n&&n.isBefore(e)?null:{start:e,end:n}:null},rerenderEvents:function(){this.elementVisible()&&this.reportEventChange()}});Re.mixin({dirDefaults:null,localeDefaults:null,overrides:null,dynamicOverrides:null,optionsModel:null,initOptionsInternals:function(e){this.overrides=t.extend({},e),this.dynamicOverrides={},this.optionsModel=new ue,this.populateOptionsHash()},option:function(t,e){var n;if("string"==typeof t){if(void 0===e)return this.optionsModel.get(t);n={},n[t]=e,this.setOptions(n)}else"object"==typeof t&&this.setOptions(t)},opt:function(t){return this.optionsModel.get(t)},setOptions:function(t){var e,n=0;this.recordOptionOverrides(t);for(e in t)n++;if(1===n){if("height"===e||"contentHeight"===e||"aspectRatio"===e)return void this.updateSize(!0);if("defaultDate"===e)return;if("businessHours"===e)return void(this.view&&(this.view.unrenderBusinessHours(),this.view.renderBusinessHours()));if("timezone"===e)return this.rezoneArrayEventSources(),void this.refetchEvents()}this.renderHeader(),this.renderFooter(),this.viewsByType={},this.reinitView()},populateOptionsHash:function(){var t,e,i,r,s;t=ut(this.dynamicOverrides.locale,this.overrides.locale),e=xe[t],e||(t=Re.defaults.locale,e=xe[t]||{}),i=ut(this.dynamicOverrides.isRTL,this.overrides.isRTL,e.isRTL,Re.defaults.isRTL),r=i?Re.rtlDefaults:{},this.dirDefaults=r,this.localeDefaults=e,s=n([Re.defaults,r,e,this.overrides,this.dynamicOverrides]),Yt(s),this.optionsModel.reset(s)},recordOptionOverrides:function(t){var e;for(e in t)this.dynamicOverrides[e]=t[e];this.viewSpecCache={},this.populateOptionsHash()}}),Re.mixin({defaultAllDayEventDuration:null,defaultTimedEventDuration:null,localeData:null,initMomentInternals:function(){var t=this;this.defaultAllDayEventDuration=e.duration(this.opt("defaultAllDayEventDuration")),this.defaultTimedEventDuration=e.duration(this.opt("defaultTimedEventDuration")),this.optionsModel.watch("buildingMomentLocale",["?locale","?monthNames","?monthNamesShort","?dayNames","?dayNamesShort","?firstDay","?weekNumberCalculation"],function(e){var n,i=e.weekNumberCalculation,r=e.firstDay;"iso"===i&&(i="ISO");var s=rt(qt(e.locale));e.monthNames&&(s._months=e.monthNames),e.monthNamesShort&&(s._monthsShort=e.monthNamesShort),e.dayNames&&(s._weekdays=e.dayNames),e.dayNamesShort&&(s._weekdaysShort=e.dayNamesShort),null==r&&"ISO"===i&&(r=1),null!=r&&(n=rt(s._week),n.dow=r,s._week=n),"ISO"!==i&&"local"!==i&&"function"!=typeof i||(s._fullCalendar_weekCalc=i),t.localeData=s,t.currentDate&&t.localizeMoment(t.currentDate)})},moment:function(){var t;return"local"===this.opt("timezone")?(t=Zt.moment.apply(null,arguments),t.hasTime()&&t.local()):t="UTC"===this.opt("timezone")?Zt.moment.utc.apply(null,arguments):Zt.moment.parseZone.apply(null,arguments),this.localizeMoment(t),t},localizeMoment:function(t){t._locale=this.localeData},getIsAmbigTimezone:function(){return"local"!==this.opt("timezone")&&"UTC"!==this.opt("timezone")},applyTimezone:function(t){if(!t.hasTime())return t.clone();var e,n=this.moment(t.toArray()),i=t.time()-n.time();return i&&(e=n.clone().add(i),t.time()-e.time()==0&&(n=e)),n},getNow:function(){var t=this.opt("now");return"function"==typeof t&&(t=t()),this.moment(t).stripZone()},humanizeDuration:function(t){return t.locale(this.opt("locale")).humanize()},getEventEnd:function(t){return t.end?t.end.clone():this.getDefaultEventEnd(t.allDay,t.start)},getDefaultEventEnd:function(t,e){var n=e.clone();return t?n.stripTime().add(this.defaultAllDayEventDuration):n.add(this.defaultTimedEventDuration),this.getIsAmbigTimezone()&&n.stripZone(),n}}),Re.mixin({viewSpecCache:null,getViewSpec:function(t){var e=this.viewSpecCache;return e[t]||(e[t]=this.buildViewSpec(t))},getUnitViewSpec:function(e){var n,i,r;if(-1!=t.inArray(e,Jt))for(n=this.header.getViewsWithButtons(),t.each(Zt.views,function(t){n.push(t)}),i=0;i").prependTo(n),this.initToolbars(),this.renderHeader(),this.renderFooter(),this.renderView(this.opt("defaultView")),this.opt("handleWindowResize")&&t(window).resize(this.windowResizeProxy=yt(this.windowResize.bind(this),this.opt("windowResizeDelay")))},destroy:function(){this.view&&this.view.removeElement(),this.toolbarsManager.proxyCall("removeElement"),this.contentEl.remove(),this.el.removeClass("fc fc-ltr fc-rtl fc-unthemed ui-widget"),this.el.off(".fc"),this.windowResizeProxy&&(t(window).unbind("resize",this.windowResizeProxy),this.windowResizeProxy=null),we.unneeded()},elementVisible:function(){return this.el.is(":visible")},renderView:function(e,n){this.ignoreWindowResize++;var i=this.view&&e&&this.view.type!==e;i&&(this.freezeContentHeight(),this.clearView()),!this.view&&e&&(this.view=this.viewsByType[e]||(this.viewsByType[e]=this.instantiateView(e)),this.view.setElement(t("
").appendTo(this.contentEl)),this.toolbarsManager.proxyCall("activateButton",e)),this.view&&(n&&this.view.addForcedScroll(n),this.elementVisible()&&(this.currentDate=this.view.setDate(this.currentDate))),i&&this.thawContentHeight(),this.ignoreWindowResize--},clearView:function(){this.toolbarsManager.proxyCall("deactivateButton",this.view.type),this.view.removeElement(),this.view=null},reinitView:function(){this.ignoreWindowResize++,this.freezeContentHeight();var t=this.view.type,e=this.view.queryScroll();this.clearView(),this.calcSize(),this.renderView(t,e),this.thawContentHeight(),this.ignoreWindowResize--},getSuggestedViewHeight:function(){return null===this.suggestedViewHeight&&this.calcSize(),this.suggestedViewHeight},isHeightAuto:function(){return"auto"===this.opt("contentHeight")||"auto"===this.opt("height")},updateSize:function(t){if(this.elementVisible())return t&&this._calcSize(),this.ignoreWindowResize++,this.view.updateSize(!0),this.ignoreWindowResize--,!0},calcSize:function(){this.elementVisible()&&this._calcSize()},_calcSize:function(){var t=this.opt("contentHeight"),e=this.opt("height");this.suggestedViewHeight="number"==typeof t?t:"function"==typeof t?t():"number"==typeof e?e-this.queryToolbarsHeight():"function"==typeof e?e()-this.queryToolbarsHeight():"parent"===e?this.el.parent().height()-this.queryToolbarsHeight():Math.round(this.contentEl.width()/Math.max(this.opt("aspectRatio"),.5))},windowResize:function(t){!this.ignoreWindowResize&&t.target===window&&this.view.renderRange&&this.updateSize(!0)&&this.view.publiclyTrigger("windowResize",this.el[0])},freezeContentHeight:function(){this.contentEl.css({width:"100%",height:this.contentEl.height(),overflow:"hidden"})},thawContentHeight:function(){this.contentEl.css({width:"",height:"",overflow:""})}}),Re.mixin({header:null,footer:null,toolbarsManager:null,initToolbars:function(){this.header=new Wt(this,this.computeHeaderOptions()),this.footer=new Wt(this,this.computeFooterOptions()),this.toolbarsManager=new _t([this.header,this.footer])},computeHeaderOptions:function(){return{extraClasses:"fc-header-toolbar",layout:this.opt("header")}},computeFooterOptions:function(){return{extraClasses:"fc-footer-toolbar",layout:this.opt("footer")}},renderHeader:function(){var t=this.header;t.setToolbarOptions(this.computeHeaderOptions()),t.render(),t.el&&this.el.prepend(t.el)},renderFooter:function(){var t=this.footer;t.setToolbarOptions(this.computeFooterOptions()),t.render(),t.el&&this.el.append(t.el)},setToolbarsTitle:function(t){this.toolbarsManager.proxyCall("updateTitle",t)},updateToolbarButtons:function(){var t=this.getNow(),e=this.view,n=e.buildDateProfile(t),i=e.buildPrevDateProfile(this.currentDate),r=e.buildNextDateProfile(this.currentDate);this.toolbarsManager.proxyCall(n.isValid&&!Z(t,e.currentRange)?"enableButton":"disableButton","today"),this.toolbarsManager.proxyCall(i.isValid?"enableButton":"disableButton","prev"),this.toolbarsManager.proxyCall(r.isValid?"enableButton":"disableButton","next")},queryToolbarsHeight:function(){return this.toolbarsManager.items.reduce(function(t,e){return t+(e.el?e.el.outerHeight(!0):0)},0)}}),Re.defaults={titleRangeSeparator:" – ",monthYearFormat:"MMMM YYYY",defaultTimedEventDuration:"02:00:00",defaultAllDayEventDuration:{days:1},forceEventDuration:!1,nextDayThreshold:"09:00:00",defaultView:"month",aspectRatio:1.35,header:{left:"title",center:"",right:"today prev,next"},weekends:!0,weekNumbers:!1,weekNumberTitle:"W",weekNumberCalculation:"local",scrollTime:"06:00:00",minTime:"00:00:00",maxTime:"24:00:00",showNonCurrentDates:!0,lazyFetching:!0,startParam:"start",endParam:"end",timezoneParam:"timezone",timezone:!1,isRTL:!1,buttonText:{prev:"prev",next:"next",prevYear:"prev year",nextYear:"next year",year:"year",today:"today",month:"month",week:"week",day:"day"},buttonIcons:{prev:"left-single-arrow",next:"right-single-arrow",prevYear:"left-double-arrow",nextYear:"right-double-arrow"},allDayText:"all-day",theme:!1,themeButtonIcons:{prev:"circle-triangle-w",next:"circle-triangle-e",prevYear:"seek-prev",nextYear:"seek-next"},dragOpacity:.75,dragRevertDuration:500,dragScroll:!0,unselectAuto:!0,dropAccept:"*",eventOrder:"title",eventLimit:!1,eventLimitText:"more",eventLimitClick:"popover",dayPopoverFormat:"LL",handleWindowResize:!0,windowResizeDelay:100,longPressDelay:1e3},Re.englishDefaults={dayPopoverFormat:"dddd, MMMM D"},Re.rtlDefaults={header:{left:"next,prev today",center:"",right:"title"},buttonIcons:{prev:"right-single-arrow",next:"left-single-arrow",prevYear:"right-double-arrow",nextYear:"left-double-arrow"},themeButtonIcons:{prev:"circle-triangle-e",next:"circle-triangle-w",nextYear:"seek-prev",prevYear:"seek-next"}};var xe=Zt.locales={};Zt.datepickerLocale=function(e,n,i){var r=xe[e]||(xe[e]={});r.isRTL=i.isRTL,r.weekNumberTitle=i.weekHeader,t.each(Ie,function(t,e){r[t]=e(i)}),t.datepicker&&(t.datepicker.regional[n]=t.datepicker.regional[e]=i,t.datepicker.regional.en=t.datepicker.regional[""],t.datepicker.setDefaults(i))},Zt.locale=function(e,i){var r,s;r=xe[e]||(xe[e]={}),i&&(r=xe[e]=n([r,i])),s=qt(e),t.each(ke,function(t,e){null==r[t]&&(r[t]=e(s,r))}),Re.defaults.locale=e};var Ie={buttonText:function(t){return{prev:ct(t.prevText),next:ct(t.nextText),today:ct(t.currentText)}},monthYearFormat:function(t){return t.showMonthAfterYear?"YYYY["+t.yearSuffix+"] MMMM":"MMMM YYYY["+t.yearSuffix+"]"}},ke={dayOfMonthFormat:function(t,e){var n=t.longDateFormat("l");return n=n.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g,""),e.isRTL?n+=" ddd":n="ddd "+n,n},mediumTimeFormat:function(t){return t.longDateFormat("LT").replace(/\s*a$/i,"a")},smallTimeFormat:function(t){return t.longDateFormat("LT").replace(":mm","(:mm)").replace(/(\Wmm)$/,"($1)").replace(/\s*a$/i,"a")},extraSmallTimeFormat:function(t){return t.longDateFormat("LT").replace(":mm","(:mm)").replace(/(\Wmm)$/,"($1)").replace(/\s*a$/i,"t")},hourFormat:function(t){return t.longDateFormat("LT").replace(":mm","").replace(/(\Wmm)$/,"").replace(/\s*a$/i,"a")},noMeridiemTimeFormat:function(t){return t.longDateFormat("LT").replace(/\s*a$/i,"")}},Me={smallDayDateFormat:function(t){return t.isRTL?"D dd":"dd D"},weekFormat:function(t){return t.isRTL?"w[ "+t.weekNumberTitle+"]":"["+t.weekNumberTitle+" ]w"},smallWeekFormat:function(t){return t.isRTL?"w["+t.weekNumberTitle+"]":"["+t.weekNumberTitle+"]w"}};Zt.locale("en",Re.englishDefaults),Zt.sourceNormalizers=[],Zt.sourceFetchers=[];var Be={dataType:"json",cache:!1},Le=1;Re.prototype.mutateSeg=function(t,e){return this.mutateEvent(t.event,e)},Re.prototype.normalizeEvent=function(t){},Re.prototype.spanContainsSpan=function(t,e){var n=t.start.clone().stripZone(),i=this.getEventEnd(t).stripZone() -;return e.start>=n&&e.end<=i},Re.prototype.getPeerEvents=function(t,e){var n,i,r=this.getEventCache(),s=[];for(n=0;nn};var Ne={id:"_fcBusinessHours",start:"09:00",end:"17:00",dow:[1,2,3,4,5],rendering:"inverse-background"};Re.prototype.getCurrentBusinessHourEvents=function(t){return this.computeBusinessHourEvents(t,this.opt("businessHours"))},Re.prototype.computeBusinessHourEvents=function(e,n){return!0===n?this.expandBusinessHourEvents(e,[{}]):t.isPlainObject(n)?this.expandBusinessHourEvents(e,[n]):t.isArray(n)?this.expandBusinessHourEvents(e,n,!0):[]},Re.prototype.expandBusinessHourEvents=function(e,n,i){var r,s,o=this.getView(),a=[];for(r=0;r1,this.opt("weekNumbers")&&(this.opt("weekNumbersWithinDays")?(this.cellWeekNumbersVisible=!0,this.colWeekNumbersVisible=!1):(this.cellWeekNumbersVisible=!1,this.colWeekNumbersVisible=!0)),this.dayGrid.numbersVisible=this.dayNumbersVisible||this.cellWeekNumbersVisible||this.colWeekNumbersVisible,this.el.addClass("fc-basic-view").html(this.renderSkeletonHtml()),this.renderHead(),this.scroller.render();var e=this.scroller.el.addClass("fc-day-grid-container"),n=t('
').appendTo(e);this.el.find(".fc-body > tr > td").append(e),this.dayGrid.setElement(n),this.dayGrid.renderDates(this.hasRigidRows())},renderHead:function(){this.headContainerEl=this.el.find(".fc-head-container").html(this.dayGrid.renderHeadHtml()),this.headRowEl=this.headContainerEl.find(".fc-row")},unrenderDates:function(){this.dayGrid.unrenderDates(),this.dayGrid.removeElement(),this.scroller.destroy()},renderBusinessHours:function(){this.dayGrid.renderBusinessHours()},unrenderBusinessHours:function(){this.dayGrid.unrenderBusinessHours()},renderSkeletonHtml:function(){return'
'},weekNumberStyleAttr:function(){return null!==this.weekNumberWidth?'style="width:'+this.weekNumberWidth+'px"':""},hasRigidRows:function(){var t=this.opt("eventLimit");return t&&"number"!=typeof t},updateWidth:function(){this.colWeekNumbersVisible&&(this.weekNumberWidth=u(this.el.find(".fc-week-number")))},setHeight:function(t,e){var n,s,o=this.opt("eventLimit");this.scroller.clear(),r(this.headRowEl),this.dayGrid.removeSegPopover(),o&&"number"==typeof o&&this.dayGrid.limitRows(o),n=this.computeScrollerHeight(t),this.setGridHeight(n,e),o&&"number"!=typeof o&&this.dayGrid.limitRows(o),e||(this.scroller.setHeight(n),s=this.scroller.getScrollbarWidths(),(s.left||s.right)&&(i(this.headRowEl,s),n=this.computeScrollerHeight(t),this.scroller.setHeight(n)),this.scroller.lockOverflow(s))},computeScrollerHeight:function(t){return t-h(this.el,this.scroller.el)},setGridHeight:function(t,e){e?l(this.dayGrid.rowEls):a(this.dayGrid.rowEls,t,!0)},computeInitialDateScroll:function(){return{top:0}},queryDateScroll:function(){return{top:this.scroller.getScrollTop()}},applyDateScroll:function(t){void 0!==t.top&&this.scroller.setScrollTop(t.top)},hitsNeeded:function(){this.dayGrid.hitsNeeded()},hitsNotNeeded:function(){this.dayGrid.hitsNotNeeded()},prepareHits:function(){this.dayGrid.prepareHits()},releaseHits:function(){this.dayGrid.releaseHits()},queryHit:function(t,e){return this.dayGrid.queryHit(t,e)},getHitSpan:function(t){return this.dayGrid.getHitSpan(t)},getHitEl:function(t){return this.dayGrid.getHitEl(t)},renderEvents:function(t){this.dayGrid.renderEvents(t),this.updateHeight()},getEventSegs:function(){return this.dayGrid.getEventSegs()},unrenderEvents:function(){this.dayGrid.unrenderEvents()},renderDrag:function(t,e){return this.dayGrid.renderDrag(t,e)},unrenderDrag:function(){this.dayGrid.unrenderDrag()},renderSelection:function(t){this.dayGrid.renderSelection(t)},unrenderSelection:function(){this.dayGrid.unrenderSelection()}}),Fe={renderHeadIntroHtml:function(){var t=this.view;return t.colWeekNumbersVisible?'"+ht(t.opt("weekNumberTitle"))+"":""},renderNumberIntroHtml:function(t){var e=this.view,n=this.getCellDate(t,0);return e.colWeekNumbersVisible?'"+e.buildGotoAnchorHtml({date:n,type:"week",forceOff:1===this.colCnt},n.format("w"))+"":""},renderBgIntroHtml:function(){var t=this.view;return t.colWeekNumbersVisible?'":""},renderIntroHtml:function(){var t=this.view;return t.colWeekNumbersVisible?'":""}},Ae=Zt.MonthView=ze.extend({buildRenderRange:function(){var t,e=ze.prototype.buildRenderRange.apply(this,arguments);return this.isFixedWeeks()&&(t=Math.ceil(e.end.diff(e.start,"weeks",!0)),e.end.add(6-t,"weeks")),e},setGridHeight:function(t,e){e&&(t*=this.rowCnt/6),a(this.dayGrid.rowEls,t,!e)},isFixedWeeks:function(){return this.opt("fixedWeekCount")}});$t.basic={class:ze},$t.basicDay={type:"basic",duration:{days:1}},$t.basicWeek={type:"basic",duration:{weeks:1}},$t.month={class:Ae,duration:{months:1},defaults:{fixedWeekCount:!0}};var Ge=Zt.AgendaView=Ce.extend({scroller:null,timeGridClass:Te,timeGrid:null,dayGridClass:De,dayGrid:null,axisWidth:null,headContainerEl:null,noScrollRowEls:null,bottomRuleEl:null,usesMinMaxTime:!0,initialize:function(){this.timeGrid=this.instantiateTimeGrid(),this.opt("allDaySlot")&&(this.dayGrid=this.instantiateDayGrid()),this.scroller=new He({overflowX:"hidden",overflowY:"auto"})},instantiateTimeGrid:function(){return new(this.timeGridClass.extend(Ve))(this)},instantiateDayGrid:function(){return new(this.dayGridClass.extend(Oe))(this)},renderDates:function(){this.timeGrid.setRange(this.renderRange),this.dayGrid&&this.dayGrid.setRange(this.renderRange),this.el.addClass("fc-agenda-view").html(this.renderSkeletonHtml()),this.renderHead(),this.scroller.render();var e=this.scroller.el.addClass("fc-time-grid-container"),n=t('
').appendTo(e);this.el.find(".fc-body > tr > td").append(e),this.timeGrid.setElement(n),this.timeGrid.renderDates(),this.bottomRuleEl=t('
').appendTo(this.timeGrid.el),this.dayGrid&&(this.dayGrid.setElement(this.el.find(".fc-day-grid")),this.dayGrid.renderDates(),this.dayGrid.bottomCoordPadding=this.dayGrid.el.next("hr").outerHeight()),this.noScrollRowEls=this.el.find(".fc-row:not(.fc-scroller *)")},renderHead:function(){this.headContainerEl=this.el.find(".fc-head-container").html(this.timeGrid.renderHeadHtml())},unrenderDates:function(){this.timeGrid.unrenderDates(),this.timeGrid.removeElement(),this.dayGrid&&(this.dayGrid.unrenderDates(),this.dayGrid.removeElement()),this.scroller.destroy()},renderSkeletonHtml:function(){return'
'+(this.dayGrid?'

':"")+"
"},axisStyleAttr:function(){return null!==this.axisWidth?'style="width:'+this.axisWidth+'px"':""},renderBusinessHours:function(){this.timeGrid.renderBusinessHours(),this.dayGrid&&this.dayGrid.renderBusinessHours()},unrenderBusinessHours:function(){this.timeGrid.unrenderBusinessHours(),this.dayGrid&&this.dayGrid.unrenderBusinessHours()},getNowIndicatorUnit:function(){return this.timeGrid.getNowIndicatorUnit()},renderNowIndicator:function(t){this.timeGrid.renderNowIndicator(t)},unrenderNowIndicator:function(){this.timeGrid.unrenderNowIndicator()},updateSize:function(t){this.timeGrid.updateSize(t),Ce.prototype.updateSize.call(this,t)},updateWidth:function(){this.axisWidth=u(this.el.find(".fc-axis"))},setHeight:function(t,e){var n,s,o;this.bottomRuleEl.hide(),this.scroller.clear(),r(this.noScrollRowEls),this.dayGrid&&(this.dayGrid.removeSegPopover(),n=this.opt("eventLimit"),n&&"number"!=typeof n&&(n=Pe),n&&this.dayGrid.limitRows(n)),e||(s=this.computeScrollerHeight(t),this.scroller.setHeight(s),o=this.scroller.getScrollbarWidths(),(o.left||o.right)&&(i(this.noScrollRowEls,o),s=this.computeScrollerHeight(t),this.scroller.setHeight(s)),this.scroller.lockOverflow(o),this.timeGrid.getTotalSlatHeight()"+e.buildGotoAnchorHtml({date:this.start,type:"week",forceOff:this.colCnt>1},ht(t))+""):'"},renderBgIntroHtml:function(){var t=this.view;return'"},renderIntroHtml:function(){return'"}},Oe={renderBgIntroHtml:function(){var t=this.view;return'"+t.getAllDayHtml()+""},renderIntroHtml:function(){return'"}},Pe=5,_e=[{hours:1},{minutes:30},{minutes:15},{seconds:30},{seconds:15}];$t.agenda={class:Ge,defaults:{allDaySlot:!0,slotDuration:"00:30:00",slotEventOverlap:!0}},$t.agendaDay={type:"agenda",duration:{days:1}},$t.agendaWeek={type:"agenda",duration:{weeks:1}};var We=Ce.extend({grid:null,scroller:null,initialize:function(){this.grid=new Ye(this),this.scroller=new He({overflowX:"hidden",overflowY:"auto"})},renderSkeleton:function(){this.el.addClass("fc-list-view "+this.widgetContentClass),this.scroller.render(),this.scroller.el.appendTo(this.el),this.grid.setElement(this.scroller.scrollEl)},unrenderSkeleton:function(){this.scroller.destroy()},setHeight:function(t,e){this.scroller.setHeight(this.computeScrollerHeight(t))},computeScrollerHeight:function(t){return t-h(this.el,this.scroller.el)},renderDates:function(){this.grid.setRange(this.renderRange)},renderEvents:function(t){this.grid.renderEvents(t)},unrenderEvents:function(){this.grid.unrenderEvents()},isEventResizable:function(t){return!1},isEventDraggable:function(t){return!1}}),Ye=be.extend({segSelector:".fc-list-item",hasDayInteractions:!1,spanToSegs:function(t){for(var e,n=this.view,i=n.renderRange.start.clone().time(0),r=0,s=[];i
'+ht(this.view.opt("noEventsMessage"))+"
")},renderSegList:function(e){var n,i,r,s=this.groupSegsByDay(e),o=t('
'),a=o.find("tbody");for(n=0;n'+(n?e.buildGotoAnchorHtml(t,{class:"fc-list-heading-main"},ht(t.format(n))):"")+(i?e.buildGotoAnchorHtml(t,{class:"fc-list-heading-alt"},ht(t.format(i))):"")+""},fgSegHtml:function(t){var e,n=this.view,i=["fc-list-item"].concat(this.getSegCustomClasses(t)),r=this.getSegBackgroundColor(t),s=t.event,o=s.url;return e=s.allDay?n.getAllDayHtml():n.isMultiDayEvent(s)?t.isStart||t.isEnd?ht(this.getEventTimeText(t)):n.getAllDayHtml():ht(this.getEventTimeText(s)),o&&i.push("fc-has-url"),''+(this.displayEventTime?''+(e||"")+"":"")+'"+ht(t.event.title||"")+""}});return $t.list={class:We,buttonTextKey:"list",defaults:{buttonText:"list",listDayFormat:"LL",noEventsMessage:"No events to display"}},$t.listDay={type:"list",duration:{days:1},defaults:{listDayFormat:"dddd"}},$t.listWeek={type:"list",duration:{weeks:1},defaults:{listDayFormat:"dddd",listDayAltFormat:"LL"}},$t.listMonth={type:"list",duration:{month:1},defaults:{listDayAltFormat:"dddd"}},$t.listYear={type:"list",duration:{year:1},defaults:{listDayAltFormat:"dddd"}},Zt}); \ No newline at end of file + +(function(factory) { + if (typeof define === 'function' && define.amd) { + define([ 'jquery', 'moment' ], factory); + } + else if (typeof exports === 'object') { // Node/CommonJS + module.exports = factory(require('jquery'), require('moment')); + } + else { + factory(jQuery, moment); + } +})(function($, moment) { + +;; + +var fc = $.fullCalendar = { version: "2.3.1" }; +var fcViews = fc.views = {}; + + +$.fn.fullCalendar = function(options) { + var args = Array.prototype.slice.call(arguments, 1); // for a possible method call + var res = this; // what this function will return (this jQuery object by default) + + this.each(function(i, _element) { // loop each DOM element involved + var element = $(_element); + var calendar = element.data('fullCalendar'); // get the existing calendar object (if any) + var singleRes; // the returned value of this single method call + + // a method call + if (typeof options === 'string') { + if (calendar && $.isFunction(calendar[options])) { + singleRes = calendar[options].apply(calendar, args); + if (!i) { + res = singleRes; // record the first method call result + } + if (options === 'destroy') { // for the destroy method, must remove Calendar object data + element.removeData('fullCalendar'); + } + } + } + // a new calendar initialization + else if (!calendar) { // don't initialize twice + calendar = new fc.CalendarBase(element, options); + element.data('fullCalendar', calendar); + calendar.render(); + } + }); + + return res; +}; + + +var complexOptions = [ // names of options that are objects whose properties should be combined + 'header', + 'buttonText', + 'buttonIcons', + 'themeButtonIcons' +]; + + +// Recursively combines all passed-in option-hash arguments into a new single option-hash. +// Given option-hashes are ordered from lowest to highest priority. +function mergeOptions() { + var chain = Array.prototype.slice.call(arguments); // convert to a real array + var complexVals = {}; // hash for each complex option's combined values + var i, name; + var combinedVal; + var j; + var val; + + // for each complex option, loop through each option-hash and accumulate the combined values + for (i = 0; i < complexOptions.length; i++) { + name = complexOptions[i]; + combinedVal = null; // an object holding the merge of all the values + + for (j = 0; j < chain.length; j++) { + val = chain[j][name]; + + if ($.isPlainObject(val)) { + combinedVal = $.extend(combinedVal || {}, val); // merge new properties + } + else if (val != null) { // a non-null non-undefined atomic option + combinedVal = null; // signal to use the atomic value + } + } + + // if not null, the final value was a combination of other objects. record it + if (combinedVal !== null) { + complexVals[name] = combinedVal; + } + } + + chain.unshift({}); // $.extend will mutate this with the result + chain.push(complexVals); // computed complex values are applied last + return $.extend.apply($, chain); // combine +} + + +// Given options specified for the calendar's constructor, massages any legacy options into a non-legacy form. +// Converts View-Option-Hashes into the View-Specific-Options format. +function massageOverrides(input) { + var overrides = { views: input.views || {} }; // the output. ensure a `views` hash + var subObj; + + // iterate through all option override properties (except `views`) + $.each(input, function(name, val) { + if (name != 'views') { + + // could the value be a legacy View-Option-Hash? + if ( + $.isPlainObject(val) && + !/(time|duration|interval)$/i.test(name) && // exclude duration options. might be given as objects + $.inArray(name, complexOptions) == -1 // complex options aren't allowed to be View-Option-Hashes + ) { + subObj = null; + + // iterate through the properties of this possible View-Option-Hash value + $.each(val, function(subName, subVal) { + + // is the property targeting a view? + if (/^(month|week|day|default|basic(Week|Day)?|agenda(Week|Day)?)$/.test(subName)) { + if (!overrides.views[subName]) { // ensure the view-target entry exists + overrides.views[subName] = {}; + } + overrides.views[subName][name] = subVal; // record the value in the `views` object + } + else { // a non-View-Option-Hash property + if (!subObj) { + subObj = {}; + } + subObj[subName] = subVal; // accumulate these unrelated values for later + } + }); + + if (subObj) { // non-View-Option-Hash properties? transfer them as-is + overrides[name] = subObj; + } + } + else { + overrides[name] = val; // transfer normal options as-is + } + } + }); + + return overrides; +} + +;; + +// exports +fc.intersectionToSeg = intersectionToSeg; +fc.applyAll = applyAll; +fc.debounce = debounce; +fc.isInt = isInt; +fc.htmlEscape = htmlEscape; +fc.cssToStr = cssToStr; +fc.proxy = proxy; + + +/* FullCalendar-specific DOM Utilities +----------------------------------------------------------------------------------------------------------------------*/ + + +// Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left +// and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that. +function compensateScroll(rowEls, scrollbarWidths) { + if (scrollbarWidths.left) { + rowEls.css({ + 'border-left-width': 1, + 'margin-left': scrollbarWidths.left - 1 + }); + } + if (scrollbarWidths.right) { + rowEls.css({ + 'border-right-width': 1, + 'margin-right': scrollbarWidths.right - 1 + }); + } +} + + +// Undoes compensateScroll and restores all borders/margins +function uncompensateScroll(rowEls) { + rowEls.css({ + 'margin-left': '', + 'margin-right': '', + 'border-left-width': '', + 'border-right-width': '' + }); +} + + +// Make the mouse cursor express that an event is not allowed in the current area +function disableCursor() { + $('body').addClass('fc-not-allowed'); +} + + +// Returns the mouse cursor to its original look +function enableCursor() { + $('body').removeClass('fc-not-allowed'); +} + + +// Given a total available height to fill, have `els` (essentially child rows) expand to accomodate. +// By default, all elements that are shorter than the recommended height are expanded uniformly, not considering +// any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and +// reduces the available height. +function distributeHeight(els, availableHeight, shouldRedistribute) { + + // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions, + // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars. + + var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element + var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE* + var flexEls = []; // elements that are allowed to expand. array of DOM nodes + var flexOffsets = []; // amount of vertical space it takes up + var flexHeights = []; // actual css height + var usedHeight = 0; + + undistributeHeight(els); // give all elements their natural height + + // find elements that are below the recommended height (expandable). + // important to query for heights in a single first pass (to avoid reflow oscillation). + els.each(function(i, el) { + var minOffset = i === els.length - 1 ? minOffset2 : minOffset1; + var naturalOffset = $(el).outerHeight(true); + + if (naturalOffset < minOffset) { + flexEls.push(el); + flexOffsets.push(naturalOffset); + flexHeights.push($(el).height()); + } + else { + // this element stretches past recommended height (non-expandable). mark the space as occupied. + usedHeight += naturalOffset; + } + }); + + // readjust the recommended height to only consider the height available to non-maxed-out rows. + if (shouldRedistribute) { + availableHeight -= usedHeight; + minOffset1 = Math.floor(availableHeight / flexEls.length); + minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE* + } + + // assign heights to all expandable elements + $(flexEls).each(function(i, el) { + var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1; + var naturalOffset = flexOffsets[i]; + var naturalHeight = flexHeights[i]; + var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding + + if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things + $(el).height(newHeight); + } + }); +} + + +// Undoes distrubuteHeight, restoring all els to their natural height +function undistributeHeight(els) { + els.height(''); +} + + +// Given `els`, a jQuery set of cells, find the cell with the largest natural width and set the widths of all the +// cells to be that width. +// PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline +function matchCellWidths(els) { + var maxInnerWidth = 0; + + els.find('> *').each(function(i, innerEl) { + var innerWidth = $(innerEl).outerWidth(); + if (innerWidth > maxInnerWidth) { + maxInnerWidth = innerWidth; + } + }); + + maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance + + els.width(maxInnerWidth); + + return maxInnerWidth; +} + + +// Turns a container element into a scroller if its contents is taller than the allotted height. +// Returns true if the element is now a scroller, false otherwise. +// NOTE: this method is best because it takes weird zooming dimensions into account +function setPotentialScroller(containerEl, height) { + containerEl.height(height).addClass('fc-scroller'); + + // are scrollbars needed? + if (containerEl[0].scrollHeight - 1 > containerEl[0].clientHeight) { // !!! -1 because IE is often off-by-one :( + return true; + } + + unsetScroller(containerEl); // undo + return false; +} + + +// Takes an element that might have been a scroller, and turns it back into a normal element. +function unsetScroller(containerEl) { + containerEl.height('').removeClass('fc-scroller'); +} + + +/* General DOM Utilities +----------------------------------------------------------------------------------------------------------------------*/ + +fc.getClientRect = getClientRect; +fc.getContentRect = getContentRect; +fc.getScrollbarWidths = getScrollbarWidths; + + +// borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51 +function getScrollParent(el) { + var position = el.css('position'), + scrollParent = el.parents().filter(function() { + var parent = $(this); + return (/(auto|scroll)/).test( + parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x') + ); + }).eq(0); + + return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent; +} + + +// Queries the outer bounding area of a jQuery element. +// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). +function getOuterRect(el) { + var offset = el.offset(); + + return { + left: offset.left, + right: offset.left + el.outerWidth(), + top: offset.top, + bottom: offset.top + el.outerHeight() + }; +} + + +// Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding. +// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). +// NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. +function getClientRect(el) { + var offset = el.offset(); + var scrollbarWidths = getScrollbarWidths(el); + var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left; + var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top; + + return { + left: left, + right: left + el[0].clientWidth, // clientWidth includes padding but NOT scrollbars + top: top, + bottom: top + el[0].clientHeight // clientHeight includes padding but NOT scrollbars + }; +} + + +// Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars. +// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). +function getContentRect(el) { + var offset = el.offset(); // just outside of border, margin not included + var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left'); + var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top'); + + return { + left: left, + right: left + el.width(), + top: top, + bottom: top + el.height() + }; +} + + +// Returns the computed left/right/top/bottom scrollbar widths for the given jQuery element. +// NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. +function getScrollbarWidths(el) { + var leftRightWidth = el.innerWidth() - el[0].clientWidth; // the paddings cancel out, leaving the scrollbars + var widths = { + left: 0, + right: 0, + top: 0, + bottom: el.innerHeight() - el[0].clientHeight // the paddings cancel out, leaving the bottom scrollbar + }; + + if (getIsLeftRtlScrollbars() && el.css('direction') == 'rtl') { // is the scrollbar on the left side? + widths.left = leftRightWidth; + } + else { + widths.right = leftRightWidth; + } + + return widths; +} + + +// Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side + +var _isLeftRtlScrollbars = null; + +function getIsLeftRtlScrollbars() { // responsible for caching the computation + if (_isLeftRtlScrollbars === null) { + _isLeftRtlScrollbars = computeIsLeftRtlScrollbars(); + } + return _isLeftRtlScrollbars; +} + +function computeIsLeftRtlScrollbars() { // creates an offscreen test element, then removes it + var el = $('
') + .css({ + position: 'absolute', + top: -1000, + left: 0, + border: 0, + padding: 0, + overflow: 'scroll', + direction: 'rtl' + }) + .appendTo('body'); + var innerEl = el.children(); + var res = innerEl.offset().left > el.offset().left; // is the inner div shifted to accommodate a left scrollbar? + el.remove(); + return res; +} + + +// Retrieves a jQuery element's computed CSS value as a floating-point number. +// If the queried value is non-numeric (ex: IE can return "medium" for border width), will just return zero. +function getCssFloat(el, prop) { + return parseFloat(el.css(prop)) || 0; +} + + +// Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac) +function isPrimaryMouseButton(ev) { + return ev.which == 1 && !ev.ctrlKey; +} + + +/* Geometry +----------------------------------------------------------------------------------------------------------------------*/ + + +// Returns a new rectangle that is the intersection of the two rectangles. If they don't intersect, returns false +function intersectRects(rect1, rect2) { + var res = { + left: Math.max(rect1.left, rect2.left), + right: Math.min(rect1.right, rect2.right), + top: Math.max(rect1.top, rect2.top), + bottom: Math.min(rect1.bottom, rect2.bottom) + }; + + if (res.left < res.right && res.top < res.bottom) { + return res; + } + return false; +} + + +// Returns a new point that will have been moved to reside within the given rectangle +function constrainPoint(point, rect) { + return { + left: Math.min(Math.max(point.left, rect.left), rect.right), + top: Math.min(Math.max(point.top, rect.top), rect.bottom) + }; +} + + +// Returns a point that is the center of the given rectangle +function getRectCenter(rect) { + return { + left: (rect.left + rect.right) / 2, + top: (rect.top + rect.bottom) / 2 + }; +} + + +// Subtracts point2's coordinates from point1's coordinates, returning a delta +function diffPoints(point1, point2) { + return { + left: point1.left - point2.left, + top: point1.top - point2.top + }; +} + + +/* FullCalendar-specific Misc Utilities +----------------------------------------------------------------------------------------------------------------------*/ + + +// Creates a basic segment with the intersection of the two ranges. Returns undefined if no intersection. +// Expects all dates to be normalized to the same timezone beforehand. +// TODO: move to date section? +function intersectionToSeg(subjectRange, constraintRange) { + var subjectStart = subjectRange.start; + var subjectEnd = subjectRange.end; + var constraintStart = constraintRange.start; + var constraintEnd = constraintRange.end; + var segStart, segEnd; + var isStart, isEnd; + + if (subjectEnd > constraintStart && subjectStart < constraintEnd) { // in bounds at all? + + if (subjectStart >= constraintStart) { + segStart = subjectStart.clone(); + isStart = true; + } + else { + segStart = constraintStart.clone(); + isStart = false; + } + + if (subjectEnd <= constraintEnd) { + segEnd = subjectEnd.clone(); + isEnd = true; + } + else { + segEnd = constraintEnd.clone(); + isEnd = false; + } + + return { + start: segStart, + end: segEnd, + isStart: isStart, + isEnd: isEnd + }; + } +} + + +/* Date Utilities +----------------------------------------------------------------------------------------------------------------------*/ + +fc.computeIntervalUnit = computeIntervalUnit; +fc.durationHasTime = durationHasTime; + +var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ]; +var intervalUnits = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ]; + + +// Diffs the two moments into a Duration where full-days are recorded first, then the remaining time. +// Moments will have their timezones normalized. +function diffDayTime(a, b) { + return moment.duration({ + days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'), + ms: a.time() - b.time() // time-of-day from day start. disregards timezone + }); +} + + +// Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations. +function diffDay(a, b) { + return moment.duration({ + days: a.clone().stripTime().diff(b.clone().stripTime(), 'days') + }); +} + + +// Diffs two moments, producing a duration, made of a whole-unit-increment of the given unit. Uses rounding. +function diffByUnit(a, b, unit) { + return moment.duration( + Math.round(a.diff(b, unit, true)), // returnFloat=true + unit + ); +} + + +// Computes the unit name of the largest whole-unit period of time. +// For example, 48 hours will be "days" whereas 49 hours will be "hours". +// Accepts start/end, a range object, or an original duration object. +function computeIntervalUnit(start, end) { + var i, unit; + var val; + + for (i = 0; i < intervalUnits.length; i++) { + unit = intervalUnits[i]; + val = computeRangeAs(unit, start, end); + + if (val >= 1 && isInt(val)) { + break; + } + } + + return unit; // will be "milliseconds" if nothing else matches +} + + +// Computes the number of units (like "hours") in the given range. +// Range can be a {start,end} object, separate start/end args, or a Duration. +// Results are based on Moment's .as() and .diff() methods, so results can depend on internal handling +// of month-diffing logic (which tends to vary from version to version). +function computeRangeAs(unit, start, end) { + + if (end != null) { // given start, end + return end.diff(start, unit, true); + } + else if (moment.isDuration(start)) { // given duration + return start.as(unit); + } + else { // given { start, end } range object + return start.end.diff(start.start, unit, true); + } +} + + +// Returns a boolean about whether the given duration has any time parts (hours/minutes/seconds/ms) +function durationHasTime(dur) { + return Boolean(dur.hours() || dur.minutes() || dur.seconds() || dur.milliseconds()); +} + + +function isNativeDate(input) { + return Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date; +} + + +// Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00" +function isTimeString(str) { + return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str); +} + + +/* General Utilities +----------------------------------------------------------------------------------------------------------------------*/ + +var hasOwnPropMethod = {}.hasOwnProperty; + + +// Create an object that has the given prototype. Just like Object.create +function createObject(proto) { + var f = function() {}; + f.prototype = proto; + return new f(); +} + + +function copyOwnProps(src, dest) { + for (var name in src) { + if (hasOwnProp(src, name)) { + dest[name] = src[name]; + } + } +} + + +// Copies over certain methods with the same names as Object.prototype methods. Overcomes an IE<=8 bug: +// https://developer.mozilla.org/en-US/docs/ECMAScript_DontEnum_attribute#JScript_DontEnum_Bug +function copyNativeMethods(src, dest) { + var names = [ 'constructor', 'toString', 'valueOf' ]; + var i, name; + + for (i = 0; i < names.length; i++) { + name = names[i]; + + if (src[name] !== Object.prototype[name]) { + dest[name] = src[name]; + } + } +} + + +function hasOwnProp(obj, name) { + return hasOwnPropMethod.call(obj, name); +} + + +// Is the given value a non-object non-function value? +function isAtomic(val) { + return /undefined|null|boolean|number|string/.test($.type(val)); +} + + +function applyAll(functions, thisObj, args) { + if ($.isFunction(functions)) { + functions = [ functions ]; + } + if (functions) { + var i; + var ret; + for (i=0; i/g, '>') + .replace(/'/g, ''') + .replace(/"/g, '"') + .replace(/\n/g, '
'); +} + + +function stripHtmlEntities(text) { + return text.replace(/&.*?;/g, ''); +} + + +// Given a hash of CSS properties, returns a string of CSS. +// Uses property names as-is (no camel-case conversion). Will not make statements for null/undefined values. +function cssToStr(cssProps) { + var statements = []; + + $.each(cssProps, function(name, val) { + if (val != null) { + statements.push(name + ':' + val); + } + }); + + return statements.join(';'); +} + + +function capitaliseFirstLetter(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + + +function compareNumbers(a, b) { // for .sort() + return a - b; +} + + +function isInt(n) { + return n % 1 === 0; +} + + +// Returns a method bound to the given object context. +// Just like one of the jQuery.proxy signatures, but without the undesired behavior of treating the same method with +// different contexts as identical when binding/unbinding events. +function proxy(obj, methodName) { + var method = obj[methodName]; + + return function() { + return method.apply(obj, arguments); + }; +} + + +// Returns a function, that, as long as it continues to be invoked, will not +// be triggered. The function will be called after it stops being called for +// N milliseconds. +// https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714 +function debounce(func, wait) { + var timeoutId; + var args; + var context; + var timestamp; // of most recent call + var later = function() { + var last = +new Date() - timestamp; + if (last < wait && last > 0) { + timeoutId = setTimeout(later, wait - last); + } + else { + timeoutId = null; + func.apply(context, args); + if (!timeoutId) { + context = args = null; + } + } + }; + + return function() { + context = this; + args = arguments; + timestamp = +new Date(); + if (!timeoutId) { + timeoutId = setTimeout(later, wait); + } + }; +} + +;; + +var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/; +var ambigTimeOrZoneRegex = + /^\s*\d{4}-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?)?$/; +var newMomentProto = moment.fn; // where we will attach our new methods +var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods +var allowValueOptimization; +var setUTCValues; // function defined below +var setLocalValues; // function defined below + + +// Creating +// ------------------------------------------------------------------------------------------------- + +// Creates a new moment, similar to the vanilla moment(...) constructor, but with +// extra features (ambiguous time, enhanced formatting). When given an existing moment, +// it will function as a clone (and retain the zone of the moment). Anything else will +// result in a moment in the local zone. +fc.moment = function() { + return makeMoment(arguments); +}; + +// Sames as fc.moment, but forces the resulting moment to be in the UTC timezone. +fc.moment.utc = function() { + var mom = makeMoment(arguments, true); + + // Force it into UTC because makeMoment doesn't guarantee it + // (if given a pre-existing moment for example) + if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone + mom.utc(); + } + + return mom; +}; + +// Same as fc.moment, but when given an ISO8601 string, the timezone offset is preserved. +// ISO8601 strings with no timezone offset will become ambiguously zoned. +fc.moment.parseZone = function() { + return makeMoment(arguments, true, true); +}; + +// Builds an enhanced moment from args. When given an existing moment, it clones. When given a +// native Date, or called with no arguments (the current time), the resulting moment will be local. +// Anything else needs to be "parsed" (a string or an array), and will be affected by: +// parseAsUTC - if there is no zone information, should we parse the input in UTC? +// parseZone - if there is zone information, should we force the zone of the moment? +function makeMoment(args, parseAsUTC, parseZone) { + var input = args[0]; + var isSingleString = args.length == 1 && typeof input === 'string'; + var isAmbigTime; + var isAmbigZone; + var ambigMatch; + var mom; + + if (moment.isMoment(input)) { + mom = moment.apply(null, args); // clone it + transferAmbigs(input, mom); // the ambig flags weren't transfered with the clone + } + else if (isNativeDate(input) || input === undefined) { + mom = moment.apply(null, args); // will be local + } + else { // "parsing" is required + isAmbigTime = false; + isAmbigZone = false; + + if (isSingleString) { + if (ambigDateOfMonthRegex.test(input)) { + // accept strings like '2014-05', but convert to the first of the month + input += '-01'; + args = [ input ]; // for when we pass it on to moment's constructor + isAmbigTime = true; + isAmbigZone = true; + } + else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) { + isAmbigTime = !ambigMatch[5]; // no time part? + isAmbigZone = true; + } + } + else if ($.isArray(input)) { + // arrays have no timezone information, so assume ambiguous zone + isAmbigZone = true; + } + // otherwise, probably a string with a format + + if (parseAsUTC || isAmbigTime) { + mom = moment.utc.apply(moment, args); + } + else { + mom = moment.apply(null, args); + } + + if (isAmbigTime) { + mom._ambigTime = true; + mom._ambigZone = true; // ambiguous time always means ambiguous zone + } + else if (parseZone) { // let's record the inputted zone somehow + if (isAmbigZone) { + mom._ambigZone = true; + } + else if (isSingleString) { + if (mom.utcOffset) { + mom.utcOffset(input); // if not a valid zone, will assign UTC + } + else { + mom.zone(input); // for moment-pre-2.9 + } + } + } + } + + mom._fullCalendar = true; // flag for extended functionality + + return mom; +} + + +// A clone method that works with the flags related to our enhanced functionality. +// In the future, use moment.momentProperties +newMomentProto.clone = function() { + var mom = oldMomentProto.clone.apply(this, arguments); + + // these flags weren't transfered with the clone + transferAmbigs(this, mom); + if (this._fullCalendar) { + mom._fullCalendar = true; + } + + return mom; +}; + + +// Week Number +// ------------------------------------------------------------------------------------------------- + + +// Returns the week number, considering the locale's custom week number calcuation +// `weeks` is an alias for `week` +newMomentProto.week = newMomentProto.weeks = function(input) { + var weekCalc = (this._locale || this._lang) // works pre-moment-2.8 + ._fullCalendar_weekCalc; + + if (input == null && typeof weekCalc === 'function') { // custom function only works for getter + return weekCalc(this); + } + else if (weekCalc === 'ISO') { + return oldMomentProto.isoWeek.apply(this, arguments); // ISO getter/setter + } + + return oldMomentProto.week.apply(this, arguments); // local getter/setter +}; + + +// Time-of-day +// ------------------------------------------------------------------------------------------------- + +// GETTER +// Returns a Duration with the hours/minutes/seconds/ms values of the moment. +// If the moment has an ambiguous time, a duration of 00:00 will be returned. +// +// SETTER +// You can supply a Duration, a Moment, or a Duration-like argument. +// When setting the time, and the moment has an ambiguous time, it then becomes unambiguous. +newMomentProto.time = function(time) { + + // Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar. + // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins. + if (!this._fullCalendar) { + return oldMomentProto.time.apply(this, arguments); + } + + if (time == null) { // getter + return moment.duration({ + hours: this.hours(), + minutes: this.minutes(), + seconds: this.seconds(), + milliseconds: this.milliseconds() + }); + } + else { // setter + + this._ambigTime = false; // mark that the moment now has a time + + if (!moment.isDuration(time) && !moment.isMoment(time)) { + time = moment.duration(time); + } + + // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day). + // Only for Duration times, not Moment times. + var dayHours = 0; + if (moment.isDuration(time)) { + dayHours = Math.floor(time.asDays()) * 24; + } + + // We need to set the individual fields. + // Can't use startOf('day') then add duration. In case of DST at start of day. + return this.hours(dayHours + time.hours()) + .minutes(time.minutes()) + .seconds(time.seconds()) + .milliseconds(time.milliseconds()); + } +}; + +// Converts the moment to UTC, stripping out its time-of-day and timezone offset, +// but preserving its YMD. A moment with a stripped time will display no time +// nor timezone offset when .format() is called. +newMomentProto.stripTime = function() { + var a; + + if (!this._ambigTime) { + + // get the values before any conversion happens + a = this.toArray(); // array of y/m/d/h/m/s/ms + + // TODO: use keepLocalTime in the future + this.utc(); // set the internal UTC flag (will clear the ambig flags) + setUTCValues(this, a.slice(0, 3)); // set the year/month/date. time will be zero + + // Mark the time as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), + // which clears all ambig flags. Same with setUTCValues with moment-timezone. + this._ambigTime = true; + this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset + } + + return this; // for chaining +}; + +// Returns if the moment has a non-ambiguous time (boolean) +newMomentProto.hasTime = function() { + return !this._ambigTime; +}; + + +// Timezone +// ------------------------------------------------------------------------------------------------- + +// Converts the moment to UTC, stripping out its timezone offset, but preserving its +// YMD and time-of-day. A moment with a stripped timezone offset will display no +// timezone offset when .format() is called. +// TODO: look into Moment's keepLocalTime functionality +newMomentProto.stripZone = function() { + var a, wasAmbigTime; + + if (!this._ambigZone) { + + // get the values before any conversion happens + a = this.toArray(); // array of y/m/d/h/m/s/ms + wasAmbigTime = this._ambigTime; + + this.utc(); // set the internal UTC flag (might clear the ambig flags, depending on Moment internals) + setUTCValues(this, a); // will set the year/month/date/hours/minutes/seconds/ms + + // the above call to .utc()/.utcOffset() unfortunately might clear the ambig flags, so restore + this._ambigTime = wasAmbigTime || false; + + // Mark the zone as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), + // which clears the ambig flags. Same with setUTCValues with moment-timezone. + this._ambigZone = true; + } + + return this; // for chaining +}; + +// Returns of the moment has a non-ambiguous timezone offset (boolean) +newMomentProto.hasZone = function() { + return !this._ambigZone; +}; + + +// this method implicitly marks a zone +newMomentProto.local = function() { + var a = this.toArray(); // year,month,date,hours,minutes,seconds,ms as an array + var wasAmbigZone = this._ambigZone; + + oldMomentProto.local.apply(this, arguments); + + // ensure non-ambiguous + // this probably already happened via local() -> utcOffset(), but don't rely on Moment's internals + this._ambigTime = false; + this._ambigZone = false; + + if (wasAmbigZone) { + // If the moment was ambiguously zoned, the date fields were stored as UTC. + // We want to preserve these, but in local time. + // TODO: look into Moment's keepLocalTime functionality + setLocalValues(this, a); + } + + return this; // for chaining +}; + + +// implicitly marks a zone +newMomentProto.utc = function() { + oldMomentProto.utc.apply(this, arguments); + + // ensure non-ambiguous + // this probably already happened via utc() -> utcOffset(), but don't rely on Moment's internals + this._ambigTime = false; + this._ambigZone = false; + + return this; +}; + + +// methods for arbitrarily manipulating timezone offset. +// should clear time/zone ambiguity when called. +$.each([ + 'zone', // only in moment-pre-2.9. deprecated afterwards + 'utcOffset' +], function(i, name) { + if (oldMomentProto[name]) { // original method exists? + + // this method implicitly marks a zone (will probably get called upon .utc() and .local()) + newMomentProto[name] = function(tzo) { + + if (tzo != null) { // setter + // these assignments needs to happen before the original zone method is called. + // I forget why, something to do with a browser crash. + this._ambigTime = false; + this._ambigZone = false; + } + + return oldMomentProto[name].apply(this, arguments); + }; + } +}); + + +// Formatting +// ------------------------------------------------------------------------------------------------- + +newMomentProto.format = function() { + if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided? + return formatDate(this, arguments[0]); // our extended formatting + } + if (this._ambigTime) { + return oldMomentFormat(this, 'YYYY-MM-DD'); + } + if (this._ambigZone) { + return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); + } + return oldMomentProto.format.apply(this, arguments); +}; + +newMomentProto.toISOString = function() { + if (this._ambigTime) { + return oldMomentFormat(this, 'YYYY-MM-DD'); + } + if (this._ambigZone) { + return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); + } + return oldMomentProto.toISOString.apply(this, arguments); +}; + + +// Querying +// ------------------------------------------------------------------------------------------------- + +// Is the moment within the specified range? `end` is exclusive. +// FYI, this method is not a standard Moment method, so always do our enhanced logic. +newMomentProto.isWithin = function(start, end) { + var a = commonlyAmbiguate([ this, start, end ]); + return a[0] >= a[1] && a[0] < a[2]; +}; + +// When isSame is called with units, timezone ambiguity is normalized before the comparison happens. +// If no units specified, the two moments must be identically the same, with matching ambig flags. +newMomentProto.isSame = function(input, units) { + var a; + + // only do custom logic if this is an enhanced moment + if (!this._fullCalendar) { + return oldMomentProto.isSame.apply(this, arguments); + } + + if (units) { + a = commonlyAmbiguate([ this, input ], true); // normalize timezones but don't erase times + return oldMomentProto.isSame.call(a[0], a[1], units); + } + else { + input = fc.moment.parseZone(input); // normalize input + return oldMomentProto.isSame.call(this, input) && + Boolean(this._ambigTime) === Boolean(input._ambigTime) && + Boolean(this._ambigZone) === Boolean(input._ambigZone); + } +}; + +// Make these query methods work with ambiguous moments +$.each([ + 'isBefore', + 'isAfter' +], function(i, methodName) { + newMomentProto[methodName] = function(input, units) { + var a; + + // only do custom logic if this is an enhanced moment + if (!this._fullCalendar) { + return oldMomentProto[methodName].apply(this, arguments); + } + + a = commonlyAmbiguate([ this, input ]); + return oldMomentProto[methodName].call(a[0], a[1], units); + }; +}); + + +// Misc Internals +// ------------------------------------------------------------------------------------------------- + +// given an array of moment-like inputs, return a parallel array w/ moments similarly ambiguated. +// for example, of one moment has ambig time, but not others, all moments will have their time stripped. +// set `preserveTime` to `true` to keep times, but only normalize zone ambiguity. +// returns the original moments if no modifications are necessary. +function commonlyAmbiguate(inputs, preserveTime) { + var anyAmbigTime = false; + var anyAmbigZone = false; + var len = inputs.length; + var moms = []; + var i, mom; + + // parse inputs into real moments and query their ambig flags + for (i = 0; i < len; i++) { + mom = inputs[i]; + if (!moment.isMoment(mom)) { + mom = fc.moment.parseZone(mom); + } + anyAmbigTime = anyAmbigTime || mom._ambigTime; + anyAmbigZone = anyAmbigZone || mom._ambigZone; + moms.push(mom); + } + + // strip each moment down to lowest common ambiguity + // use clones to avoid modifying the original moments + for (i = 0; i < len; i++) { + mom = moms[i]; + if (!preserveTime && anyAmbigTime && !mom._ambigTime) { + moms[i] = mom.clone().stripTime(); + } + else if (anyAmbigZone && !mom._ambigZone) { + moms[i] = mom.clone().stripZone(); + } + } + + return moms; +} + +// Transfers all the flags related to ambiguous time/zone from the `src` moment to the `dest` moment +// TODO: look into moment.momentProperties for this. +function transferAmbigs(src, dest) { + if (src._ambigTime) { + dest._ambigTime = true; + } + else if (dest._ambigTime) { + dest._ambigTime = false; + } + + if (src._ambigZone) { + dest._ambigZone = true; + } + else if (dest._ambigZone) { + dest._ambigZone = false; + } +} + + +// Sets the year/month/date/etc values of the moment from the given array. +// Inefficient because it calls each individual setter. +function setMomentValues(mom, a) { + mom.year(a[0] || 0) + .month(a[1] || 0) + .date(a[2] || 0) + .hours(a[3] || 0) + .minutes(a[4] || 0) + .seconds(a[5] || 0) + .milliseconds(a[6] || 0); +} + +// Can we set the moment's internal date directly? +allowValueOptimization = '_d' in moment() && 'updateOffset' in moment; + +// Utility function. Accepts a moment and an array of the UTC year/month/date/etc values to set. +// Assumes the given moment is already in UTC mode. +setUTCValues = allowValueOptimization ? function(mom, a) { + // simlate what moment's accessors do + mom._d.setTime(Date.UTC.apply(Date, a)); + moment.updateOffset(mom, false); // keepTime=false +} : setMomentValues; + +// Utility function. Accepts a moment and an array of the local year/month/date/etc values to set. +// Assumes the given moment is already in local mode. +setLocalValues = allowValueOptimization ? function(mom, a) { + // simlate what moment's accessors do + mom._d.setTime(+new Date( // FYI, there is now way to apply an array of args to a constructor + a[0] || 0, + a[1] || 0, + a[2] || 0, + a[3] || 0, + a[4] || 0, + a[5] || 0, + a[6] || 0 + )); + moment.updateOffset(mom, false); // keepTime=false +} : setMomentValues; + +;; + +// Single Date Formatting +// ------------------------------------------------------------------------------------------------- + + +// call this if you want Moment's original format method to be used +function oldMomentFormat(mom, formatStr) { + return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js +} + + +// Formats `date` with a Moment formatting string, but allow our non-zero areas and +// additional token. +function formatDate(date, formatStr) { + return formatDateWithChunks(date, getFormatStringChunks(formatStr)); +} + + +function formatDateWithChunks(date, chunks) { + var s = ''; + var i; + + for (i=0; i "MMMM D YYYY" + formatStr = localeData.longDateFormat(formatStr) || formatStr; + // BTW, this is not important for `formatDate` because it is impossible to put custom tokens + // or non-zero areas in Moment's localized format strings. + + separator = separator || ' - '; + + return formatRangeWithChunks( + date1, + date2, + getFormatStringChunks(formatStr), + separator, + isRTL + ); +} +fc.formatRange = formatRange; // expose + + +function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { + var chunkStr; // the rendering of the chunk + var leftI; + var leftStr = ''; + var rightI; + var rightStr = ''; + var middleI; + var middleStr1 = ''; + var middleStr2 = ''; + var middleStr = ''; + + // Start at the leftmost side of the formatting string and continue until you hit a token + // that is not the same between dates. + for (leftI=0; leftIleftI; rightI--) { + chunkStr = formatSimilarChunk(date1, date2, chunks[rightI]); + if (chunkStr === false) { + break; + } + rightStr = chunkStr + rightStr; + } + + // The area in the middle is different for both of the dates. + // Collect them distinctly so we can jam them together later. + for (middleI=leftI; middleI<=rightI; middleI++) { + middleStr1 += formatDateWithChunk(date1, chunks[middleI]); + middleStr2 += formatDateWithChunk(date2, chunks[middleI]); + } + + if (middleStr1 || middleStr2) { + if (isRTL) { + middleStr = middleStr2 + separator + middleStr1; + } + else { + middleStr = middleStr1 + separator + middleStr2; + } + } + + return leftStr + middleStr + rightStr; +} + + +var similarUnitMap = { + Y: 'year', + M: 'month', + D: 'day', // day of month + d: 'day', // day of week + // prevents a separator between anything time-related... + A: 'second', // AM/PM + a: 'second', // am/pm + T: 'second', // A/P + t: 'second', // a/p + H: 'second', // hour (24) + h: 'second', // hour (12) + m: 'second', // minute + s: 'second' // second +}; +// TODO: week maybe? + + +// Given a formatting chunk, and given that both dates are similar in the regard the +// formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`. +function formatSimilarChunk(date1, date2, chunk) { + var token; + var unit; + + if (typeof chunk === 'string') { // a literal string + return chunk; + } + else if ((token = chunk.token)) { + unit = similarUnitMap[token.charAt(0)]; + // are the dates the same for this unit of measurement? + if (unit && date1.isSame(date2, unit)) { + return oldMomentFormat(date1, token); // would be the same if we used `date2` + // BTW, don't support custom tokens + } + } + + return false; // the chunk is NOT the same for the two dates + // BTW, don't support splitting on non-zero areas +} + + +// Chunking Utils +// ------------------------------------------------------------------------------------------------- + + +var formatStringChunkCache = {}; + + +function getFormatStringChunks(formatStr) { + if (formatStr in formatStringChunkCache) { + return formatStringChunkCache[formatStr]; + } + return (formatStringChunkCache[formatStr] = chunkFormatString(formatStr)); +} + + +// Break the formatting string into an array of chunks +function chunkFormatString(formatStr) { + var chunks = []; + var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g; // TODO: more descrimination + var match; + + while ((match = chunker.exec(formatStr))) { + if (match[1]) { // a literal string inside [ ... ] + chunks.push(match[1]); + } + else if (match[2]) { // non-zero formatting inside ( ... ) + chunks.push({ maybe: chunkFormatString(match[2]) }); + } + else if (match[3]) { // a formatting token + chunks.push({ token: match[3] }); + } + else if (match[5]) { // an unenclosed literal string + chunks.push(match[5]); + } + } + + return chunks; +} + +;; + +fc.Class = Class; // export + +// class that all other classes will inherit from +function Class() { } + +// called upon a class to create a subclass +Class.extend = function(members) { + var superClass = this; + var subClass; + + members = members || {}; + + // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist + if (hasOwnProp(members, 'constructor')) { + subClass = members.constructor; + } + if (typeof subClass !== 'function') { + subClass = members.constructor = function() { + superClass.apply(this, arguments); + }; + } + + // build the base prototype for the subclass, which is an new object chained to the superclass's prototype + subClass.prototype = createObject(superClass.prototype); + + // copy each member variable/method onto the the subclass's prototype + copyOwnProps(members, subClass.prototype); + copyNativeMethods(members, subClass.prototype); // hack for IE8 + + // copy over all class variables/methods to the subclass, such as `extend` and `mixin` + copyOwnProps(superClass, subClass); + + return subClass; +}; + +// adds new member variables/methods to the class's prototype. +// can be called with another class, or a plain object hash containing new members. +Class.mixin = function(members) { + copyOwnProps(members.prototype || members, this.prototype); +}; +;; + +/* A rectangular panel that is absolutely positioned over other content +------------------------------------------------------------------------------------------------------------------------ +Options: + - className (string) + - content (HTML string or jQuery element set) + - parentEl + - top + - left + - right (the x coord of where the right edge should be. not a "CSS" right) + - autoHide (boolean) + - show (callback) + - hide (callback) +*/ + +var Popover = Class.extend({ + + isHidden: true, + options: null, + el: null, // the container element for the popover. generated by this object + documentMousedownProxy: null, // document mousedown handler bound to `this` + margin: 10, // the space required between the popover and the edges of the scroll container + + + constructor: function(options) { + this.options = options || {}; + }, + + + // Shows the popover on the specified position. Renders it if not already + show: function() { + if (this.isHidden) { + if (!this.el) { + this.render(); + } + this.el.show(); + this.position(); + this.isHidden = false; + this.trigger('show'); + } + }, + + + // Hides the popover, through CSS, but does not remove it from the DOM + hide: function() { + if (!this.isHidden) { + this.el.hide(); + this.isHidden = true; + this.trigger('hide'); + } + }, + + + // Creates `this.el` and renders content inside of it + render: function() { + var _this = this; + var options = this.options; + + this.el = $('
') + .addClass(options.className || '') + .css({ + // position initially to the top left to avoid creating scrollbars + top: 0, + left: 0 + }) + .append(options.content) + .appendTo(options.parentEl); + + // when a click happens on anything inside with a 'fc-close' className, hide the popover + this.el.on('click', '.fc-close', function() { + _this.hide(); + }); + + if (options.autoHide) { + $(document).on('mousedown', this.documentMousedownProxy = proxy(this, 'documentMousedown')); + } + }, + + + // Triggered when the user clicks *anywhere* in the document, for the autoHide feature + documentMousedown: function(ev) { + // only hide the popover if the click happened outside the popover + if (this.el && !$(ev.target).closest(this.el).length) { + this.hide(); + } + }, + + + // Hides and unregisters any handlers + destroy: function() { + this.hide(); + + if (this.el) { + this.el.remove(); + this.el = null; + } + + $(document).off('mousedown', this.documentMousedownProxy); + }, + + + // Positions the popover optimally, using the top/left/right options + position: function() { + var options = this.options; + var origin = this.el.offsetParent().offset(); + var width = this.el.outerWidth(); + var height = this.el.outerHeight(); + var windowEl = $(window); + var viewportEl = getScrollParent(this.el); + var viewportTop; + var viewportLeft; + var viewportOffset; + var top; // the "position" (not "offset") values for the popover + var left; // + + // compute top and left + top = options.top || 0; + if (options.left !== undefined) { + left = options.left; + } + else if (options.right !== undefined) { + left = options.right - width; // derive the left value from the right value + } + else { + left = 0; + } + + if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result + viewportEl = windowEl; + viewportTop = 0; // the window is always at the top left + viewportLeft = 0; // (and .offset() won't work if called here) + } + else { + viewportOffset = viewportEl.offset(); + viewportTop = viewportOffset.top; + viewportLeft = viewportOffset.left; + } + + // if the window is scrolled, it causes the visible area to be further down + viewportTop += windowEl.scrollTop(); + viewportLeft += windowEl.scrollLeft(); + + // constrain to the view port. if constrained by two edges, give precedence to top/left + if (options.viewportConstrain !== false) { + top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin); + top = Math.max(top, viewportTop + this.margin); + left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin); + left = Math.max(left, viewportLeft + this.margin); + } + + this.el.css({ + top: top - origin.top, + left: left - origin.left + }); + }, + + + // Triggers a callback. Calls a function in the option hash of the same name. + // Arguments beyond the first `name` are forwarded on. + // TODO: better code reuse for this. Repeat code + trigger: function(name) { + if (this.options[name]) { + this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); + } + } + +}); + +;; + +/* A "coordinate map" converts pixel coordinates into an associated cell, which has an associated date +------------------------------------------------------------------------------------------------------------------------ +Common interface: + + CoordMap.prototype = { + build: function() {}, + getCell: function(x, y) {} + }; + +*/ + +/* Coordinate map for a grid component +----------------------------------------------------------------------------------------------------------------------*/ + +var GridCoordMap = Class.extend({ + + grid: null, // reference to the Grid + rowCoords: null, // array of {top,bottom} objects + colCoords: null, // array of {left,right} objects + + containerEl: null, // container element that all coordinates are constrained to. optionally assigned + bounds: null, + + + constructor: function(grid) { + this.grid = grid; + }, + + + // Queries the grid for the coordinates of all the cells + build: function() { + this.rowCoords = this.grid.computeRowCoords(); + this.colCoords = this.grid.computeColCoords(); + this.computeBounds(); + }, + + + // Clears the coordinates data to free up memory + clear: function() { + this.rowCoords = null; + this.colCoords = null; + }, + + + // Given a coordinate of the document, gets the associated cell. If no cell is underneath, returns null + getCell: function(x, y) { + var rowCoords = this.rowCoords; + var rowCnt = rowCoords.length; + var colCoords = this.colCoords; + var colCnt = colCoords.length; + var hitRow = null; + var hitCol = null; + var i, coords; + var cell; + + if (this.inBounds(x, y)) { + + for (i = 0; i < rowCnt; i++) { + coords = rowCoords[i]; + if (y >= coords.top && y < coords.bottom) { + hitRow = i; + break; + } + } + + for (i = 0; i < colCnt; i++) { + coords = colCoords[i]; + if (x >= coords.left && x < coords.right) { + hitCol = i; + break; + } + } + + if (hitRow !== null && hitCol !== null) { + + cell = this.grid.getCell(hitRow, hitCol); // expected to return a fresh object we can modify + cell.grid = this.grid; // for CellDragListener's isCellsEqual. dragging between grids + + // make the coordinates available on the cell object + $.extend(cell, rowCoords[hitRow], colCoords[hitCol]); + + return cell; + } + } + + return null; + }, + + + // If there is a containerEl, compute the bounds into min/max values + computeBounds: function() { + this.bounds = this.containerEl ? + getClientRect(this.containerEl) : // area within scrollbars + null; + }, + + + // Determines if the given coordinates are in bounds. If no `containerEl`, always true + inBounds: function(x, y) { + var bounds = this.bounds; + + if (bounds) { + return x >= bounds.left && x < bounds.right && y >= bounds.top && y < bounds.bottom; + } + + return true; + } + +}); + + +/* Coordinate map that is a combination of multiple other coordinate maps +----------------------------------------------------------------------------------------------------------------------*/ + +var ComboCoordMap = Class.extend({ + + coordMaps: null, // an array of CoordMaps + + + constructor: function(coordMaps) { + this.coordMaps = coordMaps; + }, + + + // Builds all coordMaps + build: function() { + var coordMaps = this.coordMaps; + var i; + + for (i = 0; i < coordMaps.length; i++) { + coordMaps[i].build(); + } + }, + + + // Queries all coordMaps for the cell underneath the given coordinates, returning the first result + getCell: function(x, y) { + var coordMaps = this.coordMaps; + var cell = null; + var i; + + for (i = 0; i < coordMaps.length && !cell; i++) { + cell = coordMaps[i].getCell(x, y); + } + + return cell; + }, + + + // Clears all coordMaps + clear: function() { + var coordMaps = this.coordMaps; + var i; + + for (i = 0; i < coordMaps.length; i++) { + coordMaps[i].clear(); + } + } + +}); + +;; + +/* Tracks a drag's mouse movement, firing various handlers +----------------------------------------------------------------------------------------------------------------------*/ + +var DragListener = fc.DragListener = Class.extend({ + + options: null, + + isListening: false, + isDragging: false, + + // coordinates of the initial mousedown + originX: null, + originY: null, + + // handler attached to the document, bound to the DragListener's `this` + mousemoveProxy: null, + mouseupProxy: null, + + // for IE8 bug-fighting behavior, for now + subjectEl: null, // the element being draged. optional + subjectHref: null, + + scrollEl: null, + scrollBounds: null, // { top, bottom, left, right } + scrollTopVel: null, // pixels per second + scrollLeftVel: null, // pixels per second + scrollIntervalId: null, // ID of setTimeout for scrolling animation loop + scrollHandlerProxy: null, // this-scoped function for handling when scrollEl is scrolled + + scrollSensitivity: 30, // pixels from edge for scrolling to start + scrollSpeed: 200, // pixels per second, at maximum speed + scrollIntervalMs: 50, // millisecond wait between scroll increment + + + constructor: function(options) { + options = options || {}; + this.options = options; + this.subjectEl = options.subjectEl; + }, + + + // Call this when the user does a mousedown. Will probably lead to startListening + mousedown: function(ev) { + if (isPrimaryMouseButton(ev)) { + + ev.preventDefault(); // prevents native selection in most browsers + + this.startListening(ev); + + // start the drag immediately if there is no minimum distance for a drag start + if (!this.options.distance) { + this.startDrag(ev); + } + } + }, + + + // Call this to start tracking mouse movements + startListening: function(ev) { + var scrollParent; + + if (!this.isListening) { + + // grab scroll container and attach handler + if (ev && this.options.scroll) { + scrollParent = getScrollParent($(ev.target)); + if (!scrollParent.is(window) && !scrollParent.is(document)) { + this.scrollEl = scrollParent; + + // scope to `this`, and use `debounce` to make sure rapid calls don't happen + this.scrollHandlerProxy = debounce(proxy(this, 'scrollHandler'), 100); + this.scrollEl.on('scroll', this.scrollHandlerProxy); + } + } + + $(document) + .on('mousemove', this.mousemoveProxy = proxy(this, 'mousemove')) + .on('mouseup', this.mouseupProxy = proxy(this, 'mouseup')) + .on('selectstart', this.preventDefault); // prevents native selection in IE<=8 + + if (ev) { + this.originX = ev.pageX; + this.originY = ev.pageY; + } + else { + // if no starting information was given, origin will be the topleft corner of the screen. + // if so, dx/dy in the future will be the absolute coordinates. + this.originX = 0; + this.originY = 0; + } + + this.isListening = true; + this.listenStart(ev); + } + }, + + + // Called when drag listening has started (but a real drag has not necessarily began) + listenStart: function(ev) { + this.trigger('listenStart', ev); + }, + + + // Called when the user moves the mouse + mousemove: function(ev) { + var dx = ev.pageX - this.originX; + var dy = ev.pageY - this.originY; + var minDistance; + var distanceSq; // current distance from the origin, squared + + if (!this.isDragging) { // if not already dragging... + // then start the drag if the minimum distance criteria is met + minDistance = this.options.distance || 1; + distanceSq = dx * dx + dy * dy; + if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem + this.startDrag(ev); + } + } + + if (this.isDragging) { + this.drag(dx, dy, ev); // report a drag, even if this mousemove initiated the drag + } + }, + + + // Call this to initiate a legitimate drag. + // This function is called internally from this class, but can also be called explicitly from outside + startDrag: function(ev) { + + if (!this.isListening) { // startDrag must have manually initiated + this.startListening(); + } + + if (!this.isDragging) { + this.isDragging = true; + this.dragStart(ev); + } + }, + + + // Called when the actual drag has started (went beyond minDistance) + dragStart: function(ev) { + var subjectEl = this.subjectEl; + + this.trigger('dragStart', ev); + + // remove a mousedown'd 's href so it is not visited (IE8 bug) + if ((this.subjectHref = subjectEl ? subjectEl.attr('href') : null)) { + subjectEl.removeAttr('href'); + } + }, + + + // Called while the mouse is being moved and when we know a legitimate drag is taking place + drag: function(dx, dy, ev) { + this.trigger('drag', dx, dy, ev); + this.updateScroll(ev); // will possibly cause scrolling + }, + + + // Called when the user does a mouseup + mouseup: function(ev) { + this.stopListening(ev); + }, + + + // Called when the drag is over. Will not cause listening to stop however. + // A concluding 'cellOut' event will NOT be triggered. + stopDrag: function(ev) { + if (this.isDragging) { + this.stopScrolling(); + this.dragStop(ev); + this.isDragging = false; + } + }, + + + // Called when dragging has been stopped + dragStop: function(ev) { + var _this = this; + + this.trigger('dragStop', ev); + + // restore a mousedown'd 's href (for IE8 bug) + setTimeout(function() { // must be outside of the click's execution + if (_this.subjectHref) { + _this.subjectEl.attr('href', _this.subjectHref); + } + }, 0); + }, + + + // Call this to stop listening to the user's mouse events + stopListening: function(ev) { + this.stopDrag(ev); // if there's a current drag, kill it + + if (this.isListening) { + + // remove the scroll handler if there is a scrollEl + if (this.scrollEl) { + this.scrollEl.off('scroll', this.scrollHandlerProxy); + this.scrollHandlerProxy = null; + } + + $(document) + .off('mousemove', this.mousemoveProxy) + .off('mouseup', this.mouseupProxy) + .off('selectstart', this.preventDefault); + + this.mousemoveProxy = null; + this.mouseupProxy = null; + + this.isListening = false; + this.listenStop(ev); + } + }, + + + // Called when drag listening has stopped + listenStop: function(ev) { + this.trigger('listenStop', ev); + }, + + + // Triggers a callback. Calls a function in the option hash of the same name. + // Arguments beyond the first `name` are forwarded on. + trigger: function(name) { + if (this.options[name]) { + this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); + } + }, + + + // Stops a given mouse event from doing it's native browser action. In our case, text selection. + preventDefault: function(ev) { + ev.preventDefault(); + }, + + + /* Scrolling + ------------------------------------------------------------------------------------------------------------------*/ + + + // Computes and stores the bounding rectangle of scrollEl + computeScrollBounds: function() { + var el = this.scrollEl; + + this.scrollBounds = el ? getOuterRect(el) : null; + // TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars + }, + + + // Called when the dragging is in progress and scrolling should be updated + updateScroll: function(ev) { + var sensitivity = this.scrollSensitivity; + var bounds = this.scrollBounds; + var topCloseness, bottomCloseness; + var leftCloseness, rightCloseness; + var topVel = 0; + var leftVel = 0; + + if (bounds) { // only scroll if scrollEl exists + + // compute closeness to edges. valid range is from 0.0 - 1.0 + topCloseness = (sensitivity - (ev.pageY - bounds.top)) / sensitivity; + bottomCloseness = (sensitivity - (bounds.bottom - ev.pageY)) / sensitivity; + leftCloseness = (sensitivity - (ev.pageX - bounds.left)) / sensitivity; + rightCloseness = (sensitivity - (bounds.right - ev.pageX)) / sensitivity; + + // translate vertical closeness into velocity. + // mouse must be completely in bounds for velocity to happen. + if (topCloseness >= 0 && topCloseness <= 1) { + topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up + } + else if (bottomCloseness >= 0 && bottomCloseness <= 1) { + topVel = bottomCloseness * this.scrollSpeed; + } + + // translate horizontal closeness into velocity + if (leftCloseness >= 0 && leftCloseness <= 1) { + leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left + } + else if (rightCloseness >= 0 && rightCloseness <= 1) { + leftVel = rightCloseness * this.scrollSpeed; + } + } + + this.setScrollVel(topVel, leftVel); + }, + + + // Sets the speed-of-scrolling for the scrollEl + setScrollVel: function(topVel, leftVel) { + + this.scrollTopVel = topVel; + this.scrollLeftVel = leftVel; + + this.constrainScrollVel(); // massages into realistic values + + // if there is non-zero velocity, and an animation loop hasn't already started, then START + if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) { + this.scrollIntervalId = setInterval( + proxy(this, 'scrollIntervalFunc'), // scope to `this` + this.scrollIntervalMs + ); + } + }, + + + // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way + constrainScrollVel: function() { + var el = this.scrollEl; + + if (this.scrollTopVel < 0) { // scrolling up? + if (el.scrollTop() <= 0) { // already scrolled all the way up? + this.scrollTopVel = 0; + } + } + else if (this.scrollTopVel > 0) { // scrolling down? + if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down? + this.scrollTopVel = 0; + } + } + + if (this.scrollLeftVel < 0) { // scrolling left? + if (el.scrollLeft() <= 0) { // already scrolled all the left? + this.scrollLeftVel = 0; + } + } + else if (this.scrollLeftVel > 0) { // scrolling right? + if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right? + this.scrollLeftVel = 0; + } + } + }, + + + // This function gets called during every iteration of the scrolling animation loop + scrollIntervalFunc: function() { + var el = this.scrollEl; + var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by + + // change the value of scrollEl's scroll + if (this.scrollTopVel) { + el.scrollTop(el.scrollTop() + this.scrollTopVel * frac); + } + if (this.scrollLeftVel) { + el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac); + } + + this.constrainScrollVel(); // since the scroll values changed, recompute the velocities + + // if scrolled all the way, which causes the vels to be zero, stop the animation loop + if (!this.scrollTopVel && !this.scrollLeftVel) { + this.stopScrolling(); + } + }, + + + // Kills any existing scrolling animation loop + stopScrolling: function() { + if (this.scrollIntervalId) { + clearInterval(this.scrollIntervalId); + this.scrollIntervalId = null; + + // when all done with scrolling, recompute positions since they probably changed + this.scrollStop(); + } + }, + + + // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce) + scrollHandler: function() { + // recompute all coordinates, but *only* if this is *not* part of our scrolling animation + if (!this.scrollIntervalId) { + this.scrollStop(); + } + }, + + + // Called when scrolling has stopped, whether through auto scroll, or the user scrolling + scrollStop: function() { + } + +}); + +;; + +/* Tracks mouse movements over a CoordMap and raises events about which cell the mouse is over. +------------------------------------------------------------------------------------------------------------------------ +options: +- subjectEl +- subjectCenter +*/ + +var CellDragListener = DragListener.extend({ + + coordMap: null, // converts coordinates to date cells + origCell: null, // the cell the mouse was over when listening started + cell: null, // the cell the mouse is over + coordAdjust: null, // delta that will be added to the mouse coordinates when computing collisions + + + constructor: function(coordMap, options) { + DragListener.prototype.constructor.call(this, options); // call the super-constructor + + this.coordMap = coordMap; + }, + + + // Called when drag listening starts (but a real drag has not necessarily began). + // ev might be undefined if dragging was started manually. + listenStart: function(ev) { + var subjectEl = this.subjectEl; + var subjectRect; + var origPoint; + var point; + + DragListener.prototype.listenStart.apply(this, arguments); // call the super-method + + this.computeCoords(); + + if (ev) { + origPoint = { left: ev.pageX, top: ev.pageY }; + point = origPoint; + + // constrain the point to bounds of the element being dragged + if (subjectEl) { + subjectRect = getOuterRect(subjectEl); // used for centering as well + point = constrainPoint(point, subjectRect); + } + + this.origCell = this.getCell(point.left, point.top); + + // treat the center of the subject as the collision point? + if (subjectEl && this.options.subjectCenter) { + + // only consider the area the subject overlaps the cell. best for large subjects + if (this.origCell) { + subjectRect = intersectRects(this.origCell, subjectRect) || + subjectRect; // in case there is no intersection + } + + point = getRectCenter(subjectRect); + } + + this.coordAdjust = diffPoints(point, origPoint); // point - origPoint + } + else { + this.origCell = null; + this.coordAdjust = null; + } + }, + + + // Recomputes the drag-critical positions of elements + computeCoords: function() { + this.coordMap.build(); + this.computeScrollBounds(); + }, + + + // Called when the actual drag has started + dragStart: function(ev) { + var cell; + + DragListener.prototype.dragStart.apply(this, arguments); // call the super-method + + cell = this.getCell(ev.pageX, ev.pageY); // might be different from this.origCell if the min-distance is large + + // report the initial cell the mouse is over + // especially important if no min-distance and drag starts immediately + if (cell) { + this.cellOver(cell); + } + }, + + + // Called when the drag moves + drag: function(dx, dy, ev) { + var cell; + + DragListener.prototype.drag.apply(this, arguments); // call the super-method + + cell = this.getCell(ev.pageX, ev.pageY); + + if (!isCellsEqual(cell, this.cell)) { // a different cell than before? + if (this.cell) { + this.cellOut(); + } + if (cell) { + this.cellOver(cell); + } + } + }, + + + // Called when dragging has been stopped + dragStop: function() { + this.cellDone(); + DragListener.prototype.dragStop.apply(this, arguments); // call the super-method + }, + + + // Called when a the mouse has just moved over a new cell + cellOver: function(cell) { + this.cell = cell; + this.trigger('cellOver', cell, isCellsEqual(cell, this.origCell), this.origCell); + }, + + + // Called when the mouse has just moved out of a cell + cellOut: function() { + if (this.cell) { + this.trigger('cellOut', this.cell); + this.cellDone(); + this.cell = null; + } + }, + + + // Called after a cellOut. Also called before a dragStop + cellDone: function() { + if (this.cell) { + this.trigger('cellDone', this.cell); + } + }, + + + // Called when drag listening has stopped + listenStop: function() { + DragListener.prototype.listenStop.apply(this, arguments); // call the super-method + + this.origCell = this.cell = null; + this.coordMap.clear(); + }, + + + // Called when scrolling has stopped, whether through auto scroll, or the user scrolling + scrollStop: function() { + DragListener.prototype.scrollStop.apply(this, arguments); // call the super-method + + this.computeCoords(); // cells' absolute positions will be in new places. recompute + }, + + + // Gets the cell underneath the coordinates for the given mouse event + getCell: function(left, top) { + + if (this.coordAdjust) { + left += this.coordAdjust.left; + top += this.coordAdjust.top; + } + + return this.coordMap.getCell(left, top); + } + +}); + + +// Returns `true` if the cells are identically equal. `false` otherwise. +// They must have the same row, col, and be from the same grid. +// Two null values will be considered equal, as two "out of the grid" states are the same. +function isCellsEqual(cell1, cell2) { + + if (!cell1 && !cell2) { + return true; + } + + if (cell1 && cell2) { + return cell1.grid === cell2.grid && + cell1.row === cell2.row && + cell1.col === cell2.col; + } + + return false; +} + +;; + +/* Creates a clone of an element and lets it track the mouse as it moves +----------------------------------------------------------------------------------------------------------------------*/ + +var MouseFollower = Class.extend({ + + options: null, + + sourceEl: null, // the element that will be cloned and made to look like it is dragging + el: null, // the clone of `sourceEl` that will track the mouse + parentEl: null, // the element that `el` (the clone) will be attached to + + // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl + top0: null, + left0: null, + + // the initial position of the mouse + mouseY0: null, + mouseX0: null, + + // the number of pixels the mouse has moved from its initial position + topDelta: null, + leftDelta: null, + + mousemoveProxy: null, // document mousemove handler, bound to the MouseFollower's `this` + + isFollowing: false, + isHidden: false, + isAnimating: false, // doing the revert animation? + + constructor: function(sourceEl, options) { + this.options = options = options || {}; + this.sourceEl = sourceEl; + this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent + }, + + + // Causes the element to start following the mouse + start: function(ev) { + if (!this.isFollowing) { + this.isFollowing = true; + + this.mouseY0 = ev.pageY; + this.mouseX0 = ev.pageX; + this.topDelta = 0; + this.leftDelta = 0; + + if (!this.isHidden) { + this.updatePosition(); + } + + $(document).on('mousemove', this.mousemoveProxy = proxy(this, 'mousemove')); + } + }, + + + // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position. + // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately. + stop: function(shouldRevert, callback) { + var _this = this; + var revertDuration = this.options.revertDuration; + + function complete() { + this.isAnimating = false; + _this.destroyEl(); + + this.top0 = this.left0 = null; // reset state for future updatePosition calls + + if (callback) { + callback(); + } + } + + if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time + this.isFollowing = false; + + $(document).off('mousemove', this.mousemoveProxy); + + if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation? + this.isAnimating = true; + this.el.animate({ + top: this.top0, + left: this.left0 + }, { + duration: revertDuration, + complete: complete + }); + } + else { + complete(); + } + } + }, + + + // Gets the tracking element. Create it if necessary + getEl: function() { + var el = this.el; + + if (!el) { + this.sourceEl.width(); // hack to force IE8 to compute correct bounding box + el = this.el = this.sourceEl.clone() + .css({ + position: 'absolute', + visibility: '', // in case original element was hidden (commonly through hideEvents()) + display: this.isHidden ? 'none' : '', // for when initially hidden + margin: 0, + right: 'auto', // erase and set width instead + bottom: 'auto', // erase and set height instead + width: this.sourceEl.width(), // explicit height in case there was a 'right' value + height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value + opacity: this.options.opacity || '', + zIndex: this.options.zIndex + }) + .appendTo(this.parentEl); + } + + return el; + }, + + + // Removes the tracking element if it has already been created + destroyEl: function() { + if (this.el) { + this.el.remove(); + this.el = null; + } + }, + + + // Update the CSS position of the tracking element + updatePosition: function() { + var sourceOffset; + var origin; + + this.getEl(); // ensure this.el + + // make sure origin info was computed + if (this.top0 === null) { + this.sourceEl.width(); // hack to force IE8 to compute correct bounding box + sourceOffset = this.sourceEl.offset(); + origin = this.el.offsetParent().offset(); + this.top0 = sourceOffset.top - origin.top; + this.left0 = sourceOffset.left - origin.left; + } + + this.el.css({ + top: this.top0 + this.topDelta, + left: this.left0 + this.leftDelta + }); + }, + + + // Gets called when the user moves the mouse + mousemove: function(ev) { + this.topDelta = ev.pageY - this.mouseY0; + this.leftDelta = ev.pageX - this.mouseX0; + + if (!this.isHidden) { + this.updatePosition(); + } + }, + + + // Temporarily makes the tracking element invisible. Can be called before following starts + hide: function() { + if (!this.isHidden) { + this.isHidden = true; + if (this.el) { + this.el.hide(); + } + } + }, + + + // Show the tracking element after it has been temporarily hidden + show: function() { + if (this.isHidden) { + this.isHidden = false; + this.updatePosition(); + this.getEl().show(); + } + } + +}); + +;; + +/* A utility class for rendering rows. +----------------------------------------------------------------------------------------------------------------------*/ +// It leverages methods of the subclass and the View to determine custom rendering behavior for each row "type" +// (such as highlight rows, day rows, helper rows, etc). + +var RowRenderer = Class.extend({ + + view: null, // a View object + isRTL: null, // shortcut to the view's isRTL option + cellHtml: '', // plain default HTML used for a cell when no other is available + + + constructor: function(view) { + this.view = view; + this.isRTL = view.opt('isRTL'); + }, + + + // Renders the HTML for a row, leveraging custom cell-HTML-renderers based on the `rowType`. + // Also applies the "intro" and "outro" cells, which are specified by the subclass and views. + // `row` is an optional row number. + rowHtml: function(rowType, row) { + var renderCell = this.getHtmlRenderer('cell', rowType); + var rowCellHtml = ''; + var col; + var cell; + + row = row || 0; + + for (col = 0; col < this.colCnt; col++) { + cell = this.getCell(row, col); + rowCellHtml += renderCell(cell); + } + + rowCellHtml = this.bookendCells(rowCellHtml, rowType, row); // apply intro and outro + + return '' + rowCellHtml + ''; + }, + + + // Applies the "intro" and "outro" HTML to the given cells. + // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro. + // `cells` can be an HTML string of 's or a jQuery element + // `row` is an optional row number. + bookendCells: function(cells, rowType, row) { + var intro = this.getHtmlRenderer('intro', rowType)(row || 0); + var outro = this.getHtmlRenderer('outro', rowType)(row || 0); + var prependHtml = this.isRTL ? outro : intro; + var appendHtml = this.isRTL ? intro : outro; + + if (typeof cells === 'string') { + return prependHtml + cells + appendHtml; + } + else { // a jQuery element + return cells.prepend(prependHtml).append(appendHtml); + } + }, + + + // Returns an HTML-rendering function given a specific `rendererName` (like cell, intro, or outro) and a specific + // `rowType` (like day, eventSkeleton, helperSkeleton), which is optional. + // If a renderer for the specific rowType doesn't exist, it will fall back to a generic renderer. + // We will query the View object first for any custom rendering functions, then the methods of the subclass. + getHtmlRenderer: function(rendererName, rowType) { + var view = this.view; + var generalName; // like "cellHtml" + var specificName; // like "dayCellHtml". based on rowType + var provider; // either the View or the RowRenderer subclass, whichever provided the method + var renderer; + + generalName = rendererName + 'Html'; + if (rowType) { + specificName = rowType + capitaliseFirstLetter(rendererName) + 'Html'; + } + + if (specificName && (renderer = view[specificName])) { + provider = view; + } + else if (specificName && (renderer = this[specificName])) { + provider = this; + } + else if ((renderer = view[generalName])) { + provider = view; + } + else if ((renderer = this[generalName])) { + provider = this; + } + + if (typeof renderer === 'function') { + return function() { + return renderer.apply(provider, arguments) || ''; // use correct `this` and always return a string + }; + } + + // the rendered can be a plain string as well. if not specified, always an empty string. + return function() { + return renderer || ''; + }; + } + +}); + +;; + +/* An abstract class comprised of a "grid" of cells that each represent a specific datetime +----------------------------------------------------------------------------------------------------------------------*/ + +var Grid = fc.Grid = RowRenderer.extend({ + + start: null, // the date of the first cell + end: null, // the date after the last cell + + rowCnt: 0, // number of rows + colCnt: 0, // number of cols + rowData: null, // array of objects, holding misc data for each row + colData: null, // array of objects, holding misc data for each column + + el: null, // the containing element + coordMap: null, // a GridCoordMap that converts pixel values to datetimes + elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name. + + externalDragStartProxy: null, // binds the Grid's scope to externalDragStart (in DayGrid.events) + + // derived from options + colHeadFormat: null, // TODO: move to another class. not applicable to all Grids + eventTimeFormat: null, + displayEventTime: null, + displayEventEnd: null, + + // if all cells are the same length of time, the duration they all share. optional. + // when defined, allows the computeCellRange shortcut, as well as improved resizing behavior. + cellDuration: null, + + // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity + // of the date cells. if not defined, assumes to be day and time granularity. + largeUnit: null, + + + constructor: function() { + RowRenderer.apply(this, arguments); // call the super-constructor + + this.coordMap = new GridCoordMap(this); + this.elsByFill = {}; + this.externalDragStartProxy = proxy(this, 'externalDragStart'); + }, + + + /* Options + ------------------------------------------------------------------------------------------------------------------*/ + + + // Generates the format string used for the text in column headers, if not explicitly defined by 'columnFormat' + // TODO: move to another class. not applicable to all Grids + computeColHeadFormat: function() { + // subclasses must implement if they want to use headHtml() + }, + + + // Generates the format string used for event time text, if not explicitly defined by 'timeFormat' + computeEventTimeFormat: function() { + return this.view.opt('smallTimeFormat'); + }, + + + // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventTime'. + // Only applies to non-all-day events. + computeDisplayEventTime: function() { + return true; + }, + + + // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd' + computeDisplayEventEnd: function() { + return true; + }, + + + /* Dates + ------------------------------------------------------------------------------------------------------------------*/ + + + // Tells the grid about what period of time to display. Grid will subsequently compute dates for cell system. + setRange: function(range) { + var view = this.view; + var displayEventTime; + var displayEventEnd; + + this.start = range.start.clone(); + this.end = range.end.clone(); + + this.rowData = []; + this.colData = []; + this.updateCells(); + + // Populate option-derived settings. Look for override first, then compute if necessary. + this.colHeadFormat = view.opt('columnFormat') || this.computeColHeadFormat(); + + this.eventTimeFormat = + view.opt('eventTimeFormat') || + view.opt('timeFormat') || // deprecated + this.computeEventTimeFormat(); + + displayEventTime = view.opt('displayEventTime'); + if (displayEventTime == null) { + displayEventTime = this.computeDisplayEventTime(); // might be based off of range + } + + displayEventEnd = view.opt('displayEventEnd'); + if (displayEventEnd == null) { + displayEventEnd = this.computeDisplayEventEnd(); // might be based off of range + } + + this.displayEventTime = displayEventTime; + this.displayEventEnd = displayEventEnd; + }, + + + // Responsible for setting rowCnt/colCnt and any other row/col data + updateCells: function() { + // subclasses must implement + }, + + + // Converts a range with an inclusive `start` and an exclusive `end` into an array of segment objects + rangeToSegs: function(range) { + // subclasses must implement + }, + + + // Diffs the two dates, returning a duration, based on granularity of the grid + diffDates: function(a, b) { + if (this.largeUnit) { + return diffByUnit(a, b, this.largeUnit); + } + else { + return diffDayTime(a, b); + } + }, + + + /* Cells + ------------------------------------------------------------------------------------------------------------------*/ + // NOTE: columns are ordered left-to-right + + + // Gets an object containing row/col number, misc data, and range information about the cell. + // Accepts row/col values, an object with row/col properties, or a single-number offset from the first cell. + getCell: function(row, col) { + var cell; + + if (col == null) { + if (typeof row === 'number') { // a single-number offset + col = row % this.colCnt; + row = Math.floor(row / this.colCnt); + } + else { // an object with row/col properties + col = row.col; + row = row.row; + } + } + + cell = { row: row, col: col }; + + $.extend(cell, this.getRowData(row), this.getColData(col)); + $.extend(cell, this.computeCellRange(cell)); + + return cell; + }, + + + // Given a cell object with index and misc data, generates a range object + // If the grid is leveraging cellDuration, this doesn't need to be defined. Only computeCellDate does. + // If being overridden, should return a range with reference-free date copies. + computeCellRange: function(cell) { + var date = this.computeCellDate(cell); + + return { + start: date, + end: date.clone().add(this.cellDuration) + }; + }, + + + // Given a cell, returns its start date. Should return a reference-free date copy. + computeCellDate: function(cell) { + // subclasses can implement + }, + + + // Retrieves misc data about the given row + getRowData: function(row) { + return this.rowData[row] || {}; + }, + + + // Retrieves misc data baout the given column + getColData: function(col) { + return this.colData[col] || {}; + }, + + + // Retrieves the element representing the given row + getRowEl: function(row) { + // subclasses should implement if leveraging the default getCellDayEl() or computeRowCoords() + }, + + + // Retrieves the element representing the given column + getColEl: function(col) { + // subclasses should implement if leveraging the default getCellDayEl() or computeColCoords() + }, + + + // Given a cell object, returns the element that represents the cell's whole-day + getCellDayEl: function(cell) { + return this.getColEl(cell.col) || this.getRowEl(cell.row); + }, + + + /* Cell Coordinates + ------------------------------------------------------------------------------------------------------------------*/ + + + // Computes the top/bottom coordinates of all rows. + // By default, queries the dimensions of the element provided by getRowEl(). + computeRowCoords: function() { + var items = []; + var i, el; + var top; + + for (i = 0; i < this.rowCnt; i++) { + el = this.getRowEl(i); + top = el.offset().top; + items.push({ + top: top, + bottom: top + el.outerHeight() + }); + } + + return items; + }, + + + // Computes the left/right coordinates of all rows. + // By default, queries the dimensions of the element provided by getColEl(). Columns can be LTR or RTL. + computeColCoords: function() { + var items = []; + var i, el; + var left; + + for (i = 0; i < this.colCnt; i++) { + el = this.getColEl(i); + left = el.offset().left; + items.push({ + left: left, + right: left + el.outerWidth() + }); + } + + return items; + }, + + + /* Rendering + ------------------------------------------------------------------------------------------------------------------*/ + + + // Sets the container element that the grid should render inside of. + // Does other DOM-related initializations. + setElement: function(el) { + var _this = this; + + this.el = el; + + // attach a handler to the grid's root element. + // jQuery will take care of unregistering them when removeElement gets called. + el.on('mousedown', function(ev) { + if ( + !$(ev.target).is('.fc-event-container *, .fc-more') && // not an an event element, or "more.." link + !$(ev.target).closest('.fc-popover').length // not on a popover (like the "more.." events one) + ) { + _this.dayMousedown(ev); + } + }); + + // attach event-element-related handlers. in Grid.events + // same garbage collection note as above. + this.bindSegHandlers(); + + this.bindGlobalHandlers(); + }, + + + // Removes the grid's container element from the DOM. Undoes any other DOM-related attachments. + // DOES NOT remove any content before hand (doens't clear events or call destroyDates), unlike View + removeElement: function() { + this.unbindGlobalHandlers(); + + this.el.remove(); + + // NOTE: we don't null-out this.el for the same reasons we don't do it within View::removeElement + }, + + + // Renders the basic structure of grid view before any content is rendered + renderSkeleton: function() { + // subclasses should implement + }, + + + // Renders the grid's date-related content (like cells that represent days/times). + // Assumes setRange has already been called and the skeleton has already been rendered. + renderDates: function() { + // subclasses should implement + }, + + + // Unrenders the grid's date-related content + destroyDates: function() { + // subclasses should implement + }, + + + /* Handlers + ------------------------------------------------------------------------------------------------------------------*/ + + + // Binds DOM handlers to elements that reside outside the grid, such as the document + bindGlobalHandlers: function() { + $(document).on('dragstart sortstart', this.externalDragStartProxy); // jqui + }, + + + // Unbinds DOM handlers from elements that reside outside the grid + unbindGlobalHandlers: function() { + $(document).off('dragstart sortstart', this.externalDragStartProxy); // jqui + }, + + + // Process a mousedown on an element that represents a day. For day clicking and selecting. + dayMousedown: function(ev) { + var _this = this; + var view = this.view; + var isSelectable = view.opt('selectable'); + var dayClickCell; // null if invalid dayClick + var selectionRange; // null if invalid selection + + // this listener tracks a mousedown on a day element, and a subsequent drag. + // if the drag ends on the same day, it is a 'dayClick'. + // if 'selectable' is enabled, this listener also detects selections. + var dragListener = new CellDragListener(this.coordMap, { + //distance: 5, // needs more work if we want dayClick to fire correctly + scroll: view.opt('dragScroll'), + dragStart: function() { + view.unselect(); // since we could be rendering a new selection, we want to clear any old one + }, + cellOver: function(cell, isOrig, origCell) { + if (origCell) { // click needs to have started on a cell + dayClickCell = isOrig ? cell : null; // single-cell selection is a day click + if (isSelectable) { + selectionRange = _this.computeSelection(origCell, cell); + if (selectionRange) { + _this.renderSelection(selectionRange); + } + else { + disableCursor(); + } + } + } + }, + cellOut: function(cell) { + dayClickCell = null; + selectionRange = null; + _this.destroySelection(); + enableCursor(); + }, + listenStop: function(ev) { + if (dayClickCell) { + view.trigger('dayClick', _this.getCellDayEl(dayClickCell), dayClickCell.start, ev); + } + if (selectionRange) { + // the selection will already have been rendered. just report it + view.reportSelection(selectionRange, ev); + } + enableCursor(); + } + }); + + dragListener.mousedown(ev); // start listening, which will eventually initiate a dragStart + }, + + + /* Event Helper + ------------------------------------------------------------------------------------------------------------------*/ + // TODO: should probably move this to Grid.events, like we did event dragging / resizing + + + // Renders a mock event over the given range + renderRangeHelper: function(range, sourceSeg) { + var fakeEvent = this.fabricateHelperEvent(range, sourceSeg); + + this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering + }, + + + // Builds a fake event given a date range it should cover, and a segment is should be inspired from. + // The range's end can be null, in which case the mock event that is rendered will have a null end time. + // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging. + fabricateHelperEvent: function(range, sourceSeg) { + var fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible + + fakeEvent.start = range.start.clone(); + fakeEvent.end = range.end ? range.end.clone() : null; + fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventRange + this.view.calendar.normalizeEventRange(fakeEvent); + + // this extra className will be useful for differentiating real events from mock events in CSS + fakeEvent.className = (fakeEvent.className || []).concat('fc-helper'); + + // if something external is being dragged in, don't render a resizer + if (!sourceSeg) { + fakeEvent.editable = false; + } + + return fakeEvent; + }, + + + // Renders a mock event + renderHelper: function(event, sourceSeg) { + // subclasses must implement + }, + + + // Unrenders a mock event + destroyHelper: function() { + // subclasses must implement + }, + + + /* Selection + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses. + renderSelection: function(range) { + this.renderHighlight(range); + }, + + + // Unrenders any visual indications of a selection. Will unrender a highlight by default. + destroySelection: function() { + this.destroyHighlight(); + }, + + + // Given the first and last cells of a selection, returns a range object. + // Will return something falsy if the selection is invalid (when outside of selectionConstraint for example). + // Subclasses can override and provide additional data in the range object. Will be passed to renderSelection(). + computeSelection: function(firstCell, lastCell) { + var dates = [ + firstCell.start, + firstCell.end, + lastCell.start, + lastCell.end + ]; + var range; + + dates.sort(compareNumbers); // sorts chronologically. works with Moments + + range = { + start: dates[0].clone(), + end: dates[3].clone() + }; + + if (!this.view.calendar.isSelectionRangeAllowed(range)) { + return null; + } + + return range; + }, + + + /* Highlight + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders an emphasis on the given date range. `start` is inclusive. `end` is exclusive. + renderHighlight: function(range) { + this.renderFill('highlight', this.rangeToSegs(range)); + }, + + + // Unrenders the emphasis on a date range + destroyHighlight: function() { + this.destroyFill('highlight'); + }, + + + // Generates an array of classNames for rendering the highlight. Used by the fill system. + highlightSegClasses: function() { + return [ 'fc-highlight' ]; + }, + + + /* Fill System (highlight, background events, business hours) + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a set of rectangles over the given segments of time. + // Returns a subset of segs, the segs that were actually rendered. + // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement + renderFill: function(type, segs) { + // subclasses must implement + }, + + + // Unrenders a specific type of fill that is currently rendered on the grid + destroyFill: function(type) { + var el = this.elsByFill[type]; + + if (el) { + el.remove(); + delete this.elsByFill[type]; + } + }, + + + // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types. + // Only returns segments that successfully rendered. + // To be harnessed by renderFill (implemented by subclasses). + // Analagous to renderFgSegEls. + renderFillSegEls: function(type, segs) { + var _this = this; + var segElMethod = this[type + 'SegEl']; + var html = ''; + var renderedSegs = []; + var i; + + if (segs.length) { + + // build a large concatenation of segment HTML + for (i = 0; i < segs.length; i++) { + html += this.fillSegHtml(type, segs[i]); + } + + // Grab individual elements from the combined HTML string. Use each as the default rendering. + // Then, compute the 'el' for each segment. + $(html).each(function(i, node) { + var seg = segs[i]; + var el = $(node); + + // allow custom filter methods per-type + if (segElMethod) { + el = segElMethod.call(_this, seg, el); + } + + if (el) { // custom filters did not cancel the render + el = $(el); // allow custom filter to return raw DOM node + + // correct element type? (would be bad if a non-TD were inserted into a table for example) + if (el.is(_this.fillSegTag)) { + seg.el = el; + renderedSegs.push(seg); + } + } + }); + } + + return renderedSegs; + }, + + + fillSegTag: 'div', // subclasses can override + + + // Builds the HTML needed for one fill segment. Generic enought o work with different types. + fillSegHtml: function(type, seg) { + + // custom hooks per-type + var classesMethod = this[type + 'SegClasses']; + var cssMethod = this[type + 'SegCss']; + + var classes = classesMethod ? classesMethod.call(this, seg) : []; + var css = cssToStr(cssMethod ? cssMethod.call(this, seg) : {}); + + return '<' + this.fillSegTag + + (classes.length ? ' class="' + classes.join(' ') + '"' : '') + + (css ? ' style="' + css + '"' : '') + + ' />'; + }, + + + /* Generic rendering utilities for subclasses + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a day-of-week header row. + // TODO: move to another class. not applicable to all Grids + headHtml: function() { + return '' + + '
' + + '' + + '' + + this.rowHtml('head') + // leverages RowRenderer + '' + + '
' + + '
'; + }, + + + // Used by the `headHtml` method, via RowRenderer, for rendering the HTML of a day-of-week header cell + // TODO: move to another class. not applicable to all Grids + headCellHtml: function(cell) { + var view = this.view; + var date = cell.start; + + return '' + + '' + + htmlEscape(date.format(this.colHeadFormat)) + + ''; + }, + + + // Renders the HTML for a single-day background cell + bgCellHtml: function(cell) { + var view = this.view; + var date = cell.start; + var classes = this.getDayClasses(date); + + classes.unshift('fc-day', view.widgetContentClass); + + return ''; + }, + + + // Computes HTML classNames for a single-day cell + getDayClasses: function(date) { + var view = this.view; + var today = view.calendar.getNow().stripTime(); + var classes = [ 'fc-' + dayIDs[date.day()] ]; + + if ( + view.intervalDuration.as('months') == 1 && + date.month() != view.intervalStart.month() + ) { + classes.push('fc-other-month'); + } + + if (date.isSame(today, 'day')) { + classes.push( + 'fc-today', + view.highlightStateClass + ); + } + else if (date < today) { + classes.push('fc-past'); + } + else { + classes.push('fc-future'); + } + + return classes; + } + +}); + +;; + +/* Event-rendering and event-interaction methods for the abstract Grid class +----------------------------------------------------------------------------------------------------------------------*/ + +Grid.mixin({ + + mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing + isDraggingSeg: false, // is a segment being dragged? boolean + isResizingSeg: false, // is a segment being resized? boolean + isDraggingExternal: false, // jqui-dragging an external element? boolean + segs: null, // the event segments currently rendered in the grid + + + // Renders the given events onto the grid + renderEvents: function(events) { + var segs = this.eventsToSegs(events); + var bgSegs = []; + var fgSegs = []; + var i, seg; + + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + + if (isBgEvent(seg.event)) { + bgSegs.push(seg); + } + else { + fgSegs.push(seg); + } + } + + // Render each different type of segment. + // Each function may return a subset of the segs, segs that were actually rendered. + bgSegs = this.renderBgSegs(bgSegs) || bgSegs; + fgSegs = this.renderFgSegs(fgSegs) || fgSegs; + + this.segs = bgSegs.concat(fgSegs); + }, + + + // Unrenders all events currently rendered on the grid + destroyEvents: function() { + this.triggerSegMouseout(); // trigger an eventMouseout if user's mouse is over an event + + this.destroyFgSegs(); + this.destroyBgSegs(); + + this.segs = null; + }, + + + // Retrieves all rendered segment objects currently rendered on the grid + getEventSegs: function() { + return this.segs || []; + }, + + + /* Foreground Segment Rendering + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders foreground event segments onto the grid. May return a subset of segs that were rendered. + renderFgSegs: function(segs) { + // subclasses must implement + }, + + + // Unrenders all currently rendered foreground segments + destroyFgSegs: function() { + // subclasses must implement + }, + + + // Renders and assigns an `el` property for each foreground event segment. + // Only returns segments that successfully rendered. + // A utility that subclasses may use. + renderFgSegEls: function(segs, disableResizing) { + var view = this.view; + var html = ''; + var renderedSegs = []; + var i; + + if (segs.length) { // don't build an empty html string + + // build a large concatenation of event segment HTML + for (i = 0; i < segs.length; i++) { + html += this.fgSegHtml(segs[i], disableResizing); + } + + // Grab individual elements from the combined HTML string. Use each as the default rendering. + // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false. + $(html).each(function(i, node) { + var seg = segs[i]; + var el = view.resolveEventEl(seg.event, $(node)); + + if (el) { + el.data('fc-seg', seg); // used by handlers + seg.el = el; + renderedSegs.push(seg); + } + }); + } + + return renderedSegs; + }, + + + // Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls() + fgSegHtml: function(seg, disableResizing) { + // subclasses should implement + }, + + + /* Background Segment Rendering + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders the given background event segments onto the grid. + // Returns a subset of the segs that were actually rendered. + renderBgSegs: function(segs) { + return this.renderFill('bgEvent', segs); + }, + + + // Unrenders all the currently rendered background event segments + destroyBgSegs: function() { + this.destroyFill('bgEvent'); + }, + + + // Renders a background event element, given the default rendering. Called by the fill system. + bgEventSegEl: function(seg, el) { + return this.view.resolveEventEl(seg.event, el); // will filter through eventRender + }, + + + // Generates an array of classNames to be used for the default rendering of a background event. + // Called by the fill system. + bgEventSegClasses: function(seg) { + var event = seg.event; + var source = event.source || {}; + + return [ 'fc-bgevent' ].concat( + event.className, + source.className || [] + ); + }, + + + // Generates a semicolon-separated CSS string to be used for the default rendering of a background event. + // Called by the fill system. + // TODO: consolidate with getEventSkinCss? + bgEventSegCss: function(seg) { + var view = this.view; + var event = seg.event; + var source = event.source || {}; + + return { + 'background-color': + event.backgroundColor || + event.color || + source.backgroundColor || + source.color || + view.opt('eventBackgroundColor') || + view.opt('eventColor') + }; + }, + + + // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system. + businessHoursSegClasses: function(seg) { + return [ 'fc-nonbusiness', 'fc-bgevent' ]; + }, + + + /* Handlers + ------------------------------------------------------------------------------------------------------------------*/ + + + // Attaches event-element-related handlers to the container element and leverage bubbling + bindSegHandlers: function() { + var _this = this; + var view = this.view; + + $.each( + { + mouseenter: function(seg, ev) { + _this.triggerSegMouseover(seg, ev); + }, + mouseleave: function(seg, ev) { + _this.triggerSegMouseout(seg, ev); + }, + click: function(seg, ev) { + return view.trigger('eventClick', this, seg.event, ev); // can return `false` to cancel + }, + mousedown: function(seg, ev) { + if ($(ev.target).is('.fc-resizer') && view.isEventResizable(seg.event)) { + _this.segResizeMousedown(seg, ev, $(ev.target).is('.fc-start-resizer')); + } + else if (view.isEventDraggable(seg.event)) { + _this.segDragMousedown(seg, ev); + } + } + }, + function(name, func) { + // attach the handler to the container element and only listen for real event elements via bubbling + _this.el.on(name, '.fc-event-container > *', function(ev) { + var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents + + // only call the handlers if there is not a drag/resize in progress + if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) { + return func.call(this, seg, ev); // `this` will be the event element + } + }); + } + ); + }, + + + // Updates internal state and triggers handlers for when an event element is moused over + triggerSegMouseover: function(seg, ev) { + if (!this.mousedOverSeg) { + this.mousedOverSeg = seg; + this.view.trigger('eventMouseover', seg.el[0], seg.event, ev); + } + }, + + + // Updates internal state and triggers handlers for when an event element is moused out. + // Can be given no arguments, in which case it will mouseout the segment that was previously moused over. + triggerSegMouseout: function(seg, ev) { + ev = ev || {}; // if given no args, make a mock mouse event + + if (this.mousedOverSeg) { + seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment + this.mousedOverSeg = null; + this.view.trigger('eventMouseout', seg.el[0], seg.event, ev); + } + }, + + + /* Event Dragging + ------------------------------------------------------------------------------------------------------------------*/ + + + // Called when the user does a mousedown on an event, which might lead to dragging. + // Generic enough to work with any type of Grid. + segDragMousedown: function(seg, ev) { + var _this = this; + var view = this.view; + var calendar = view.calendar; + var el = seg.el; + var event = seg.event; + var dropLocation; + + // A clone of the original element that will move with the mouse + var mouseFollower = new MouseFollower(seg.el, { + parentEl: view.el, + opacity: view.opt('dragOpacity'), + revertDuration: view.opt('dragRevertDuration'), + zIndex: 2 // one above the .fc-view + }); + + // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents + // of the view. + var dragListener = new CellDragListener(view.coordMap, { + distance: 5, + scroll: view.opt('dragScroll'), + subjectEl: el, + subjectCenter: true, + listenStart: function(ev) { + mouseFollower.hide(); // don't show until we know this is a real drag + mouseFollower.start(ev); + }, + dragStart: function(ev) { + _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported + _this.segDragStart(seg, ev); + view.hideEvent(event); // hide all event segments. our mouseFollower will take over + }, + cellOver: function(cell, isOrig, origCell) { + + // starting cell could be forced (DayGrid.limit) + if (seg.cell) { + origCell = seg.cell; + } + + dropLocation = _this.computeEventDrop(origCell, cell, event); + + if (dropLocation && !calendar.isEventRangeAllowed(dropLocation, event)) { + disableCursor(); + dropLocation = null; + } + + // if a valid drop location, have the subclass render a visual indication + if (dropLocation && view.renderDrag(dropLocation, seg)) { + mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own + } + else { + mouseFollower.show(); // otherwise, have the helper follow the mouse (no snapping) + } + + if (isOrig) { + dropLocation = null; // needs to have moved cells to be a valid drop + } + }, + cellOut: function() { // called before mouse moves to a different cell OR moved out of all cells + view.destroyDrag(); // unrender whatever was done in renderDrag + mouseFollower.show(); // show in case we are moving out of all cells + dropLocation = null; + }, + cellDone: function() { // Called after a cellOut OR before a dragStop + enableCursor(); + }, + dragStop: function(ev) { + // do revert animation if hasn't changed. calls a callback when finished (whether animation or not) + mouseFollower.stop(!dropLocation, function() { + view.destroyDrag(); + view.showEvent(event); + _this.segDragStop(seg, ev); + + if (dropLocation) { + view.reportEventDrop(event, dropLocation, this.largeUnit, el, ev); + } + }); + }, + listenStop: function() { + mouseFollower.stop(); // put in listenStop in case there was a mousedown but the drag never started + } + }); + + dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart + }, + + + // Called before event segment dragging starts + segDragStart: function(seg, ev) { + this.isDraggingSeg = true; + this.view.trigger('eventDragStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy + }, + + + // Called after event segment dragging stops + segDragStop: function(seg, ev) { + this.isDraggingSeg = false; + this.view.trigger('eventDragStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy + }, + + + // Given the cell an event drag began, and the cell event was dropped, calculates the new start/end/allDay + // values for the event. Subclasses may override and set additional properties to be used by renderDrag. + // A falsy returned value indicates an invalid drop. + computeEventDrop: function(startCell, endCell, event) { + var calendar = this.view.calendar; + var dragStart = startCell.start; + var dragEnd = endCell.start; + var delta; + var dropLocation; + + if (dragStart.hasTime() === dragEnd.hasTime()) { + delta = this.diffDates(dragEnd, dragStart); + + // if an all-day event was in a timed area and it was dragged to a different time, + // guarantee an end and adjust start/end to have times + if (event.allDay && durationHasTime(delta)) { + dropLocation = { + start: event.start.clone(), + end: calendar.getEventEnd(event), // will be an ambig day + allDay: false // for normalizeEventRangeTimes + }; + calendar.normalizeEventRangeTimes(dropLocation); + } + // othewise, work off existing values + else { + dropLocation = { + start: event.start.clone(), + end: event.end ? event.end.clone() : null, + allDay: event.allDay // keep it the same + }; + } + + dropLocation.start.add(delta); + if (dropLocation.end) { + dropLocation.end.add(delta); + } + } + else { + // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared + dropLocation = { + start: dragEnd.clone(), + end: null, // end should be cleared + allDay: !dragEnd.hasTime() + }; + } + + return dropLocation; + }, + + + // Utility for apply dragOpacity to a jQuery set + applyDragOpacity: function(els) { + var opacity = this.view.opt('dragOpacity'); + + if (opacity != null) { + els.each(function(i, node) { + // Don't use jQuery (will set an IE filter), do it the old fashioned way. + // In IE8, a helper element will disappears if there's a filter. + node.style.opacity = opacity; + }); + } + }, + + + /* External Element Dragging + ------------------------------------------------------------------------------------------------------------------*/ + + + // Called when a jQuery UI drag is initiated anywhere in the DOM + externalDragStart: function(ev, ui) { + var view = this.view; + var el; + var accept; + + if (view.opt('droppable')) { // only listen if this setting is on + el = $((ui ? ui.item : null) || ev.target); + + // Test that the dragged element passes the dropAccept selector or filter function. + // FYI, the default is "*" (matches all) + accept = view.opt('dropAccept'); + if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) { + if (!this.isDraggingExternal) { // prevent double-listening if fired twice + this.listenToExternalDrag(el, ev, ui); + } + } + } + }, + + + // Called when a jQuery UI drag starts and it needs to be monitored for cell dropping + listenToExternalDrag: function(el, ev, ui) { + var _this = this; + var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create + var dragListener; + var dropLocation; // a null value signals an unsuccessful drag + + // listener that tracks mouse movement over date-associated pixel regions + dragListener = new CellDragListener(this.coordMap, { + listenStart: function() { + _this.isDraggingExternal = true; + }, + cellOver: function(cell) { + dropLocation = _this.computeExternalDrop(cell, meta); + if (dropLocation) { + _this.renderDrag(dropLocation); // called without a seg parameter + } + else { // invalid drop cell + disableCursor(); + } + }, + cellOut: function() { + dropLocation = null; // signal unsuccessful + _this.destroyDrag(); + enableCursor(); + }, + dragStop: function() { + _this.destroyDrag(); + enableCursor(); + + if (dropLocation) { // element was dropped on a valid date/time cell + _this.view.reportExternalDrop(meta, dropLocation, el, ev, ui); + } + }, + listenStop: function() { + _this.isDraggingExternal = false; + } + }); + + dragListener.startDrag(ev); // start listening immediately + }, + + + // Given a cell to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object), + // returns start/end dates for the event that would result from the hypothetical drop. end might be null. + // Returning a null value signals an invalid drop cell. + computeExternalDrop: function(cell, meta) { + var dropLocation = { + start: cell.start.clone(), + end: null + }; + + // if dropped on an all-day cell, and element's metadata specified a time, set it + if (meta.startTime && !dropLocation.start.hasTime()) { + dropLocation.start.time(meta.startTime); + } + + if (meta.duration) { + dropLocation.end = dropLocation.start.clone().add(meta.duration); + } + + if (!this.view.calendar.isExternalDropRangeAllowed(dropLocation, meta.eventProps)) { + return null; + } + + return dropLocation; + }, + + + + /* Drag Rendering (for both events and an external elements) + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of an event or external element being dragged. + // `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null. + // `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null. + // A truthy returned value indicates this method has rendered a helper element. + renderDrag: function(dropLocation, seg) { + // subclasses must implement + }, + + + // Unrenders a visual indication of an event or external element being dragged + destroyDrag: function() { + // subclasses must implement + }, + + + /* Resizing + ------------------------------------------------------------------------------------------------------------------*/ + + + // Called when the user does a mousedown on an event's resizer, which might lead to resizing. + // Generic enough to work with any type of Grid. + segResizeMousedown: function(seg, ev, isStart) { + var _this = this; + var view = this.view; + var calendar = view.calendar; + var el = seg.el; + var event = seg.event; + var eventEnd = calendar.getEventEnd(event); + var dragListener; + var resizeLocation; // falsy if invalid resize + + // Tracks mouse movement over the *grid's* coordinate map + dragListener = new CellDragListener(this.coordMap, { + distance: 5, + scroll: view.opt('dragScroll'), + subjectEl: el, + dragStart: function(ev) { + _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported + _this.segResizeStart(seg, ev); + }, + cellOver: function(cell, isOrig, origCell) { + resizeLocation = isStart ? + _this.computeEventStartResize(origCell, cell, event) : + _this.computeEventEndResize(origCell, cell, event); + + if (resizeLocation) { + if (!calendar.isEventRangeAllowed(resizeLocation, event)) { + disableCursor(); + resizeLocation = null; + } + // no change? (TODO: how does this work with timezones?) + else if (resizeLocation.start.isSame(event.start) && resizeLocation.end.isSame(eventEnd)) { + resizeLocation = null; + } + } + + if (resizeLocation) { + view.hideEvent(event); + _this.renderEventResize(resizeLocation, seg); + } + }, + cellOut: function() { // called before mouse moves to a different cell OR moved out of all cells + resizeLocation = null; + }, + cellDone: function() { // resets the rendering to show the original event + _this.destroyEventResize(); + view.showEvent(event); + enableCursor(); + }, + dragStop: function(ev) { + _this.segResizeStop(seg, ev); + + if (resizeLocation) { // valid date to resize to? + view.reportEventResize(event, resizeLocation, this.largeUnit, el, ev); + } + } + }); + + dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart + }, + + + // Called before event segment resizing starts + segResizeStart: function(seg, ev) { + this.isResizingSeg = true; + this.view.trigger('eventResizeStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy + }, + + + // Called after event segment resizing stops + segResizeStop: function(seg, ev) { + this.isResizingSeg = false; + this.view.trigger('eventResizeStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy + }, + + + // Returns new date-information for an event segment being resized from its start + computeEventStartResize: function(startCell, endCell, event) { + return this.computeEventResize('start', startCell, endCell, event); + }, + + + // Returns new date-information for an event segment being resized from its end + computeEventEndResize: function(startCell, endCell, event) { + return this.computeEventResize('end', startCell, endCell, event); + }, + + + // Returns new date-information for an event segment being resized from its start OR end + // `type` is either 'start' or 'end' + computeEventResize: function(type, startCell, endCell, event) { + var calendar = this.view.calendar; + var delta = this.diffDates(endCell[type], startCell[type]); + var range; + var defaultDuration; + + // build original values to work from, guaranteeing a start and end + range = { + start: event.start.clone(), + end: calendar.getEventEnd(event), + allDay: event.allDay + }; + + // if an all-day event was in a timed area and was resized to a time, adjust start/end to have times + if (range.allDay && durationHasTime(delta)) { + range.allDay = false; + calendar.normalizeEventRangeTimes(range); + } + + range[type].add(delta); // apply delta to start or end + + // if the event was compressed too small, find a new reasonable duration for it + if (!range.start.isBefore(range.end)) { + + defaultDuration = event.allDay ? + calendar.defaultAllDayEventDuration : + calendar.defaultTimedEventDuration; + + // between the cell's duration and the event's default duration, use the smaller of the two. + // example: if year-length slots, and compressed to one slot, we don't want the event to be a year long + if (this.cellDuration && this.cellDuration < defaultDuration) { + defaultDuration = this.cellDuration; + } + + if (type == 'start') { // resizing the start? + range.start = range.end.clone().subtract(defaultDuration); + } + else { // resizing the end? + range.end = range.start.clone().add(defaultDuration); + } + } + + return range; + }, + + + // Renders a visual indication of an event being resized. + // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag. + renderEventResize: function(range, seg) { + // subclasses must implement + }, + + + // Unrenders a visual indication of an event being resized. + destroyEventResize: function() { + // subclasses must implement + }, + + + /* Rendering Utils + ------------------------------------------------------------------------------------------------------------------*/ + + + // Compute the text that should be displayed on an event's element. + // `range` can be the Event object itself, or something range-like, with at least a `start`. + // If event times are disabled, or the event has no time, will return a blank string. + // If not specified, formatStr will default to the eventTimeFormat setting, + // and displayEnd will default to the displayEventEnd setting. + getEventTimeText: function(range, formatStr, displayEnd) { + + if (formatStr == null) { + formatStr = this.eventTimeFormat; + } + + if (displayEnd == null) { + displayEnd = this.displayEventEnd; + } + + if (this.displayEventTime && range.start.hasTime()) { + if (displayEnd && range.end) { + return this.view.formatRange(range, formatStr); + } + else { + return range.start.format(formatStr); + } + } + + return ''; + }, + + + // Generic utility for generating the HTML classNames for an event segment's element + getSegClasses: function(seg, isDraggable, isResizable) { + var event = seg.event; + var classes = [ + 'fc-event', + seg.isStart ? 'fc-start' : 'fc-not-start', + seg.isEnd ? 'fc-end' : 'fc-not-end' + ].concat( + event.className, + event.source ? event.source.className : [] + ); + + if (isDraggable) { + classes.push('fc-draggable'); + } + if (isResizable) { + classes.push('fc-resizable'); + } + + return classes; + }, + + + // Utility for generating event skin-related CSS properties + getEventSkinCss: function(event) { + var view = this.view; + var source = event.source || {}; + var eventColor = event.color; + var sourceColor = source.color; + var optionColor = view.opt('eventColor'); + + return { + 'background-color': + event.backgroundColor || + eventColor || + source.backgroundColor || + sourceColor || + view.opt('eventBackgroundColor') || + optionColor, + 'border-color': + event.borderColor || + eventColor || + source.borderColor || + sourceColor || + view.opt('eventBorderColor') || + optionColor, + color: + event.textColor || + source.textColor || + view.opt('eventTextColor') + }; + }, + + + /* Converting events -> ranges -> segs + ------------------------------------------------------------------------------------------------------------------*/ + + + // Converts an array of event objects into an array of event segment objects. + // A custom `rangeToSegsFunc` may be given for arbitrarily slicing up events. + // Doesn't guarantee an order for the resulting array. + eventsToSegs: function(events, rangeToSegsFunc) { + var eventRanges = this.eventsToRanges(events); + var segs = []; + var i; + + for (i = 0; i < eventRanges.length; i++) { + segs.push.apply( + segs, + this.eventRangeToSegs(eventRanges[i], rangeToSegsFunc) + ); + } + + return segs; + }, + + + // Converts an array of events into an array of "range" objects. + // A "range" object is a plain object with start/end properties denoting the time it covers. Also an event property. + // For "normal" events, this will be identical to the event's start/end, but for "inverse-background" events, + // will create an array of ranges that span the time *not* covered by the given event. + // Doesn't guarantee an order for the resulting array. + eventsToRanges: function(events) { + var _this = this; + var eventsById = groupEventsById(events); + var ranges = []; + + // group by ID so that related inverse-background events can be rendered together + $.each(eventsById, function(id, eventGroup) { + if (eventGroup.length) { + ranges.push.apply( + ranges, + isInverseBgEvent(eventGroup[0]) ? + _this.eventsToInverseRanges(eventGroup) : + _this.eventsToNormalRanges(eventGroup) + ); + } + }); + + return ranges; + }, + + + // Converts an array of "normal" events (not inverted rendering) into a parallel array of ranges + eventsToNormalRanges: function(events) { + var calendar = this.view.calendar; + var ranges = []; + var i, event; + var eventStart, eventEnd; + + for (i = 0; i < events.length; i++) { + event = events[i]; + + // make copies and normalize by stripping timezone + eventStart = event.start.clone().stripZone(); + eventEnd = calendar.getEventEnd(event).stripZone(); + + ranges.push({ + event: event, + start: eventStart, + end: eventEnd, + eventStartMS: +eventStart, + eventDurationMS: eventEnd - eventStart + }); + } + + return ranges; + }, + + + // Converts an array of events, with inverse-background rendering, into an array of range objects. + // The range objects will cover all the time NOT covered by the events. + eventsToInverseRanges: function(events) { + var view = this.view; + var viewStart = view.start.clone().stripZone(); // normalize timezone + var viewEnd = view.end.clone().stripZone(); // normalize timezone + var normalRanges = this.eventsToNormalRanges(events); // will give us normalized dates we can use w/o copies + var inverseRanges = []; + var event0 = events[0]; // assign this to each range's `.event` + var start = viewStart; // the end of the previous range. the start of the new range + var i, normalRange; + + // ranges need to be in order. required for our date-walking algorithm + normalRanges.sort(compareNormalRanges); + + for (i = 0; i < normalRanges.length; i++) { + normalRange = normalRanges[i]; + + // add the span of time before the event (if there is any) + if (normalRange.start > start) { // compare millisecond time (skip any ambig logic) + inverseRanges.push({ + event: event0, + start: start, + end: normalRange.start + }); + } + + start = normalRange.end; + } + + // add the span of time after the last event (if there is any) + if (start < viewEnd) { // compare millisecond time (skip any ambig logic) + inverseRanges.push({ + event: event0, + start: start, + end: viewEnd + }); + } + + return inverseRanges; + }, + + + // Slices the given event range into one or more segment objects. + // A `rangeToSegsFunc` custom slicing function can be given. + eventRangeToSegs: function(eventRange, rangeToSegsFunc) { + var segs; + var i, seg; + + if (rangeToSegsFunc) { + segs = rangeToSegsFunc(eventRange); + } + else { + segs = this.rangeToSegs(eventRange); // defined by the subclass + } + + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + seg.event = eventRange.event; + seg.eventStartMS = eventRange.eventStartMS; + seg.eventDurationMS = eventRange.eventDurationMS; + } + + return segs; + } + +}); + + +/* Utilities +----------------------------------------------------------------------------------------------------------------------*/ + + +function isBgEvent(event) { // returns true if background OR inverse-background + var rendering = getEventRendering(event); + return rendering === 'background' || rendering === 'inverse-background'; +} + + +function isInverseBgEvent(event) { + return getEventRendering(event) === 'inverse-background'; +} + + +function getEventRendering(event) { + return firstDefined((event.source || {}).rendering, event.rendering); +} + + +function groupEventsById(events) { + var eventsById = {}; + var i, event; + + for (i = 0; i < events.length; i++) { + event = events[i]; + (eventsById[event._id] || (eventsById[event._id] = [])).push(event); + } + + return eventsById; +} + + +// A cmp function for determining which non-inverted "ranges" (see above) happen earlier +function compareNormalRanges(range1, range2) { + return range1.eventStartMS - range2.eventStartMS; // earlier ranges go first +} + + +// A cmp function for determining which segments should take visual priority +// DOES NOT WORK ON INVERTED BACKGROUND EVENTS because they have no eventStartMS/eventDurationMS +function compareSegs(seg1, seg2) { + return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first + seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first + seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1) + (seg1.event.title || '').localeCompare(seg2.event.title); // tie? alphabetically by title +} + +fc.compareSegs = compareSegs; // export + + +/* External-Dragging-Element Data +----------------------------------------------------------------------------------------------------------------------*/ + +// Require all HTML5 data-* attributes used by FullCalendar to have this prefix. +// A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event. +fc.dataAttrPrefix = ''; + +// Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure +// to be used for Event Object creation. +// A defined `.eventProps`, even when empty, indicates that an event should be created. +function getDraggedElMeta(el) { + var prefix = fc.dataAttrPrefix; + var eventProps; // properties for creating the event, not related to date/time + var startTime; // a Duration + var duration; + var stick; + + if (prefix) { prefix += '-'; } + eventProps = el.data(prefix + 'event') || null; + + if (eventProps) { + if (typeof eventProps === 'object') { + eventProps = $.extend({}, eventProps); // make a copy + } + else { // something like 1 or true. still signal event creation + eventProps = {}; + } + + // pluck special-cased date/time properties + startTime = eventProps.start; + if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well + duration = eventProps.duration; + stick = eventProps.stick; + delete eventProps.start; + delete eventProps.time; + delete eventProps.duration; + delete eventProps.stick; + } + + // fallback to standalone attribute values for each of the date/time properties + if (startTime == null) { startTime = el.data(prefix + 'start'); } + if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well + if (duration == null) { duration = el.data(prefix + 'duration'); } + if (stick == null) { stick = el.data(prefix + 'stick'); } + + // massage into correct data types + startTime = startTime != null ? moment.duration(startTime) : null; + duration = duration != null ? moment.duration(duration) : null; + stick = Boolean(stick); + + return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick }; +} + + +;; + +/* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week. +----------------------------------------------------------------------------------------------------------------------*/ + +var DayGrid = Grid.extend({ + + numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal + bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid + breakOnWeeks: null, // should create a new row for each week? set by outside view + + cellDates: null, // flat chronological array of each cell's dates + dayToCellOffsets: null, // maps days offsets from grid's start date, to cell offsets + + rowEls: null, // set of fake row elements + dayEls: null, // set of whole-day elements comprising the row's background + helperEls: null, // set of cell skeleton elements for rendering the mock event "helper" + + + constructor: function() { + Grid.apply(this, arguments); + + this.cellDuration = moment.duration(1, 'day'); // for Grid system + }, + + + // Renders the rows and columns into the component's `this.el`, which should already be assigned. + // isRigid determins whether the individual rows should ignore the contents and be a constant height. + // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient. + renderDates: function(isRigid) { + var view = this.view; + var rowCnt = this.rowCnt; + var colCnt = this.colCnt; + var cellCnt = rowCnt * colCnt; + var html = ''; + var row; + var i, cell; + + for (row = 0; row < rowCnt; row++) { + html += this.dayRowHtml(row, isRigid); + } + this.el.html(html); + + this.rowEls = this.el.find('.fc-row'); + this.dayEls = this.el.find('.fc-day'); + + // trigger dayRender with each cell's element + for (i = 0; i < cellCnt; i++) { + cell = this.getCell(i); + view.trigger('dayRender', null, cell.start, this.dayEls.eq(i)); + } + }, + + + destroyDates: function() { + this.destroySegPopover(); + }, + + + renderBusinessHours: function() { + var events = this.view.calendar.getBusinessHoursEvents(true); // wholeDay=true + var segs = this.eventsToSegs(events); + + this.renderFill('businessHours', segs, 'bgevent'); + }, + + + // Generates the HTML for a single row. `row` is the row number. + dayRowHtml: function(row, isRigid) { + var view = this.view; + var classes = [ 'fc-row', 'fc-week', view.widgetContentClass ]; + + if (isRigid) { + classes.push('fc-rigid'); + } + + return '' + + '
' + + '
' + + '' + + this.rowHtml('day', row) + // leverages RowRenderer. calls dayCellHtml() + '
' + + '
' + + '
' + + '' + + (this.numbersVisible ? + '' + + this.rowHtml('number', row) + // leverages RowRenderer. View will define render method + '' : + '' + ) + + '
' + + '
' + + '
'; + }, + + + // Renders the HTML for a whole-day cell. Will eventually end up in the day-row's background. + // We go through a 'day' row type instead of just doing a 'bg' row type so that the View can do custom rendering + // specifically for whole-day rows, whereas a 'bg' might also be used for other purposes (TimeGrid bg for example). + dayCellHtml: function(cell) { + return this.bgCellHtml(cell); + }, + + + /* Options + ------------------------------------------------------------------------------------------------------------------*/ + + + // Computes a default column header formatting string if `colFormat` is not explicitly defined + computeColHeadFormat: function() { + if (this.rowCnt > 1) { // more than one week row. day numbers will be in each cell + return 'ddd'; // "Sat" + } + else if (this.colCnt > 1) { // multiple days, so full single date string WON'T be in title text + return this.view.opt('dayOfMonthFormat'); // "Sat 12/10" + } + else { // single day, so full single date string will probably be in title text + return 'dddd'; // "Saturday" + } + }, + + + // Computes a default event time formatting string if `timeFormat` is not explicitly defined + computeEventTimeFormat: function() { + return this.view.opt('extraSmallTimeFormat'); // like "6p" or "6:30p" + }, + + + // Computes a default `displayEventEnd` value if one is not expliclty defined + computeDisplayEventEnd: function() { + return this.colCnt == 1; // we'll likely have space if there's only one day + }, + + + /* Cell System + ------------------------------------------------------------------------------------------------------------------*/ + + + // Initializes row/col information + updateCells: function() { + var cellDates; + var firstDay; + var rowCnt; + var colCnt; + + this.updateCellDates(); // populates cellDates and dayToCellOffsets + cellDates = this.cellDates; + + if (this.breakOnWeeks) { + // count columns until the day-of-week repeats + firstDay = cellDates[0].day(); + for (colCnt = 1; colCnt < cellDates.length; colCnt++) { + if (cellDates[colCnt].day() == firstDay) { + break; + } + } + rowCnt = Math.ceil(cellDates.length / colCnt); + } + else { + rowCnt = 1; + colCnt = cellDates.length; + } + + this.rowCnt = rowCnt; + this.colCnt = colCnt; + }, + + + // Populates cellDates and dayToCellOffsets + updateCellDates: function() { + var view = this.view; + var date = this.start.clone(); + var dates = []; + var offset = -1; + var offsets = []; + + while (date.isBefore(this.end)) { // loop each day from start to end + if (view.isHiddenDay(date)) { + offsets.push(offset + 0.5); // mark that it's between offsets + } + else { + offset++; + offsets.push(offset); + dates.push(date.clone()); + } + date.add(1, 'days'); + } + + this.cellDates = dates; + this.dayToCellOffsets = offsets; + }, + + + // Given a cell object, generates its start date. Returns a reference-free copy. + computeCellDate: function(cell) { + var colCnt = this.colCnt; + var index = cell.row * colCnt + (this.isRTL ? colCnt - cell.col - 1 : cell.col); + + return this.cellDates[index].clone(); + }, + + + // Retrieves the element representing the given row + getRowEl: function(row) { + return this.rowEls.eq(row); + }, + + + // Retrieves the element representing the given column + getColEl: function(col) { + return this.dayEls.eq(col); + }, + + + // Gets the whole-day element associated with the cell + getCellDayEl: function(cell) { + return this.dayEls.eq(cell.row * this.colCnt + cell.col); + }, + + + // Overrides Grid's method for when row coordinates are computed + computeRowCoords: function() { + var rowCoords = Grid.prototype.computeRowCoords.call(this); // call the super-method + + // hack for extending last row (used by AgendaView) + rowCoords[rowCoords.length - 1].bottom += this.bottomCoordPadding; + + return rowCoords; + }, + + + /* Dates + ------------------------------------------------------------------------------------------------------------------*/ + + + // Slices up a date range by row into an array of segments + rangeToSegs: function(range) { + var isRTL = this.isRTL; + var rowCnt = this.rowCnt; + var colCnt = this.colCnt; + var segs = []; + var first, last; // inclusive cell-offset range for given range + var row; + var rowFirst, rowLast; // inclusive cell-offset range for current row + var isStart, isEnd; + var segFirst, segLast; // inclusive cell-offset range for segment + var seg; + + range = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold + first = this.dateToCellOffset(range.start); + last = this.dateToCellOffset(range.end.subtract(1, 'days')); // offset of inclusive end date + + for (row = 0; row < rowCnt; row++) { + rowFirst = row * colCnt; + rowLast = rowFirst + colCnt - 1; + + // intersect segment's offset range with the row's + segFirst = Math.max(rowFirst, first); + segLast = Math.min(rowLast, last); + + // deal with in-between indices + segFirst = Math.ceil(segFirst); // in-between starts round to next cell + segLast = Math.floor(segLast); // in-between ends round to prev cell + + if (segFirst <= segLast) { // was there any intersection with the current row? + + // must be matching integers to be the segment's start/end + isStart = segFirst === first; + isEnd = segLast === last; + + // translate offsets to be relative to start-of-row + segFirst -= rowFirst; + segLast -= rowFirst; + + seg = { row: row, isStart: isStart, isEnd: isEnd }; + if (isRTL) { + seg.leftCol = colCnt - segLast - 1; + seg.rightCol = colCnt - segFirst - 1; + } + else { + seg.leftCol = segFirst; + seg.rightCol = segLast; + } + segs.push(seg); + } + } + + return segs; + }, + + + // Given a date, returns its chronolocial cell-offset from the first cell of the grid. + // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets. + // If before the first offset, returns a negative number. + // If after the last offset, returns an offset past the last cell offset. + // Only works for *start* dates of cells. Will not work for exclusive end dates for cells. + dateToCellOffset: function(date) { + var offsets = this.dayToCellOffsets; + var day = date.diff(this.start, 'days'); + + if (day < 0) { + return offsets[0] - 1; + } + else if (day >= offsets.length) { + return offsets[offsets.length - 1] + 1; + } + else { + return offsets[day]; + } + }, + + + /* Event Drag Visualization + ------------------------------------------------------------------------------------------------------------------*/ + // TODO: move to DayGrid.event, similar to what we did with Grid's drag methods + + + // Renders a visual indication of an event or external element being dragged. + // The dropLocation's end can be null. seg can be null. See Grid::renderDrag for more info. + renderDrag: function(dropLocation, seg) { + + // always render a highlight underneath + this.renderHighlight( + this.view.calendar.ensureVisibleEventRange(dropLocation) // needs to be a proper range + ); + + // if a segment from the same calendar but another component is being dragged, render a helper event + if (seg && !seg.el.closest(this.el).length) { + + this.renderRangeHelper(dropLocation, seg); + this.applyDragOpacity(this.helperEls); + + return true; // a helper has been rendered + } + }, + + + // Unrenders any visual indication of a hovering event + destroyDrag: function() { + this.destroyHighlight(); + this.destroyHelper(); + }, + + + /* Event Resize Visualization + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of an event being resized + renderEventResize: function(range, seg) { + this.renderHighlight(range); + this.renderRangeHelper(range, seg); + }, + + + // Unrenders a visual indication of an event being resized + destroyEventResize: function() { + this.destroyHighlight(); + this.destroyHelper(); + }, + + + /* Event Helper + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null. + renderHelper: function(event, sourceSeg) { + var helperNodes = []; + var segs = this.eventsToSegs([ event ]); + var rowStructs; + + segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered + rowStructs = this.renderSegRows(segs); + + // inject each new event skeleton into each associated row + this.rowEls.each(function(row, rowNode) { + var rowEl = $(rowNode); // the .fc-row + var skeletonEl = $('
'); // will be absolutely positioned + var skeletonTop; + + // If there is an original segment, match the top position. Otherwise, put it at the row's top level + if (sourceSeg && sourceSeg.row === row) { + skeletonTop = sourceSeg.el.position().top; + } + else { + skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top; + } + + skeletonEl.css('top', skeletonTop) + .find('table') + .append(rowStructs[row].tbodyEl); + + rowEl.append(skeletonEl); + helperNodes.push(skeletonEl[0]); + }); + + this.helperEls = $(helperNodes); // array -> jQuery set + }, + + + // Unrenders any visual indication of a mock helper event + destroyHelper: function() { + if (this.helperEls) { + this.helperEls.remove(); + this.helperEls = null; + } + }, + + + /* Fill System (highlight, background events, business hours) + ------------------------------------------------------------------------------------------------------------------*/ + + + fillSegTag: 'td', // override the default tag name + + + // Renders a set of rectangles over the given segments of days. + // Only returns segments that successfully rendered. + renderFill: function(type, segs, className) { + var nodes = []; + var i, seg; + var skeletonEl; + + segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs + + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + skeletonEl = this.renderFillRow(type, seg, className); + this.rowEls.eq(seg.row).append(skeletonEl); + nodes.push(skeletonEl[0]); + } + + this.elsByFill[type] = $(nodes); + + return segs; + }, + + + // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered. + renderFillRow: function(type, seg, className) { + var colCnt = this.colCnt; + var startCol = seg.leftCol; + var endCol = seg.rightCol + 1; + var skeletonEl; + var trEl; + + className = className || type.toLowerCase(); + + skeletonEl = $( + '
' + + '
' + + '
' + ); + trEl = skeletonEl.find('tr'); + + if (startCol > 0) { + trEl.append(''); + } + + trEl.append( + seg.el.attr('colspan', endCol - startCol) + ); + + if (endCol < colCnt) { + trEl.append(''); + } + + this.bookendCells(trEl, type); + + return skeletonEl; + } + +}); + +;; + +/* Event-rendering methods for the DayGrid class +----------------------------------------------------------------------------------------------------------------------*/ + +DayGrid.mixin({ + + rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering + + + // Unrenders all events currently rendered on the grid + destroyEvents: function() { + this.destroySegPopover(); // removes the "more.." events popover + Grid.prototype.destroyEvents.apply(this, arguments); // calls the super-method + }, + + + // Retrieves all rendered segment objects currently rendered on the grid + getEventSegs: function() { + return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method + .concat(this.popoverSegs || []); // append the segments from the "more..." popover + }, + + + // Renders the given background event segments onto the grid + renderBgSegs: function(segs) { + + // don't render timed background events + var allDaySegs = $.grep(segs, function(seg) { + return seg.event.allDay; + }); + + return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method + }, + + + // Renders the given foreground event segments onto the grid + renderFgSegs: function(segs) { + var rowStructs; + + // render an `.el` on each seg + // returns a subset of the segs. segs that were actually rendered + segs = this.renderFgSegEls(segs); + + rowStructs = this.rowStructs = this.renderSegRows(segs); + + // append to each row's content skeleton + this.rowEls.each(function(i, rowNode) { + $(rowNode).find('.fc-content-skeleton > table').append( + rowStructs[i].tbodyEl + ); + }); + + return segs; // return only the segs that were actually rendered + }, + + + // Unrenders all currently rendered foreground event segments + destroyFgSegs: function() { + var rowStructs = this.rowStructs || []; + var rowStruct; + + while ((rowStruct = rowStructs.pop())) { + rowStruct.tbodyEl.remove(); + } + + this.rowStructs = null; + }, + + + // Uses the given events array to generate elements that should be appended to each row's content skeleton. + // Returns an array of rowStruct objects (see the bottom of `renderSegRow`). + // PRECONDITION: each segment shoud already have a rendered and assigned `.el` + renderSegRows: function(segs) { + var rowStructs = []; + var segRows; + var row; + + segRows = this.groupSegRows(segs); // group into nested arrays + + // iterate each row of segment groupings + for (row = 0; row < segRows.length; row++) { + rowStructs.push( + this.renderSegRow(row, segRows[row]) + ); + } + + return rowStructs; + }, + + + // Builds the HTML to be used for the default element for an individual segment + fgSegHtml: function(seg, disableResizing) { + var view = this.view; + var event = seg.event; + var isDraggable = view.isEventDraggable(event); + var isResizableFromStart = !disableResizing && event.allDay && + seg.isStart && view.isEventResizableFromStart(event); + var isResizableFromEnd = !disableResizing && event.allDay && + seg.isEnd && view.isEventResizableFromEnd(event); + var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); + var skinCss = cssToStr(this.getEventSkinCss(event)); + var timeHtml = ''; + var timeText; + var titleHtml; + + classes.unshift('fc-day-grid-event', 'fc-h-event'); + + // Only display a timed events time if it is the starting segment + if (seg.isStart) { + timeText = this.getEventTimeText(event); + if (timeText) { + timeHtml = '' + htmlEscape(timeText) + ''; + } + } + + titleHtml = + '' + + (htmlEscape(event.title || '') || ' ') + // we always want one line of height + ''; + + return '
' + + '
' + + (this.isRTL ? + titleHtml + ' ' + timeHtml : // put a natural space in between + timeHtml + ' ' + titleHtml // + ) + + '
' + + (isResizableFromStart ? + '
' : + '' + ) + + (isResizableFromEnd ? + '
' : + '' + ) + + ''; + }, + + + // Given a row # and an array of segments all in the same row, render a element, a skeleton that contains + // the segments. Returns object with a bunch of internal data about how the render was calculated. + // NOTE: modifies rowSegs + renderSegRow: function(row, rowSegs) { + var colCnt = this.colCnt; + var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels + var levelCnt = Math.max(1, segLevels.length); // ensure at least one level + var tbody = $(''); + var segMatrix = []; // lookup for which segments are rendered into which level+col cells + var cellMatrix = []; // lookup for all elements of the level+col matrix + var loneCellMatrix = []; // lookup for elements that only take up a single column + var i, levelSegs; + var col; + var tr; + var j, seg; + var td; + + // populates empty cells from the current column (`col`) to `endCol` + function emptyCellsUntil(endCol) { + while (col < endCol) { + // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell + td = (loneCellMatrix[i - 1] || [])[col]; + if (td) { + td.attr( + 'rowspan', + parseInt(td.attr('rowspan') || 1, 10) + 1 + ); + } + else { + td = $(''); + tr.append(td); + } + cellMatrix[i][col] = td; + loneCellMatrix[i][col] = td; + col++; + } + } + + for (i = 0; i < levelCnt; i++) { // iterate through all levels + levelSegs = segLevels[i]; + col = 0; + tr = $(''); + + segMatrix.push([]); + cellMatrix.push([]); + loneCellMatrix.push([]); + + // levelCnt might be 1 even though there are no actual levels. protect against this. + // this single empty row is useful for styling. + if (levelSegs) { + for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level + seg = levelSegs[j]; + + emptyCellsUntil(seg.leftCol); + + // create a container that occupies or more columns. append the event element. + td = $('').append(seg.el); + if (seg.leftCol != seg.rightCol) { + td.attr('colspan', seg.rightCol - seg.leftCol + 1); + } + else { // a single-column segment + loneCellMatrix[i][col] = td; + } + + while (col <= seg.rightCol) { + cellMatrix[i][col] = td; + segMatrix[i][col] = seg; + col++; + } + + tr.append(td); + } + } + + emptyCellsUntil(colCnt); // finish off the row + this.bookendCells(tr, 'eventSkeleton'); + tbody.append(tr); + } + + return { // a "rowStruct" + row: row, // the row number + tbodyEl: tbody, + cellMatrix: cellMatrix, + segMatrix: segMatrix, + segLevels: segLevels, + segs: rowSegs + }; + }, + + + // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels. + // NOTE: modifies segs + buildSegLevels: function(segs) { + var levels = []; + var i, seg; + var j; + + // Give preference to elements with certain criteria, so they have + // a chance to be closer to the top. + segs.sort(compareSegs); + + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + + // loop through levels, starting with the topmost, until the segment doesn't collide with other segments + for (j = 0; j < levels.length; j++) { + if (!isDaySegCollision(seg, levels[j])) { + break; + } + } + // `j` now holds the desired subrow index + seg.level = j; + + // create new level array if needed and append segment + (levels[j] || (levels[j] = [])).push(seg); + } + + // order segments left-to-right. very important if calendar is RTL + for (j = 0; j < levels.length; j++) { + levels[j].sort(compareDaySegCols); + } + + return levels; + }, + + + // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row + groupSegRows: function(segs) { + var segRows = []; + var i; + + for (i = 0; i < this.rowCnt; i++) { + segRows.push([]); + } + + for (i = 0; i < segs.length; i++) { + segRows[segs[i].row].push(segs[i]); + } + + return segRows; + } + +}); + + +// Computes whether two segments' columns collide. They are assumed to be in the same row. +function isDaySegCollision(seg, otherSegs) { + var i, otherSeg; + + for (i = 0; i < otherSegs.length; i++) { + otherSeg = otherSegs[i]; + + if ( + otherSeg.leftCol <= seg.rightCol && + otherSeg.rightCol >= seg.leftCol + ) { + return true; + } + } + + return false; +} + + +// A cmp function for determining the leftmost event +function compareDaySegCols(a, b) { + return a.leftCol - b.leftCol; +} + +;; + +/* Methods relate to limiting the number events for a given day on a DayGrid +----------------------------------------------------------------------------------------------------------------------*/ +// NOTE: all the segs being passed around in here are foreground segs + +DayGrid.mixin({ + + segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible + popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible + + + destroySegPopover: function() { + if (this.segPopover) { + this.segPopover.hide(); // will trigger destruction of `segPopover` and `popoverSegs` + } + }, + + + // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid. + // `levelLimit` can be false (don't limit), a number, or true (should be computed). + limitRows: function(levelLimit) { + var rowStructs = this.rowStructs || []; + var row; // row # + var rowLevelLimit; + + for (row = 0; row < rowStructs.length; row++) { + this.unlimitRow(row); + + if (!levelLimit) { + rowLevelLimit = false; + } + else if (typeof levelLimit === 'number') { + rowLevelLimit = levelLimit; + } + else { + rowLevelLimit = this.computeRowLevelLimit(row); + } + + if (rowLevelLimit !== false) { + this.limitRow(row, rowLevelLimit); + } + } + }, + + + // Computes the number of levels a row will accomodate without going outside its bounds. + // Assumes the row is "rigid" (maintains a constant height regardless of what is inside). + // `row` is the row number. + computeRowLevelLimit: function(row) { + var rowEl = this.rowEls.eq(row); // the containing "fake" row div + var rowHeight = rowEl.height(); // TODO: cache somehow? + var trEls = this.rowStructs[row].tbodyEl.children(); + var i, trEl; + var trHeight; + + function iterInnerHeights(i, childNode) { + trHeight = Math.max(trHeight, $(childNode).outerHeight()); + } + + // Reveal one level at a time and stop when we find one out of bounds + for (i = 0; i < trEls.length; i++) { + trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal) + + // with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell, + // so instead, find the tallest inner content element. + trHeight = 0; + trEl.find('> td > :first-child').each(iterInnerHeights); + + if (trEl.position().top + trHeight > rowHeight) { + return i; + } + } + + return false; // should not limit at all + }, + + + // Limits the given grid row to the maximum number of levels and injects "more" links if necessary. + // `row` is the row number. + // `levelLimit` is a number for the maximum (inclusive) number of levels allowed. + limitRow: function(row, levelLimit) { + var _this = this; + var rowStruct = this.rowStructs[row]; + var moreNodes = []; // array of "more" links and DOM nodes + var col = 0; // col #, left-to-right (not chronologically) + var cell; + var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right + var cellMatrix; // a matrix (by level, then column) of all jQuery elements in the row + var limitedNodes; // array of temporarily hidden level and segment DOM nodes + var i, seg; + var segsBelow; // array of segment objects below `seg` in the current `col` + var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies + var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column) + var td, rowspan; + var segMoreNodes; // array of "more" cells that will stand-in for the current seg's cell + var j; + var moreTd, moreWrap, moreLink; + + // Iterates through empty level cells and places "more" links inside if need be + function emptyCellsUntil(endCol) { // goes from current `col` to `endCol` + while (col < endCol) { + cell = _this.getCell(row, col); + segsBelow = _this.getCellSegs(cell, levelLimit); + if (segsBelow.length) { + td = cellMatrix[levelLimit - 1][col]; + moreLink = _this.renderMoreLink(cell, segsBelow); + moreWrap = $('
').append(moreLink); + td.append(moreWrap); + moreNodes.push(moreWrap[0]); + } + col++; + } + } + + if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit? + levelSegs = rowStruct.segLevels[levelLimit - 1]; + cellMatrix = rowStruct.cellMatrix; + + limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level elements past the limit + .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array + + // iterate though segments in the last allowable level + for (i = 0; i < levelSegs.length; i++) { + seg = levelSegs[i]; + emptyCellsUntil(seg.leftCol); // process empty cells before the segment + + // determine *all* segments below `seg` that occupy the same columns + colSegsBelow = []; + totalSegsBelow = 0; + while (col <= seg.rightCol) { + cell = this.getCell(row, col); + segsBelow = this.getCellSegs(cell, levelLimit); + colSegsBelow.push(segsBelow); + totalSegsBelow += segsBelow.length; + col++; + } + + if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links? + td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell + rowspan = td.attr('rowspan') || 1; + segMoreNodes = []; + + // make a replacement for each column the segment occupies. will be one for each colspan + for (j = 0; j < colSegsBelow.length; j++) { + moreTd = $('').attr('rowspan', rowspan); + segsBelow = colSegsBelow[j]; + cell = this.getCell(row, seg.leftCol + j); + moreLink = this.renderMoreLink(cell, [ seg ].concat(segsBelow)); // count seg as hidden too + moreWrap = $('
').append(moreLink); + moreTd.append(moreWrap); + segMoreNodes.push(moreTd[0]); + moreNodes.push(moreTd[0]); + } + + td.addClass('fc-limited').after($(segMoreNodes)); // hide original and inject replacements + limitedNodes.push(td[0]); + } + } + + emptyCellsUntil(this.colCnt); // finish off the level + rowStruct.moreEls = $(moreNodes); // for easy undoing later + rowStruct.limitedEls = $(limitedNodes); // for easy undoing later + } + }, + + + // Reveals all levels and removes all "more"-related elements for a grid's row. + // `row` is a row number. + unlimitRow: function(row) { + var rowStruct = this.rowStructs[row]; + + if (rowStruct.moreEls) { + rowStruct.moreEls.remove(); + rowStruct.moreEls = null; + } + + if (rowStruct.limitedEls) { + rowStruct.limitedEls.removeClass('fc-limited'); + rowStruct.limitedEls = null; + } + }, + + + // Renders an element that represents hidden event element for a cell. + // Responsible for attaching click handler as well. + renderMoreLink: function(cell, hiddenSegs) { + var _this = this; + var view = this.view; + + return $('') + .text( + this.getMoreLinkText(hiddenSegs.length) + ) + .on('click', function(ev) { + var clickOption = view.opt('eventLimitClick'); + var date = cell.start; + var moreEl = $(this); + var dayEl = _this.getCellDayEl(cell); + var allSegs = _this.getCellSegs(cell); + + // rescope the segments to be within the cell's date + var reslicedAllSegs = _this.resliceDaySegs(allSegs, date); + var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date); + + if (typeof clickOption === 'function') { + // the returned value can be an atomic option + clickOption = view.trigger('eventLimitClick', null, { + date: date, + dayEl: dayEl, + moreEl: moreEl, + segs: reslicedAllSegs, + hiddenSegs: reslicedHiddenSegs + }, ev); + } + + if (clickOption === 'popover') { + _this.showSegPopover(cell, moreEl, reslicedAllSegs); + } + else if (typeof clickOption === 'string') { // a view name + view.calendar.zoomTo(date, clickOption); + } + }); + }, + + + // Reveals the popover that displays all events within a cell + showSegPopover: function(cell, moreLink, segs) { + var _this = this; + var view = this.view; + var moreWrap = moreLink.parent(); // the
wrapper around the + var topEl; // the element we want to match the top coordinate of + var options; + + if (this.rowCnt == 1) { + topEl = view.el; // will cause the popover to cover any sort of header + } + else { + topEl = this.rowEls.eq(cell.row); // will align with top of row + } + + options = { + className: 'fc-more-popover', + content: this.renderSegPopoverContent(cell, segs), + parentEl: this.el, + top: topEl.offset().top, + autoHide: true, // when the user clicks elsewhere, hide the popover + viewportConstrain: view.opt('popoverViewportConstrain'), + hide: function() { + // destroy everything when the popover is hidden + _this.segPopover.destroy(); + _this.segPopover = null; + _this.popoverSegs = null; + } + }; + + // Determine horizontal coordinate. + // We use the moreWrap instead of the to avoid border confusion. + if (this.isRTL) { + options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border + } + else { + options.left = moreWrap.offset().left - 1; // -1 to be over cell border + } + + this.segPopover = new Popover(options); + this.segPopover.show(); + }, + + + // Builds the inner DOM contents of the segment popover + renderSegPopoverContent: function(cell, segs) { + var view = this.view; + var isTheme = view.opt('theme'); + var title = cell.start.format(view.opt('dayPopoverFormat')); + var content = $( + '
' + + '' + + '' + + htmlEscape(title) + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + ); + var segContainer = content.find('.fc-event-container'); + var i; + + // render each seg's `el` and only return the visible segs + segs = this.renderFgSegEls(segs, true); // disableResizing=true + this.popoverSegs = segs; + + for (i = 0; i < segs.length; i++) { + + // because segments in the popover are not part of a grid coordinate system, provide a hint to any + // grids that want to do drag-n-drop about which cell it came from + segs[i].cell = cell; + + segContainer.append(segs[i].el); + } + + return content; + }, + + + // Given the events within an array of segment objects, reslice them to be in a single day + resliceDaySegs: function(segs, dayDate) { + + // build an array of the original events + var events = $.map(segs, function(seg) { + return seg.event; + }); + + var dayStart = dayDate.clone().stripTime(); + var dayEnd = dayStart.clone().add(1, 'days'); + var dayRange = { start: dayStart, end: dayEnd }; + + // slice the events with a custom slicing function + segs = this.eventsToSegs( + events, + function(range) { + var seg = intersectionToSeg(range, dayRange); // undefind if no intersection + return seg ? [ seg ] : []; // must return an array of segments + } + ); + + // force an order because eventsToSegs doesn't guarantee one + segs.sort(compareSegs); + + return segs; + }, + + + // Generates the text that should be inside a "more" link, given the number of events it represents + getMoreLinkText: function(num) { + var opt = this.view.opt('eventLimitText'); + + if (typeof opt === 'function') { + return opt(num); + } + else { + return '+' + num + ' ' + opt; + } + }, + + + // Returns segments within a given cell. + // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs. + getCellSegs: function(cell, startLevel) { + var segMatrix = this.rowStructs[cell.row].segMatrix; + var level = startLevel || 0; + var segs = []; + var seg; + + while (level < segMatrix.length) { + seg = segMatrix[level][cell.col]; + if (seg) { + segs.push(seg); + } + level++; + } + + return segs; + } + +}); + +;; + +/* A component that renders one or more columns of vertical time slots +----------------------------------------------------------------------------------------------------------------------*/ + +var TimeGrid = Grid.extend({ + + slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines + snapDuration: null, // granularity of time for dragging and selecting + + minTime: null, // Duration object that denotes the first visible time of any given day + maxTime: null, // Duration object that denotes the exclusive visible end time of any given day + + axisFormat: null, // formatting string for times running along vertical axis + + dayEls: null, // cells elements in the day-row background + slatEls: null, // elements running horizontally across all columns + + slatTops: null, // an array of top positions, relative to the container. last item holds bottom of last slot + + helperEl: null, // cell skeleton element for rendering the mock event "helper" + + businessHourSegs: null, + + + constructor: function() { + Grid.apply(this, arguments); // call the super-constructor + this.processOptions(); + }, + + + // Renders the time grid into `this.el`, which should already be assigned. + // Relies on the view's colCnt. In the future, this component should probably be self-sufficient. + renderDates: function() { + this.el.html(this.renderHtml()); + this.dayEls = this.el.find('.fc-day'); + this.slatEls = this.el.find('.fc-slats tr'); + }, + + + renderBusinessHours: function() { + var events = this.view.calendar.getBusinessHoursEvents(); + this.businessHourSegs = this.renderFill('businessHours', this.eventsToSegs(events), 'bgevent'); + }, + + + // Renders the basic HTML skeleton for the grid + renderHtml: function() { + return '' + + '
' + + '' + + this.rowHtml('slotBg') + // leverages RowRenderer, which will call slotBgCellHtml + '
' + + '
' + + '
' + + '' + + this.slatRowHtml() + + '
' + + '
'; + }, + + + // Renders the HTML for a vertical background cell behind the slots. + // This method is distinct from 'bg' because we wanted a new `rowType` so the View could customize the rendering. + slotBgCellHtml: function(cell) { + return this.bgCellHtml(cell); + }, + + + // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL. + slatRowHtml: function() { + var view = this.view; + var isRTL = this.isRTL; + var html = ''; + var slotNormal = this.slotDuration.asMinutes() % 15 === 0; + var slotTime = moment.duration(+this.minTime); // wish there was .clone() for durations + var slotDate; // will be on the view's first day, but we only care about its time + var minutes; + var axisHtml; + + // Calculate the time for each slot + while (slotTime < this.maxTime) { + slotDate = this.start.clone().time(slotTime); // will be in UTC but that's good. to avoid DST issues + minutes = slotDate.minutes(); + + axisHtml = + '' + + ((!slotNormal || !minutes) ? // if irregular slot duration, or on the hour, then display the time + '' + // for matchCellWidths + htmlEscape(slotDate.format(this.axisFormat)) + + '' : + '' + ) + + ''; + + html += + '' + + (!isRTL ? axisHtml : '') + + '' + + (isRTL ? axisHtml : '') + + ""; + + slotTime.add(this.slotDuration); + } + + return html; + }, + + + /* Options + ------------------------------------------------------------------------------------------------------------------*/ + + + // Parses various options into properties of this object + processOptions: function() { + var view = this.view; + var slotDuration = view.opt('slotDuration'); + var snapDuration = view.opt('snapDuration'); + + slotDuration = moment.duration(slotDuration); + snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration; + + this.slotDuration = slotDuration; + this.snapDuration = snapDuration; + this.cellDuration = snapDuration; // for Grid system + + this.minTime = moment.duration(view.opt('minTime')); + this.maxTime = moment.duration(view.opt('maxTime')); + + this.axisFormat = view.opt('axisFormat') || view.opt('smallTimeFormat'); + }, + + + // Computes a default column header formatting string if `colFormat` is not explicitly defined + computeColHeadFormat: function() { + if (this.colCnt > 1) { // multiple days, so full single date string WON'T be in title text + return this.view.opt('dayOfMonthFormat'); // "Sat 12/10" + } + else { // single day, so full single date string will probably be in title text + return 'dddd'; // "Saturday" + } + }, + + + // Computes a default event time formatting string if `timeFormat` is not explicitly defined + computeEventTimeFormat: function() { + return this.view.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM) + }, + + + // Computes a default `displayEventEnd` value if one is not expliclty defined + computeDisplayEventEnd: function() { + return true; + }, + + + /* Cell System + ------------------------------------------------------------------------------------------------------------------*/ + + + // Initializes row/col information + updateCells: function() { + var view = this.view; + var colData = []; + var date; + + date = this.start.clone(); + while (date.isBefore(this.end)) { + colData.push({ + day: date.clone() + }); + date.add(1, 'day'); + date = view.skipHiddenDays(date); + } + + if (this.isRTL) { + colData.reverse(); + } + + this.colData = colData; + this.colCnt = colData.length; + this.rowCnt = Math.ceil((this.maxTime - this.minTime) / this.snapDuration); // # of vertical snaps + }, + + + // Given a cell object, generates its start date. Returns a reference-free copy. + computeCellDate: function(cell) { + var time = this.computeSnapTime(cell.row); + + return this.view.calendar.rezoneDate(cell.day).time(time); + }, + + + // Retrieves the element representing the given column + getColEl: function(col) { + return this.dayEls.eq(col); + }, + + + /* Dates + ------------------------------------------------------------------------------------------------------------------*/ + + + // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day + computeSnapTime: function(row) { + return moment.duration(this.minTime + this.snapDuration * row); + }, + + + // Slices up a date range by column into an array of segments + rangeToSegs: function(range) { + var colCnt = this.colCnt; + var segs = []; + var seg; + var col; + var colDate; + var colRange; + + // normalize :( + range = { + start: range.start.clone().stripZone(), + end: range.end.clone().stripZone() + }; + + for (col = 0; col < colCnt; col++) { + colDate = this.colData[col].day; // will be ambig time/timezone + colRange = { + start: colDate.clone().time(this.minTime), + end: colDate.clone().time(this.maxTime) + }; + seg = intersectionToSeg(range, colRange); // both will be ambig timezone + if (seg) { + seg.col = col; + segs.push(seg); + } + } + + return segs; + }, + + + /* Coordinates + ------------------------------------------------------------------------------------------------------------------*/ + + + updateSize: function(isResize) { // NOT a standard Grid method + this.computeSlatTops(); + + if (isResize) { + this.updateSegVerticals(); + } + }, + + + // Computes the top/bottom coordinates of each "snap" rows + computeRowCoords: function() { + var originTop = this.el.offset().top; + var items = []; + var i; + var item; + + for (i = 0; i < this.rowCnt; i++) { + item = { + top: originTop + this.computeTimeTop(this.computeSnapTime(i)) + }; + if (i > 0) { + items[i - 1].bottom = item.top; + } + items.push(item); + } + item.bottom = item.top + this.computeTimeTop(this.computeSnapTime(i)); + + return items; + }, + + + // Computes the top coordinate, relative to the bounds of the grid, of the given date. + // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight. + computeDateTop: function(date, startOfDayDate) { + return this.computeTimeTop( + moment.duration( + date.clone().stripZone() - startOfDayDate.clone().stripTime() + ) + ); + }, + + + // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration). + computeTimeTop: function(time) { + var slatCoverage = (time - this.minTime) / this.slotDuration; // floating-point value of # of slots covered + var slatIndex; + var slatRemainder; + var slatTop; + var slatBottom; + + // constrain. because minTime/maxTime might be customized + slatCoverage = Math.max(0, slatCoverage); + slatCoverage = Math.min(this.slatEls.length, slatCoverage); + + slatIndex = Math.floor(slatCoverage); // an integer index of the furthest whole slot + slatRemainder = slatCoverage - slatIndex; + slatTop = this.slatTops[slatIndex]; // the top position of the furthest whole slot + + if (slatRemainder) { // time spans part-way into the slot + slatBottom = this.slatTops[slatIndex + 1]; + return slatTop + (slatBottom - slatTop) * slatRemainder; // part-way between slots + } + else { + return slatTop; + } + }, + + + // Queries each `slatEl` for its position relative to the grid's container and stores it in `slatTops`. + // Includes the the bottom of the last slat as the last item in the array. + computeSlatTops: function() { + var tops = []; + var top; + + this.slatEls.each(function(i, node) { + top = $(node).position().top; + tops.push(top); + }); + + tops.push(top + this.slatEls.last().outerHeight()); // bottom of the last slat + + this.slatTops = tops; + }, + + + /* Event Drag Visualization + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of an event being dragged over the specified date(s). + // dropLocation's end might be null, as well as `seg`. See Grid::renderDrag for more info. + // A returned value of `true` signals that a mock "helper" event has been rendered. + renderDrag: function(dropLocation, seg) { + + if (seg) { // if there is event information for this drag, render a helper event + this.renderRangeHelper(dropLocation, seg); + this.applyDragOpacity(this.helperEl); + + return true; // signal that a helper has been rendered + } + else { + // otherwise, just render a highlight + this.renderHighlight( + this.view.calendar.ensureVisibleEventRange(dropLocation) // needs to be a proper range + ); + } + }, + + + // Unrenders any visual indication of an event being dragged + destroyDrag: function() { + this.destroyHelper(); + this.destroyHighlight(); + }, + + + /* Event Resize Visualization + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of an event being resized + renderEventResize: function(range, seg) { + this.renderRangeHelper(range, seg); + }, + + + // Unrenders any visual indication of an event being resized + destroyEventResize: function() { + this.destroyHelper(); + }, + + + /* Event Helper + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag) + renderHelper: function(event, sourceSeg) { + var segs = this.eventsToSegs([ event ]); + var tableEl; + var i, seg; + var sourceEl; + + segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered + tableEl = this.renderSegTable(segs); + + // Try to make the segment that is in the same row as sourceSeg look the same + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + if (sourceSeg && sourceSeg.col === seg.col) { + sourceEl = sourceSeg.el; + seg.el.css({ + left: sourceEl.css('left'), + right: sourceEl.css('right'), + 'margin-left': sourceEl.css('margin-left'), + 'margin-right': sourceEl.css('margin-right') + }); + } + } + + this.helperEl = $('
') + .append(tableEl) + .appendTo(this.el); + }, + + + // Unrenders any mock helper event + destroyHelper: function() { + if (this.helperEl) { + this.helperEl.remove(); + this.helperEl = null; + } + }, + + + /* Selection + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight. + renderSelection: function(range) { + if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered + this.renderRangeHelper(range); + } + else { + this.renderHighlight(range); + } + }, + + + // Unrenders any visual indication of a selection + destroySelection: function() { + this.destroyHelper(); + this.destroyHighlight(); + }, + + + /* Fill System (highlight, background events, business hours) + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a set of rectangles over the given time segments. + // Only returns segments that successfully rendered. + renderFill: function(type, segs, className) { + var segCols; + var skeletonEl; + var trEl; + var col, colSegs; + var tdEl; + var containerEl; + var dayDate; + var i, seg; + + if (segs.length) { + + segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs + segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg + + className = className || type.toLowerCase(); + skeletonEl = $( + '
' + + '
' + + '
' + ); + trEl = skeletonEl.find('tr'); + + for (col = 0; col < segCols.length; col++) { + colSegs = segCols[col]; + tdEl = $('').appendTo(trEl); + + if (colSegs.length) { + containerEl = $('
').appendTo(tdEl); + dayDate = this.colData[col].day; + + for (i = 0; i < colSegs.length; i++) { + seg = colSegs[i]; + containerEl.append( + seg.el.css({ + top: this.computeDateTop(seg.start, dayDate), + bottom: -this.computeDateTop(seg.end, dayDate) // the y position of the bottom edge + }) + ); + } + } + } + + this.bookendCells(trEl, type); + + this.el.append(skeletonEl); + this.elsByFill[type] = skeletonEl; + } + + return segs; + } + +}); + +;; + +/* Event-rendering methods for the TimeGrid class +----------------------------------------------------------------------------------------------------------------------*/ + +TimeGrid.mixin({ + + eventSkeletonEl: null, // has cells with event-containers, which contain absolutely positioned event elements + + + // Renders the given foreground event segments onto the grid + renderFgSegs: function(segs) { + segs = this.renderFgSegEls(segs); // returns a subset of the segs. segs that were actually rendered + + this.el.append( + this.eventSkeletonEl = $('
') + .append(this.renderSegTable(segs)) + ); + + return segs; // return only the segs that were actually rendered + }, + + + // Unrenders all currently rendered foreground event segments + destroyFgSegs: function(segs) { + if (this.eventSkeletonEl) { + this.eventSkeletonEl.remove(); + this.eventSkeletonEl = null; + } + }, + + + // Renders and returns the portion of the event-skeleton. + // Returns an object with properties 'tbodyEl' and 'segs'. + renderSegTable: function(segs) { + var tableEl = $('
'); + var trEl = tableEl.find('tr'); + var segCols; + var i, seg; + var col, colSegs; + var containerEl; + + segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg + + this.computeSegVerticals(segs); // compute and assign top/bottom + + for (col = 0; col < segCols.length; col++) { // iterate each column grouping + colSegs = segCols[col]; + placeSlotSegs(colSegs); // compute horizontal coordinates, z-index's, and reorder the array + + containerEl = $('
'); + + // assign positioning CSS and insert into container + for (i = 0; i < colSegs.length; i++) { + seg = colSegs[i]; + seg.el.css(this.generateSegPositionCss(seg)); + + // if the height is short, add a className for alternate styling + if (seg.bottom - seg.top < 30) { + seg.el.addClass('fc-short'); + } + + containerEl.append(seg.el); + } + + trEl.append($('').append(containerEl)); + } + + this.bookendCells(trEl, 'eventSkeleton'); + + return tableEl; + }, + + + // Refreshes the CSS top/bottom coordinates for each segment element. Probably after a window resize/zoom. + // Repositions business hours segs too, so not just for events. Maybe shouldn't be here. + updateSegVerticals: function() { + var allSegs = (this.segs || []).concat(this.businessHourSegs || []); + var i; + + this.computeSegVerticals(allSegs); + + for (i = 0; i < allSegs.length; i++) { + allSegs[i].el.css( + this.generateSegVerticalCss(allSegs[i]) + ); + } + }, + + + // For each segment in an array, computes and assigns its top and bottom properties + computeSegVerticals: function(segs) { + var i, seg; + + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + seg.top = this.computeDateTop(seg.start, seg.start); + seg.bottom = this.computeDateTop(seg.end, seg.start); + } + }, + + + // Renders the HTML for a single event segment's default rendering + fgSegHtml: function(seg, disableResizing) { + var view = this.view; + var event = seg.event; + var isDraggable = view.isEventDraggable(event); + var isResizableFromStart = !disableResizing && seg.isStart && view.isEventResizableFromStart(event); + var isResizableFromEnd = !disableResizing && seg.isEnd && view.isEventResizableFromEnd(event); + var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); + var skinCss = cssToStr(this.getEventSkinCss(event)); + var timeText; + var fullTimeText; // more verbose time text. for the print stylesheet + var startTimeText; // just the start time text + + classes.unshift('fc-time-grid-event', 'fc-v-event'); + + if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day... + // Don't display time text on segments that run entirely through a day. + // That would appear as midnight-midnight and would look dumb. + // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am) + if (seg.isStart || seg.isEnd) { + timeText = this.getEventTimeText(seg); + fullTimeText = this.getEventTimeText(seg, 'LT'); + startTimeText = this.getEventTimeText(seg, null, false); // displayEnd=false + } + } else { + // Display the normal time text for the *event's* times + timeText = this.getEventTimeText(event); + fullTimeText = this.getEventTimeText(event, 'LT'); + startTimeText = this.getEventTimeText(event, null, false); // displayEnd=false + } + + return '' + + '
' + + (timeText ? + '
' + + '' + htmlEscape(timeText) + '' + + '
' : + '' + ) + + (event.title ? + '
' + + htmlEscape(event.title) + + '
' : + '' + ) + + '
' + + '
' + + /* TODO: write CSS for this + (isResizableFromStart ? + '
' : + '' + ) + + */ + (isResizableFromEnd ? + '
' : + '' + ) + + ''; + }, + + + // Generates an object with CSS properties/values that should be applied to an event segment element. + // Contains important positioning-related properties that should be applied to any event element, customized or not. + generateSegPositionCss: function(seg) { + var shouldOverlap = this.view.opt('slotEventOverlap'); + var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point + var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point + var props = this.generateSegVerticalCss(seg); // get top/bottom first + var left; // amount of space from left edge, a fraction of the total width + var right; // amount of space from right edge, a fraction of the total width + + if (shouldOverlap) { + // double the width, but don't go beyond the maximum forward coordinate (1.0) + forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2); + } + + if (this.isRTL) { + left = 1 - forwardCoord; + right = backwardCoord; + } + else { + left = backwardCoord; + right = 1 - forwardCoord; + } + + props.zIndex = seg.level + 1; // convert from 0-base to 1-based + props.left = left * 100 + '%'; + props.right = right * 100 + '%'; + + if (shouldOverlap && seg.forwardPressure) { + // add padding to the edge so that forward stacked events don't cover the resizer's icon + props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width + } + + return props; + }, + + + // Generates an object with CSS properties for the top/bottom coordinates of a segment element + generateSegVerticalCss: function(seg) { + return { + top: seg.top, + bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container + }; + }, + + + // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col + groupSegCols: function(segs) { + var segCols = []; + var i; + + for (i = 0; i < this.colCnt; i++) { + segCols.push([]); + } + + for (i = 0; i < segs.length; i++) { + segCols[segs[i].col].push(segs[i]); + } + + return segCols; + } + +}); + + +// Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each. +// NOTE: Also reorders the given array by date! +function placeSlotSegs(segs) { + var levels; + var level0; + var i; + + segs.sort(compareSegs); // order by date + levels = buildSlotSegLevels(segs); + computeForwardSlotSegs(levels); + + if ((level0 = levels[0])) { + + for (i = 0; i < level0.length; i++) { + computeSlotSegPressures(level0[i]); + } + + for (i = 0; i < level0.length; i++) { + computeSlotSegCoords(level0[i], 0, 0); + } + } +} + + +// Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is +// left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date. +function buildSlotSegLevels(segs) { + var levels = []; + var i, seg; + var j; + + for (i=0; i seg2.top && seg1.top < seg2.bottom; +} + + +// A cmp function for determining which forward segment to rely on more when computing coordinates. +function compareForwardSlotSegs(seg1, seg2) { + // put higher-pressure first + return seg2.forwardPressure - seg1.forwardPressure || + // put segments that are closer to initial edge first (and favor ones with no coords yet) + (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) || + // do normal sorting... + compareSegs(seg1, seg2); +} + +;; + +/* An abstract class from which other views inherit from +----------------------------------------------------------------------------------------------------------------------*/ + +var View = fc.View = Class.extend({ + + type: null, // subclass' view name (string) + name: null, // deprecated. use `type` instead + title: null, // the text that will be displayed in the header's title + + calendar: null, // owner Calendar object + options: null, // hash containing all options. already merged with view-specific-options + coordMap: null, // a CoordMap object for converting pixel regions to dates + el: null, // the view's containing element. set by Calendar + + isDisplayed: false, + isSkeletonRendered: false, + isEventsRendered: false, + + // range the view is actually displaying (moments) + start: null, + end: null, // exclusive + + // range the view is formally responsible for (moments) + // may be different from start/end. for example, a month view might have 1st-31st, excluding padded dates + intervalStart: null, + intervalEnd: null, // exclusive + intervalDuration: null, + intervalUnit: null, // name of largest unit being displayed, like "month" or "week" + + isSelected: false, // boolean whether a range of time is user-selected or not + + // subclasses can optionally use a scroll container + scrollerEl: null, // the element that will most likely scroll when content is too tall + scrollTop: null, // cached vertical scroll value + + // classNames styled by jqui themes + widgetHeaderClass: null, + widgetContentClass: null, + highlightStateClass: null, + + // for date utils, computed from options + nextDayThreshold: null, + isHiddenDayHash: null, + + // document handlers, bound to `this` object + documentMousedownProxy: null, // TODO: doesn't work with touch + + + constructor: function(calendar, type, options, intervalDuration) { + + this.calendar = calendar; + this.type = this.name = type; // .name is deprecated + this.options = options; + this.intervalDuration = intervalDuration || moment.duration(1, 'day'); + + this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold')); + this.initThemingProps(); + this.initHiddenDays(); + + this.documentMousedownProxy = proxy(this, 'documentMousedown'); + + this.initialize(); + }, + + + // A good place for subclasses to initialize member variables + initialize: function() { + // subclasses can implement + }, + + + // Retrieves an option with the given name + opt: function(name) { + return this.options[name]; + }, + + + // Triggers handlers that are view-related. Modifies args before passing to calendar. + trigger: function(name, thisObj) { // arguments beyond thisObj are passed along + var calendar = this.calendar; + + return calendar.trigger.apply( + calendar, + [name, thisObj || this].concat( + Array.prototype.slice.call(arguments, 2), // arguments beyond thisObj + [ this ] // always make the last argument a reference to the view. TODO: deprecate + ) + ); + }, + + + /* Dates + ------------------------------------------------------------------------------------------------------------------*/ + + + // Updates all internal dates to center around the given current date + setDate: function(date) { + this.setRange(this.computeRange(date)); + }, + + + // Updates all internal dates for displaying the given range. + // Expects all values to be normalized (like what computeRange does). + setRange: function(range) { + $.extend(this, range); + this.updateTitle(); + }, + + + // Given a single current date, produce information about what range to display. + // Subclasses can override. Must return all properties. + computeRange: function(date) { + var intervalUnit = computeIntervalUnit(this.intervalDuration); + var intervalStart = date.clone().startOf(intervalUnit); + var intervalEnd = intervalStart.clone().add(this.intervalDuration); + var start, end; + + // normalize the range's time-ambiguity + if (/year|month|week|day/.test(intervalUnit)) { // whole-days? + intervalStart.stripTime(); + intervalEnd.stripTime(); + } + else { // needs to have a time? + if (!intervalStart.hasTime()) { + intervalStart = this.calendar.rezoneDate(intervalStart); // convert to current timezone, with 00:00 + } + if (!intervalEnd.hasTime()) { + intervalEnd = this.calendar.rezoneDate(intervalEnd); // convert to current timezone, with 00:00 + } + } + + start = intervalStart.clone(); + start = this.skipHiddenDays(start); + end = intervalEnd.clone(); + end = this.skipHiddenDays(end, -1, true); // exclusively move backwards + + return { + intervalUnit: intervalUnit, + intervalStart: intervalStart, + intervalEnd: intervalEnd, + start: start, + end: end + }; + }, + + + // Computes the new date when the user hits the prev button, given the current date + computePrevDate: function(date) { + return this.massageCurrentDate( + date.clone().startOf(this.intervalUnit).subtract(this.intervalDuration), -1 + ); + }, + + + // Computes the new date when the user hits the next button, given the current date + computeNextDate: function(date) { + return this.massageCurrentDate( + date.clone().startOf(this.intervalUnit).add(this.intervalDuration) + ); + }, + + + // Given an arbitrarily calculated current date of the calendar, returns a date that is ensured to be completely + // visible. `direction` is optional and indicates which direction the current date was being + // incremented or decremented (1 or -1). + massageCurrentDate: function(date, direction) { + if (this.intervalDuration.as('days') <= 1) { // if the view displays a single day or smaller + if (this.isHiddenDay(date)) { + date = this.skipHiddenDays(date, direction); + date.startOf('day'); + } + } + + return date; + }, + + + /* Title and Date Formatting + ------------------------------------------------------------------------------------------------------------------*/ + + + // Sets the view's title property to the most updated computed value + updateTitle: function() { + this.title = this.computeTitle(); + }, + + + // Computes what the title at the top of the calendar should be for this view + computeTitle: function() { + return this.formatRange( + { start: this.intervalStart, end: this.intervalEnd }, + this.opt('titleFormat') || this.computeTitleFormat(), + this.opt('titleRangeSeparator') + ); + }, + + + // Generates the format string that should be used to generate the title for the current date range. + // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`. + computeTitleFormat: function() { + if (this.intervalUnit == 'year') { + return 'YYYY'; + } + else if (this.intervalUnit == 'month') { + return this.opt('monthYearFormat'); // like "September 2014" + } + else if (this.intervalDuration.as('days') > 1) { + return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014" + } + else { + return 'LL'; // one day. longer, like "September 9 2014" + } + }, + + + // Utility for formatting a range. Accepts a range object, formatting string, and optional separator. + // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account. + formatRange: function(range, formatStr, separator) { + var end = range.end; + + if (!end.hasTime()) { // all-day? + end = end.clone().subtract(1); // convert to inclusive. last ms of previous day + } + + return formatRange(range.start, end, formatStr, separator, this.opt('isRTL')); + }, + + + /* Rendering + ------------------------------------------------------------------------------------------------------------------*/ + + + // Sets the container element that the view should render inside of. + // Does other DOM-related initializations. + setElement: function(el) { + this.el = el; + this.bindGlobalHandlers(); + }, + + + // Removes the view's container element from the DOM, clearing any content beforehand. + // Undoes any other DOM-related attachments. + removeElement: function() { + this.clear(); // clears all content + + // clean up the skeleton + if (this.isSkeletonRendered) { + this.destroySkeleton(); + this.isSkeletonRendered = false; + } + + this.unbindGlobalHandlers(); + + this.el.remove(); + + // NOTE: don't null-out this.el in case the View was destroyed within an API callback. + // We don't null-out the View's other jQuery element references upon destroy, so why should we kill this.el? + }, + + + // Does everything necessary to display the view centered around the given date. + // Does every type of rendering EXCEPT rendering events. + display: function(date) { + var scrollState = null; + + if (this.isDisplayed) { + scrollState = this.queryScroll(); + } + + this.clear(); // clear the old content + this.setDate(date); + this.render(); + this.updateSize(); + this.renderBusinessHours(); // might need coordinates, so should go after updateSize() + this.isDisplayed = true; + + scrollState = this.computeInitialScroll(scrollState); + this.forceScroll(scrollState); + + this.triggerRender(); + }, + + + // Does everything necessary to clear the content of the view. + // Clears dates and events. Does not clear the skeleton. + clear: function() { // clears the view of *content* but not the skeleton + if (this.isDisplayed) { + this.unselect(); + this.clearEvents(); + this.triggerDestroy(); + this.destroyBusinessHours(); + this.destroy(); + this.isDisplayed = false; + } + }, + + + // Renders the view's date-related content, rendering the view's non-content skeleton if necessary + render: function() { + if (!this.isSkeletonRendered) { + this.renderSkeleton(); + this.isSkeletonRendered = true; + } + this.renderDates(); + }, + + + // Unrenders the view's date-related content. + // Call this instead of destroyDates directly in case the View subclass wants to use a render/destroy pattern + // where both the skeleton and the content always get rendered/unrendered together. + destroy: function() { + this.destroyDates(); + }, + + + // Renders the basic structure of the view before any content is rendered + renderSkeleton: function() { + // subclasses should implement + }, + + + // Unrenders the basic structure of the view + destroySkeleton: function() { + // subclasses should implement + }, + + + // Renders the view's date-related content (like cells that represent days/times). + // Assumes setRange has already been called and the skeleton has already been rendered. + renderDates: function() { + // subclasses should implement + }, + + + // Unrenders the view's date-related content + destroyDates: function() { + // subclasses should override + }, + + + // Renders business-hours onto the view. Assumes updateSize has already been called. + renderBusinessHours: function() { + // subclasses should implement + }, + + + // Unrenders previously-rendered business-hours + destroyBusinessHours: function() { + // subclasses should implement + }, + + + // Signals that the view's content has been rendered + triggerRender: function() { + this.trigger('viewRender', this, this, this.el); + }, + + + // Signals that the view's content is about to be unrendered + triggerDestroy: function() { + this.trigger('viewDestroy', this, this, this.el); + }, + + + // Binds DOM handlers to elements that reside outside the view container, such as the document + bindGlobalHandlers: function() { + $(document).on('mousedown', this.documentMousedownProxy); + }, + + + // Unbinds DOM handlers from elements that reside outside the view container + unbindGlobalHandlers: function() { + $(document).off('mousedown', this.documentMousedownProxy); + }, + + + // Initializes internal variables related to theming + initThemingProps: function() { + var tm = this.opt('theme') ? 'ui' : 'fc'; + + this.widgetHeaderClass = tm + '-widget-header'; + this.widgetContentClass = tm + '-widget-content'; + this.highlightStateClass = tm + '-state-highlight'; + }, + + + /* Dimensions + ------------------------------------------------------------------------------------------------------------------*/ + + + // Refreshes anything dependant upon sizing of the container element of the grid + updateSize: function(isResize) { + var scrollState; + + if (isResize) { + scrollState = this.queryScroll(); + } + + this.updateHeight(); + this.updateWidth(); + + if (isResize) { + this.setScroll(scrollState); + } + }, + + + // Refreshes the horizontal dimensions of the calendar + updateWidth: function() { + // subclasses should implement + }, + + + // Refreshes the vertical dimensions of the calendar + updateHeight: function() { + var calendar = this.calendar; // we poll the calendar for height information + + this.setHeight( + calendar.getSuggestedViewHeight(), + calendar.isHeightAuto() + ); + }, + + + // Updates the vertical dimensions of the calendar to the specified height. + // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height. + setHeight: function(height, isAuto) { + // subclasses should implement + }, + + + /* Scroller + ------------------------------------------------------------------------------------------------------------------*/ + + + // Given the total height of the view, return the number of pixels that should be used for the scroller. + // Utility for subclasses. + computeScrollerHeight: function(totalHeight) { + var scrollerEl = this.scrollerEl; + var both; + var otherHeight; // cumulative height of everything that is not the scrollerEl in the view (header+borders) + + both = this.el.add(scrollerEl); + + // fuckin IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked + both.css({ + position: 'relative', // cause a reflow, which will force fresh dimension recalculation + left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll + }); + otherHeight = this.el.outerHeight() - scrollerEl.height(); // grab the dimensions + both.css({ position: '', left: '' }); // undo hack + + return totalHeight - otherHeight; + }, + + + // Computes the initial pre-configured scroll state prior to allowing the user to change it. + // Given the scroll state from the previous rendering. If first time rendering, given null. + computeInitialScroll: function(previousScrollState) { + return 0; + }, + + + // Retrieves the view's current natural scroll state. Can return an arbitrary format. + queryScroll: function() { + if (this.scrollerEl) { + return this.scrollerEl.scrollTop(); // operates on scrollerEl by default + } + }, + + + // Sets the view's scroll state. Will accept the same format computeInitialScroll and queryScroll produce. + setScroll: function(scrollState) { + if (this.scrollerEl) { + return this.scrollerEl.scrollTop(scrollState); // operates on scrollerEl by default + } + }, + + + // Sets the scroll state, making sure to overcome any predefined scroll value the browser has in mind + forceScroll: function(scrollState) { + var _this = this; + + this.setScroll(scrollState); + setTimeout(function() { + _this.setScroll(scrollState); + }, 0); + }, + + + /* Event Elements / Segments + ------------------------------------------------------------------------------------------------------------------*/ + + + // Does everything necessary to display the given events onto the current view + displayEvents: function(events) { + var scrollState = this.queryScroll(); + + this.clearEvents(); + this.renderEvents(events); + this.isEventsRendered = true; + this.setScroll(scrollState); + this.triggerEventRender(); + }, + + + // Does everything necessary to clear the view's currently-rendered events + clearEvents: function() { + if (this.isEventsRendered) { + this.triggerEventDestroy(); + this.destroyEvents(); + this.isEventsRendered = false; + } + }, + + + // Renders the events onto the view. + renderEvents: function(events) { + // subclasses should implement + }, + + + // Removes event elements from the view. + destroyEvents: function() { + // subclasses should implement + }, + + + // Signals that all events have been rendered + triggerEventRender: function() { + this.renderedEventSegEach(function(seg) { + this.trigger('eventAfterRender', seg.event, seg.event, seg.el); + }); + this.trigger('eventAfterAllRender'); + }, + + + // Signals that all event elements are about to be removed + triggerEventDestroy: function() { + this.renderedEventSegEach(function(seg) { + this.trigger('eventDestroy', seg.event, seg.event, seg.el); + }); + }, + + + // Given an event and the default element used for rendering, returns the element that should actually be used. + // Basically runs events and elements through the eventRender hook. + resolveEventEl: function(event, el) { + var custom = this.trigger('eventRender', event, event, el); + + if (custom === false) { // means don't render at all + el = null; + } + else if (custom && custom !== true) { + el = $(custom); + } + + return el; + }, + + + // Hides all rendered event segments linked to the given event + showEvent: function(event) { + this.renderedEventSegEach(function(seg) { + seg.el.css('visibility', ''); + }, event); + }, + + + // Shows all rendered event segments linked to the given event + hideEvent: function(event) { + this.renderedEventSegEach(function(seg) { + seg.el.css('visibility', 'hidden'); + }, event); + }, + + + // Iterates through event segments that have been rendered (have an el). Goes through all by default. + // If the optional `event` argument is specified, only iterates through segments linked to that event. + // The `this` value of the callback function will be the view. + renderedEventSegEach: function(func, event) { + var segs = this.getEventSegs(); + var i; + + for (i = 0; i < segs.length; i++) { + if (!event || segs[i].event._id === event._id) { + if (segs[i].el) { + func.call(this, segs[i]); + } + } + } + }, + + + // Retrieves all the rendered segment objects for the view + getEventSegs: function() { + // subclasses must implement + return []; + }, + + + /* Event Drag-n-Drop + ------------------------------------------------------------------------------------------------------------------*/ + + + // Computes if the given event is allowed to be dragged by the user + isEventDraggable: function(event) { + var source = event.source || {}; + + return firstDefined( + event.startEditable, + source.startEditable, + this.opt('eventStartEditable'), + event.editable, + source.editable, + this.opt('editable') + ); + }, + + + // Must be called when an event in the view is dropped onto new location. + // `dropLocation` is an object that contains the new start/end/allDay values for the event. + reportEventDrop: function(event, dropLocation, largeUnit, el, ev) { + var calendar = this.calendar; + var mutateResult = calendar.mutateEvent(event, dropLocation, largeUnit); + var undoFunc = function() { + mutateResult.undo(); + calendar.reportEventChange(); + }; + + this.triggerEventDrop(event, mutateResult.dateDelta, undoFunc, el, ev); + calendar.reportEventChange(); // will rerender events + }, + + + // Triggers event-drop handlers that have subscribed via the API + triggerEventDrop: function(event, dateDelta, undoFunc, el, ev) { + this.trigger('eventDrop', el[0], event, dateDelta, undoFunc, ev, {}); // {} = jqui dummy + }, + + + /* External Element Drag-n-Drop + ------------------------------------------------------------------------------------------------------------------*/ + + + // Must be called when an external element, via jQuery UI, has been dropped onto the calendar. + // `meta` is the parsed data that has been embedded into the dragging event. + // `dropLocation` is an object that contains the new start/end/allDay values for the event. + reportExternalDrop: function(meta, dropLocation, el, ev, ui) { + var eventProps = meta.eventProps; + var eventInput; + var event; + + // Try to build an event object and render it. TODO: decouple the two + if (eventProps) { + eventInput = $.extend({}, eventProps, dropLocation); + event = this.calendar.renderEvent(eventInput, meta.stick)[0]; // renderEvent returns an array + } + + this.triggerExternalDrop(event, dropLocation, el, ev, ui); + }, + + + // Triggers external-drop handlers that have subscribed via the API + triggerExternalDrop: function(event, dropLocation, el, ev, ui) { + + // trigger 'drop' regardless of whether element represents an event + this.trigger('drop', el[0], dropLocation.start, ev, ui); + + if (event) { + this.trigger('eventReceive', null, event); // signal an external event landed + } + }, + + + /* Drag-n-Drop Rendering (for both events and external elements) + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of a event or external-element drag over the given drop zone. + // If an external-element, seg will be `null` + renderDrag: function(dropLocation, seg) { + // subclasses must implement + }, + + + // Unrenders a visual indication of an event or external-element being dragged. + destroyDrag: function() { + // subclasses must implement + }, + + + /* Event Resizing + ------------------------------------------------------------------------------------------------------------------*/ + + + // Computes if the given event is allowed to be resized from its starting edge + isEventResizableFromStart: function(event) { + return this.opt('eventResizableFromStart') && this.isEventResizable(event); + }, + + + // Computes if the given event is allowed to be resized from its ending edge + isEventResizableFromEnd: function(event) { + return this.isEventResizable(event); + }, + + + // Computes if the given event is allowed to be resized by the user at all + isEventResizable: function(event) { + var source = event.source || {}; + + return firstDefined( + event.durationEditable, + source.durationEditable, + this.opt('eventDurationEditable'), + event.editable, + source.editable, + this.opt('editable') + ); + }, + + + // Must be called when an event in the view has been resized to a new length + reportEventResize: function(event, resizeLocation, largeUnit, el, ev) { + var calendar = this.calendar; + var mutateResult = calendar.mutateEvent(event, resizeLocation, largeUnit); + var undoFunc = function() { + mutateResult.undo(); + calendar.reportEventChange(); + }; + + this.triggerEventResize(event, mutateResult.durationDelta, undoFunc, el, ev); + calendar.reportEventChange(); // will rerender events + }, + + + // Triggers event-resize handlers that have subscribed via the API + triggerEventResize: function(event, durationDelta, undoFunc, el, ev) { + this.trigger('eventResize', el[0], event, durationDelta, undoFunc, ev, {}); // {} = jqui dummy + }, + + + /* Selection + ------------------------------------------------------------------------------------------------------------------*/ + + + // Selects a date range on the view. `start` and `end` are both Moments. + // `ev` is the native mouse event that begin the interaction. + select: function(range, ev) { + this.unselect(ev); + this.renderSelection(range); + this.reportSelection(range, ev); + }, + + + // Renders a visual indication of the selection + renderSelection: function(range) { + // subclasses should implement + }, + + + // Called when a new selection is made. Updates internal state and triggers handlers. + reportSelection: function(range, ev) { + this.isSelected = true; + this.trigger('select', null, range.start, range.end, ev); + }, + + + // Undoes a selection. updates in the internal state and triggers handlers. + // `ev` is the native mouse event that began the interaction. + unselect: function(ev) { + if (this.isSelected) { + this.isSelected = false; + this.destroySelection(); + this.trigger('unselect', null, ev); + } + }, + + + // Unrenders a visual indication of selection + destroySelection: function() { + // subclasses should implement + }, + + + // Handler for unselecting when the user clicks something and the 'unselectAuto' setting is on + documentMousedown: function(ev) { + var ignore; + + // is there a selection, and has the user made a proper left click? + if (this.isSelected && this.opt('unselectAuto') && isPrimaryMouseButton(ev)) { + + // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element + ignore = this.opt('unselectCancel'); + if (!ignore || !$(ev.target).closest(ignore).length) { + this.unselect(ev); + } + } + }, + + + /* Date Utils + ------------------------------------------------------------------------------------------------------------------*/ + + + // Initializes internal variables related to calculating hidden days-of-week + initHiddenDays: function() { + var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden + var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool) + var dayCnt = 0; + var i; + + if (this.opt('weekends') === false) { + hiddenDays.push(0, 6); // 0=sunday, 6=saturday + } + + for (i = 0; i < 7; i++) { + if ( + !(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1) + ) { + dayCnt++; + } + } + + if (!dayCnt) { + throw 'invalid hiddenDays'; // all days were hidden? bad. + } + + this.isHiddenDayHash = isHiddenDayHash; + }, + + + // Is the current day hidden? + // `day` is a day-of-week index (0-6), or a Moment + isHiddenDay: function(day) { + if (moment.isMoment(day)) { + day = day.day(); + } + return this.isHiddenDayHash[day]; + }, + + + // Incrementing the current day until it is no longer a hidden day, returning a copy. + // If the initial value of `date` is not a hidden day, don't do anything. + // Pass `isExclusive` as `true` if you are dealing with an end date. + // `inc` defaults to `1` (increment one day forward each time) + skipHiddenDays: function(date, inc, isExclusive) { + var out = date.clone(); + inc = inc || 1; + while ( + this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7] + ) { + out.add(inc, 'days'); + } + return out; + }, + + + // Returns the date range of the full days the given range visually appears to occupy. + // Returns a new range object. + computeDayRange: function(range) { + var startDay = range.start.clone().stripTime(); // the beginning of the day the range starts + var end = range.end; + var endDay = null; + var endTimeMS; + + if (end) { + endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends + endTimeMS = +end.time(); // # of milliseconds into `endDay` + + // If the end time is actually inclusively part of the next day and is equal to or + // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`. + // Otherwise, leaving it as inclusive will cause it to exclude `endDay`. + if (endTimeMS && endTimeMS >= this.nextDayThreshold) { + endDay.add(1, 'days'); + } + } + + // If no end was specified, or if it is within `startDay` but not past nextDayThreshold, + // assign the default duration of one day. + if (!end || endDay <= startDay) { + endDay = startDay.clone().add(1, 'days'); + } + + return { start: startDay, end: endDay }; + }, + + + // Does the given event visually appear to occupy more than one day? + isMultiDayEvent: function(event) { + var range = this.computeDayRange(event); // event is range-ish + + return range.end.diff(range.start, 'days') > 1; + } + +}); + +;; + +var Calendar = fc.Calendar = fc.CalendarBase = Class.extend({ + + dirDefaults: null, // option defaults related to LTR or RTL + langDefaults: null, // option defaults related to current locale + overrides: null, // option overrides given to the fullCalendar constructor + options: null, // all defaults combined with overrides + viewSpecCache: null, // cache of view definitions + view: null, // current View object + header: null, + + + // a lot of this class' OOP logic is scoped within this constructor function, + // but in the future, write individual methods on the prototype. + constructor: Calendar_constructor, + + + // Initializes `this.options` and other important options-related objects + initOptions: function(overrides) { + var lang, langDefaults; + var isRTL, dirDefaults; + + // converts legacy options into non-legacy ones. + // in the future, when this is removed, don't use `overrides` reference. make a copy. + overrides = massageOverrides(overrides); + + lang = overrides.lang; + langDefaults = langOptionHash[lang]; + if (!langDefaults) { + lang = Calendar.defaults.lang; + langDefaults = langOptionHash[lang] || {}; + } + + isRTL = firstDefined( + overrides.isRTL, + langDefaults.isRTL, + Calendar.defaults.isRTL + ); + dirDefaults = isRTL ? Calendar.rtlDefaults : {}; + + this.dirDefaults = dirDefaults; + this.langDefaults = langDefaults; + this.overrides = overrides; + this.options = mergeOptions( // merge defaults and overrides. lowest to highest precedence + Calendar.defaults, // global defaults + dirDefaults, + langDefaults, + overrides + ); + populateInstanceComputableOptions(this.options); + + this.viewSpecCache = {}; // somewhat unrelated + }, + + + // Gets information about how to create a view. Will use a cache. + getViewSpec: function(viewType) { + var cache = this.viewSpecCache; + + return cache[viewType] || (cache[viewType] = this.buildViewSpec(viewType)); + }, + + + // Given a duration singular unit, like "week" or "day", finds a matching view spec. + // Preference is given to views that have corresponding buttons. + getUnitViewSpec: function(unit) { + var viewTypes; + var i; + var spec; + + if ($.inArray(unit, intervalUnits) != -1) { + + // put views that have buttons first. there will be duplicates, but oh well + viewTypes = this.header.getViewsWithButtons(); + $.each(fc.views, function(viewType) { // all views + viewTypes.push(viewType); + }); + + for (i = 0; i < viewTypes.length; i++) { + spec = this.getViewSpec(viewTypes[i]); + if (spec) { + if (spec.singleUnit == unit) { + return spec; + } + } + } + } + }, + + + // Builds an object with information on how to create a given view + buildViewSpec: function(requestedViewType) { + var viewOverrides = this.overrides.views || {}; + var defaultsChain = []; // for the view. lowest to highest priority + var overridesChain = []; // for the view. lowest to highest priority + var viewType = requestedViewType; + var viewClass; + var defaults; // for the view + var overrides; // for the view + var duration; + var unit; + var spec; + + // iterate from the specific view definition to a more general one until we hit an actual View class + while (viewType && !viewClass) { + defaults = fcViews[viewType] || {}; + overrides = viewOverrides[viewType] || {}; + duration = duration || overrides.duration || defaults.duration; + viewType = overrides.type || defaults.type; // for next iteration + + if (typeof defaults === 'function') { // a class + viewClass = defaults; + defaultsChain.unshift(viewClass.defaults || {}); + } + else { // an options object + defaultsChain.unshift(defaults); + } + overridesChain.unshift(overrides); + } + + if (viewClass) { + spec = { 'class': viewClass, type: requestedViewType }; + + if (duration) { + duration = moment.duration(duration); + if (!duration.valueOf()) { // invalid? + duration = null; + } + } + if (duration) { + spec.duration = duration; + unit = computeIntervalUnit(duration); + + // view is a single-unit duration, like "week" or "day" + // incorporate options for this. lowest priority + if (duration.as(unit) === 1) { + spec.singleUnit = unit; + overridesChain.unshift(viewOverrides[unit] || {}); + } + } + + // collapse into single objects + spec.defaults = mergeOptions.apply(null, defaultsChain); + spec.overrides = mergeOptions.apply(null, overridesChain); + + this.buildViewSpecOptions(spec); + this.buildViewSpecButtonText(spec, requestedViewType); + + return spec; + } + }, + + + // Builds and assigns a view spec's options object from its already-assigned defaults and overrides + buildViewSpecOptions: function(spec) { + spec.options = mergeOptions( // lowest to highest priority + Calendar.defaults, // global defaults + spec.defaults, // view's defaults (from ViewSubclass.defaults) + this.dirDefaults, + this.langDefaults, // locale and dir take precedence over view's defaults! + this.overrides, // calendar's overrides (options given to constructor) + spec.overrides // view's overrides (view-specific options) + ); + populateInstanceComputableOptions(spec.options); + }, + + + // Computes and assigns a view spec's buttonText-related options + buildViewSpecButtonText: function(spec, requestedViewType) { + + // given an options object with a possible `buttonText` hash, lookup the buttonText for the + // requested view, falling back to a generic unit entry like "week" or "day" + function queryButtonText(options) { + var buttonText = options.buttonText || {}; + return buttonText[requestedViewType] || + (spec.singleUnit ? buttonText[spec.singleUnit] : null); + } + + // highest to lowest priority + spec.buttonTextOverride = + queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence + spec.overrides.buttonText; // `buttonText` for view-specific options is a string + + // highest to lowest priority. mirrors buildViewSpecOptions + spec.buttonTextDefault = + queryButtonText(this.langDefaults) || + queryButtonText(this.dirDefaults) || + spec.defaults.buttonText || // a single string. from ViewSubclass.defaults + queryButtonText(Calendar.defaults) || + (spec.duration ? this.humanizeDuration(spec.duration) : null) || // like "3 days" + requestedViewType; // fall back to given view name + }, + + + // Given a view name for a custom view or a standard view, creates a ready-to-go View object + instantiateView: function(viewType) { + var spec = this.getViewSpec(viewType); + + return new spec['class'](this, viewType, spec.options, spec.duration); + }, + + + // Returns a boolean about whether the view is okay to instantiate at some point + isValidViewType: function(viewType) { + return Boolean(this.getViewSpec(viewType)); + } + +}); + + +function Calendar_constructor(element, overrides) { + var t = this; + + + t.initOptions(overrides || {}); + var options = this.options; + + + // Exports + // ----------------------------------------------------------------------------------- + + t.render = render; + t.destroy = destroy; + t.refetchEvents = refetchEvents; + t.reportEvents = reportEvents; + t.reportEventChange = reportEventChange; + t.rerenderEvents = renderEvents; // `renderEvents` serves as a rerender. an API method + t.changeView = renderView; // `renderView` will switch to another view + t.select = select; + t.unselect = unselect; + t.prev = prev; + t.next = next; + t.prevYear = prevYear; + t.nextYear = nextYear; + t.today = today; + t.gotoDate = gotoDate; + t.incrementDate = incrementDate; + t.zoomTo = zoomTo; + t.getDate = getDate; + t.getCalendar = getCalendar; + t.getView = getView; + t.option = option; + t.trigger = trigger; + + + + // Language-data Internals + // ----------------------------------------------------------------------------------- + // Apply overrides to the current language's data + + + var localeData = createObject( // make a cheap copy + getMomentLocaleData(options.lang) // will fall back to en + ); + + if (options.monthNames) { + localeData._months = options.monthNames; + } + if (options.monthNamesShort) { + localeData._monthsShort = options.monthNamesShort; + } + if (options.dayNames) { + localeData._weekdays = options.dayNames; + } + if (options.dayNamesShort) { + localeData._weekdaysShort = options.dayNamesShort; + } + if (options.firstDay != null) { + var _week = createObject(localeData._week); // _week: { dow: # } + _week.dow = options.firstDay; + localeData._week = _week; + } + + // assign a normalized value, to be used by our .week() moment extension + localeData._fullCalendar_weekCalc = (function(weekCalc) { + if (typeof weekCalc === 'function') { + return weekCalc; + } + else if (weekCalc === 'local') { + return weekCalc; + } + else if (weekCalc === 'iso' || weekCalc === 'ISO') { + return 'ISO'; + } + })(options.weekNumberCalculation); + + + + // Calendar-specific Date Utilities + // ----------------------------------------------------------------------------------- + + + t.defaultAllDayEventDuration = moment.duration(options.defaultAllDayEventDuration); + t.defaultTimedEventDuration = moment.duration(options.defaultTimedEventDuration); + + + // Builds a moment using the settings of the current calendar: timezone and language. + // Accepts anything the vanilla moment() constructor accepts. + t.moment = function() { + var mom; + + if (options.timezone === 'local') { + mom = fc.moment.apply(null, arguments); + + // Force the moment to be local, because fc.moment doesn't guarantee it. + if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone + mom.local(); + } + } + else if (options.timezone === 'UTC') { + mom = fc.moment.utc.apply(null, arguments); // process as UTC + } + else { + mom = fc.moment.parseZone.apply(null, arguments); // let the input decide the zone + } + + if ('_locale' in mom) { // moment 2.8 and above + mom._locale = localeData; + } + else { // pre-moment-2.8 + mom._lang = localeData; + } + + return mom; + }; + + + // Returns a boolean about whether or not the calendar knows how to calculate + // the timezone offset of arbitrary dates in the current timezone. + t.getIsAmbigTimezone = function() { + return options.timezone !== 'local' && options.timezone !== 'UTC'; + }; + + + // Returns a copy of the given date in the current timezone of it is ambiguously zoned. + // This will also give the date an unambiguous time. + t.rezoneDate = function(date) { + return t.moment(date.toArray()); + }; + + + // Returns a moment for the current date, as defined by the client's computer, + // or overridden by the `now` option. + t.getNow = function() { + var now = options.now; + if (typeof now === 'function') { + now = now(); + } + return t.moment(now); + }; + + + // Get an event's normalized end date. If not present, calculate it from the defaults. + t.getEventEnd = function(event) { + if (event.end) { + return event.end.clone(); + } + else { + return t.getDefaultEventEnd(event.allDay, event.start); + } + }; + + + // Given an event's allDay status and start date, return swhat its fallback end date should be. + t.getDefaultEventEnd = function(allDay, start) { // TODO: rename to computeDefaultEventEnd + var end = start.clone(); + + if (allDay) { + end.stripTime().add(t.defaultAllDayEventDuration); + } + else { + end.add(t.defaultTimedEventDuration); + } + + if (t.getIsAmbigTimezone()) { + end.stripZone(); // we don't know what the tzo should be + } + + return end; + }; + + + // Produces a human-readable string for the given duration. + // Side-effect: changes the locale of the given duration. + t.humanizeDuration = function(duration) { + return (duration.locale || duration.lang).call(duration, options.lang) // works moment-pre-2.8 + .humanize(); + }; + + + + // Imports + // ----------------------------------------------------------------------------------- + + + EventManager.call(t, options); + var isFetchNeeded = t.isFetchNeeded; + var fetchEvents = t.fetchEvents; + + + + // Locals + // ----------------------------------------------------------------------------------- + + + var _element = element[0]; + var header; + var headerElement; + var content; + var tm; // for making theme classes + var currentView; // NOTE: keep this in sync with this.view + var viewsByType = {}; // holds all instantiated view instances, current or not + var suggestedViewHeight; + var windowResizeProxy; // wraps the windowResize function + var ignoreWindowResize = 0; + var date; + var events = []; + + + + // Main Rendering + // ----------------------------------------------------------------------------------- + + + if (options.defaultDate != null) { + date = t.moment(options.defaultDate); + } + else { + date = t.getNow(); + } + + + function render() { + if (!content) { + initialRender(); + } + else if (elementVisible()) { + // mainly for the public API + calcSize(); + renderView(); + } + } + + + function initialRender() { + tm = options.theme ? 'ui' : 'fc'; + element.addClass('fc'); + + if (options.isRTL) { + element.addClass('fc-rtl'); + } + else { + element.addClass('fc-ltr'); + } + + if (options.theme) { + element.addClass('ui-widget'); + } + else { + element.addClass('fc-unthemed'); + } + + content = $("
").prependTo(element); + + header = t.header = new Header(t, options); + headerElement = header.render(); + if (headerElement) { + element.prepend(headerElement); + } + + renderView(options.defaultView); + + if (options.handleWindowResize) { + windowResizeProxy = debounce(windowResize, options.windowResizeDelay); // prevents rapid calls + $(window).resize(windowResizeProxy); + } + } + + + function destroy() { + + if (currentView) { + currentView.removeElement(); + + // NOTE: don't null-out currentView/t.view in case API methods are called after destroy. + // It is still the "current" view, just not rendered. + } + + header.destroy(); + content.remove(); + element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget'); + + if (windowResizeProxy) { + $(window).unbind('resize', windowResizeProxy); + } + } + + + function elementVisible() { + return element.is(':visible'); + } + + + + // View Rendering + // ----------------------------------------------------------------------------------- + + + // Renders a view because of a date change, view-type change, or for the first time. + // If not given a viewType, keep the current view but render different dates. + function renderView(viewType) { + ignoreWindowResize++; + + // if viewType is changing, destroy the old view + if (currentView && viewType && currentView.type !== viewType) { + header.deactivateButton(currentView.type); + freezeContentHeight(); // prevent a scroll jump when view element is removed + currentView.removeElement(); + currentView = t.view = null; + } + + // if viewType changed, or the view was never created, create a fresh view + if (!currentView && viewType) { + currentView = t.view = + viewsByType[viewType] || + (viewsByType[viewType] = t.instantiateView(viewType)); + + currentView.setElement( + $("
").appendTo(content) + ); + header.activateButton(viewType); + } + + if (currentView) { + + // in case the view should render a period of time that is completely hidden + date = currentView.massageCurrentDate(date); + + // render or rerender the view + if ( + !currentView.isDisplayed || + !date.isWithin(currentView.intervalStart, currentView.intervalEnd) // implicit date window change + ) { + if (elementVisible()) { + + freezeContentHeight(); + currentView.display(date); + unfreezeContentHeight(); + + // need to do this after View::render, so dates are calculated + updateHeaderTitle(); + updateTodayButton(); + + getAndRenderEvents(); + } + } + } + + unfreezeContentHeight(); // undo any lone freezeContentHeight calls + ignoreWindowResize--; + } + + + + // Resizing + // ----------------------------------------------------------------------------------- + + + t.getSuggestedViewHeight = function() { + if (suggestedViewHeight === undefined) { + calcSize(); + } + return suggestedViewHeight; + }; + + + t.isHeightAuto = function() { + return options.contentHeight === 'auto' || options.height === 'auto'; + }; + + + function updateSize(shouldRecalc) { + if (elementVisible()) { + + if (shouldRecalc) { + _calcSize(); + } + + ignoreWindowResize++; + currentView.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto() + ignoreWindowResize--; + + return true; // signal success + } + } + + + function calcSize() { + if (elementVisible()) { + _calcSize(); + } + } + + + function _calcSize() { // assumes elementVisible + if (typeof options.contentHeight === 'number') { // exists and not 'auto' + suggestedViewHeight = options.contentHeight; + } + else if (typeof options.height === 'number') { // exists and not 'auto' + suggestedViewHeight = options.height - (headerElement ? headerElement.outerHeight(true) : 0); + } + else { + suggestedViewHeight = Math.round(content.width() / Math.max(options.aspectRatio, .5)); + } + } + + + function windowResize(ev) { + if ( + !ignoreWindowResize && + ev.target === window && // so we don't process jqui "resize" events that have bubbled up + currentView.start // view has already been rendered + ) { + if (updateSize(true)) { + currentView.trigger('windowResize', _element); + } + } + } + + + + /* Event Fetching/Rendering + -----------------------------------------------------------------------------*/ + // TODO: going forward, most of this stuff should be directly handled by the view + + + function refetchEvents() { // can be called as an API method + destroyEvents(); // so that events are cleared before user starts waiting for AJAX + fetchAndRenderEvents(); + } + + + function renderEvents() { // destroys old events if previously rendered + if (elementVisible()) { + freezeContentHeight(); + currentView.displayEvents(events); + unfreezeContentHeight(); + } + } + + + function destroyEvents() { + freezeContentHeight(); + currentView.clearEvents(); + unfreezeContentHeight(); + } + + + function getAndRenderEvents() { + if (!options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) { + fetchAndRenderEvents(); + } + else { + renderEvents(); + } + } + + + function fetchAndRenderEvents() { + fetchEvents(currentView.start, currentView.end); + // ... will call reportEvents + // ... which will call renderEvents + } + + + // called when event data arrives + function reportEvents(_events) { + events = _events; + renderEvents(); + } + + + // called when a single event's data has been changed + function reportEventChange() { + renderEvents(); + } + + + + /* Header Updating + -----------------------------------------------------------------------------*/ + + + function updateHeaderTitle() { + header.updateTitle(currentView.title); + } + + + function updateTodayButton() { + var now = t.getNow(); + if (now.isWithin(currentView.intervalStart, currentView.intervalEnd)) { + header.disableButton('today'); + } + else { + header.enableButton('today'); + } + } + + + + /* Selection + -----------------------------------------------------------------------------*/ + + + function select(start, end) { + + start = t.moment(start); + if (end) { + end = t.moment(end); + } + else if (start.hasTime()) { + end = start.clone().add(t.defaultTimedEventDuration); + } + else { + end = start.clone().add(t.defaultAllDayEventDuration); + } + + currentView.select({ start: start, end: end }); // accepts a range + } + + + function unselect() { // safe to be called before renderView + if (currentView) { + currentView.unselect(); + } + } + + + + /* Date + -----------------------------------------------------------------------------*/ + + + function prev() { + date = currentView.computePrevDate(date); + renderView(); + } + + + function next() { + date = currentView.computeNextDate(date); + renderView(); + } + + + function prevYear() { + date.add(-1, 'years'); + renderView(); + } + + + function nextYear() { + date.add(1, 'years'); + renderView(); + } + + + function today() { + date = t.getNow(); + renderView(); + } + + + function gotoDate(dateInput) { + date = t.moment(dateInput); + renderView(); + } + + + function incrementDate(delta) { + date.add(moment.duration(delta)); + renderView(); + } + + + // Forces navigation to a view for the given date. + // `viewType` can be a specific view name or a generic one like "week" or "day". + function zoomTo(newDate, viewType) { + var spec; + + viewType = viewType || 'day'; // day is default zoom + spec = t.getViewSpec(viewType) || t.getUnitViewSpec(viewType); + + date = newDate; + renderView(spec ? spec.type : null); + } + + + function getDate() { + return date.clone(); + } + + + + /* Height "Freezing" + -----------------------------------------------------------------------------*/ + // TODO: move this into the view + + + function freezeContentHeight() { + content.css({ + width: '100%', + height: content.height(), + overflow: 'hidden' + }); + } + + + function unfreezeContentHeight() { + content.css({ + width: '', + height: '', + overflow: '' + }); + } + + + + /* Misc + -----------------------------------------------------------------------------*/ + + + function getCalendar() { + return t; + } + + + function getView() { + return currentView; + } + + + function option(name, value) { + if (value === undefined) { + return options[name]; + } + if (name == 'height' || name == 'contentHeight' || name == 'aspectRatio') { + options[name] = value; + updateSize(true); // true = allow recalculation of height + } + } + + + function trigger(name, thisObj) { + if (options[name]) { + return options[name].apply( + thisObj || _element, + Array.prototype.slice.call(arguments, 2) + ); + } + } + +} + +;; + +Calendar.defaults = { + + titleRangeSeparator: ' \u2014 ', // emphasized dash + monthYearFormat: 'MMMM YYYY', // required for en. other languages rely on datepicker computable option + + defaultTimedEventDuration: '02:00:00', + defaultAllDayEventDuration: { days: 1 }, + forceEventDuration: false, + nextDayThreshold: '09:00:00', // 9am + + // display + defaultView: 'month', + aspectRatio: 1.35, + header: { + left: 'title', + center: '', + right: 'today prev,next' + }, + weekends: true, + weekNumbers: false, + + weekNumberTitle: 'W', + weekNumberCalculation: 'local', + + //editable: false, + + // event ajax + lazyFetching: true, + startParam: 'start', + endParam: 'end', + timezoneParam: 'timezone', + + timezone: false, + + //allDayDefault: undefined, + + // locale + isRTL: false, + buttonText: { + prev: "prev", + next: "next", + prevYear: "prev year", + nextYear: "next year", + year: 'year', // TODO: locale files need to specify this + today: 'today', + month: 'month', + week: 'week', + day: 'day' + }, + + buttonIcons: { + prev: 'left-single-arrow', + next: 'right-single-arrow', + prevYear: 'left-double-arrow', + nextYear: 'right-double-arrow' + }, + + // jquery-ui theming + theme: false, + themeButtonIcons: { + prev: 'circle-triangle-w', + next: 'circle-triangle-e', + prevYear: 'seek-prev', + nextYear: 'seek-next' + }, + + //eventResizableFromStart: false, + dragOpacity: .75, + dragRevertDuration: 500, + dragScroll: true, + + //selectable: false, + unselectAuto: true, + + dropAccept: '*', + + eventLimit: false, + eventLimitText: 'more', + eventLimitClick: 'popover', + dayPopoverFormat: 'LL', + + handleWindowResize: true, + windowResizeDelay: 200 // milliseconds before an updateSize happens + +}; + + +Calendar.englishDefaults = { // used by lang.js + dayPopoverFormat: 'dddd, MMMM D' +}; + + +Calendar.rtlDefaults = { // right-to-left defaults + header: { // TODO: smarter solution (first/center/last ?) + left: 'next,prev today', + center: '', + right: 'title' + }, + buttonIcons: { + prev: 'right-single-arrow', + next: 'left-single-arrow', + prevYear: 'right-double-arrow', + nextYear: 'left-double-arrow' + }, + themeButtonIcons: { + prev: 'circle-triangle-e', + next: 'circle-triangle-w', + nextYear: 'seek-prev', + prevYear: 'seek-next' + } +}; + +;; + +var langOptionHash = fc.langs = {}; // initialize and expose + + +// TODO: document the structure and ordering of a FullCalendar lang file +// TODO: rename everything "lang" to "locale", like what the moment project did + + +// Initialize jQuery UI datepicker translations while using some of the translations +// Will set this as the default language for datepicker. +fc.datepickerLang = function(langCode, dpLangCode, dpOptions) { + + // get the FullCalendar internal option hash for this language. create if necessary + var fcOptions = langOptionHash[langCode] || (langOptionHash[langCode] = {}); + + // transfer some simple options from datepicker to fc + fcOptions.isRTL = dpOptions.isRTL; + fcOptions.weekNumberTitle = dpOptions.weekHeader; + + // compute some more complex options from datepicker + $.each(dpComputableOptions, function(name, func) { + fcOptions[name] = func(dpOptions); + }); + + // is jQuery UI Datepicker is on the page? + if ($.datepicker) { + + // Register the language data. + // FullCalendar and MomentJS use language codes like "pt-br" but Datepicker + // does it like "pt-BR" or if it doesn't have the language, maybe just "pt". + // Make an alias so the language can be referenced either way. + $.datepicker.regional[dpLangCode] = + $.datepicker.regional[langCode] = // alias + dpOptions; + + // Alias 'en' to the default language data. Do this every time. + $.datepicker.regional.en = $.datepicker.regional['']; + + // Set as Datepicker's global defaults. + $.datepicker.setDefaults(dpOptions); + } +}; + + +// Sets FullCalendar-specific translations. Will set the language as the global default. +fc.lang = function(langCode, newFcOptions) { + var fcOptions; + var momOptions; + + // get the FullCalendar internal option hash for this language. create if necessary + fcOptions = langOptionHash[langCode] || (langOptionHash[langCode] = {}); + + // provided new options for this language? merge them in + if (newFcOptions) { + fcOptions = langOptionHash[langCode] = mergeOptions(fcOptions, newFcOptions); + } + + // compute language options that weren't defined. + // always do this. newFcOptions can be undefined when initializing from i18n file, + // so no way to tell if this is an initialization or a default-setting. + momOptions = getMomentLocaleData(langCode); // will fall back to en + $.each(momComputableOptions, function(name, func) { + if (fcOptions[name] == null) { + fcOptions[name] = func(momOptions, fcOptions); + } + }); + + // set it as the default language for FullCalendar + Calendar.defaults.lang = langCode; +}; + + +// NOTE: can't guarantee any of these computations will run because not every language has datepicker +// configs, so make sure there are English fallbacks for these in the defaults file. +var dpComputableOptions = { + + buttonText: function(dpOptions) { + return { + // the translations sometimes wrongly contain HTML entities + prev: stripHtmlEntities(dpOptions.prevText), + next: stripHtmlEntities(dpOptions.nextText), + today: stripHtmlEntities(dpOptions.currentText) + }; + }, + + // Produces format strings like "MMMM YYYY" -> "September 2014" + monthYearFormat: function(dpOptions) { + return dpOptions.showMonthAfterYear ? + 'YYYY[' + dpOptions.yearSuffix + '] MMMM' : + 'MMMM YYYY[' + dpOptions.yearSuffix + ']'; + } + +}; + +var momComputableOptions = { + + // Produces format strings like "ddd M/D" -> "Fri 9/15" + dayOfMonthFormat: function(momOptions, fcOptions) { + var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY" + + // strip the year off the edge, as well as other misc non-whitespace chars + format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, ''); + + if (fcOptions.isRTL) { + format += ' ddd'; // for RTL, add day-of-week to end + } + else { + format = 'ddd ' + format; // for LTR, add day-of-week to beginning + } + return format; + }, + + // Produces format strings like "h:mma" -> "6:00pm" + mediumTimeFormat: function(momOptions) { // can't be called `timeFormat` because collides with option + return momOptions.longDateFormat('LT') + .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand + }, + + // Produces format strings like "h(:mm)a" -> "6pm" / "6:30pm" + smallTimeFormat: function(momOptions) { + return momOptions.longDateFormat('LT') + .replace(':mm', '(:mm)') + .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs + .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand + }, + + // Produces format strings like "h(:mm)t" -> "6p" / "6:30p" + extraSmallTimeFormat: function(momOptions) { + return momOptions.longDateFormat('LT') + .replace(':mm', '(:mm)') + .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs + .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand + }, + + // Produces format strings like "ha" / "H" -> "6pm" / "18" + hourFormat: function(momOptions) { + return momOptions.longDateFormat('LT') + .replace(':mm', '') + .replace(/(\Wmm)$/, '') // like above, but for foreign langs + .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand + }, + + // Produces format strings like "h:mm" -> "6:30" (with no AM/PM) + noMeridiemTimeFormat: function(momOptions) { + return momOptions.longDateFormat('LT') + .replace(/\s*a$/i, ''); // remove trailing AM/PM + } + +}; + + +// options that should be computed off live calendar options (considers override options) +var instanceComputableOptions = { // TODO: best place for this? related to lang? + + // Produces format strings for results like "Mo 16" + smallDayDateFormat: function(options) { + return options.isRTL ? + 'D dd' : + 'dd D'; + }, + + // Produces format strings for results like "Wk 5" + weekFormat: function(options) { + return options.isRTL ? + 'w[ ' + options.weekNumberTitle + ']' : + '[' + options.weekNumberTitle + ' ]w'; + }, + + // Produces format strings for results like "Wk5" + smallWeekFormat: function(options) { + return options.isRTL ? + 'w[' + options.weekNumberTitle + ']' : + '[' + options.weekNumberTitle + ']w'; + } + +}; + +function populateInstanceComputableOptions(options) { + $.each(instanceComputableOptions, function(name, func) { + if (options[name] == null) { + options[name] = func(options); + } + }); +} + + +// Returns moment's internal locale data. If doesn't exist, returns English. +// Works with moment-pre-2.8 +function getMomentLocaleData(langCode) { + var func = moment.localeData || moment.langData; + return func.call(moment, langCode) || + func.call(moment, 'en'); // the newer localData could return null, so fall back to en +} + + +// Initialize English by forcing computation of moment-derived options. +// Also, sets it as the default. +fc.lang('en', Calendar.englishDefaults); + +;; + +/* Top toolbar area with buttons and title +----------------------------------------------------------------------------------------------------------------------*/ +// TODO: rename all header-related things to "toolbar" + +function Header(calendar, options) { + var t = this; + + // exports + t.render = render; + t.destroy = destroy; + t.updateTitle = updateTitle; + t.activateButton = activateButton; + t.deactivateButton = deactivateButton; + t.disableButton = disableButton; + t.enableButton = enableButton; + t.getViewsWithButtons = getViewsWithButtons; + + // locals + var el = $(); + var viewsWithButtons = []; + var tm; + + + function render() { + var sections = options.header; + + tm = options.theme ? 'ui' : 'fc'; + + if (sections) { + el = $("
") + .append(renderSection('left')) + .append(renderSection('right')) + .append(renderSection('center')) + .append('
'); + + return el; + } + } + + + function destroy() { + el.remove(); + } + + + function renderSection(position) { + var sectionEl = $('
'); + var buttonStr = options.header[position]; + + if (buttonStr) { + $.each(buttonStr.split(' '), function(i) { + var groupChildren = $(); + var isOnlyButtons = true; + var groupEl; + + $.each(this.split(','), function(j, buttonName) { + var viewSpec; + var buttonClick; + var overrideText; // text explicitly set by calendar's constructor options. overcomes icons + var defaultText; + var themeIcon; + var normalIcon; + var innerHtml; + var classes; + var button; + + if (buttonName == 'title') { + groupChildren = groupChildren.add($('

 

')); // we always want it to take up height + isOnlyButtons = false; + } + else { + viewSpec = calendar.getViewSpec(buttonName); + + if (viewSpec) { + buttonClick = function() { + calendar.changeView(buttonName); + }; + viewsWithButtons.push(buttonName); + overrideText = viewSpec.buttonTextOverride; + defaultText = viewSpec.buttonTextDefault; + } + else if (calendar[buttonName]) { // a calendar method + buttonClick = function() { + calendar[buttonName](); + }; + overrideText = (calendar.overrides.buttonText || {})[buttonName]; + defaultText = options.buttonText[buttonName]; // everything else is considered default + } + + if (buttonClick) { + + themeIcon = options.themeButtonIcons[buttonName]; + normalIcon = options.buttonIcons[buttonName]; + + if (overrideText) { + innerHtml = htmlEscape(overrideText); + } + else if (themeIcon && options.theme) { + innerHtml = ""; + } + else if (normalIcon && !options.theme) { + innerHtml = ""; + } + else { + innerHtml = htmlEscape(defaultText); + } + + classes = [ + 'fc-' + buttonName + '-button', + tm + '-button', + tm + '-state-default' + ]; + + button = $( // type="button" so that it doesn't submit a form + '' + ) + .click(function() { + // don't process clicks for disabled buttons + if (!button.hasClass(tm + '-state-disabled')) { + + buttonClick(); + + // after the click action, if the button becomes the "active" tab, or disabled, + // it should never have a hover class, so remove it now. + if ( + button.hasClass(tm + '-state-active') || + button.hasClass(tm + '-state-disabled') + ) { + button.removeClass(tm + '-state-hover'); + } + } + }) + .mousedown(function() { + // the *down* effect (mouse pressed in). + // only on buttons that are not the "active" tab, or disabled + button + .not('.' + tm + '-state-active') + .not('.' + tm + '-state-disabled') + .addClass(tm + '-state-down'); + }) + .mouseup(function() { + // undo the *down* effect + button.removeClass(tm + '-state-down'); + }) + .hover( + function() { + // the *hover* effect. + // only on buttons that are not the "active" tab, or disabled + button + .not('.' + tm + '-state-active') + .not('.' + tm + '-state-disabled') + .addClass(tm + '-state-hover'); + }, + function() { + // undo the *hover* effect + button + .removeClass(tm + '-state-hover') + .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup + } + ); + + groupChildren = groupChildren.add(button); + } + } + }); + + if (isOnlyButtons) { + groupChildren + .first().addClass(tm + '-corner-left').end() + .last().addClass(tm + '-corner-right').end(); + } + + if (groupChildren.length > 1) { + groupEl = $('
'); + if (isOnlyButtons) { + groupEl.addClass('fc-button-group'); + } + groupEl.append(groupChildren); + sectionEl.append(groupEl); + } + else { + sectionEl.append(groupChildren); // 1 or 0 children + } + }); + } + + return sectionEl; + } + + + function updateTitle(text) { + el.find('h2').text(text); + } + + + function activateButton(buttonName) { + el.find('.fc-' + buttonName + '-button') + .addClass(tm + '-state-active'); + } + + + function deactivateButton(buttonName) { + el.find('.fc-' + buttonName + '-button') + .removeClass(tm + '-state-active'); + } + + + function disableButton(buttonName) { + el.find('.fc-' + buttonName + '-button') + .attr('disabled', 'disabled') + .addClass(tm + '-state-disabled'); + } + + + function enableButton(buttonName) { + el.find('.fc-' + buttonName + '-button') + .removeAttr('disabled') + .removeClass(tm + '-state-disabled'); + } + + + function getViewsWithButtons() { + return viewsWithButtons; + } + +} + +;; + +fc.sourceNormalizers = []; +fc.sourceFetchers = []; + +var ajaxDefaults = { + dataType: 'json', + cache: false +}; + +var eventGUID = 1; + + +function EventManager(options) { // assumed to be a calendar + var t = this; + + + // exports + t.isFetchNeeded = isFetchNeeded; + t.fetchEvents = fetchEvents; + t.addEventSource = addEventSource; + t.removeEventSource = removeEventSource; + t.updateEvent = updateEvent; + t.renderEvent = renderEvent; + t.removeEvents = removeEvents; + t.clientEvents = clientEvents; + t.mutateEvent = mutateEvent; + t.normalizeEventRange = normalizeEventRange; + t.normalizeEventRangeTimes = normalizeEventRangeTimes; + t.ensureVisibleEventRange = ensureVisibleEventRange; + + + // imports + var trigger = t.trigger; + var getView = t.getView; + var reportEvents = t.reportEvents; + + + // locals + var stickySource = { events: [] }; + var sources = [ stickySource ]; + var rangeStart, rangeEnd; + var currentFetchID = 0; + var pendingSourceCnt = 0; + var loadingLevel = 0; + var cache = []; // holds events that have already been expanded + + + $.each( + (options.events ? [ options.events ] : []).concat(options.eventSources || []), + function(i, sourceInput) { + var source = buildEventSource(sourceInput); + if (source) { + sources.push(source); + } + } + ); + + + + /* Fetching + -----------------------------------------------------------------------------*/ + + + function isFetchNeeded(start, end) { + return !rangeStart || // nothing has been fetched yet? + // or, a part of the new range is outside of the old range? (after normalizing) + start.clone().stripZone() < rangeStart.clone().stripZone() || + end.clone().stripZone() > rangeEnd.clone().stripZone(); + } + + + function fetchEvents(start, end) { + rangeStart = start; + rangeEnd = end; + cache = []; + var fetchID = ++currentFetchID; + var len = sources.length; + pendingSourceCnt = len; + for (var i=0; i eventRange system + function ensureVisibleEventRange(range) { + var allDay; + + if (!range.end) { + + allDay = range.allDay; // range might be more event-ish than we think + if (allDay == null) { + allDay = !range.start.hasTime(); + } + + range = $.extend({}, range); // make a copy, copying over other misc properties + range.end = t.getDefaultEventEnd(allDay, range.start); + } + return range; + } + + + // If the given event is a recurring event, break it down into an array of individual instances. + // If not a recurring event, return an array with the single original event. + // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array. + // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours). + function expandEvent(abstractEvent, _rangeStart, _rangeEnd) { + var events = []; + var dowHash; + var dow; + var i; + var date; + var startTime, endTime; + var start, end; + var event; + + _rangeStart = _rangeStart || rangeStart; + _rangeEnd = _rangeEnd || rangeEnd; + + if (abstractEvent) { + if (abstractEvent._recurring) { + + // make a boolean hash as to whether the event occurs on each day-of-week + if ((dow = abstractEvent.dow)) { + dowHash = {}; + for (i = 0; i < dow.length; i++) { + dowHash[dow[i]] = true; + } + } + + // iterate through every day in the current range + date = _rangeStart.clone().stripTime(); // holds the date of the current day + while (date.isBefore(_rangeEnd)) { + + if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week + + startTime = abstractEvent.start; // the stored start and end properties are times (Durations) + endTime = abstractEvent.end; // " + start = date.clone(); + end = null; + + if (startTime) { + start = start.time(startTime); + } + if (endTime) { + end = date.clone().time(endTime); + } + + event = $.extend({}, abstractEvent); // make a copy of the original + assignDatesToEvent( + start, end, + !startTime && !endTime, // allDay? + event + ); + events.push(event); + } + + date.add(1, 'days'); + } + } + else { + events.push(abstractEvent); // return the original event. will be a one-item array + } + } + + return events; + } + + + + /* Event Modification Math + -----------------------------------------------------------------------------------------*/ + + + // Modifies an event and all related events by applying the given properties. + // Special date-diffing logic is used for manipulation of dates. + // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end. + // All date comparisons are done against the event's pristine _start and _end dates. + // Returns an object with delta information and a function to undo all operations. + // For making computations in a granularity greater than day/time, specify largeUnit. + // NOTE: The given `newProps` might be mutated for normalization purposes. + function mutateEvent(event, newProps, largeUnit) { + var miscProps = {}; + var oldProps; + var clearEnd; + var startDelta; + var endDelta; + var durationDelta; + var undoFunc; + + // diffs the dates in the appropriate way, returning a duration + function diffDates(date1, date0) { // date1 - date0 + if (largeUnit) { + return diffByUnit(date1, date0, largeUnit); + } + else if (newProps.allDay) { + return diffDay(date1, date0); + } + else { + return diffDayTime(date1, date0); + } + } + + newProps = newProps || {}; + + // normalize new date-related properties + if (!newProps.start) { + newProps.start = event.start.clone(); + } + if (newProps.end === undefined) { + newProps.end = event.end ? event.end.clone() : null; + } + if (newProps.allDay == null) { // is null or undefined? + newProps.allDay = event.allDay; + } + normalizeEventRange(newProps); + + // create normalized versions of the original props to compare against + // need a real end value, for diffing + oldProps = { + start: event._start.clone(), + end: event._end ? event._end.clone() : t.getDefaultEventEnd(event._allDay, event._start), + allDay: newProps.allDay // normalize the dates in the same regard as the new properties + }; + normalizeEventRange(oldProps); + + // need to clear the end date if explicitly changed to null + clearEnd = event._end !== null && newProps.end === null; + + // compute the delta for moving the start date + startDelta = diffDates(newProps.start, oldProps.start); + + // compute the delta for moving the end date + if (newProps.end) { + endDelta = diffDates(newProps.end, oldProps.end); + durationDelta = endDelta.subtract(startDelta); + } + else { + durationDelta = null; + } + + // gather all non-date-related properties + $.each(newProps, function(name, val) { + if (isMiscEventPropName(name)) { + if (val !== undefined) { + miscProps[name] = val; + } + } + }); + + // apply the operations to the event and all related events + undoFunc = mutateEvents( + clientEvents(event._id), // get events with this ID + clearEnd, + newProps.allDay, + startDelta, + durationDelta, + miscProps + ); + + return { + dateDelta: startDelta, + durationDelta: durationDelta, + undo: undoFunc + }; + } + + + // Modifies an array of events in the following ways (operations are in order): + // - clear the event's `end` + // - convert the event to allDay + // - add `dateDelta` to the start and end + // - add `durationDelta` to the event's duration + // - assign `miscProps` to the event + // + // Returns a function that can be called to undo all the operations. + // + // TODO: don't use so many closures. possible memory issues when lots of events with same ID. + // + function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) { + var isAmbigTimezone = t.getIsAmbigTimezone(); + var undoFunctions = []; + + // normalize zero-length deltas to be null + if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; } + if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; } + + $.each(events, function(i, event) { + var oldProps; + var newProps; + + // build an object holding all the old values, both date-related and misc. + // for the undo function. + oldProps = { + start: event.start.clone(), + end: event.end ? event.end.clone() : null, + allDay: event.allDay + }; + $.each(miscProps, function(name) { + oldProps[name] = event[name]; + }); + + // new date-related properties. work off the original date snapshot. + // ok to use references because they will be thrown away when backupEventDates is called. + newProps = { + start: event._start, + end: event._end, + allDay: allDay // normalize the dates in the same regard as the new properties + }; + normalizeEventRange(newProps); // massages start/end/allDay + + // strip or ensure the end date + if (clearEnd) { + newProps.end = null; + } + else if (durationDelta && !newProps.end) { // the duration translation requires an end date + newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start); + } + + if (dateDelta) { + newProps.start.add(dateDelta); + if (newProps.end) { + newProps.end.add(dateDelta); + } + } + + if (durationDelta) { + newProps.end.add(durationDelta); // end already ensured above + } + + // if the dates have changed, and we know it is impossible to recompute the + // timezone offsets, strip the zone. + if ( + isAmbigTimezone && + !newProps.allDay && + (dateDelta || durationDelta) + ) { + newProps.start.stripZone(); + if (newProps.end) { + newProps.end.stripZone(); + } + } + + $.extend(event, miscProps, newProps); // copy over misc props, then date-related props + backupEventDates(event); // regenerate internal _start/_end/_allDay + + undoFunctions.push(function() { + $.extend(event, oldProps); + backupEventDates(event); // regenerate internal _start/_end/_allDay + }); + }); + + return function() { + for (var i = 0; i < undoFunctions.length; i++) { + undoFunctions[i](); + } + }; + } + + + /* Business Hours + -----------------------------------------------------------------------------------------*/ + + t.getBusinessHoursEvents = getBusinessHoursEvents; + + + // Returns an array of events as to when the business hours occur in the given view. + // Abuse of our event system :( + function getBusinessHoursEvents(wholeDay) { + var optionVal = options.businessHours; + var defaultVal = { + className: 'fc-nonbusiness', + start: '09:00', + end: '17:00', + dow: [ 1, 2, 3, 4, 5 ], // monday - friday + rendering: 'inverse-background' + }; + var view = t.getView(); + var eventInput; + + if (optionVal) { // `true` (which means "use the defaults") or an override object + eventInput = $.extend( + {}, // copy to a new object in either case + defaultVal, + typeof optionVal === 'object' ? optionVal : {} // override the defaults + ); + } + + if (eventInput) { + + // if a whole-day series is requested, clear the start/end times + if (wholeDay) { + eventInput.start = null; + eventInput.end = null; + } + + return expandEvent( + buildEventFromInput(eventInput), + view.start, + view.end + ); + } + + return []; + } + + + /* Overlapping / Constraining + -----------------------------------------------------------------------------------------*/ + + t.isEventRangeAllowed = isEventRangeAllowed; + t.isSelectionRangeAllowed = isSelectionRangeAllowed; + t.isExternalDropRangeAllowed = isExternalDropRangeAllowed; + + + function isEventRangeAllowed(range, event) { + var source = event.source || {}; + var constraint = firstDefined( + event.constraint, + source.constraint, + options.eventConstraint + ); + var overlap = firstDefined( + event.overlap, + source.overlap, + options.eventOverlap + ); + + range = ensureVisibleEventRange(range); // ensure a proper range with an end for isRangeAllowed + + return isRangeAllowed(range, constraint, overlap, event); + } + + + function isSelectionRangeAllowed(range) { + return isRangeAllowed(range, options.selectConstraint, options.selectOverlap); + } + + + // when `eventProps` is defined, consider this an event. + // `eventProps` can contain misc non-date-related info about the event. + function isExternalDropRangeAllowed(range, eventProps) { + var eventInput; + var event; + + // note: very similar logic is in View's reportExternalDrop + if (eventProps) { + eventInput = $.extend({}, eventProps, range); + event = expandEvent(buildEventFromInput(eventInput))[0]; + } + + if (event) { + return isEventRangeAllowed(range, event); + } + else { // treat it as a selection + + range = ensureVisibleEventRange(range); // ensure a proper range with an end for isSelectionRangeAllowed + + return isSelectionRangeAllowed(range); + } + } + + + // Returns true if the given range (caused by an event drop/resize or a selection) is allowed to exist + // according to the constraint/overlap settings. + // `event` is not required if checking a selection. + function isRangeAllowed(range, constraint, overlap, event) { + var constraintEvents; + var anyContainment; + var peerEvents; + var i, peerEvent; + var peerOverlap; + + // normalize. fyi, we're normalizing in too many places :( + range = $.extend({}, range); // copy all properties in case there are misc non-date properties + range.start = range.start.clone().stripZone(); + range.end = range.end.clone().stripZone(); + + // the range must be fully contained by at least one of produced constraint events + if (constraint != null) { + + // not treated as an event! intermediate data structure + // TODO: use ranges in the future + constraintEvents = constraintToEvents(constraint); + + anyContainment = false; + for (i = 0; i < constraintEvents.length; i++) { + if (eventContainsRange(constraintEvents[i], range)) { + anyContainment = true; + break; + } + } + + if (!anyContainment) { + return false; + } + } + + peerEvents = t.getPeerEvents(event, range); + + for (i = 0; i < peerEvents.length; i++) { + peerEvent = peerEvents[i]; + + // there needs to be an actual intersection before disallowing anything + if (eventIntersectsRange(peerEvent, range)) { + + // evaluate overlap for the given range and short-circuit if necessary + if (overlap === false) { + return false; + } + // if the event's overlap is a test function, pass the peer event in question as the first param + else if (typeof overlap === 'function' && !overlap(peerEvent, event)) { + return false; + } + + // if we are computing if the given range is allowable for an event, consider the other event's + // EventObject-specific or Source-specific `overlap` property + if (event) { + peerOverlap = firstDefined( + peerEvent.overlap, + (peerEvent.source || {}).overlap + // we already considered the global `eventOverlap` + ); + if (peerOverlap === false) { + return false; + } + // if the peer event's overlap is a test function, pass the subject event as the first param + if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) { + return false; + } + } + } + } + + return true; + } + + + // Given an event input from the API, produces an array of event objects. Possible event inputs: + // 'businessHours' + // An event ID (number or string) + // An object with specific start/end dates or a recurring event (like what businessHours accepts) + function constraintToEvents(constraintInput) { + + if (constraintInput === 'businessHours') { + return getBusinessHoursEvents(); + } + + if (typeof constraintInput === 'object') { + return expandEvent(buildEventFromInput(constraintInput)); + } + + return clientEvents(constraintInput); // probably an ID + } + + + // Does the event's date range fully contain the given range? + // start/end already assumed to have stripped zones :( + function eventContainsRange(event, range) { + var eventStart = event.start.clone().stripZone(); + var eventEnd = t.getEventEnd(event).stripZone(); + + return range.start >= eventStart && range.end <= eventEnd; + } + + + // Does the event's date range intersect with the given range? + // start/end already assumed to have stripped zones :( + function eventIntersectsRange(event, range) { + var eventStart = event.start.clone().stripZone(); + var eventEnd = t.getEventEnd(event).stripZone(); + + return range.start < eventEnd && range.end > eventStart; + } + + + t.getEventCache = function() { + return cache; + }; + +} + + +// Returns a list of events that the given event should be compared against when being considered for a move to +// the specified range. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar. +Calendar.prototype.getPeerEvents = function(event, range) { + var cache = this.getEventCache(); + var peerEvents = []; + var i, otherEvent; + + for (i = 0; i < cache.length; i++) { + otherEvent = cache[i]; + if ( + !event || + event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events + ) { + peerEvents.push(otherEvent); + } + } + + return peerEvents; +}; + + +// updates the "backup" properties, which are preserved in order to compute diffs later on. +function backupEventDates(event) { + event._allDay = event.allDay; + event._start = event.start.clone(); + event._end = event.end ? event.end.clone() : null; +} + +;; + +/* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells. +----------------------------------------------------------------------------------------------------------------------*/ +// It is a manager for a DayGrid subcomponent, which does most of the heavy lifting. +// It is responsible for managing width/height. + +var BasicView = fcViews.basic = View.extend({ + + dayGrid: null, // the main subcomponent that does most of the heavy lifting + + dayNumbersVisible: false, // display day numbers on each day cell? + weekNumbersVisible: false, // display week numbers along the side? + + weekNumberWidth: null, // width of all the week-number cells running down the side + + headRowEl: null, // the fake row element of the day-of-week header + + + initialize: function() { + this.dayGrid = new DayGrid(this); + this.coordMap = this.dayGrid.coordMap; // the view's date-to-cell mapping is identical to the subcomponent's + }, + + + // Sets the display range and computes all necessary dates + setRange: function(range) { + View.prototype.setRange.call(this, range); // call the super-method + + this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange + this.dayGrid.setRange(range); + }, + + + // Compute the value to feed into setRange. Overrides superclass. + computeRange: function(date) { + var range = View.prototype.computeRange.call(this, date); // get value from the super-method + + // year and month views should be aligned with weeks. this is already done for week + if (/year|month/.test(range.intervalUnit)) { + range.start.startOf('week'); + range.start = this.skipHiddenDays(range.start); + + // make end-of-week if not already + if (range.end.weekday()) { + range.end.add(1, 'week').startOf('week'); + range.end = this.skipHiddenDays(range.end, -1, true); // exclusively move backwards + } + } + + return range; + }, + + + // Renders the view into `this.el`, which should already be assigned + render: function() { + + this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible + this.weekNumbersVisible = this.opt('weekNumbers'); + this.dayGrid.numbersVisible = this.dayNumbersVisible || this.weekNumbersVisible; + + this.el.addClass('fc-basic-view').html(this.renderHtml()); + + this.headRowEl = this.el.find('thead .fc-row'); + + this.scrollerEl = this.el.find('.fc-day-grid-container'); + this.dayGrid.coordMap.containerEl = this.scrollerEl; // constrain clicks/etc to the dimensions of the scroller + + this.dayGrid.setElement(this.el.find('.fc-day-grid')); + this.dayGrid.renderDates(this.hasRigidRows()); + }, + + + // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, + // always completely kill the dayGrid's rendering. + destroy: function() { + this.dayGrid.destroyDates(); + this.dayGrid.removeElement(); + }, + + + renderBusinessHours: function() { + this.dayGrid.renderBusinessHours(); + }, + + + // Builds the HTML skeleton for the view. + // The day-grid component will render inside of a container defined by this HTML. + renderHtml: function() { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + this.dayGrid.headHtml() + // render the day-of-week headers + '
' + + '
' + + '
' + + '
' + + '
'; + }, + + + // Generates the HTML that will go before the day-of week header cells. + // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL. + headIntroHtml: function() { + if (this.weekNumbersVisible) { + return '' + + '' + + '' + // needed for matchCellWidths + htmlEscape(this.opt('weekNumberTitle')) + + '' + + ''; + } + }, + + + // Generates the HTML that will go before content-skeleton cells that display the day/week numbers. + // Queried by the DayGrid subcomponent. Ordering depends on isRTL. + numberIntroHtml: function(row) { + if (this.weekNumbersVisible) { + return '' + + '' + + '' + // needed for matchCellWidths + this.dayGrid.getCell(row, 0).start.format('w') + + '' + + ''; + } + }, + + + // Generates the HTML that goes before the day bg cells for each day-row. + // Queried by the DayGrid subcomponent. Ordering depends on isRTL. + dayIntroHtml: function() { + if (this.weekNumbersVisible) { + return ''; + } + }, + + + // Generates the HTML that goes before every other type of row generated by DayGrid. Ordering depends on isRTL. + // Affects helper-skeleton and highlight-skeleton rows. + introHtml: function() { + if (this.weekNumbersVisible) { + return ''; + } + }, + + + // Generates the HTML for the s of the "number" row in the DayGrid's content skeleton. + // The number row will only exist if either day numbers or week numbers are turned on. + numberCellHtml: function(cell) { + var date = cell.start; + var classes; + + if (!this.dayNumbersVisible) { // if there are week numbers but not day numbers + return ''; // will create an empty space above events :( + } + + classes = this.dayGrid.getDayClasses(date); + classes.unshift('fc-day-number'); + + return '' + + '' + + date.date() + + ''; + }, + + + // Generates an HTML attribute string for setting the width of the week number column, if it is known + weekNumberStyleAttr: function() { + if (this.weekNumberWidth !== null) { + return 'style="width:' + this.weekNumberWidth + 'px"'; + } + return ''; + }, + + + // Determines whether each row should have a constant height + hasRigidRows: function() { + var eventLimit = this.opt('eventLimit'); + return eventLimit && typeof eventLimit !== 'number'; + }, + + + /* Dimensions + ------------------------------------------------------------------------------------------------------------------*/ + + + // Refreshes the horizontal dimensions of the view + updateWidth: function() { + if (this.weekNumbersVisible) { + // Make sure all week number cells running down the side have the same width. + // Record the width for cells created later. + this.weekNumberWidth = matchCellWidths( + this.el.find('.fc-week-number') + ); + } + }, + + + // Adjusts the vertical dimensions of the view to the specified values + setHeight: function(totalHeight, isAuto) { + var eventLimit = this.opt('eventLimit'); + var scrollerHeight; + + // reset all heights to be natural + unsetScroller(this.scrollerEl); + uncompensateScroll(this.headRowEl); + + this.dayGrid.destroySegPopover(); // kill the "more" popover if displayed + + // is the event limit a constant level number? + if (eventLimit && typeof eventLimit === 'number') { + this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after + } + + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.setGridHeight(scrollerHeight, isAuto); + + // is the event limit dynamically calculated? + if (eventLimit && typeof eventLimit !== 'number') { + this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set + } + + if (!isAuto && setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars? + + compensateScroll(this.headRowEl, getScrollbarWidths(this.scrollerEl)); + + // doing the scrollbar compensation might have created text overflow which created more height. redo + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.scrollerEl.height(scrollerHeight); + } + }, + + + // Sets the height of just the DayGrid component in this view + setGridHeight: function(height, isAuto) { + if (isAuto) { + undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding + } + else { + distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows + } + }, + + + /* Events + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders the given events onto the view and populates the segments array + renderEvents: function(events) { + this.dayGrid.renderEvents(events); + + this.updateHeight(); // must compensate for events that overflow the row + }, + + + // Retrieves all segment objects that are rendered in the view + getEventSegs: function() { + return this.dayGrid.getEventSegs(); + }, + + + // Unrenders all event elements and clears internal segment data + destroyEvents: function() { + this.dayGrid.destroyEvents(); + + // we DON'T need to call updateHeight() because: + // A) a renderEvents() call always happens after this, which will eventually call updateHeight() + // B) in IE8, this causes a flash whenever events are rerendered + }, + + + /* Dragging (for both events and external elements) + ------------------------------------------------------------------------------------------------------------------*/ + + + // A returned value of `true` signals that a mock "helper" event has been rendered. + renderDrag: function(dropLocation, seg) { + return this.dayGrid.renderDrag(dropLocation, seg); + }, + + + destroyDrag: function() { + this.dayGrid.destroyDrag(); + }, + + + /* Selection + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of a selection + renderSelection: function(range) { + this.dayGrid.renderSelection(range); + }, + + + // Unrenders a visual indications of a selection + destroySelection: function() { + this.dayGrid.destroySelection(); + } + +}); + +;; + +/* A month view with day cells running in rows (one-per-week) and columns +----------------------------------------------------------------------------------------------------------------------*/ + +var MonthView = fcViews.month = BasicView.extend({ + + // Produces information about what range to display + computeRange: function(date) { + var range = BasicView.prototype.computeRange.call(this, date); // get value from super-method + var rowCnt; + + // ensure 6 weeks + if (this.isFixedWeeks()) { + rowCnt = Math.ceil(range.end.diff(range.start, 'weeks', true)); // could be partial weeks due to hiddenDays + range.end.add(6 - rowCnt, 'weeks'); + } + + return range; + }, + + + // Overrides the default BasicView behavior to have special multi-week auto-height logic + setGridHeight: function(height, isAuto) { + + isAuto = isAuto || this.opt('weekMode') === 'variable'; // LEGACY: weekMode is deprecated + + // if auto, make the height of each row the height that it would be if there were 6 weeks + if (isAuto) { + height *= this.rowCnt / 6; + } + + distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows + }, + + + isFixedWeeks: function() { + var weekMode = this.opt('weekMode'); // LEGACY: weekMode is deprecated + if (weekMode) { + return weekMode === 'fixed'; // if any other type of weekMode, assume NOT fixed + } + + return this.opt('fixedWeekCount'); + } + +}); + +MonthView.duration = { months: 1 }; // important for prev/next + +MonthView.defaults = { + fixedWeekCount: true +}; +;; + +/* A week view with simple day cells running horizontally +----------------------------------------------------------------------------------------------------------------------*/ + +fcViews.basicWeek = { + type: 'basic', + duration: { weeks: 1 } +}; +;; + +/* A view with a single simple day cell +----------------------------------------------------------------------------------------------------------------------*/ + +fcViews.basicDay = { + type: 'basic', + duration: { days: 1 } +}; +;; + +/* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically. +----------------------------------------------------------------------------------------------------------------------*/ +// Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on). +// Responsible for managing width/height. + +var AGENDA_DEFAULTS = { + allDaySlot: true, + allDayText: 'all-day', + scrollTime: '06:00:00', + slotDuration: '00:30:00', + minTime: '00:00:00', + maxTime: '24:00:00', + slotEventOverlap: true // a bad name. confused with overlap/constraint system +}; + +var AGENDA_ALL_DAY_EVENT_LIMIT = 5; + +var AgendaView = fcViews.agenda = View.extend({ + + timeGrid: null, // the main time-grid subcomponent of this view + dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null + + axisWidth: null, // the width of the time axis running down the side + + noScrollRowEls: null, // set of fake row elements that must compensate when scrollerEl has scrollbars + + // when the time-grid isn't tall enough to occupy the given height, we render an
underneath + bottomRuleEl: null, + bottomRuleHeight: null, + + + initialize: function() { + this.timeGrid = new TimeGrid(this); + + if (this.opt('allDaySlot')) { // should we display the "all-day" area? + this.dayGrid = new DayGrid(this); // the all-day subcomponent of this view + + // the coordinate grid will be a combination of both subcomponents' grids + this.coordMap = new ComboCoordMap([ + this.dayGrid.coordMap, + this.timeGrid.coordMap + ]); + } + else { + this.coordMap = this.timeGrid.coordMap; + } + }, + + + /* Rendering + ------------------------------------------------------------------------------------------------------------------*/ + + + // Sets the display range and computes all necessary dates + setRange: function(range) { + View.prototype.setRange.call(this, range); // call the super-method + + this.timeGrid.setRange(range); + if (this.dayGrid) { + this.dayGrid.setRange(range); + } + }, + + + // Renders the view into `this.el`, which has already been assigned + render: function() { + + this.el.addClass('fc-agenda-view').html(this.renderHtml()); + + // the element that wraps the time-grid that will probably scroll + this.scrollerEl = this.el.find('.fc-time-grid-container'); + this.timeGrid.coordMap.containerEl = this.scrollerEl; // don't accept clicks/etc outside of this + + this.timeGrid.setElement(this.el.find('.fc-time-grid')); + this.timeGrid.renderDates(); + + // the
that sometimes displays under the time-grid + this.bottomRuleEl = $('
') + .appendTo(this.timeGrid.el); // inject it into the time-grid + + if (this.dayGrid) { + this.dayGrid.setElement(this.el.find('.fc-day-grid')); + this.dayGrid.renderDates(); + + // have the day-grid extend it's coordinate area over the
dividing the two grids + this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight(); + } + + this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller + }, + + + // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, + // always completely kill each grid's rendering. + destroy: function() { + this.timeGrid.destroyDates(); + this.timeGrid.removeElement(); + + if (this.dayGrid) { + this.dayGrid.destroyDates(); + this.dayGrid.removeElement(); + } + }, + + + renderBusinessHours: function() { + this.timeGrid.renderBusinessHours(); + + if (this.dayGrid) { + this.dayGrid.renderBusinessHours(); + } + }, + + + // Builds the HTML skeleton for the view. + // The day-grid and time-grid components will render inside containers defined by this HTML. + renderHtml: function() { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + this.timeGrid.headHtml() + // render the day-of-week headers + '
' + + (this.dayGrid ? + '
' + + '
' : + '' + ) + + '
' + + '
' + + '
' + + '
'; + }, + + + // Generates the HTML that will go before the day-of week header cells. + // Queried by the TimeGrid subcomponent when generating rows. Ordering depends on isRTL. + headIntroHtml: function() { + var date; + var weekText; + + if (this.opt('weekNumbers')) { + date = this.timeGrid.getCell(0).start; + weekText = date.format(this.opt('smallWeekFormat')); + + return '' + + '' + + '' + // needed for matchCellWidths + htmlEscape(weekText) + + '' + + ''; + } + else { + return ''; + } + }, + + + // Generates the HTML that goes before the all-day cells. + // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL. + dayIntroHtml: function() { + return '' + + '' + + '' + // needed for matchCellWidths + (this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'))) + + '' + + ''; + }, + + + // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column. + slotBgIntroHtml: function() { + return ''; + }, + + + // Generates the HTML that goes before all other types of cells. + // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. + // Queried by the TimeGrid and DayGrid subcomponents when generating rows. Ordering depends on isRTL. + introHtml: function() { + return ''; + }, + + + // Generates an HTML attribute string for setting the width of the axis, if it is known + axisStyleAttr: function() { + if (this.axisWidth !== null) { + return 'style="width:' + this.axisWidth + 'px"'; + } + return ''; + }, + + + /* Dimensions + ------------------------------------------------------------------------------------------------------------------*/ + + + updateSize: function(isResize) { + this.timeGrid.updateSize(isResize); + + View.prototype.updateSize.call(this, isResize); // call the super-method + }, + + + // Refreshes the horizontal dimensions of the view + updateWidth: function() { + // make all axis cells line up, and record the width so newly created axis cells will have it + this.axisWidth = matchCellWidths(this.el.find('.fc-axis')); + }, + + + // Adjusts the vertical dimensions of the view to the specified values + setHeight: function(totalHeight, isAuto) { + var eventLimit; + var scrollerHeight; + + if (this.bottomRuleHeight === null) { + // calculate the height of the rule the very first time + this.bottomRuleHeight = this.bottomRuleEl.outerHeight(); + } + this.bottomRuleEl.hide(); // .show() will be called later if this
is necessary + + // reset all dimensions back to the original state + this.scrollerEl.css('overflow', ''); + unsetScroller(this.scrollerEl); + uncompensateScroll(this.noScrollRowEls); + + // limit number of events in the all-day area + if (this.dayGrid) { + this.dayGrid.destroySegPopover(); // kill the "more" popover if displayed + + eventLimit = this.opt('eventLimit'); + if (eventLimit && typeof eventLimit !== 'number') { + eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number + } + if (eventLimit) { + this.dayGrid.limitRows(eventLimit); + } + } + + if (!isAuto) { // should we force dimensions of the scroll container, or let the contents be natural height? + + scrollerHeight = this.computeScrollerHeight(totalHeight); + if (setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars? + + // make the all-day and header rows lines up + compensateScroll(this.noScrollRowEls, getScrollbarWidths(this.scrollerEl)); + + // the scrollbar compensation might have changed text flow, which might affect height, so recalculate + // and reapply the desired height to the scroller. + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.scrollerEl.height(scrollerHeight); + } + else { // no scrollbars + // still, force a height and display the bottom rule (marks the end of day) + this.scrollerEl.height(scrollerHeight).css('overflow', 'hidden'); // in case
goes outside + this.bottomRuleEl.show(); + } + } + }, + + + // Computes the initial pre-configured scroll state prior to allowing the user to change it + computeInitialScroll: function() { + var scrollTime = moment.duration(this.opt('scrollTime')); + var top = this.timeGrid.computeTimeTop(scrollTime); + + // zoom can give weird floating-point values. rather scroll a little bit further + top = Math.ceil(top); + + if (top) { + top++; // to overcome top border that slots beyond the first have. looks better + } + + return top; + }, + + + /* Events + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders events onto the view and populates the View's segment array + renderEvents: function(events) { + var dayEvents = []; + var timedEvents = []; + var daySegs = []; + var timedSegs; + var i; + + // separate the events into all-day and timed + for (i = 0; i < events.length; i++) { + if (events[i].allDay) { + dayEvents.push(events[i]); + } + else { + timedEvents.push(events[i]); + } + } + + // render the events in the subcomponents + timedSegs = this.timeGrid.renderEvents(timedEvents); + if (this.dayGrid) { + daySegs = this.dayGrid.renderEvents(dayEvents); + } + + // the all-day area is flexible and might have a lot of events, so shift the height + this.updateHeight(); + }, + + + // Retrieves all segment objects that are rendered in the view + getEventSegs: function() { + return this.timeGrid.getEventSegs().concat( + this.dayGrid ? this.dayGrid.getEventSegs() : [] + ); + }, + + + // Unrenders all event elements and clears internal segment data + destroyEvents: function() { + + // destroy the events in the subcomponents + this.timeGrid.destroyEvents(); + if (this.dayGrid) { + this.dayGrid.destroyEvents(); + } + + // we DON'T need to call updateHeight() because: + // A) a renderEvents() call always happens after this, which will eventually call updateHeight() + // B) in IE8, this causes a flash whenever events are rerendered + }, + + + /* Dragging (for events and external elements) + ------------------------------------------------------------------------------------------------------------------*/ + + + // A returned value of `true` signals that a mock "helper" event has been rendered. + renderDrag: function(dropLocation, seg) { + if (dropLocation.start.hasTime()) { + return this.timeGrid.renderDrag(dropLocation, seg); + } + else if (this.dayGrid) { + return this.dayGrid.renderDrag(dropLocation, seg); + } + }, + + + destroyDrag: function() { + this.timeGrid.destroyDrag(); + if (this.dayGrid) { + this.dayGrid.destroyDrag(); + } + }, + + + /* Selection + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of a selection + renderSelection: function(range) { + if (range.start.hasTime() || range.end.hasTime()) { + this.timeGrid.renderSelection(range); + } + else if (this.dayGrid) { + this.dayGrid.renderSelection(range); + } + }, + + + // Unrenders a visual indications of a selection + destroySelection: function() { + this.timeGrid.destroySelection(); + if (this.dayGrid) { + this.dayGrid.destroySelection(); + } + } + +}); + +AgendaView.defaults = AGENDA_DEFAULTS; + +;; + +/* A week view with an all-day cell area at the top, and a time grid below +----------------------------------------------------------------------------------------------------------------------*/ + +fcViews.agendaWeek = { + type: 'agenda', + duration: { weeks: 1 } +}; +;; + +/* A day view with an all-day cell area at the top, and a time grid below +----------------------------------------------------------------------------------------------------------------------*/ + +fcViews.agendaDay = { + type: 'agenda', + duration: { days: 1 } +}; +;; + +return fc; // export for Node/CommonJS +}); \ No newline at end of file diff --git a/app/partials/scheduler/scheduler.controller.js b/app/partials/scheduler/scheduler.controller.js index 9445bf9..c671f13 100644 --- a/app/partials/scheduler/scheduler.controller.js +++ b/app/partials/scheduler/scheduler.controller.js @@ -3,80 +3,59 @@ //Load controller angular.module('acufuel') - .controller('schedulerController', ['$scope', function($scope) { + .controller('schedulerController', ['$scope','$compile', 'uiCalendarConfig', function($scope, $compile, uiCalendarConfig) { $scope.test = "Testing..."; + + var date = new Date(); + var d = date.getDate(); + var m = date.getMonth(); + var y = date.getFullYear(); + - - /*$('#calendar').fullCalendar({ - header: { - left: 'prev,next today', - center: 'title', - right: 'month,agendaWeek,agendaDay,listWeek' - }, - defaultDate: '2017-05-12', - navLinks: true, // can click day/week names to navigate views - editable: true, - eventLimit: true, // allow "more" link when too many events - events: [ - { - title: 'All Day Event', - start: '2017-05-01' - }, - { - title: 'Long Event', - start: '2017-05-07', - end: '2017-05-10' - }, - { - id: 999, - title: 'Repeating Event', - start: '2017-05-09T16:00:00' - }, - { - id: 999, - title: 'Repeating Event', - start: '2017-05-16T16:00:00' - }, - { - title: 'Conference', - start: '2017-05-11', - end: '2017-05-13' - }, - { - title: 'Meeting', - start: '2017-05-12T10:30:00', - end: '2017-05-12T12:30:00' - }, - { - title: 'Lunch', - start: '2017-05-12T12:00:00' - }, - { - title: 'Meeting', - start: '2017-05-12T14:30:00' - }, - { - title: 'Happy Hour', - start: '2017-05-12T17:30:00' - }, - { - title: 'Dinner', - start: '2017-05-12T20:00:00' - }, - { - title: 'Birthday Party', - start: '2017-05-13T07:00:00' - }, - { - title: 'Click for Google', - start: '2017-05-28' + $scope.eventList=[ + {title:'Event 1'}, + {title:'Event 2'}, + {title:'Event 3'}, + {title:'Event 4'} + ]; + + $scope.eventSources=[]; + + $scope.events = [ + {title: 'All Day Event', start: new Date(y, m, 1)}, + {title: 'Birthday Party', start: new Date(y, m, d + 1, 19, 0), end: new Date(y, m, d + 1, 22, 30), allDay: false}, + {title: 'Click for Google', start: new Date(y, m, 28), end: new Date(y, m, 29)} + ]; + + $scope.uiConfig = { + calendar:{ + height: 450, + editable: true, + droppable: true, + drop: function (date, allDay, jsEvent, ui) { + console.log('Here ,but where is the object?'); + }, + header:{ + left: 'title', + center: '', + right: 'today prev,next' + }, + eventResize: true, } - ] - }); - - });*/ + }; + $scope.eventSources = [$scope.events]; + //$scope.eventSources = []; + //$scope.eventSources.push($scope.events); + + $scope.addEvent = function(index) { + console.log('INDEX', index); + console.log('EVENTS', $scope.eventSources); + //$scope.events.push($scope.eventList[index]); + } + + console.log($scope.eventSources); }]); diff --git a/app/partials/scheduler/scheduler.html b/app/partials/scheduler/scheduler.html index 870c798..a5640a3 100644 --- a/app/partials/scheduler/scheduler.html +++ b/app/partials/scheduler/scheduler.html @@ -65,31 +65,34 @@
-
-
+
-
--> +
+ + -
+
+
diff --git a/app/partials/updateFuelManager/updateFuelManager.controller.js b/app/partials/updateFuelManager/updateFuelManager.controller.js index 00ee36a..95b6eab 100644 --- a/app/partials/updateFuelManager/updateFuelManager.controller.js +++ b/app/partials/updateFuelManager/updateFuelManager.controller.js @@ -584,10 +584,11 @@ $scope.newFuelPricing[i].futureFuelPricing.deployDate = $scope.newFuelPricing[i].futureFuelPricing.deployDate.getTime(); } - $scope.newFuelPricing[i].futureFuelPricing.papTotal = parseFloat($scope.newFuelPricing[i].futureFuelPricing.cost) + parseFloat($scope.newFuelPricing[i].fuelPricing.papMargin); + $scope.newFuelPricing[i].futureFuelPricing.papTotal = parseFloat($scope.newFuelPricing[i].futureFuelPricing.cost) + parseFloat($scope.newFuelPricing[i].futureFuelPricing.papMargin); + //$scope.newFuelPricing[i].futureFuelPricing.papTotal; $scope.updateFutureFuelPricing.futureFuelPricingList.push({ 'cost': $scope.newFuelPricing[i].futureFuelPricing.cost, - 'papMargin': $scope.newFuelPricing[i].fuelPricing.papMargin, + 'papMargin': $scope.newFuelPricing[i].futureFuelPricing.papMargin, //'papTotal': $scope.newFuelPricing[i].futureFuelPricing.papTotal, 'papTotal': $scope.newFuelPricing[i].futureFuelPricing.papTotal, 'expirationDate': $scope.newFuelPricing[i].futureFuelPricing.nextExpiration, @@ -604,6 +605,7 @@ $scope.newFuelPricing[i].futureFuelPricing.deployDate = '';*/ } } + //console.log('$scope.updateFutureFuelPricing', $scope.updateFutureFuelPricing); updateFuelManagerService.updateFutureFuelPricing($scope.updateFutureFuelPricing).then(function(result) { toastr.success('Successfully Updated', { closeButton: true diff --git a/app/partials/updateFuelManager/updateFuelManager.html b/app/partials/updateFuelManager/updateFuelManager.html index 2c33066..2bd5030 100644 --- a/app/partials/updateFuelManager/updateFuelManager.html +++ b/app/partials/updateFuelManager/updateFuelManager.html @@ -8,7 +8,7 @@
-
+
@@ -53,7 +53,7 @@ - $ {{fuelPricing.futureFuelPricing.cost -- fuelPricing.fuelPricing.papMargin | number : 2}} + $ {{fuelPricing.futureFuelPricing.cost -- fuelPricing.futureFuelPricing.papMargin | number : 2}} @@ -104,7 +104,7 @@ - +
diff --git a/bower.json b/bower.json index 8186726..b23d16e 100644 --- a/bower.json +++ b/bower.json @@ -36,6 +36,7 @@ "angular-bootstrap-toggle": "~0.1.2", "angular-ui-select2": "^0.0.5", "angular-ckeditor": "^1.0.3", - "angular-ui-calendar": "^1.0.2" + "angular-ui-calendar": "^1.0.2", + "angular-dragdrop": "^1.0.13" } } -- 2.0.0