/*!
* fullcalendar v2.6.1
* docs & license: http://fullcalendar.io/
* (c) 2015 adam shaw
*/
(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.6.1",
internalapiversion: 3
};
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 calendar(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'
];
// merges an array of option objects into a single object
function mergeoptions(optionobjs) {
return mergeprops(optionobjs, complexoptions);
}
// 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.intersectranges = intersectranges;
fc.applyall = applyall;
fc.debounce = debounce;
fc.isint = isint;
fc.htmlescape = htmlescape;
fc.csstostr = csstostr;
fc.proxy = proxy;
fc.capitalisefirstletter = capitalisefirstletter;
/* 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('> span').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.getouterrect = getouterrect;
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 = $('
';
},
renderbgintrohtml: function(row) {
return this.renderintrohtml(); // fall back to generic
},
renderbgcellshtml: function(row) {
var htmls = [];
var col, date;
for (col = 0; col < this.colcnt; col++) {
date = this.getcelldate(row, col);
htmls.push(this.renderbgcellhtml(date));
}
return htmls.join('');
},
renderbgcellhtml: function(date, otherattrs) {
var view = this.view;
var classes = this.getdayclasses(date);
classes.unshift('fc-day', view.widgetcontentclass);
return '
';
},
/* generic
------------------------------------------------------------------------------------------------------------------*/
// generates the default html intro for any row. user classes should override
renderintrohtml: function() {
},
// todo: a generic method for dealing with
, rtl, intro
// when increment internalapiversion
// wraptr (scheduler)
/* utils
------------------------------------------------------------------------------------------------------------------*/
// applies the generic "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.
bookendcells: function(trel) {
var introhtml = this.renderintrohtml();
if (introhtml) {
if (this.isrtl) {
trel.append(introhtml);
}
else {
trel.prepend(introhtml);
}
}
}
};
;;
/* a component that renders a grid of whole-days that runs horizontally. there can be multiple rows, one per week.
----------------------------------------------------------------------------------------------------------------------*/
var daygrid = fc.daygrid = grid.extend(daytablemixin, {
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
rowels: null, // set of fake row elements
cellels: null, // set of whole-day elements comprising the row's background
helperels: null, // set of cell skeleton elements for rendering the mock event "helper"
rowcoordcache: null,
colcoordcache: null,
// 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 html = '';
var row;
var col;
for (row = 0; row < rowcnt; row++) {
html += this.renderdayrowhtml(row, isrigid);
}
this.el.html(html);
this.rowels = this.el.find('.fc-row');
this.cellels = this.el.find('.fc-day');
this.rowcoordcache = new coordcache({
els: this.rowels,
isvertical: true
});
this.colcoordcache = new coordcache({
els: this.cellels.slice(0, this.colcnt), // only the first row
ishorizontal: true
});
// trigger dayrender with each cell's element
for (row = 0; row < rowcnt; row++) {
for (col = 0; col < colcnt; col++) {
view.trigger(
'dayrender',
null,
this.getcelldate(row, col),
this.getcellel(row, col)
);
}
}
},
unrenderdates: function() {
this.removesegpopover();
},
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, which is a div that wraps a table.
// `row` is the row number.
renderdayrowhtml: function(row, isrigid) {
var view = this.view;
var classes = [ 'fc-row', 'fc-week', view.widgetcontentclass ];
if (isrigid) {
classes.push('fc-rigid');
}
return '' +
'
';
},
rendernumberintrohtml: function(row) {
return this.renderintrohtml();
},
rendernumbercellshtml: function(row) {
var htmls = [];
var col, date;
for (col = 0; col < this.colcnt; col++) {
date = this.getcelldate(row, col);
htmls.push(this.rendernumbercellhtml(date));
}
return htmls.join('');
},
// 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.
rendernumbercellhtml: function(date) {
var classes;
if (!this.view.daynumbersvisible) { // if there are week numbers but not day numbers
return '
'; // will create an empty space above events :(
}
classes = this.getdayclasses(date);
classes.unshift('fc-day-number');
return '' +
'
' +
date.date() +
'
';
},
/* options
------------------------------------------------------------------------------------------------------------------*/
// 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
},
/* dates
------------------------------------------------------------------------------------------------------------------*/
rangeupdated: function() {
this.updatedaytable();
},
// slices up the given span (unzoned start/end with other misc data) into an array of segments
spantosegs: function(span) {
var segs = this.slicerangebyrow(span);
var i, seg;
for (i = 0; i < segs.length; i++) {
seg = segs[i];
if (this.isrtl) {
seg.leftcol = this.daysperrow - 1 - seg.lastrowdayindex;
seg.rightcol = this.daysperrow - 1 - seg.firstrowdayindex;
}
else {
seg.leftcol = seg.firstrowdayindex;
seg.rightcol = seg.lastrowdayindex;
}
}
return segs;
},
/* hit system
------------------------------------------------------------------------------------------------------------------*/
preparehits: function() {
this.colcoordcache.build();
this.rowcoordcache.build();
this.rowcoordcache.bottoms[this.rowcnt - 1] += this.bottomcoordpadding; // hack
},
releasehits: function() {
this.colcoordcache.clear();
this.rowcoordcache.clear();
},
queryhit: function(leftoffset, topoffset) {
var col = this.colcoordcache.gethorizontalindex(leftoffset);
var row = this.rowcoordcache.getverticalindex(topoffset);
if (row != null && col != null) {
return this.getcellhit(row, col);
}
},
gethitspan: function(hit) {
return this.getcellrange(hit.row, hit.col);
},
gethitel: function(hit) {
return this.getcellel(hit.row, hit.col);
},
/* cell system
------------------------------------------------------------------------------------------------------------------*/
// fyi: the first column is the leftmost column, regardless of date
getcellhit: function(row, col) {
return {
row: row,
col: col,
component: this, // needed unfortunately :(
left: this.colcoordcache.getleftoffset(col),
right: this.colcoordcache.getrightoffset(col),
top: this.rowcoordcache.gettopoffset(row),
bottom: this.rowcoordcache.getbottomoffset(row)
};
},
getcellel: function(row, col) {
return this.cellels.eq(row * this.colcnt + col);
},
/* 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.
// `eventlocation` has zoned start and end (optional)
renderdrag: function(eventlocation, seg) {
// always render a highlight underneath
this.renderhighlight(this.eventtospan(eventlocation));
// 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.rendereventlocationhelper(eventlocation, seg);
this.applydragopacity(this.helperels);
return true; // a helper has been rendered
}
},
// unrenders any visual indication of a hovering event
unrenderdrag: function() {
this.unrenderhighlight();
this.unrenderhelper();
},
/* event resize visualization
------------------------------------------------------------------------------------------------------------------*/
// renders a visual indication of an event being resized
rendereventresize: function(eventlocation, seg) {
this.renderhighlight(this.eventtospan(eventlocation));
this.rendereventlocationhelper(eventlocation, seg);
},
// unrenders a visual indication of an event being resized
unrendereventresize: function() {
this.unrenderhighlight();
this.unrenderhelper();
},
/* 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.eventtosegs(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
unrenderhelper: 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 = $(
'
');
}
this.bookendcells(trel);
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
unrenderevents: function() {
this.removesegpopover(); // removes the "more.." events popover
grid.prototype.unrenderevents.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
unrenderfgsegs: 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.getsegskincss(seg));
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 '' +
'
' +
(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);
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.
this.sorteventsegs(segs);
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
removesegpopover: function() {
if (this.segpopover) {
this.segpopover.hide(); // in handler, will call segpopover's removeelement
}
},
// 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 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) {
segsbelow = _this.getcellsegs(row, col, levellimit);
if (segsbelow.length) {
td = cellmatrix[levellimit - 1][col];
morelink = _this.rendermorelink(row, col, 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) {
segsbelow = this.getcellsegs(row, col, 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];
morelink = this.rendermorelink(
row,
seg.leftcol + j,
[ 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(row, col, 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 = _this.getcelldate(row, col);
var moreel = $(this);
var dayel = _this.getcellel(row, col);
var allsegs = _this.getcellsegs(row, col);
// 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(row, col, 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(row, col, morelink, segs) {
var _this = this;
var view = this.view;
var morewrap = morelink.parent(); // 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(row, col, segs) {
var view = this.view;
var istheme = view.opt('theme');
var title = this.getcelldate(row, col).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
this.preparehits();
segs[i].hit = this.getcellhit(row, col);
this.releasehits();
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();
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 = intersectranges(range, dayrange); // undefind if no intersection
return seg ? [ seg ] : []; // must return an array of segments
}
);
// force an order because eventstosegs doesn't guarantee one
this.sorteventsegs(segs);
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(row, col, startlevel) {
var segmatrix = this.rowstructs[row].segmatrix;
var level = startlevel || 0;
var segs = [];
var seg;
while (level < segmatrix.length) {
seg = segmatrix[level][col];
if (seg) {
segs.push(seg);
}
level++;
}
return segs;
}
});
;;
/* a component that renders one or more columns of vertical time slots
----------------------------------------------------------------------------------------------------------------------*/
// we mixin daytable, even though there is only a single row of days
var timegrid = fc.timegrid = grid.extend(daytablemixin, {
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
snapsperslot: null,
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
labelformat: null, // formatting string for times running along vertical axis
labelinterval: null, // duration of how often a label should be displayed for a slot
colels: null, // cells elements in the day-row background
slatels: null, // elements running horizontally across all columns
nowindicatorels: null,
colcoordcache: null,
slatcoordcache: 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.colels = this.el.find('.fc-day');
this.slatels = this.el.find('.fc-slats tr');
this.colcoordcache = new coordcache({
els: this.colels,
ishorizontal: true
});
this.slatcoordcache = new coordcache({
els: this.slatels,
isvertical: true
});
this.rendercontentskeleton();
},
// renders the basic html skeleton for the grid
renderhtml: function() {
return '' +
'
' +
'
' +
this.renderbgtrhtml(0) + // row=0
'
' +
'
' +
'
' +
'
' +
this.renderslatrowhtml() +
'
' +
'
';
},
// generates the html for the horizontal "slats" that run width-wise. has a time axis on a side. depends on rtl.
renderslatrowhtml: function() {
var view = this.view;
var isrtl = this.isrtl;
var html = '';
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 islabeled;
var axishtml;
// calculate the time for each slot
while (slottime < this.maxtime) {
slotdate = this.start.clone().time(slottime);
islabeled = isint(dividedurationbyduration(slottime, this.labelinterval));
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');
var input;
slotduration = moment.duration(slotduration);
snapduration = snapduration ? moment.duration(snapduration) : slotduration;
this.slotduration = slotduration;
this.snapduration = snapduration;
this.snapsperslot = slotduration / snapduration; // todo: ensure an integer multiple?
this.minresizeduration = snapduration; // hack
this.mintime = moment.duration(view.opt('mintime'));
this.maxtime = moment.duration(view.opt('maxtime'));
// might be an array value (for timelineview).
// if so, getting the most granular entry (the last one probably).
input = view.opt('slotlabelformat');
if ($.isarray(input)) {
input = input[input.length - 1];
}
this.labelformat =
input ||
view.opt('axisformat') || // deprecated
view.opt('smalltimeformat'); // the computed default
input = view.opt('slotlabelinterval');
this.labelinterval = input ?
moment.duration(input) :
this.computelabelinterval(slotduration);
},
// computes an automatic value for slotlabelinterval
computelabelinterval: function(slotduration) {
var i;
var labelinterval;
var slotsperlabel;
// find the smallest stock label interval that results in more than one slots-per-label
for (i = agenda_stock_sub_durations.length - 1; i >= 0; i--) {
labelinterval = moment.duration(agenda_stock_sub_durations[i]);
slotsperlabel = dividedurationbyduration(labelinterval, slotduration);
if (isint(slotsperlabel) && slotsperlabel > 1) {
return labelinterval;
}
}
return moment.duration(slotduration); // fall back. clone
},
// 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;
},
/* hit system
------------------------------------------------------------------------------------------------------------------*/
preparehits: function() {
this.colcoordcache.build();
this.slatcoordcache.build();
},
releasehits: function() {
this.colcoordcache.clear();
// note: don't clear slatcoordcache because we rely on it for computetimetop
},
queryhit: function(leftoffset, topoffset) {
var snapsperslot = this.snapsperslot;
var colcoordcache = this.colcoordcache;
var slatcoordcache = this.slatcoordcache;
var colindex = colcoordcache.gethorizontalindex(leftoffset);
var slatindex = slatcoordcache.getverticalindex(topoffset);
if (colindex != null && slatindex != null) {
var slattop = slatcoordcache.gettopoffset(slatindex);
var slatheight = slatcoordcache.getheight(slatindex);
var partial = (topoffset - slattop) / slatheight; // floating point number between 0 and 1
var localsnapindex = math.floor(partial * snapsperslot); // the snap # relative to start of slat
var snapindex = slatindex * snapsperslot + localsnapindex;
var snaptop = slattop + (localsnapindex / snapsperslot) * slatheight;
var snapbottom = slattop + ((localsnapindex + 1) / snapsperslot) * slatheight;
return {
col: colindex,
snap: snapindex,
component: this, // needed unfortunately :(
left: colcoordcache.getleftoffset(colindex),
right: colcoordcache.getrightoffset(colindex),
top: snaptop,
bottom: snapbottom
};
}
},
gethitspan: function(hit) {
var start = this.getcelldate(0, hit.col); // row=0
var time = this.computesnaptime(hit.snap); // pass in the snap-index
var end;
start.time(time);
end = start.clone().add(this.snapduration);
return { start: start, end: end };
},
gethitel: function(hit) {
return this.colels.eq(hit.col);
},
/* dates
------------------------------------------------------------------------------------------------------------------*/
rangeupdated: function() {
this.updatedaytable();
},
// given a row number of the grid, representing a "snap", returns a time (duration) from its start-of-day
computesnaptime: function(snapindex) {
return moment.duration(this.mintime + this.snapduration * snapindex);
},
// slices up the given span (unzoned start/end with other misc data) into an array of segments
spantosegs: function(span) {
var segs = this.slicerangebytimes(span);
var i;
for (i = 0; i < segs.length; i++) {
if (this.isrtl) {
segs[i].col = this.daysperrow - 1 - segs[i].dayindex;
}
else {
segs[i].col = segs[i].dayindex;
}
}
return segs;
},
slicerangebytimes: function(range) {
var segs = [];
var seg;
var dayindex;
var daydate;
var dayrange;
for (dayindex = 0; dayindex < this.daysperrow; dayindex++) {
daydate = this.daydates[dayindex].clone(); // todo: better api for this?
dayrange = {
start: daydate.clone().time(this.mintime),
end: daydate.clone().time(this.maxtime)
};
seg = intersectranges(range, dayrange); // both will be ambig timezone
if (seg) {
seg.dayindex = dayindex;
segs.push(seg);
}
}
return segs;
},
/* coordinates
------------------------------------------------------------------------------------------------------------------*/
updatesize: function(isresize) { // not a standard grid method
this.slatcoordcache.build();
if (isresize) {
this.updatesegverticals(
[].concat(this.fgsegs || [], this.bgsegs || [], this.businesssegs || [])
);
}
},
// 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 - startofdaydate.clone().striptime()
)
);
},
// computes the top coordinate, relative to the bounds of the grid, of the given time (a duration).
computetimetop: function(time) {
var len = this.slatels.length;
var slatcoverage = (time - this.mintime) / this.slotduration; // floating-point value of # of slots covered
var slatindex;
var slatremainder;
// compute a floating-point number for how many slats should be progressed through.
// from 0 to number of slats (inclusive)
// constrained because mintime/maxtime might be customized.
slatcoverage = math.max(0, slatcoverage);
slatcoverage = math.min(len, slatcoverage);
// an integer index of the furthest whole slat
// from 0 to number slats (*exclusive*, so len-1)
slatindex = math.floor(slatcoverage);
slatindex = math.min(slatindex, len - 1);
// how much further through the slatindex slat (from 0.0-1.0) must be covered in addition.
// could be 1.0 if slatcoverage is covering *all* the slots
slatremainder = slatcoverage - slatindex;
return this.slatcoordcache.gettopposition(slatindex) +
this.slatcoordcache.getheight(slatindex) * slatremainder;
},
/* event drag visualization
------------------------------------------------------------------------------------------------------------------*/
// renders a visual indication of an event being dragged over the specified date(s).
// a returned value of `true` signals that a mock "helper" event has been rendered.
renderdrag: function(eventlocation, seg) {
if (seg) { // if there is event information for this drag, render a helper event
this.rendereventlocationhelper(eventlocation, seg);
for (var i = 0; i < this.helpersegs.length; i++) {
this.applydragopacity(this.helpersegs[i].el);
}
return true; // signal that a helper has been rendered
}
else {
// otherwise, just render a highlight
this.renderhighlight(this.eventtospan(eventlocation));
}
},
// unrenders any visual indication of an event being dragged
unrenderdrag: function() {
this.unrenderhelper();
this.unrenderhighlight();
},
/* event resize visualization
------------------------------------------------------------------------------------------------------------------*/
// renders a visual indication of an event being resized
rendereventresize: function(eventlocation, seg) {
this.rendereventlocationhelper(eventlocation, seg);
},
// unrenders any visual indication of an event being resized
unrendereventresize: function() {
this.unrenderhelper();
},
/* event helper
------------------------------------------------------------------------------------------------------------------*/
// renders a mock "helper" event. `sourceseg` is the original segment object and might be null (an external drag)
renderhelper: function(event, sourceseg) {
this.renderhelpersegs(this.eventtosegs(event), sourceseg);
},
// unrenders any mock helper event
unrenderhelper: function() {
this.unrenderhelpersegs();
},
/* business hours
------------------------------------------------------------------------------------------------------------------*/
renderbusinesshours: function() {
var events = this.view.calendar.getbusinesshoursevents();
var segs = this.eventstosegs(events);
this.renderbusinesssegs(segs);
},
unrenderbusinesshours: function() {
this.unrenderbusinesssegs();
},
/* now indicator
------------------------------------------------------------------------------------------------------------------*/
getnowindicatorunit: function() {
return 'minute'; // will refresh on the minute
},
rendernowindicator: function(date) {
// seg system might be overkill, but it handles scenario where line needs to be rendered
// more than once because of columns with the same date (resources columns for example)
var segs = this.spantosegs({ start: date, end: date });
var top = this.computedatetop(date, date);
var nodes = [];
var i;
// render lines within the columns
for (i = 0; i < segs.length; i++) {
nodes.push($('')
.css('top', top)
.appendto(this.colcontainerels.eq(segs[i].col))[0]);
}
// render an arrow over the axis
if (segs.length > 0) { // is the current time in view?
nodes.push($('')
.css('top', top)
.appendto(this.el.find('.fc-content-skeleton'))[0]);
}
this.nowindicatorels = $(nodes);
},
unrendernowindicator: function() {
if (this.nowindicatorels) {
this.nowindicatorels.remove();
this.nowindicatorels = null;
}
},
/* selection
------------------------------------------------------------------------------------------------------------------*/
// renders a visual indication of a selection. overrides the default, which was to simply render a highlight.
renderselection: function(span) {
if (this.view.opt('selecthelper')) { // this setting signals that a mock helper event should be rendered
// normally acceps an eventlocation, span has a start/end, which is good enough
this.rendereventlocationhelper(span);
}
else {
this.renderhighlight(span);
}
},
// unrenders any visual indication of a selection
unrenderselection: function() {
this.unrenderhelper();
this.unrenderhighlight();
},
/* highlight
------------------------------------------------------------------------------------------------------------------*/
renderhighlight: function(span) {
this.renderhighlightsegs(this.spantosegs(span));
},
unrenderhighlight: function() {
this.unrenderhighlightsegs();
}
});
;;
/* methods for rendering segments, pieces of content that live on the view
( this file is no longer just for events )
----------------------------------------------------------------------------------------------------------------------*/
timegrid.mixin({
colcontainerels: null, // containers for each column
// inner-containers for each column where different types of segs live
fgcontainerels: null,
bgcontainerels: null,
helpercontainerels: null,
highlightcontainerels: null,
businesscontainerels: null,
// arrays of different types of displayed segments
fgsegs: null,
bgsegs: null,
helpersegs: null,
highlightsegs: null,
businesssegs: null,
// renders the dom that the view's content will live in
rendercontentskeleton: function() {
var cellhtml = '';
var i;
var skeletonel;
for (i = 0; i < this.colcnt; i++) {
cellhtml +=
'
' +
'
' +
'' +
'' +
'' +
'' +
'' +
'
' +
'
';
}
skeletonel = $(
'
' +
'
' +
'
' + cellhtml + '
' +
'
' +
'
'
);
this.colcontainerels = skeletonel.find('.fc-content-col');
this.helpercontainerels = skeletonel.find('.fc-helper-container');
this.fgcontainerels = skeletonel.find('.fc-event-container:not(.fc-helper-container)');
this.bgcontainerels = skeletonel.find('.fc-bgevent-container');
this.highlightcontainerels = skeletonel.find('.fc-highlight-container');
this.businesscontainerels = skeletonel.find('.fc-business-container');
this.bookendcells(skeletonel.find('tr')); // todo: do this on string level
this.el.append(skeletonel);
},
/* foreground events
------------------------------------------------------------------------------------------------------------------*/
renderfgsegs: function(segs) {
segs = this.renderfgsegsintocontainers(segs, this.fgcontainerels);
this.fgsegs = segs;
return segs; // needed for grid::renderevents
},
unrenderfgsegs: function() {
this.unrendernamedsegs('fgsegs');
},
/* foreground helper events
------------------------------------------------------------------------------------------------------------------*/
renderhelpersegs: function(segs, sourceseg) {
var i, seg;
var sourceel;
segs = this.renderfgsegsintocontainers(segs, this.helpercontainerels);
// 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.helpersegs = segs;
},
unrenderhelpersegs: function() {
this.unrendernamedsegs('helpersegs');
},
/* background events
------------------------------------------------------------------------------------------------------------------*/
renderbgsegs: function(segs) {
segs = this.renderfillsegels('bgevent', segs); // todo: old fill system
this.updatesegverticals(segs);
this.attachsegsbycol(this.groupsegsbycol(segs), this.bgcontainerels);
this.bgsegs = segs;
return segs; // needed for grid::renderevents
},
unrenderbgsegs: function() {
this.unrendernamedsegs('bgsegs');
},
/* highlight
------------------------------------------------------------------------------------------------------------------*/
renderhighlightsegs: function(segs) {
segs = this.renderfillsegels('highlight', segs); // todo: old fill system
this.updatesegverticals(segs);
this.attachsegsbycol(this.groupsegsbycol(segs), this.highlightcontainerels);
this.highlightsegs = segs;
},
unrenderhighlightsegs: function() {
this.unrendernamedsegs('highlightsegs');
},
/* business hours
------------------------------------------------------------------------------------------------------------------*/
renderbusinesssegs: function(segs) {
segs = this.renderfillsegels('businesshours', segs); // todo: old fill system
this.updatesegverticals(segs);
this.attachsegsbycol(this.groupsegsbycol(segs), this.businesscontainerels);
this.businesssegs = segs;
},
unrenderbusinesssegs: function() {
this.unrendernamedsegs('businesssegs');
},
/* seg rendering utils
------------------------------------------------------------------------------------------------------------------*/
// given a flat array of segments, return an array of sub-arrays, grouped by each segment's col
groupsegsbycol: function(segs) {
var segsbycol = [];
var i;
for (i = 0; i < this.colcnt; i++) {
segsbycol.push([]);
}
for (i = 0; i < segs.length; i++) {
segsbycol[segs[i].col].push(segs[i]);
}
return segsbycol;
},
// given segments grouped by column, insert the segments' elements into a parallel array of container
// elements, each living within a column.
attachsegsbycol: function(segsbycol, containerels) {
var col;
var segs;
var i;
for (col = 0; col < this.colcnt; col++) { // iterate each column grouping
segs = segsbycol[col];
for (i = 0; i < segs.length; i++) {
containerels.eq(col).append(segs[i].el);
}
}
},
// given the name of a property of `this` object, assumed to be an array of segments,
// loops through each segment and removes from dom. will null-out the property afterwards.
unrendernamedsegs: function(propname) {
var segs = this[propname];
var i;
if (segs) {
for (i = 0; i < segs.length; i++) {
segs[i].el.remove();
}
this[propname] = null;
}
},
/* foreground event rendering utils
------------------------------------------------------------------------------------------------------------------*/
// given an array of foreground segments, render a dom element for each, computes position,
// and attaches to the column inner-container elements.
renderfgsegsintocontainers: function(segs, containerels) {
var segsbycol;
var col;
segs = this.renderfgsegels(segs); // will call fgseghtml
segsbycol = this.groupsegsbycol(segs);
for (col = 0; col < this.colcnt; col++) {
this.updatefgsegcoords(segsbycol[col]);
}
this.attachsegsbycol(segsbycol, containerels);
return segs;
},
// 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.getsegskincss(seg));
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 '' +
'
' +
'' +
/* todo: write css for this
(isresizablefromstart ?
'' :
''
) +
*/
(isresizablefromend ?
'' :
''
) +
'';
},
/* seg position utils
------------------------------------------------------------------------------------------------------------------*/
// refreshes the css top/bottom coordinates for each segment element.
// works when called after initial render, after a window resize/zoom for example.
updatesegverticals: function(segs) {
this.computesegverticals(segs);
this.assignsegverticals(segs);
},
// 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);
}
},
// given segments that already have their top/bottom properties computed, applies those values to
// the segments' elements.
assignsegverticals: function(segs) {
var i, seg;
for (i = 0; i < segs.length; i++) {
seg = segs[i];
seg.el.css(this.generatesegverticalcss(seg));
}
},
// 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
};
},
/* foreground event positioning utils
------------------------------------------------------------------------------------------------------------------*/
// given segments that are assumed to all live in the *same column*,
// compute their verical/horizontal coordinates and assign to their elements.
updatefgsegcoords: function(segs) {
this.computesegverticals(segs); // horizontals relies on this
this.computefgseghorizontals(segs); // compute horizontal coordinates, z-index's, and reorder the array
this.assignsegverticals(segs);
this.assignfgseghorizontals(segs);
},
// 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!
computefgseghorizontals: function(segs) {
var levels;
var level0;
var i;
this.sorteventsegs(segs); // order by certain criteria
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++) {
this.computefgsegforwardback(level0[i], 0, 0);
}
}
},
// calculate seg.forwardcoord and seg.backwardcoord for the segment, where both values range
// from 0 to 1. if the calendar is left-to-right, the seg.backwardcoord maps to "left" and
// seg.forwardcoord maps to "right" (via percentage). vice-versa if the calendar is right-to-left.
//
// the segment might be part of a "series", which means consecutive segments with the same pressure
// who's width is unknown until an edge has been hit. `seriesbackwardpressure` is the number of
// segments behind this one in the current series, and `seriesbackwardcoord` is the starting
// coordinate of the first segment in the series.
computefgsegforwardback: function(seg, seriesbackwardpressure, seriesbackwardcoord) {
var forwardsegs = seg.forwardsegs;
var i;
if (seg.forwardcoord === undefined) { // not already computed
if (!forwardsegs.length) {
// if there are no forward segments, this segment should butt up against the edge
seg.forwardcoord = 1;
}
else {
// sort highest pressure first
this.sortforwardsegs(forwardsegs);
// this segment's forwardcoord will be calculated from the backwardcoord of the
// highest-pressure forward segment.
this.computefgsegforwardback(forwardsegs[0], seriesbackwardpressure + 1, seriesbackwardcoord);
seg.forwardcoord = forwardsegs[0].backwardcoord;
}
// calculate the backwardcoord from the forwardcoord. consider the series
seg.backwardcoord = seg.forwardcoord -
(seg.forwardcoord - seriesbackwardcoord) / // available width for series
(seriesbackwardpressure + 1); // # of segments in the series
// use this segment's coordinates to computed the coordinates of the less-pressurized
// forward segments
for (i=0; i seg2.top && seg1.top < seg2.bottom;
}
;;
/* 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
el: null, // the view's containing element. set by calendar
displaying: null, // a promise representing the state of rendering. null if no render requested
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"
isrtl: false,
isselected: false, // boolean whether a range of time is user-selected or not
eventorderspecs: null, // criteria for ordering events when they have same date/time
// 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
// now indicator
isnowindicatorrendered: null,
initialnowdate: null, // result first getnow call
initialnowqueriedms: null, // ms time the getnow was called
nowindicatortimeoutid: null, // for refresh timing of now indicator
nowindicatorintervalid: null, // "
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.isrtl = this.opt('isrtl');
this.eventorderspecs = parsefieldspecs(this.opt('eventorder'));
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 unzoned date.
setdate: function(date) {
this.setrange(this.computerange(date));
},
// updates all internal dates for displaying the given unzoned range.
setrange: function(range) {
$.extend(this, range); // assigns every property to this object's member variables
this.updatetitle();
},
// given a single current unzoned 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.time(0); // give 00:00 time
}
if (!intervalend.hastime()) {
intervalend = this.calendar.time(0); // give 00:00 time
}
}
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(
{
// in case intervalstart/end has a time, make sure timezone is correct
start: this.calendar.applytimezone(this.intervalstart),
end: this.calendar.applytimezone(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.
// the timezones of the dates within `range` will be respected.
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.unrenderskeleton();
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 we shouldn't kill this.el either.
},
// does everything necessary to display the view centered around the given unzoned date.
// does every type of rendering except rendering events.
// is asychronous and returns a promise.
display: function(date) {
var _this = this;
var scrollstate = null;
if (this.displaying) {
scrollstate = this.queryscroll();
}
this.calendar.freezecontentheight();
return this.clear().then(function() { // clear the content first (async)
return (
_this.displaying =
$.when(_this.displayview(date)) // displayview might return a promise
.then(function() {
_this.forcescroll(_this.computeinitialscroll(scrollstate));
_this.calendar.unfreezecontentheight();
_this.triggerrender();
})
);
});
},
// does everything necessary to clear the content of the view.
// clears dates and events. does not clear the skeleton.
// is asychronous and returns a promise.
clear: function() {
var _this = this;
var displaying = this.displaying;
if (displaying) { // previously displayed, or in the process of being displayed?
return displaying.then(function() { // wait for the display to finish
_this.displaying = null;
_this.clearevents();
return _this.clearview(); // might return a promise. chain it
});
}
else {
return $.when(); // an immediately-resolved promise
}
},
// displays the view's non-event content, such as date-related content or anything required by events.
// renders the view's non-content skeleton if necessary.
// can be asynchronous and return a promise.
displayview: function(date) {
if (!this.isskeletonrendered) {
this.renderskeleton();
this.isskeletonrendered = true;
}
if (date) {
this.setdate(date);
}
if (this.render) {
this.render(); // todo: deprecate
}
this.renderdates();
this.updatesize();
this.renderbusinesshours(); // might need coordinates, so should go after updatesize()
this.startnowindicator();
},
// unrenders the view content that was rendered in displayview.
// can be asynchronous and return a promise.
clearview: function() {
this.unselect();
this.stopnowindicator();
this.triggerunrender();
this.unrenderbusinesshours();
this.unrenderdates();
if (this.destroy) {
this.destroy(); // todo: deprecate
}
},
// renders the basic structure of the view before any content is rendered
renderskeleton: function() {
// subclasses should implement
},
// unrenders the basic structure of the view
unrenderskeleton: function() {
// subclasses should implement
},
// renders the view's date-related content.
// 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
unrenderdates: function() {
// subclasses should override
},
// 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
triggerunrender: 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';
},
/* business hours
------------------------------------------------------------------------------------------------------------------*/
// renders business-hours onto the view. assumes updatesize has already been called.
renderbusinesshours: function() {
// subclasses should implement
},
// unrenders previously-rendered business-hours
unrenderbusinesshours: function() {
// subclasses should implement
},
/* now indicator
------------------------------------------------------------------------------------------------------------------*/
// immediately render the current time indicator and begins re-rendering it at an interval,
// which is defined by this.getnowindicatorunit().
// todo: somehow do this for the current whole day's background too
startnowindicator: function() {
var _this = this;
var unit;
var update;
var delay; // ms wait value
if (this.opt('nowindicator')) {
unit = this.getnowindicatorunit();
if (unit) {
update = proxy(this, 'updatenowindicator'); // bind to `this`
this.initialnowdate = this.calendar.getnow();
this.initialnowqueriedms = +new date();
this.rendernowindicator(this.initialnowdate);
this.isnowindicatorrendered = true;
// wait until the beginning of the next interval
delay = this.initialnowdate.clone().startof(unit).add(1, unit) - this.initialnowdate;
this.nowindicatortimeoutid = settimeout(function() {
_this.nowindicatortimeoutid = null;
update();
delay = +moment.duration(1, unit);
delay = math.max(100, delay); // prevent too frequent
_this.nowindicatorintervalid = setinterval(update, delay); // update every interval
}, delay);
}
}
},
// rerenders the now indicator, computing the new current time from the amount of time that has passed
// since the initial getnow call.
updatenowindicator: function() {
if (this.isnowindicatorrendered) {
this.unrendernowindicator();
this.rendernowindicator(
this.initialnowdate.clone().add(new date() - this.initialnowqueriedms) // add ms
);
}
},
// immediately unrenders the view's current time indicator and stops any re-rendering timers.
// won't cause side effects if indicator isn't rendered.
stopnowindicator: function() {
if (this.isnowindicatorrendered) {
if (this.nowindicatortimeoutid) {
cleartimeout(this.nowindicatortimeoutid);
this.nowindicatortimeoutid = null;
}
if (this.nowindicatorintervalid) {
cleartimeout(this.nowindicatorintervalid);
this.nowindicatorintervalid = null;
}
this.unrendernowindicator();
this.isnowindicatorrendered = false;
}
},
// returns a string unit, like 'second' or 'minute' that defined how often the current time indicator
// should be refreshed. if something falsy is returned, no time indicator is rendered at all.
getnowindicatorunit: function() {
// subclasses should implement
},
// renders a current time indicator at the given datetime
rendernowindicator: function(date) {
// subclasses should implement
},
// undoes the rendering actions from rendernowindicator
unrendernowindicator: function() {
// subclasses should implement
},
/* 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(isresize);
this.updatewidth(isresize);
this.updatenowindicator();
if (isresize) {
this.setscroll(scrollstate);
}
},
// refreshes the horizontal dimensions of the calendar
updatewidth: function(isresize) {
// subclasses should implement
},
// refreshes the vertical dimensions of the calendar
updateheight: function(isresize) {
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() {
var scrollstate;
if (this.iseventsrendered) {
// todo: optimize: if we know this is part of a displayevents call, don't queryscroll/setscroll
scrollstate = this.queryscroll();
this.triggereventunrender();
if (this.destroyevents) {
this.destroyevents(); // todo: deprecate
}
this.unrenderevents();
this.setscroll(scrollstate);
this.iseventsrendered = false;
}
},
// renders the events onto the view.
renderevents: function(events) {
// subclasses should implement
},
// removes event elements from the view.
unrenderevents: 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
triggereventunrender: 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 zoned 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 zoned 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.
unrenderdrag: 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 span on the view. `start` and `end` are both moments.
// `ev` is the native mouse event that begin the interaction.
select: function(span, ev) {
this.unselect(ev);
this.renderselection(span);
this.reportselection(span, ev);
},
// renders a visual indication of the selection
renderselection: function(span) {
// subclasses should implement
},
// called when a new selection is made. updates internal state and triggers handlers.
reportselection: function(span, ev) {
this.isselected = true;
this.triggerselect(span, ev);
},
// triggers handlers to 'select'
triggerselect: function(span, ev) {
this.trigger(
'select',
null,
this.calendar.applytimezone(span.start), // convert to calendar's tz for external api
this.calendar.applytimezone(span.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;
if (this.destroyselection) {
this.destroyselection(); // todo: deprecate
}
this.unrenderselection();
this.trigger('unselect', null, ev);
}
},
// unrenders a visual indication of selection
unrenderselection: 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);
}
}
},
/* day click
------------------------------------------------------------------------------------------------------------------*/
// triggers handlers to 'dayclick'
// span has start/end of the clicked area. only the start is useful.
triggerdayclick: function(span, dayel, ev) {
this.trigger(
'dayclick',
dayel,
this.calendar.applytimezone(span.start), // convert to calendar's timezone for external api
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 = 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,
loadinglevel: 0, // number of simultaneous loading tasks
// 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,
// subclasses can override this for initialization logic after the constructor has been called
initialize: function() {
},
// 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 specchain = []; // for the view. lowest to highest priority
var defaultschain = []; // for the view. lowest to highest priority
var overrideschain = []; // for the view. lowest to highest priority
var viewtype = requestedviewtype;
var spec; // for the view
var overrides; // for the view
var duration;
var unit;
// iterate from the specific view definition to a more general one until we hit an actual view class
while (viewtype) {
spec = fcviews[viewtype];
overrides = viewoverrides[viewtype];
viewtype = null; // clear. might repopulate for another iteration
if (typeof spec === 'function') { // todo: deprecate
spec = { 'class': spec };
}
if (spec) {
specchain.unshift(spec);
defaultschain.unshift(spec.defaults || {});
duration = duration || spec.duration;
viewtype = viewtype || spec.type;
}
if (overrides) {
overrideschain.unshift(overrides); // view-specific option hashes have options at zero-level
duration = duration || overrides.duration;
viewtype = viewtype || overrides.type;
}
}
spec = mergeprops(specchain);
spec.type = requestedviewtype;
if (!spec['class']) {
return false;
}
if (duration) {
duration = moment.duration(duration);
if (duration.valueof()) { // valid?
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] || {});
}
}
}
spec.defaults = mergeoptions(defaultschain);
spec.overrides = mergeoptions(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));
},
// should be called when any type of async data fetching begins
pushloading: function() {
if (!(this.loadinglevel++)) {
this.trigger('loading', null, true, this.view);
}
},
// should be called when any type of async data fetching completes
poploading: function() {
if (!(--this.loadinglevel)) {
this.trigger('loading', null, false, this.view);
}
},
// given arguments to the select method in the api, returns a span (unzoned start/end and other info)
buildselectspan: function(zonedstartinput, zonedendinput) {
var start = this.moment(zonedstartinput).stripzone();
var end;
if (zonedendinput) {
end = this.moment(zonedendinput).stripzone();
}
else if (start.hastime()) {
end = start.clone().add(this.defaulttimedeventduration);
}
else {
end = start.clone().add(this.defaultalldayeventduration);
}
return { start: start, end: end };
}
});
calendar.mixin(emitter);
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. has no effect on dates without times.
t.applytimezone = function(date) {
if (!date.hastime()) {
return date.clone();
}
var zoneddate = t.moment(date.toarray());
var timeadjust = date.time() - zoneddate.time();
var adjustedzoneddate;
// safari sometimes has problems with this coersion when near dst. adjust if necessary. (bug #2396)
if (timeadjust) { // is the time result different than expected?
adjustedzoneddate = zoneddate.clone().add(timeadjust); // add milliseconds
if (date.time() - adjustedzoneddate.time() === 0) { // does it match perfectly now?
zoneddate = adjustedzoneddate;
}
}
return zoneddate;
};
// returns a moment for the current date, as defined by the client's computer or from the `now` option.
// will return an moment with an ambiguous timezone.
t.getnow = function() {
var now = options.now;
if (typeof now === 'function') {
now = now();
}
return t.moment(now).stripzone();
};
// 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 what its fallback end date should be.
// todo: rename to computedefaulteventend
t.getdefaulteventend = function(allday, zonedstart) {
var end = zonedstart.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 events = [];
var date; // unzoned
// main rendering
// -----------------------------------------------------------------------------------
// compute the initial ambig-timezone date
if (options.defaultdate != null) {
date = t.moment(options.defaultdate).stripzone();
}
else {
date = t.getnow(); // getnow already returns unzoned
}
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.removeelement();
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, remove the old view's rendering
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.displaying ||
!date.iswithin(currentview.intervalstart, currentview.intervalend) // implicit date window change
) {
if (elementvisible()) {
currentview.display(date); // will call freezecontentheight
unfreezecontentheight(); // immediately unfreeze regardless of whether display is async
// 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
-----------------------------------------------------------------------------*/
// this public method receives start/end dates in any format, with any timezone
function select(zonedstartinput, zonedendinput) {
currentview.select(
t.buildselectspan.apply(t, arguments)
);
}
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(zoneddateinput) {
date = t.moment(zoneddateinput).stripzone();
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.clone();
renderview(spec ? spec.type : null);
}
// for external api
function getdate() {
return t.applytimezone(date); // infuse the calendar's timezone
}
/* height "freezing"
-----------------------------------------------------------------------------*/
// todo: move this into the view
t.freezecontentheight = freezecontentheight;
t.unfreezecontentheight = unfreezecontentheight;
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) { // overrides the emitter's trigger method :(
var args = array.prototype.slice.call(arguments, 2);
thisobj = thisobj || _element;
this.triggerwith(name, thisobj, args); // emitter's method
if (options[name]) {
return options[name].apply(thisobj, args);
}
}
t.initialize();
}
;;
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,
//nowindicator: false,
scrolltime: '06:00:00',
// 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: '*',
eventorder: 'title',
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)
// todo: best place for this? related to lang?
// todo: flipping text based on isrtl is a bad idea because the css `direction` might want to handle it
var instancecomputableoptions = {
// 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.removeelement = removeelement;
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 removeelement() {
el.remove();
el = $();
}
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 custombuttonprops;
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; // the element
if (buttonname == 'title') {
groupchildren = groupchildren.add($('
')); // we always want it to take up height
isonlybuttons = false;
}
else {
if ((custombuttonprops = (calendar.options.custombuttons || {})[buttonname])) {
buttonclick = function(ev) {
if (custombuttonprops.click) {
custombuttonprops.click.call(button[0], ev);
}
};
overridetext = ''; // icons will override text
defaulttext = custombuttonprops.text;
}
else if ((viewspec = calendar.getviewspec(buttonname))) {
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 =
custombuttonprops ?
custombuttonprops.themeicon :
options.themebuttonicons[buttonname];
normalicon =
custombuttonprops ?
custombuttonprops.icon :
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(ev) {
// don't process clicks for disabled buttons
if (!button.hasclass(tm + '-state-disabled')) {
buttonclick(ev);
// 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.normalizeeventdates = normalizeeventdates;
t.normalizeeventtimes = normalizeeventtimes;
// imports
var reportevents = t.reportevents;
// locals
var stickysource = { events: [] };
var sources = [ stickysource ];
var rangestart, rangeend;
var currentfetchid = 0;
var pendingsourcecnt = 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
-----------------------------------------------------------------------------*/
// start and end are assumed to be unzoned
function isfetchneeded(start, end) {
return !rangestart || // nothing has been fetched yet?
start < rangestart || end > rangeend; // is part of the new range outside of the old range?
}
function fetchevents(start, end) {
rangestart = start;
rangeend = end;
cache = [];
var fetchid = ++currentfetchid;
var len = sources.length;
pendingsourcecnt = len;
for (var i=0; i= 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 span. attached to the calendar's prototype because eventmanager is a mixin for a calendar.
calendar.prototype.getpeerevents = function(span, event) {
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 = fc.basicview = view.extend({
daygridclass: daygrid, // class the daygrid will be instantiated from (overridable by subclasses)
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
headcontainerel: null, // div that hold's the daygrid's rendered date header
headrowel: null, // the fake row element of the day-of-week header
initialize: function() {
this.daygrid = this.instantiatedaygrid();
},
// generates the daygrid object this view needs. draws from this.daygridclass
instantiatedaygrid: function() {
// generate a subclass on the fly with basicview-specific behavior
// todo: cache this subclass
var subclass = this.daygridclass.extend(basicdaygridmethods);
return new subclass(this);
},
// 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
renderdates: 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.renderskeletonhtml());
this.renderhead();
this.scrollerel = this.el.find('.fc-day-grid-container');
this.daygrid.setelement(this.el.find('.fc-day-grid'));
this.daygrid.renderdates(this.hasrigidrows());
},
// render the day-of-week headers
renderhead: function() {
this.headcontainerel =
this.el.find('.fc-head-container')
.html(this.daygrid.renderheadhtml());
this.headrowel = this.headcontainerel.find('.fc-row');
},
// unrenders the content of the view. since we haven't separated skeleton rendering from date rendering,
// always completely kill the daygrid's rendering.
unrenderdates: function() {
this.daygrid.unrenderdates();
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.
renderskeletonhtml: function() {
return '' +
'
' +
'' +
'
' +
'
' +
'
' +
'' +
'' +
'
' +
'
' +
'
' +
'' +
'
' +
'
' +
'
' +
'' +
'
';
},
// 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.removesegpopover(); // 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
}
},
/* hit areas
------------------------------------------------------------------------------------------------------------------*/
// forward all hit-related method calls to daygrid
preparehits: function() {
this.daygrid.preparehits();
},
releasehits: function() {
this.daygrid.releasehits();
},
queryhit: function(left, top) {
return this.daygrid.queryhit(left, top);
},
gethitspan: function(hit) {
return this.daygrid.gethitspan(hit);
},
gethitel: function(hit) {
return this.daygrid.gethitel(hit);
},
/* 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
unrenderevents: function() {
this.daygrid.unrenderevents();
// 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);
},
unrenderdrag: function() {
this.daygrid.unrenderdrag();
},
/* selection
------------------------------------------------------------------------------------------------------------------*/
// renders a visual indication of a selection
renderselection: function(span) {
this.daygrid.renderselection(span);
},
// unrenders a visual indications of a selection
unrenderselection: function() {
this.daygrid.unrenderselection();
}
});
// methods that will customize the rendering behavior of the basicview's daygrid
var basicdaygridmethods = {
// generates the html that will go before the day-of week header cells
renderheadintrohtml: function() {
var view = this.view;
if (view.weeknumbersvisible) {
return '' +
'
';
}
return '';
},
// generates the html that will go before content-skeleton cells that display the day/week numbers
rendernumberintrohtml: function(row) {
var view = this.view;
if (view.weeknumbersvisible) {
return '' +
'
';
}
return '';
},
// generates the html that goes before the day bg cells for each day-row
renderbgintrohtml: function() {
var view = this.view;
if (view.weeknumbersvisible) {
return '
';
}
return '';
},
// generates the html that goes before every other type of row generated by daygrid.
// affects helper-skeleton and highlight-skeleton rows.
renderintrohtml: function() {
var view = this.view;
if (view.weeknumbersvisible) {
return '
';
}
return '';
}
};
;;
/* a month view with day cells running in rows (one-per-week) and columns
----------------------------------------------------------------------------------------------------------------------*/
var monthview = fc.monthview = 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');
}
});
;;
fcviews.basic = {
'class': basicview
};
fcviews.basicday = {
type: 'basic',
duration: { days: 1 }
};
fcviews.basicweek = {
type: 'basic',
duration: { weeks: 1 }
};
fcviews.month = {
'class': monthview,
duration: { months: 1 }, // important for prev/next
defaults: {
fixedweekcount: true
}
};
;;
/* 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 agendaview = fc.agendaview = view.extend({
timegridclass: timegrid, // class used to instantiate the timegrid. subclasses can override
timegrid: null, // the main time-grid subcomponent of this view
daygridclass: daygrid, // class used to instantiate the daygrid. subclasses can override
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
headcontainerel: null, // div that hold's the timegrid's rendered date header
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 = this.instantiatetimegrid();
if (this.opt('alldayslot')) { // should we display the "all-day" area?
this.daygrid = this.instantiatedaygrid(); // the all-day subcomponent of this view
}
},
// instantiates the timegrid object this view needs. draws from this.timegridclass
instantiatetimegrid: function() {
var subclass = this.timegridclass.extend(agendatimegridmethods);
return new subclass(this);
},
// instantiates the daygrid object this view might need. draws from this.daygridclass
instantiatedaygrid: function() {
var subclass = this.daygridclass.extend(agendadaygridmethods);
return new subclass(this);
},
/* 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
renderdates: function() {
this.el.addclass('fc-agenda-view').html(this.renderskeletonhtml());
this.renderhead();
// the element that wraps the time-grid that will probably scroll
this.scrollerel = this.el.find('.fc-time-grid-container');
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
},
// render the day-of-week headers
renderhead: function() {
this.headcontainerel =
this.el.find('.fc-head-container')
.html(this.timegrid.renderheadhtml());
},
// unrenders the content of the view. since we haven't separated skeleton rendering from date rendering,
// always completely kill each grid's rendering.
unrenderdates: function() {
this.timegrid.unrenderdates();
this.timegrid.removeelement();
if (this.daygrid) {
this.daygrid.unrenderdates();
this.daygrid.removeelement();
}
},
// builds the html skeleton for the view.
// the day-grid and time-grid components will render inside containers defined by this html.
renderskeletonhtml: function() {
return '' +
'
' +
'' +
'
' +
'
' +
'
' +
'' +
'' +
'
' +
'
' +
(this.daygrid ?
'' +
'' :
''
) +
'
' +
'' +
'
' +
'
' +
'
' +
'' +
'
';
},
// 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 '';
},
/* business hours
------------------------------------------------------------------------------------------------------------------*/
renderbusinesshours: function() {
this.timegrid.renderbusinesshours();
if (this.daygrid) {
this.daygrid.renderbusinesshours();
}
},
unrenderbusinesshours: function() {
this.timegrid.unrenderbusinesshours();
if (this.daygrid) {
this.daygrid.unrenderbusinesshours();
}
},
/* now indicator
------------------------------------------------------------------------------------------------------------------*/
getnowindicatorunit: function() {
return this.timegrid.getnowindicatorunit();
},
rendernowindicator: function(date) {
this.timegrid.rendernowindicator(date);
},
unrendernowindicator: function() {
this.timegrid.unrendernowindicator();
},
/* 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.removesegpopover(); // 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;
},
/* hit areas
------------------------------------------------------------------------------------------------------------------*/
// forward all hit-related method calls to the grids (daygrid might not be defined)
preparehits: function() {
this.timegrid.preparehits();
if (this.daygrid) {
this.daygrid.preparehits();
}
},
releasehits: function() {
this.timegrid.releasehits();
if (this.daygrid) {
this.daygrid.releasehits();
}
},
queryhit: function(left, top) {
var hit = this.timegrid.queryhit(left, top);
if (!hit && this.daygrid) {
hit = this.daygrid.queryhit(left, top);
}
return hit;
},
gethitspan: function(hit) {
// todo: hit.component is set as a hack to identify where the hit came from
return hit.component.gethitspan(hit);
},
gethitel: function(hit) {
// todo: hit.component is set as a hack to identify where the hit came from
return hit.component.gethitel(hit);
},
/* 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
unrenderevents: function() {
// unrender the events in the subcomponents
this.timegrid.unrenderevents();
if (this.daygrid) {
this.daygrid.unrenderevents();
}
// 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);
}
},
unrenderdrag: function() {
this.timegrid.unrenderdrag();
if (this.daygrid) {
this.daygrid.unrenderdrag();
}
},
/* selection
------------------------------------------------------------------------------------------------------------------*/
// renders a visual indication of a selection
renderselection: function(span) {
if (span.start.hastime() || span.end.hastime()) {
this.timegrid.renderselection(span);
}
else if (this.daygrid) {
this.daygrid.renderselection(span);
}
},
// unrenders a visual indications of a selection
unrenderselection: function() {
this.timegrid.unrenderselection();
if (this.daygrid) {
this.daygrid.unrenderselection();
}
}
});
// methods that will customize the rendering behavior of the agendaview's timegrid
// todo: move into timegrid
var agendatimegridmethods = {
// generates the html that will go before the day-of week header cells
renderheadintrohtml: function() {
var view = this.view;
var weektext;
if (view.opt('weeknumbers')) {
weektext = this.start.format(view.opt('smallweekformat'));
return '' +
'
';
}
},
// generates the html that goes before the bg of the timegrid slot area. long vertical column.
renderbgintrohtml: function() {
var view = this.view;
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.
renderintrohtml: function() {
var view = this.view;
return '
';
}
};
// methods that will customize the rendering behavior of the agendaview's daygrid
var agendadaygridmethods = {
// generates the html that goes before the all-day cells
renderbgintrohtml: function() {
var view = this.view;
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.
renderintrohtml: function() {
var view = this.view;
return '
';
}
};
;;
var agenda_all_day_event_limit = 5;
// potential nice values for the slot-duration and interval-duration
// from largest to smallest
var agenda_stock_sub_durations = [
{ hours: 1 },
{ minutes: 30 },
{ minutes: 15 },
{ seconds: 30 },
{ seconds: 15 }
];
fcviews.agenda = {
'class': agendaview,
defaults: {
alldayslot: true,
alldaytext: 'all-day',
slotduration: '00:30:00',
mintime: '00:00:00',
maxtime: '24:00:00',
sloteventoverlap: true // a bad name. confused with overlap/constraint system
}
};
fcviews.agendaday = {
type: 'agenda',
duration: { days: 1 }
};
fcviews.agendaweek = {
type: 'agenda',
duration: { weeks: 1 }
};
;;
return fc; // export for node/commonjs
});