/*! * 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 = $('
') .css({ position: 'absolute', top: -1000, left: 0, border: 0, padding: 0, overflow: 'scroll', direction: 'rtl' }) .appendto('body'); var innerel = el.children(); var res = innerel.offset().left > el.offset().left; // is the inner div shifted to accommodate a left scrollbar? el.remove(); return res; } // retrieves a jquery element's computed css value as a floating-point number. // if the queried value is non-numeric (ex: ie can return "medium" for border width), will just return zero. function getcssfloat(el, prop) { return parsefloat(el.css(prop)) || 0; } // returns a boolean whether this was a left mouse click and no ctrl key (which means right click on mac) function isprimarymousebutton(ev) { return ev.which == 1 && !ev.ctrlkey; } /* geometry ----------------------------------------------------------------------------------------------------------------------*/ fc.intersectrects = intersectrects; // returns a new rectangle that is the intersection of the two rectangles. if they don't intersect, returns false function intersectrects(rect1, rect2) { var res = { left: math.max(rect1.left, rect2.left), right: math.min(rect1.right, rect2.right), top: math.max(rect1.top, rect2.top), bottom: math.min(rect1.bottom, rect2.bottom) }; if (res.left < res.right && res.top < res.bottom) { return res; } return false; } // returns a new point that will have been moved to reside within the given rectangle function constrainpoint(point, rect) { return { left: math.min(math.max(point.left, rect.left), rect.right), top: math.min(math.max(point.top, rect.top), rect.bottom) }; } // returns a point that is the center of the given rectangle function getrectcenter(rect) { return { left: (rect.left + rect.right) / 2, top: (rect.top + rect.bottom) / 2 }; } // subtracts point2's coordinates from point1's coordinates, returning a delta function diffpoints(point1, point2) { return { left: point1.left - point2.left, top: point1.top - point2.top }; } /* object ordering by field ----------------------------------------------------------------------------------------------------------------------*/ fc.parsefieldspecs = parsefieldspecs; fc.comparebyfieldspecs = comparebyfieldspecs; fc.comparebyfieldspec = comparebyfieldspec; fc.flexiblecompare = flexiblecompare; function parsefieldspecs(input) { var specs = []; var tokens = []; var i, token; if (typeof input === 'string') { tokens = input.split(/\s*,\s*/); } else if (typeof input === 'function') { tokens = [ input ]; } else if ($.isarray(input)) { tokens = input; } for (i = 0; i < tokens.length; i++) { token = tokens[i]; if (typeof token === 'string') { specs.push( token.charat(0) == '-' ? { field: token.substring(1), order: -1 } : { field: token, order: 1 } ); } else if (typeof token === 'function') { specs.push({ func: token }); } } return specs; } function comparebyfieldspecs(obj1, obj2, fieldspecs) { var i; var cmp; for (i = 0; i < fieldspecs.length; i++) { cmp = comparebyfieldspec(obj1, obj2, fieldspecs[i]); if (cmp) { return cmp; } } return 0; } function comparebyfieldspec(obj1, obj2, fieldspec) { if (fieldspec.func) { return fieldspec.func(obj1, obj2); } return flexiblecompare(obj1[fieldspec.field], obj2[fieldspec.field]) * (fieldspec.order || 1); } function flexiblecompare(a, b) { if (!a && !b) { return 0; } if (b == null) { return -1; } if (a == null) { return 1; } if ($.type(a) === 'string' || $.type(b) === 'string') { return string(a).localecompare(string(b)); } return a - b; } /* fullcalendar-specific misc utilities ----------------------------------------------------------------------------------------------------------------------*/ // computes the intersection of the two ranges. returns undefined if no intersection. // expects all dates to be normalized to the same timezone beforehand. // todo: move to date section? function intersectranges(subjectrange, constraintrange) { var subjectstart = subjectrange.start; var subjectend = subjectrange.end; var constraintstart = constraintrange.start; var constraintend = constraintrange.end; var segstart, segend; var isstart, isend; if (subjectend > constraintstart && subjectstart < constraintend) { // in bounds at all? if (subjectstart >= constraintstart) { segstart = subjectstart.clone(); isstart = true; } else { segstart = constraintstart.clone(); isstart = false; } if (subjectend <= constraintend) { segend = subjectend.clone(); isend = true; } else { segend = constraintend.clone(); isend = false; } return { start: segstart, end: segend, isstart: isstart, isend: isend }; } } /* date utilities ----------------------------------------------------------------------------------------------------------------------*/ fc.computeintervalunit = computeintervalunit; fc.dividerangebyduration = dividerangebyduration; fc.dividedurationbyduration = dividedurationbyduration; fc.multiplyduration = multiplyduration; fc.durationhastime = durationhastime; var dayids = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ]; var intervalunits = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ]; // diffs the two moments into a duration where full-days are recorded first, then the remaining time. // moments will have their timezones normalized. function diffdaytime(a, b) { return moment.duration({ days: a.clone().striptime().diff(b.clone().striptime(), 'days'), ms: a.time() - b.time() // time-of-day from day start. disregards timezone }); } // diffs the two moments via their start-of-day (regardless of timezone). produces whole-day durations. function diffday(a, b) { return moment.duration({ days: a.clone().striptime().diff(b.clone().striptime(), 'days') }); } // diffs two moments, producing a duration, made of a whole-unit-increment of the given unit. uses rounding. function diffbyunit(a, b, unit) { return moment.duration( math.round(a.diff(b, unit, true)), // returnfloat=true unit ); } // computes the unit name of the largest whole-unit period of time. // for example, 48 hours will be "days" whereas 49 hours will be "hours". // accepts start/end, a range object, or an original duration object. function computeintervalunit(start, end) { var i, unit; var val; for (i = 0; i < intervalunits.length; i++) { unit = intervalunits[i]; val = computerangeas(unit, start, end); if (val >= 1 && isint(val)) { break; } } return unit; // will be "milliseconds" if nothing else matches } // computes the number of units (like "hours") in the given range. // range can be a {start,end} object, separate start/end args, or a duration. // results are based on moment's .as() and .diff() methods, so results can depend on internal handling // of month-diffing logic (which tends to vary from version to version). function computerangeas(unit, start, end) { if (end != null) { // given start, end return end.diff(start, unit, true); } else if (moment.isduration(start)) { // given duration return start.as(unit); } else { // given { start, end } range object return start.end.diff(start.start, unit, true); } } // intelligently divides a range (specified by a start/end params) by a duration function dividerangebyduration(start, end, dur) { var months; if (durationhastime(dur)) { return (end - start) / dur; } months = dur.asmonths(); if (math.abs(months) >= 1 && isint(months)) { return end.diff(start, 'months', true) / months; } return end.diff(start, 'days', true) / dur.asdays(); } // intelligently divides one duration by another function dividedurationbyduration(dur1, dur2) { var months1, months2; if (durationhastime(dur1) || durationhastime(dur2)) { return dur1 / dur2; } months1 = dur1.asmonths(); months2 = dur2.asmonths(); if ( math.abs(months1) >= 1 && isint(months1) && math.abs(months2) >= 1 && isint(months2) ) { return months1 / months2; } return dur1.asdays() / dur2.asdays(); } // intelligently multiplies a duration by a number function multiplyduration(dur, n) { var months; if (durationhastime(dur)) { return moment.duration(dur * n); } months = dur.asmonths(); if (math.abs(months) >= 1 && isint(months)) { return moment.duration({ months: months * n }); } return moment.duration({ days: dur.asdays() * n }); } // returns a boolean about whether the given duration has any time parts (hours/minutes/seconds/ms) function durationhastime(dur) { return boolean(dur.hours() || dur.minutes() || dur.seconds() || dur.milliseconds()); } function isnativedate(input) { return object.prototype.tostring.call(input) === '[object date]' || input instanceof date; } // returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00" function istimestring(str) { return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str); } /* logging and debug ----------------------------------------------------------------------------------------------------------------------*/ fc.log = function() { var console = window.console; if (console && console.log) { return console.log.apply(console, arguments); } }; fc.warn = function() { var console = window.console; if (console && console.warn) { return console.warn.apply(console, arguments); } else { return fc.log.apply(fc, arguments); } }; /* general utilities ----------------------------------------------------------------------------------------------------------------------*/ var hasownpropmethod = {}.hasownproperty; // merges an array of objects into a single object. // the second argument allows for an array of property names who's object values will be merged together. function mergeprops(propobjs, complexprops) { var dest = {}; var i, name; var complexobjs; var j, val; var props; if (complexprops) { for (i = 0; i < complexprops.length; i++) { name = complexprops[i]; complexobjs = []; // collect the trailing object values, stopping when a non-object is discovered for (j = propobjs.length - 1; j >= 0; j--) { val = propobjs[j][name]; if (typeof val === 'object') { complexobjs.unshift(val); } else if (val !== undefined) { dest[name] = val; // if there were no objects, this value will be used break; } } // if the trailing values were objects, use the merged value if (complexobjs.length) { dest[name] = mergeprops(complexobjs); } } } // copy values into the destination, going from last to first for (i = propobjs.length - 1; i >= 0; i--) { props = propobjs[i]; for (name in props) { if (!(name in dest)) { // if already assigned by previous props or complex props, don't reassign dest[name] = props[name]; } } } return dest; } // create an object that has the given prototype. just like object.create function createobject(proto) { var f = function() {}; f.prototype = proto; return new f(); } function copyownprops(src, dest) { for (var name in src) { if (hasownprop(src, name)) { dest[name] = src[name]; } } } // copies over certain methods with the same names as object.prototype methods. overcomes an ie<=8 bug: // https://developer.mozilla.org/en-us/docs/ecmascript_dontenum_attribute#jscript_dontenum_bug function copynativemethods(src, dest) { var names = [ 'constructor', 'tostring', 'valueof' ]; var i, name; for (i = 0; i < names.length; i++) { name = names[i]; if (src[name] !== object.prototype[name]) { dest[name] = src[name]; } } } function hasownprop(obj, name) { return hasownpropmethod.call(obj, name); } // is the given value a non-object non-function value? function isatomic(val) { return /undefined|null|boolean|number|string/.test($.type(val)); } function applyall(functions, thisobj, args) { if ($.isfunction(functions)) { functions = [ functions ]; } if (functions) { var i; var ret; for (i=0; i/g, '>') .replace(/'/g, ''') .replace(/"/g, '"') .replace(/\n/g, '
'); } function striphtmlentities(text) { return text.replace(/&.*?;/g, ''); } // given a hash of css properties, returns a string of css. // uses property names as-is (no camel-case conversion). will not make statements for null/undefined values. function csstostr(cssprops) { var statements = []; $.each(cssprops, function(name, val) { if (val != null) { statements.push(name + ':' + val); } }); return statements.join(';'); } function capitalisefirstletter(str) { return str.charat(0).touppercase() + str.slice(1); } function comparenumbers(a, b) { // for .sort() return a - b; } function isint(n) { return n % 1 === 0; } // returns a method bound to the given object context. // just like one of the jquery.proxy signatures, but without the undesired behavior of treating the same method with // different contexts as identical when binding/unbinding events. function proxy(obj, methodname) { var method = obj[methodname]; return function() { return method.apply(obj, arguments); }; } // returns a function, that, as long as it continues to be invoked, will not // be triggered. the function will be called after it stops being called for // n milliseconds. // https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#l714 function debounce(func, wait) { var timeoutid; var args; var context; var timestamp; // of most recent call var later = function() { var last = +new date() - timestamp; if (last < wait && last > 0) { timeoutid = settimeout(later, wait - last); } else { timeoutid = null; func.apply(context, args); if (!timeoutid) { context = args = null; } } }; return function() { context = this; args = arguments; timestamp = +new date(); if (!timeoutid) { timeoutid = settimeout(later, wait); } }; } ;; var ambigdateofmonthregex = /^\s*\d{4}-\d\d$/; var ambigtimeorzoneregex = /^\s*\d{4}-(?:(\d\d-\d\d)|(w\d\d$)|(w\d\d-\d)|(\d\d\d))((t| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?)?$/; var newmomentproto = moment.fn; // where we will attach our new methods var oldmomentproto = $.extend({}, newmomentproto); // copy of original moment methods var allowvalueoptimization; var setutcvalues; // function defined below var setlocalvalues; // function defined below // creating // ------------------------------------------------------------------------------------------------- // creates a new moment, similar to the vanilla moment(...) constructor, but with // extra features (ambiguous time, enhanced formatting). when given an existing moment, // it will function as a clone (and retain the zone of the moment). anything else will // result in a moment in the local zone. fc.moment = function() { return makemoment(arguments); }; // sames as fc.moment, but forces the resulting moment to be in the utc timezone. fc.moment.utc = function() { var mom = makemoment(arguments, true); // force it into utc because makemoment doesn't guarantee it // (if given a pre-existing moment for example) if (mom.hastime()) { // don't give ambiguously-timed moments a utc zone mom.utc(); } return mom; }; // same as fc.moment, but when given an iso8601 string, the timezone offset is preserved. // iso8601 strings with no timezone offset will become ambiguously zoned. fc.moment.parsezone = function() { return makemoment(arguments, true, true); }; // builds an enhanced moment from args. when given an existing moment, it clones. when given a // native date, or called with no arguments (the current time), the resulting moment will be local. // anything else needs to be "parsed" (a string or an array), and will be affected by: // parseasutc - if there is no zone information, should we parse the input in utc? // parsezone - if there is zone information, should we force the zone of the moment? function makemoment(args, parseasutc, parsezone) { var input = args[0]; var issinglestring = args.length == 1 && typeof input === 'string'; var isambigtime; var isambigzone; var ambigmatch; var mom; if (moment.ismoment(input)) { mom = moment.apply(null, args); // clone it transferambigs(input, mom); // the ambig flags weren't transfered with the clone } else if (isnativedate(input) || input === undefined) { mom = moment.apply(null, args); // will be local } else { // "parsing" is required isambigtime = false; isambigzone = false; if (issinglestring) { if (ambigdateofmonthregex.test(input)) { // accept strings like '2014-05', but convert to the first of the month input += '-01'; args = [ input ]; // for when we pass it on to moment's constructor isambigtime = true; isambigzone = true; } else if ((ambigmatch = ambigtimeorzoneregex.exec(input))) { isambigtime = !ambigmatch[5]; // no time part? isambigzone = true; } } else if ($.isarray(input)) { // arrays have no timezone information, so assume ambiguous zone isambigzone = true; } // otherwise, probably a string with a format if (parseasutc || isambigtime) { mom = moment.utc.apply(moment, args); } else { mom = moment.apply(null, args); } if (isambigtime) { mom._ambigtime = true; mom._ambigzone = true; // ambiguous time always means ambiguous zone } else if (parsezone) { // let's record the inputted zone somehow if (isambigzone) { mom._ambigzone = true; } else if (issinglestring) { if (mom.utcoffset) { mom.utcoffset(input); // if not a valid zone, will assign utc } else { mom.zone(input); // for moment-pre-2.9 } } } } mom._fullcalendar = true; // flag for extended functionality return mom; } // a clone method that works with the flags related to our enhanced functionality. // in the future, use moment.momentproperties newmomentproto.clone = function() { var mom = oldmomentproto.clone.apply(this, arguments); // these flags weren't transfered with the clone transferambigs(this, mom); if (this._fullcalendar) { mom._fullcalendar = true; } return mom; }; // week number // ------------------------------------------------------------------------------------------------- // returns the week number, considering the locale's custom week number calcuation // `weeks` is an alias for `week` newmomentproto.week = newmomentproto.weeks = function(input) { var weekcalc = (this._locale || this._lang) // works pre-moment-2.8 ._fullcalendar_weekcalc; if (input == null && typeof weekcalc === 'function') { // custom function only works for getter return weekcalc(this); } else if (weekcalc === 'iso') { return oldmomentproto.isoweek.apply(this, arguments); // iso getter/setter } return oldmomentproto.week.apply(this, arguments); // local getter/setter }; // time-of-day // ------------------------------------------------------------------------------------------------- // getter // returns a duration with the hours/minutes/seconds/ms values of the moment. // if the moment has an ambiguous time, a duration of 00:00 will be returned. // // setter // you can supply a duration, a moment, or a duration-like argument. // when setting the time, and the moment has an ambiguous time, it then becomes unambiguous. newmomentproto.time = function(time) { // fallback to the original method (if there is one) if this moment wasn't created via fullcalendar. // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins. if (!this._fullcalendar) { return oldmomentproto.time.apply(this, arguments); } if (time == null) { // getter return moment.duration({ hours: this.hours(), minutes: this.minutes(), seconds: this.seconds(), milliseconds: this.milliseconds() }); } else { // setter this._ambigtime = false; // mark that the moment now has a time if (!moment.isduration(time) && !moment.ismoment(time)) { time = moment.duration(time); } // the day value should cause overflow (so 24 hours becomes 00:00:00 of next day). // only for duration times, not moment times. var dayhours = 0; if (moment.isduration(time)) { dayhours = math.floor(time.asdays()) * 24; } // we need to set the individual fields. // can't use startof('day') then add duration. in case of dst at start of day. return this.hours(dayhours + time.hours()) .minutes(time.minutes()) .seconds(time.seconds()) .milliseconds(time.milliseconds()); } }; // converts the moment to utc, stripping out its time-of-day and timezone offset, // but preserving its ymd. a moment with a stripped time will display no time // nor timezone offset when .format() is called. newmomentproto.striptime = function() { var a; if (!this._ambigtime) { // get the values before any conversion happens a = this.toarray(); // array of y/m/d/h/m/s/ms // todo: use keeplocaltime in the future this.utc(); // set the internal utc flag (will clear the ambig flags) setutcvalues(this, a.slice(0, 3)); // set the year/month/date. time will be zero // mark the time as ambiguous. this needs to happen after the .utc() call, which might call .utcoffset(), // which clears all ambig flags. same with setutcvalues with moment-timezone. this._ambigtime = true; this._ambigzone = true; // if ambiguous time, also ambiguous timezone offset } return this; // for chaining }; // returns if the moment has a non-ambiguous time (boolean) newmomentproto.hastime = function() { return !this._ambigtime; }; // timezone // ------------------------------------------------------------------------------------------------- // converts the moment to utc, stripping out its timezone offset, but preserving its // ymd and time-of-day. a moment with a stripped timezone offset will display no // timezone offset when .format() is called. // todo: look into moment's keeplocaltime functionality newmomentproto.stripzone = function() { var a, wasambigtime; if (!this._ambigzone) { // get the values before any conversion happens a = this.toarray(); // array of y/m/d/h/m/s/ms wasambigtime = this._ambigtime; this.utc(); // set the internal utc flag (might clear the ambig flags, depending on moment internals) setutcvalues(this, a); // will set the year/month/date/hours/minutes/seconds/ms // the above call to .utc()/.utcoffset() unfortunately might clear the ambig flags, so restore this._ambigtime = wasambigtime || false; // mark the zone as ambiguous. this needs to happen after the .utc() call, which might call .utcoffset(), // which clears the ambig flags. same with setutcvalues with moment-timezone. this._ambigzone = true; } return this; // for chaining }; // returns of the moment has a non-ambiguous timezone offset (boolean) newmomentproto.haszone = function() { return !this._ambigzone; }; // this method implicitly marks a zone newmomentproto.local = function() { var a = this.toarray(); // year,month,date,hours,minutes,seconds,ms as an array var wasambigzone = this._ambigzone; oldmomentproto.local.apply(this, arguments); // ensure non-ambiguous // this probably already happened via local() -> utcoffset(), but don't rely on moment's internals this._ambigtime = false; this._ambigzone = false; if (wasambigzone) { // if the moment was ambiguously zoned, the date fields were stored as utc. // we want to preserve these, but in local time. // todo: look into moment's keeplocaltime functionality setlocalvalues(this, a); } return this; // for chaining }; // implicitly marks a zone newmomentproto.utc = function() { oldmomentproto.utc.apply(this, arguments); // ensure non-ambiguous // this probably already happened via utc() -> utcoffset(), but don't rely on moment's internals this._ambigtime = false; this._ambigzone = false; return this; }; // methods for arbitrarily manipulating timezone offset. // should clear time/zone ambiguity when called. $.each([ 'zone', // only in moment-pre-2.9. deprecated afterwards 'utcoffset' ], function(i, name) { if (oldmomentproto[name]) { // original method exists? // this method implicitly marks a zone (will probably get called upon .utc() and .local()) newmomentproto[name] = function(tzo) { if (tzo != null) { // setter // these assignments needs to happen before the original zone method is called. // i forget why, something to do with a browser crash. this._ambigtime = false; this._ambigzone = false; } return oldmomentproto[name].apply(this, arguments); }; } }); // formatting // ------------------------------------------------------------------------------------------------- newmomentproto.format = function() { if (this._fullcalendar && arguments[0]) { // an enhanced moment? and a format string provided? return formatdate(this, arguments[0]); // our extended formatting } if (this._ambigtime) { return oldmomentformat(this, 'yyyy-mm-dd'); } if (this._ambigzone) { return oldmomentformat(this, 'yyyy-mm-dd[t]hh:mm:ss'); } return oldmomentproto.format.apply(this, arguments); }; newmomentproto.toisostring = function() { if (this._ambigtime) { return oldmomentformat(this, 'yyyy-mm-dd'); } if (this._ambigzone) { return oldmomentformat(this, 'yyyy-mm-dd[t]hh:mm:ss'); } return oldmomentproto.toisostring.apply(this, arguments); }; // querying // ------------------------------------------------------------------------------------------------- // is the moment within the specified range? `end` is exclusive. // fyi, this method is not a standard moment method, so always do our enhanced logic. newmomentproto.iswithin = function(start, end) { var a = commonlyambiguate([ this, start, end ]); return a[0] >= a[1] && a[0] < a[2]; }; // when issame is called with units, timezone ambiguity is normalized before the comparison happens. // if no units specified, the two moments must be identically the same, with matching ambig flags. newmomentproto.issame = function(input, units) { var a; // only do custom logic if this is an enhanced moment if (!this._fullcalendar) { return oldmomentproto.issame.apply(this, arguments); } if (units) { a = commonlyambiguate([ this, input ], true); // normalize timezones but don't erase times return oldmomentproto.issame.call(a[0], a[1], units); } else { input = fc.moment.parsezone(input); // normalize input return oldmomentproto.issame.call(this, input) && boolean(this._ambigtime) === boolean(input._ambigtime) && boolean(this._ambigzone) === boolean(input._ambigzone); } }; // make these query methods work with ambiguous moments $.each([ 'isbefore', 'isafter' ], function(i, methodname) { newmomentproto[methodname] = function(input, units) { var a; // only do custom logic if this is an enhanced moment if (!this._fullcalendar) { return oldmomentproto[methodname].apply(this, arguments); } a = commonlyambiguate([ this, input ]); return oldmomentproto[methodname].call(a[0], a[1], units); }; }); // misc internals // ------------------------------------------------------------------------------------------------- // given an array of moment-like inputs, return a parallel array w/ moments similarly ambiguated. // for example, of one moment has ambig time, but not others, all moments will have their time stripped. // set `preservetime` to `true` to keep times, but only normalize zone ambiguity. // returns the original moments if no modifications are necessary. function commonlyambiguate(inputs, preservetime) { var anyambigtime = false; var anyambigzone = false; var len = inputs.length; var moms = []; var i, mom; // parse inputs into real moments and query their ambig flags for (i = 0; i < len; i++) { mom = inputs[i]; if (!moment.ismoment(mom)) { mom = fc.moment.parsezone(mom); } anyambigtime = anyambigtime || mom._ambigtime; anyambigzone = anyambigzone || mom._ambigzone; moms.push(mom); } // strip each moment down to lowest common ambiguity // use clones to avoid modifying the original moments for (i = 0; i < len; i++) { mom = moms[i]; if (!preservetime && anyambigtime && !mom._ambigtime) { moms[i] = mom.clone().striptime(); } else if (anyambigzone && !mom._ambigzone) { moms[i] = mom.clone().stripzone(); } } return moms; } // transfers all the flags related to ambiguous time/zone from the `src` moment to the `dest` moment // todo: look into moment.momentproperties for this. function transferambigs(src, dest) { if (src._ambigtime) { dest._ambigtime = true; } else if (dest._ambigtime) { dest._ambigtime = false; } if (src._ambigzone) { dest._ambigzone = true; } else if (dest._ambigzone) { dest._ambigzone = false; } } // sets the year/month/date/etc values of the moment from the given array. // inefficient because it calls each individual setter. function setmomentvalues(mom, a) { mom.year(a[0] || 0) .month(a[1] || 0) .date(a[2] || 0) .hours(a[3] || 0) .minutes(a[4] || 0) .seconds(a[5] || 0) .milliseconds(a[6] || 0); } // can we set the moment's internal date directly? allowvalueoptimization = '_d' in moment() && 'updateoffset' in moment; // utility function. accepts a moment and an array of the utc year/month/date/etc values to set. // assumes the given moment is already in utc mode. setutcvalues = allowvalueoptimization ? function(mom, a) { // simlate what moment's accessors do mom._d.settime(date.utc.apply(date, a)); moment.updateoffset(mom, false); // keeptime=false } : setmomentvalues; // utility function. accepts a moment and an array of the local year/month/date/etc values to set. // assumes the given moment is already in local mode. setlocalvalues = allowvalueoptimization ? function(mom, a) { // simlate what moment's accessors do mom._d.settime(+new date( // fyi, there is now way to apply an array of args to a constructor a[0] || 0, a[1] || 0, a[2] || 0, a[3] || 0, a[4] || 0, a[5] || 0, a[6] || 0 )); moment.updateoffset(mom, false); // keeptime=false } : setmomentvalues; ;; // single date formatting // ------------------------------------------------------------------------------------------------- // call this if you want moment's original format method to be used function oldmomentformat(mom, formatstr) { return oldmomentproto.format.call(mom, formatstr); // oldmomentproto defined in moment-ext.js } // formats `date` with a moment formatting string, but allow our non-zero areas and // additional token. function formatdate(date, formatstr) { return formatdatewithchunks(date, getformatstringchunks(formatstr)); } function formatdatewithchunks(date, chunks) { var s = ''; var i; for (i=0; i "mmmm d yyyy" formatstr = localedata.longdateformat(formatstr) || formatstr; // btw, this is not important for `formatdate` because it is impossible to put custom tokens // or non-zero areas in moment's localized format strings. separator = separator || ' - '; return formatrangewithchunks( date1, date2, getformatstringchunks(formatstr), separator, isrtl ); } fc.formatrange = formatrange; // expose function formatrangewithchunks(date1, date2, chunks, separator, isrtl) { var unzoneddate1 = date1.clone().stripzone(); // for formatsimilarchunk var unzoneddate2 = date2.clone().stripzone(); // " var chunkstr; // the rendering of the chunk var lefti; var leftstr = ''; var righti; var rightstr = ''; var middlei; var middlestr1 = ''; var middlestr2 = ''; var middlestr = ''; // start at the leftmost side of the formatting string and continue until you hit a token // that is not the same between dates. for (lefti=0; leftilefti; righti--) { chunkstr = formatsimilarchunk(date1, date2, unzoneddate1, unzoneddate2, chunks[righti]); if (chunkstr === false) { break; } rightstr = chunkstr + rightstr; } // the area in the middle is different for both of the dates. // collect them distinctly so we can jam them together later. for (middlei=lefti; middlei<=righti; middlei++) { middlestr1 += formatdatewithchunk(date1, chunks[middlei]); middlestr2 += formatdatewithchunk(date2, chunks[middlei]); } if (middlestr1 || middlestr2) { if (isrtl) { middlestr = middlestr2 + separator + middlestr1; } else { middlestr = middlestr1 + separator + middlestr2; } } return leftstr + middlestr + rightstr; } var similarunitmap = { y: 'year', m: 'month', d: 'day', // day of month d: 'day', // day of week // prevents a separator between anything time-related... a: 'second', // am/pm a: 'second', // am/pm t: 'second', // a/p t: 'second', // a/p h: 'second', // hour (24) h: 'second', // hour (12) m: 'second', // minute s: 'second' // second }; // todo: week maybe? // given a formatting chunk, and given that both dates are similar in the regard the // formatting chunk is concerned, format date1 against `chunk`. otherwise, return `false`. function formatsimilarchunk(date1, date2, unzoneddate1, unzoneddate2, chunk) { var token; var unit; if (typeof chunk === 'string') { // a literal string return chunk; } else if ((token = chunk.token)) { unit = similarunitmap[token.charat(0)]; // are the dates the same for this unit of measurement? // use the unzoned dates for this calculation because unreliable when near dst (bug #2396) if (unit && unzoneddate1.issame(unzoneddate2, unit)) { return oldmomentformat(date1, token); // would be the same if we used `date2` // btw, don't support custom tokens } } return false; // the chunk is not the same for the two dates // btw, don't support splitting on non-zero areas } // chunking utils // ------------------------------------------------------------------------------------------------- var formatstringchunkcache = {}; function getformatstringchunks(formatstr) { if (formatstr in formatstringchunkcache) { return formatstringchunkcache[formatstr]; } return (formatstringchunkcache[formatstr] = chunkformatstring(formatstr)); } // break the formatting string into an array of chunks function chunkformatstring(formatstr) { var chunks = []; var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(lts|lt|(\w)\4*o?)|([^\w\[\(]+)/g; // todo: more descrimination var match; while ((match = chunker.exec(formatstr))) { if (match[1]) { // a literal string inside [ ... ] chunks.push(match[1]); } else if (match[2]) { // non-zero formatting inside ( ... ) chunks.push({ maybe: chunkformatstring(match[2]) }); } else if (match[3]) { // a formatting token chunks.push({ token: match[3] }); } else if (match[5]) { // an unenclosed literal string chunks.push(match[5]); } } return chunks; } ;; fc.class = class; // export // class that all other classes will inherit from function class() { } // called on a class to create a subclass. // last argument contains instance methods. any argument before the last are considered mixins. class.extend = function() { var len = arguments.length; var i; var members; for (i = 0; i < len; i++) { members = arguments[i]; if (i < len - 1) { // not the last argument? mixintoclass(this, members); } } return extendclass(this, members || {}); // members will be undefined if no arguments }; // adds new member variables/methods to the class's prototype. // can be called with another class, or a plain object hash containing new members. class.mixin = function(members) { mixintoclass(this, members); }; function extendclass(superclass, members) { var subclass; // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist if (hasownprop(members, 'constructor')) { subclass = members.constructor; } if (typeof subclass !== 'function') { subclass = members.constructor = function() { superclass.apply(this, arguments); }; } // build the base prototype for the subclass, which is an new object chained to the superclass's prototype subclass.prototype = createobject(superclass.prototype); // copy each member variable/method onto the the subclass's prototype copyownprops(members, subclass.prototype); copynativemethods(members, subclass.prototype); // hack for ie8 // copy over all class variables/methods to the subclass, such as `extend` and `mixin` copyownprops(superclass, subclass); return subclass; } function mixintoclass(theclass, members) { copyownprops(members.prototype || members, theclass.prototype); // todo: copynativemethods? } ;; var emitter = fc.emitter = class.extend({ callbackhash: null, on: function(name, callback) { this.getcallbacks(name).add(callback); return this; // for chaining }, off: function(name, callback) { this.getcallbacks(name).remove(callback); return this; // for chaining }, trigger: function(name) { // args... var args = array.prototype.slice.call(arguments, 1); this.triggerwith(name, this, args); return this; // for chaining }, triggerwith: function(name, context, args) { var callbacks = this.getcallbacks(name); callbacks.firewith(context, args); return this; // for chaining }, getcallbacks: function(name) { var callbacks; if (!this.callbackhash) { this.callbackhash = {}; } callbacks = this.callbackhash[name]; if (!callbacks) { callbacks = this.callbackhash[name] = $.callbacks(); } return callbacks; } }); ;; /* a rectangular panel that is absolutely positioned over other content ------------------------------------------------------------------------------------------------------------------------ options: - classname (string) - content (html string or jquery element set) - parentel - top - left - right (the x coord of where the right edge should be. not a "css" right) - autohide (boolean) - show (callback) - hide (callback) */ var popover = class.extend({ ishidden: true, options: null, el: null, // the container element for the popover. generated by this object documentmousedownproxy: null, // document mousedown handler bound to `this` margin: 10, // the space required between the popover and the edges of the scroll container constructor: function(options) { this.options = options || {}; }, // shows the popover on the specified position. renders it if not already show: function() { if (this.ishidden) { if (!this.el) { this.render(); } this.el.show(); this.position(); this.ishidden = false; this.trigger('show'); } }, // hides the popover, through css, but does not remove it from the dom hide: function() { if (!this.ishidden) { this.el.hide(); this.ishidden = true; this.trigger('hide'); } }, // creates `this.el` and renders content inside of it render: function() { var _this = this; var options = this.options; this.el = $('
') .addclass(options.classname || '') .css({ // position initially to the top left to avoid creating scrollbars top: 0, left: 0 }) .append(options.content) .appendto(options.parentel); // when a click happens on anything inside with a 'fc-close' classname, hide the popover this.el.on('click', '.fc-close', function() { _this.hide(); }); if (options.autohide) { $(document).on('mousedown', this.documentmousedownproxy = proxy(this, 'documentmousedown')); } }, // triggered when the user clicks *anywhere* in the document, for the autohide feature documentmousedown: function(ev) { // only hide the popover if the click happened outside the popover if (this.el && !$(ev.target).closest(this.el).length) { this.hide(); } }, // hides and unregisters any handlers removeelement: function() { this.hide(); if (this.el) { this.el.remove(); this.el = null; } $(document).off('mousedown', this.documentmousedownproxy); }, // positions the popover optimally, using the top/left/right options position: function() { var options = this.options; var origin = this.el.offsetparent().offset(); var width = this.el.outerwidth(); var height = this.el.outerheight(); var windowel = $(window); var viewportel = getscrollparent(this.el); var viewporttop; var viewportleft; var viewportoffset; var top; // the "position" (not "offset") values for the popover var left; // // compute top and left top = options.top || 0; if (options.left !== undefined) { left = options.left; } else if (options.right !== undefined) { left = options.right - width; // derive the left value from the right value } else { left = 0; } if (viewportel.is(window) || viewportel.is(document)) { // normalize getscrollparent's result viewportel = windowel; viewporttop = 0; // the window is always at the top left viewportleft = 0; // (and .offset() won't work if called here) } else { viewportoffset = viewportel.offset(); viewporttop = viewportoffset.top; viewportleft = viewportoffset.left; } // if the window is scrolled, it causes the visible area to be further down viewporttop += windowel.scrolltop(); viewportleft += windowel.scrollleft(); // constrain to the view port. if constrained by two edges, give precedence to top/left if (options.viewportconstrain !== false) { top = math.min(top, viewporttop + viewportel.outerheight() - height - this.margin); top = math.max(top, viewporttop + this.margin); left = math.min(left, viewportleft + viewportel.outerwidth() - width - this.margin); left = math.max(left, viewportleft + this.margin); } this.el.css({ top: top - origin.top, left: left - origin.left }); }, // triggers a callback. calls a function in the option hash of the same name. // arguments beyond the first `name` are forwarded on. // todo: better code reuse for this. repeat code trigger: function(name) { if (this.options[name]) { this.options[name].apply(this, array.prototype.slice.call(arguments, 1)); } } }); ;; /* a cache for the left/right/top/bottom/width/height values for one or more elements. works with both offset (from topleft document) and position (from offsetparent). options: - els - ishorizontal - isvertical */ var coordcache = fc.coordcache = class.extend({ els: null, // jquery set (assumed to be siblings) forcedoffsetparentel: null, // options can override the natural offsetparent origin: null, // {left,top} position of offsetparent of els boundingrect: null, // constrain cordinates to this rectangle. {left,right,top,bottom} or null ishorizontal: false, // whether to query for left/right/width isvertical: false, // whether to query for top/bottom/height // arrays of coordinates (offsets from topleft of document) lefts: null, rights: null, tops: null, bottoms: null, constructor: function(options) { this.els = $(options.els); this.ishorizontal = options.ishorizontal; this.isvertical = options.isvertical; this.forcedoffsetparentel = options.offsetparent ? $(options.offsetparent) : null; }, // queries the els for coordinates and stores them. // call this method before using and of the get* methods below. build: function() { var offsetparentel = this.forcedoffsetparentel || this.els.eq(0).offsetparent(); this.origin = offsetparentel.offset(); this.boundingrect = this.queryboundingrect(); if (this.ishorizontal) { this.buildelhorizontals(); } if (this.isvertical) { this.buildelverticals(); } }, // destroys all internal data about coordinates, freeing memory clear: function() { this.origin = null; this.boundingrect = null; this.lefts = null; this.rights = null; this.tops = null; this.bottoms = null; }, // when called, if coord caches aren't built, builds them ensurebuilt: function() { if (!this.origin) { this.build(); } }, // compute and return what the elements' bounding rectangle is, from the user's perspective. // right now, only returns a rectangle if constrained by an overflow:scroll element. queryboundingrect: function() { var scrollparentel = getscrollparent(this.els.eq(0)); if (!scrollparentel.is(document)) { return getclientrect(scrollparentel); } }, // populates the left/right internal coordinate arrays buildelhorizontals: function() { var lefts = []; var rights = []; this.els.each(function(i, node) { var el = $(node); var left = el.offset().left; var width = el.outerwidth(); lefts.push(left); rights.push(left + width); }); this.lefts = lefts; this.rights = rights; }, // populates the top/bottom internal coordinate arrays buildelverticals: function() { var tops = []; var bottoms = []; this.els.each(function(i, node) { var el = $(node); var top = el.offset().top; var height = el.outerheight(); tops.push(top); bottoms.push(top + height); }); this.tops = tops; this.bottoms = bottoms; }, // given a left offset (from document left), returns the index of the el that it horizontally intersects. // if no intersection is made, or outside of the boundingrect, returns undefined. gethorizontalindex: function(leftoffset) { this.ensurebuilt(); var boundingrect = this.boundingrect; var lefts = this.lefts; var rights = this.rights; var len = lefts.length; var i; if (!boundingrect || (leftoffset >= boundingrect.left && leftoffset < boundingrect.right)) { for (i = 0; i < len; i++) { if (leftoffset >= lefts[i] && leftoffset < rights[i]) { return i; } } } }, // given a top offset (from document top), returns the index of the el that it vertically intersects. // if no intersection is made, or outside of the boundingrect, returns undefined. getverticalindex: function(topoffset) { this.ensurebuilt(); var boundingrect = this.boundingrect; var tops = this.tops; var bottoms = this.bottoms; var len = tops.length; var i; if (!boundingrect || (topoffset >= boundingrect.top && topoffset < boundingrect.bottom)) { for (i = 0; i < len; i++) { if (topoffset >= tops[i] && topoffset < bottoms[i]) { return i; } } } }, // gets the left offset (from document left) of the element at the given index getleftoffset: function(leftindex) { this.ensurebuilt(); return this.lefts[leftindex]; }, // gets the left position (from offsetparent left) of the element at the given index getleftposition: function(leftindex) { this.ensurebuilt(); return this.lefts[leftindex] - this.origin.left; }, // gets the right offset (from document left) of the element at the given index. // this value is not relative to the document's right edge, like the css concept of "right" would be. getrightoffset: function(leftindex) { this.ensurebuilt(); return this.rights[leftindex]; }, // gets the right position (from offsetparent left) of the element at the given index. // this value is not relative to the offsetparent's right edge, like the css concept of "right" would be. getrightposition: function(leftindex) { this.ensurebuilt(); return this.rights[leftindex] - this.origin.left; }, // gets the width of the element at the given index getwidth: function(leftindex) { this.ensurebuilt(); return this.rights[leftindex] - this.lefts[leftindex]; }, // gets the top offset (from document top) of the element at the given index gettopoffset: function(topindex) { this.ensurebuilt(); return this.tops[topindex]; }, // gets the top position (from offsetparent top) of the element at the given position gettopposition: function(topindex) { this.ensurebuilt(); return this.tops[topindex] - this.origin.top; }, // gets the bottom offset (from the document top) of the element at the given index. // this value is not relative to the offsetparent's bottom edge, like the css concept of "bottom" would be. getbottomoffset: function(topindex) { this.ensurebuilt(); return this.bottoms[topindex]; }, // gets the bottom position (from the offsetparent top) of the element at the given index. // this value is not relative to the offsetparent's bottom edge, like the css concept of "bottom" would be. getbottomposition: function(topindex) { this.ensurebuilt(); return this.bottoms[topindex] - this.origin.top; }, // gets the height of the element at the given index getheight: function(topindex) { this.ensurebuilt(); return this.bottoms[topindex] - this.tops[topindex]; } }); ;; /* tracks a drag's mouse movement, firing various handlers ----------------------------------------------------------------------------------------------------------------------*/ // todo: use emitter var draglistener = fc.draglistener = class.extend({ options: null, islistening: false, isdragging: false, // coordinates of the initial mousedown originx: null, originy: null, // handler attached to the document, bound to the draglistener's `this` mousemoveproxy: null, mouseupproxy: null, // for ie8 bug-fighting behavior, for now subjectel: null, // the element being draged. optional subjecthref: null, scrollel: null, scrollbounds: null, // { top, bottom, left, right } scrolltopvel: null, // pixels per second scrollleftvel: null, // pixels per second scrollintervalid: null, // id of settimeout for scrolling animation loop scrollhandlerproxy: null, // this-scoped function for handling when scrollel is scrolled scrollsensitivity: 30, // pixels from edge for scrolling to start scrollspeed: 200, // pixels per second, at maximum speed scrollintervalms: 50, // millisecond wait between scroll increment constructor: function(options) { options = options || {}; this.options = options; this.subjectel = options.subjectel; }, // call this when the user does a mousedown. will probably lead to startlistening mousedown: function(ev) { if (isprimarymousebutton(ev)) { ev.preventdefault(); // prevents native selection in most browsers this.startlistening(ev); // start the drag immediately if there is no minimum distance for a drag start if (!this.options.distance) { this.startdrag(ev); } } }, // call this to start tracking mouse movements startlistening: function(ev) { var scrollparent; if (!this.islistening) { // grab scroll container and attach handler if (ev && this.options.scroll) { scrollparent = getscrollparent($(ev.target)); if (!scrollparent.is(window) && !scrollparent.is(document)) { this.scrollel = scrollparent; // scope to `this`, and use `debounce` to make sure rapid calls don't happen this.scrollhandlerproxy = debounce(proxy(this, 'scrollhandler'), 100); this.scrollel.on('scroll', this.scrollhandlerproxy); } } $(document) .on('mousemove', this.mousemoveproxy = proxy(this, 'mousemove')) .on('mouseup', this.mouseupproxy = proxy(this, 'mouseup')) .on('selectstart', this.preventdefault); // prevents native selection in ie<=8 if (ev) { this.originx = ev.pagex; this.originy = ev.pagey; } else { // if no starting information was given, origin will be the topleft corner of the screen. // if so, dx/dy in the future will be the absolute coordinates. this.originx = 0; this.originy = 0; } this.islistening = true; this.listenstart(ev); } }, // called when drag listening has started (but a real drag has not necessarily began) listenstart: function(ev) { this.trigger('listenstart', ev); }, // called when the user moves the mouse mousemove: function(ev) { var dx = ev.pagex - this.originx; var dy = ev.pagey - this.originy; var mindistance; var distancesq; // current distance from the origin, squared if (!this.isdragging) { // if not already dragging... // then start the drag if the minimum distance criteria is met mindistance = this.options.distance || 1; distancesq = dx * dx + dy * dy; if (distancesq >= mindistance * mindistance) { // use pythagorean theorem this.startdrag(ev); } } if (this.isdragging) { this.drag(dx, dy, ev); // report a drag, even if this mousemove initiated the drag } }, // call this to initiate a legitimate drag. // this function is called internally from this class, but can also be called explicitly from outside startdrag: function(ev) { if (!this.islistening) { // startdrag must have manually initiated this.startlistening(); } if (!this.isdragging) { this.isdragging = true; this.dragstart(ev); } }, // called when the actual drag has started (went beyond mindistance) dragstart: function(ev) { var subjectel = this.subjectel; this.trigger('dragstart', ev); // remove a mousedown'd 's href so it is not visited (ie8 bug) if ((this.subjecthref = subjectel ? subjectel.attr('href') : null)) { subjectel.removeattr('href'); } }, // called while the mouse is being moved and when we know a legitimate drag is taking place drag: function(dx, dy, ev) { this.trigger('drag', dx, dy, ev); this.updatescroll(ev); // will possibly cause scrolling }, // called when the user does a mouseup mouseup: function(ev) { this.stoplistening(ev); }, // called when the drag is over. will not cause listening to stop however. // a concluding 'cellout' event will not be triggered. stopdrag: function(ev) { if (this.isdragging) { this.stopscrolling(); this.dragstop(ev); this.isdragging = false; } }, // called when dragging has been stopped dragstop: function(ev) { var _this = this; this.trigger('dragstop', ev); // restore a mousedown'd 's href (for ie8 bug) settimeout(function() { // must be outside of the click's execution if (_this.subjecthref) { _this.subjectel.attr('href', _this.subjecthref); } }, 0); }, // call this to stop listening to the user's mouse events stoplistening: function(ev) { this.stopdrag(ev); // if there's a current drag, kill it if (this.islistening) { // remove the scroll handler if there is a scrollel if (this.scrollel) { this.scrollel.off('scroll', this.scrollhandlerproxy); this.scrollhandlerproxy = null; } $(document) .off('mousemove', this.mousemoveproxy) .off('mouseup', this.mouseupproxy) .off('selectstart', this.preventdefault); this.mousemoveproxy = null; this.mouseupproxy = null; this.islistening = false; this.listenstop(ev); } }, // called when drag listening has stopped listenstop: function(ev) { this.trigger('listenstop', ev); }, // triggers a callback. calls a function in the option hash of the same name. // arguments beyond the first `name` are forwarded on. trigger: function(name) { if (this.options[name]) { this.options[name].apply(this, array.prototype.slice.call(arguments, 1)); } }, // stops a given mouse event from doing it's native browser action. in our case, text selection. preventdefault: function(ev) { ev.preventdefault(); }, /* scrolling ------------------------------------------------------------------------------------------------------------------*/ // computes and stores the bounding rectangle of scrollel computescrollbounds: function() { var el = this.scrollel; this.scrollbounds = el ? getouterrect(el) : null; // todo: use getclientrect in future. but prevents auto scrolling when on top of scrollbars }, // called when the dragging is in progress and scrolling should be updated updatescroll: function(ev) { var sensitivity = this.scrollsensitivity; var bounds = this.scrollbounds; var topcloseness, bottomcloseness; var leftcloseness, rightcloseness; var topvel = 0; var leftvel = 0; if (bounds) { // only scroll if scrollel exists // compute closeness to edges. valid range is from 0.0 - 1.0 topcloseness = (sensitivity - (ev.pagey - bounds.top)) / sensitivity; bottomcloseness = (sensitivity - (bounds.bottom - ev.pagey)) / sensitivity; leftcloseness = (sensitivity - (ev.pagex - bounds.left)) / sensitivity; rightcloseness = (sensitivity - (bounds.right - ev.pagex)) / sensitivity; // translate vertical closeness into velocity. // mouse must be completely in bounds for velocity to happen. if (topcloseness >= 0 && topcloseness <= 1) { topvel = topcloseness * this.scrollspeed * -1; // negative. for scrolling up } else if (bottomcloseness >= 0 && bottomcloseness <= 1) { topvel = bottomcloseness * this.scrollspeed; } // translate horizontal closeness into velocity if (leftcloseness >= 0 && leftcloseness <= 1) { leftvel = leftcloseness * this.scrollspeed * -1; // negative. for scrolling left } else if (rightcloseness >= 0 && rightcloseness <= 1) { leftvel = rightcloseness * this.scrollspeed; } } this.setscrollvel(topvel, leftvel); }, // sets the speed-of-scrolling for the scrollel setscrollvel: function(topvel, leftvel) { this.scrolltopvel = topvel; this.scrollleftvel = leftvel; this.constrainscrollvel(); // massages into realistic values // if there is non-zero velocity, and an animation loop hasn't already started, then start if ((this.scrolltopvel || this.scrollleftvel) && !this.scrollintervalid) { this.scrollintervalid = setinterval( proxy(this, 'scrollintervalfunc'), // scope to `this` this.scrollintervalms ); } }, // forces scrolltopvel and scrollleftvel to be zero if scrolling has already gone all the way constrainscrollvel: function() { var el = this.scrollel; if (this.scrolltopvel < 0) { // scrolling up? if (el.scrolltop() <= 0) { // already scrolled all the way up? this.scrolltopvel = 0; } } else if (this.scrolltopvel > 0) { // scrolling down? if (el.scrolltop() + el[0].clientheight >= el[0].scrollheight) { // already scrolled all the way down? this.scrolltopvel = 0; } } if (this.scrollleftvel < 0) { // scrolling left? if (el.scrollleft() <= 0) { // already scrolled all the left? this.scrollleftvel = 0; } } else if (this.scrollleftvel > 0) { // scrolling right? if (el.scrollleft() + el[0].clientwidth >= el[0].scrollwidth) { // already scrolled all the way right? this.scrollleftvel = 0; } } }, // this function gets called during every iteration of the scrolling animation loop scrollintervalfunc: function() { var el = this.scrollel; var frac = this.scrollintervalms / 1000; // considering animation frequency, what the vel should be mult'd by // change the value of scrollel's scroll if (this.scrolltopvel) { el.scrolltop(el.scrolltop() + this.scrolltopvel * frac); } if (this.scrollleftvel) { el.scrollleft(el.scrollleft() + this.scrollleftvel * frac); } this.constrainscrollvel(); // since the scroll values changed, recompute the velocities // if scrolled all the way, which causes the vels to be zero, stop the animation loop if (!this.scrolltopvel && !this.scrollleftvel) { this.stopscrolling(); } }, // kills any existing scrolling animation loop stopscrolling: function() { if (this.scrollintervalid) { clearinterval(this.scrollintervalid); this.scrollintervalid = null; // when all done with scrolling, recompute positions since they probably changed this.scrollstop(); } }, // get called when the scrollel is scrolled (note: this is delayed via debounce) scrollhandler: function() { // recompute all coordinates, but *only* if this is *not* part of our scrolling animation if (!this.scrollintervalid) { this.scrollstop(); } }, // called when scrolling has stopped, whether through auto scroll, or the user scrolling scrollstop: function() { } }); ;; /* tracks mouse movements over a component and raises events about which hit the mouse is over. ------------------------------------------------------------------------------------------------------------------------ options: - subjectel - subjectcenter */ var hitdraglistener = draglistener.extend({ component: null, // converts coordinates to hits // methods: preparehits, releasehits, queryhit orighit: null, // the hit the mouse was over when listening started hit: null, // the hit the mouse is over coordadjust: null, // delta that will be added to the mouse coordinates when computing collisions constructor: function(component, options) { draglistener.call(this, options); // call the super-constructor this.component = component; }, // called when drag listening starts (but a real drag has not necessarily began). // ev might be undefined if dragging was started manually. listenstart: function(ev) { var subjectel = this.subjectel; var subjectrect; var origpoint; var point; draglistener.prototype.listenstart.apply(this, arguments); // call the super-method this.computecoords(); if (ev) { origpoint = { left: ev.pagex, top: ev.pagey }; point = origpoint; // constrain the point to bounds of the element being dragged if (subjectel) { subjectrect = getouterrect(subjectel); // used for centering as well point = constrainpoint(point, subjectrect); } this.orighit = this.queryhit(point.left, point.top); // treat the center of the subject as the collision point? if (subjectel && this.options.subjectcenter) { // only consider the area the subject overlaps the hit. best for large subjects. // todo: skip this if hit didn't supply left/right/top/bottom if (this.orighit) { subjectrect = intersectrects(this.orighit, subjectrect) || subjectrect; // in case there is no intersection } point = getrectcenter(subjectrect); } this.coordadjust = diffpoints(point, origpoint); // point - origpoint } else { this.orighit = null; this.coordadjust = null; } }, // recomputes the drag-critical positions of elements computecoords: function() { this.component.preparehits(); this.computescrollbounds(); // why is this here??? }, // called when the actual drag has started dragstart: function(ev) { var hit; draglistener.prototype.dragstart.apply(this, arguments); // call the super-method // might be different from this.orighit if the min-distance is large hit = this.queryhit(ev.pagex, ev.pagey); // report the initial hit the mouse is over // especially important if no min-distance and drag starts immediately if (hit) { this.hitover(hit); } }, // called when the drag moves drag: function(dx, dy, ev) { var hit; draglistener.prototype.drag.apply(this, arguments); // call the super-method hit = this.queryhit(ev.pagex, ev.pagey); if (!ishitsequal(hit, this.hit)) { // a different hit than before? if (this.hit) { this.hitout(); } if (hit) { this.hitover(hit); } } }, // called when dragging has been stopped dragstop: function() { this.hitdone(); draglistener.prototype.dragstop.apply(this, arguments); // call the super-method }, // called when a the mouse has just moved over a new hit hitover: function(hit) { var isorig = ishitsequal(hit, this.orighit); this.hit = hit; this.trigger('hitover', this.hit, isorig, this.orighit); }, // called when the mouse has just moved out of a hit hitout: function() { if (this.hit) { this.trigger('hitout', this.hit); this.hitdone(); this.hit = null; } }, // called after a hitout. also called before a dragstop hitdone: function() { if (this.hit) { this.trigger('hitdone', this.hit); } }, // called when drag listening has stopped listenstop: function() { draglistener.prototype.listenstop.apply(this, arguments); // call the super-method this.orighit = null; this.hit = null; this.component.releasehits(); }, // called when scrolling has stopped, whether through auto scroll, or the user scrolling scrollstop: function() { draglistener.prototype.scrollstop.apply(this, arguments); // call the super-method this.computecoords(); // hits' absolute positions will be in new places. recompute }, // gets the hit underneath the coordinates for the given mouse event queryhit: function(left, top) { if (this.coordadjust) { left += this.coordadjust.left; top += this.coordadjust.top; } return this.component.queryhit(left, top); } }); // returns `true` if the hits are identically equal. `false` otherwise. must be from the same component. // two null values will be considered equal, as two "out of the component" states are the same. function ishitsequal(hit0, hit1) { if (!hit0 && !hit1) { return true; } if (hit0 && hit1) { return hit0.component === hit1.component && ishitpropswithin(hit0, hit1) && ishitpropswithin(hit1, hit0); // ensures all props are identical } return false; } // returns true if all of subhit's non-standard properties are within superhit function ishitpropswithin(subhit, superhit) { for (var propname in subhit) { if (!/^(component|left|right|top|bottom)$/.test(propname)) { if (subhit[propname] !== superhit[propname]) { return false; } } } return true; } ;; /* creates a clone of an element and lets it track the mouse as it moves ----------------------------------------------------------------------------------------------------------------------*/ var mousefollower = class.extend({ options: null, sourceel: null, // the element that will be cloned and made to look like it is dragging el: null, // the clone of `sourceel` that will track the mouse parentel: null, // the element that `el` (the clone) will be attached to // the initial position of el, relative to the offset parent. made to match the initial offset of sourceel top0: null, left0: null, // the initial position of the mouse mousey0: null, mousex0: null, // the number of pixels the mouse has moved from its initial position topdelta: null, leftdelta: null, mousemoveproxy: null, // document mousemove handler, bound to the mousefollower's `this` isfollowing: false, ishidden: false, isanimating: false, // doing the revert animation? constructor: function(sourceel, options) { this.options = options = options || {}; this.sourceel = sourceel; this.parentel = options.parentel ? $(options.parentel) : sourceel.parent(); // default to sourceel's parent }, // causes the element to start following the mouse start: function(ev) { if (!this.isfollowing) { this.isfollowing = true; this.mousey0 = ev.pagey; this.mousex0 = ev.pagex; this.topdelta = 0; this.leftdelta = 0; if (!this.ishidden) { this.updateposition(); } $(document).on('mousemove', this.mousemoveproxy = proxy(this, 'mousemove')); } }, // causes the element to stop following the mouse. if shouldrevert is true, will animate back to original position. // `callback` gets invoked when the animation is complete. if no animation, it is invoked immediately. stop: function(shouldrevert, callback) { var _this = this; var revertduration = this.options.revertduration; function complete() { this.isanimating = false; _this.removeelement(); this.top0 = this.left0 = null; // reset state for future updateposition calls if (callback) { callback(); } } if (this.isfollowing && !this.isanimating) { // disallow more than one stop animation at a time this.isfollowing = false; $(document).off('mousemove', this.mousemoveproxy); if (shouldrevert && revertduration && !this.ishidden) { // do a revert animation? this.isanimating = true; this.el.animate({ top: this.top0, left: this.left0 }, { duration: revertduration, complete: complete }); } else { complete(); } } }, // gets the tracking element. create it if necessary getel: function() { var el = this.el; if (!el) { this.sourceel.width(); // hack to force ie8 to compute correct bounding box el = this.el = this.sourceel.clone() .css({ position: 'absolute', visibility: '', // in case original element was hidden (commonly through hideevents()) display: this.ishidden ? 'none' : '', // for when initially hidden margin: 0, right: 'auto', // erase and set width instead bottom: 'auto', // erase and set height instead width: this.sourceel.width(), // explicit height in case there was a 'right' value height: this.sourceel.height(), // explicit width in case there was a 'bottom' value opacity: this.options.opacity || '', zindex: this.options.zindex }) .appendto(this.parentel); } return el; }, // removes the tracking element if it has already been created removeelement: function() { if (this.el) { this.el.remove(); this.el = null; } }, // update the css position of the tracking element updateposition: function() { var sourceoffset; var origin; this.getel(); // ensure this.el // make sure origin info was computed if (this.top0 === null) { this.sourceel.width(); // hack to force ie8 to compute correct bounding box sourceoffset = this.sourceel.offset(); origin = this.el.offsetparent().offset(); this.top0 = sourceoffset.top - origin.top; this.left0 = sourceoffset.left - origin.left; } this.el.css({ top: this.top0 + this.topdelta, left: this.left0 + this.leftdelta }); }, // gets called when the user moves the mouse mousemove: function(ev) { this.topdelta = ev.pagey - this.mousey0; this.leftdelta = ev.pagex - this.mousex0; if (!this.ishidden) { this.updateposition(); } }, // temporarily makes the tracking element invisible. can be called before following starts hide: function() { if (!this.ishidden) { this.ishidden = true; if (this.el) { this.el.hide(); } } }, // show the tracking element after it has been temporarily hidden show: function() { if (this.ishidden) { this.ishidden = false; this.updateposition(); this.getel().show(); } } }); ;; /* an abstract class comprised of a "grid" of areas that each represent a specific datetime ----------------------------------------------------------------------------------------------------------------------*/ var grid = fc.grid = class.extend({ view: null, // a view object isrtl: null, // shortcut to the view's isrtl option start: null, end: null, el: null, // the containing element elsbyfill: null, // a hash of jquery element sets used for rendering each fill. keyed by fill name. externaldragstartproxy: null, // binds the grid's scope to externaldragstart (in daygrid.events) // derived from options eventtimeformat: null, displayeventtime: null, displayeventend: null, minresizeduration: null, // todo: hack. set by subclasses. minumum event resize duration // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity // of the date areas. if not defined, assumes to be day and time granularity. // todo: port istimescale into same system? largeunit: null, constructor: function(view) { this.view = view; this.isrtl = view.opt('isrtl'); this.elsbyfill = {}; this.externaldragstartproxy = proxy(this, 'externaldragstart'); }, /* options ------------------------------------------------------------------------------------------------------------------*/ // generates the format string used for event time text, if not explicitly defined by 'timeformat' computeeventtimeformat: function() { return this.view.opt('smalltimeformat'); }, // determines whether events should have their end times displayed, if not explicitly defined by 'displayeventtime'. // only applies to non-all-day events. computedisplayeventtime: function() { return true; }, // determines whether events should have their end times displayed, if not explicitly defined by 'displayeventend' computedisplayeventend: function() { return true; }, /* dates ------------------------------------------------------------------------------------------------------------------*/ // tells the grid about what period of time to display. // any date-related internal data should be generated. setrange: function(range) { this.start = range.start.clone(); this.end = range.end.clone(); this.rangeupdated(); this.processrangeoptions(); }, // called when internal variables that rely on the range should be updated rangeupdated: function() { }, // updates values that rely on options and also relate to range processrangeoptions: function() { var view = this.view; var displayeventtime; var displayeventend; this.eventtimeformat = view.opt('eventtimeformat') || view.opt('timeformat') || // deprecated this.computeeventtimeformat(); displayeventtime = view.opt('displayeventtime'); if (displayeventtime == null) { displayeventtime = this.computedisplayeventtime(); // might be based off of range } displayeventend = view.opt('displayeventend'); if (displayeventend == null) { displayeventend = this.computedisplayeventend(); // might be based off of range } this.displayeventtime = displayeventtime; this.displayeventend = displayeventend; }, // converts a span (has unzoned start/end and any other grid-specific location information) // into an array of segments (pieces of events whose format is decided by the grid). spantosegs: function(span) { // subclasses must implement }, // diffs the two dates, returning a duration, based on granularity of the grid // todo: port istimescale into this system? diffdates: function(a, b) { if (this.largeunit) { return diffbyunit(a, b, this.largeunit); } else { return diffdaytime(a, b); } }, /* hit area ------------------------------------------------------------------------------------------------------------------*/ // called before one or more queryhit calls might happen. should prepare any cached coordinates for queryhit preparehits: function() { }, // called when queryhit calls have subsided. good place to clear any coordinate caches. releasehits: function() { }, // given coordinates from the topleft of the document, return data about the date-related area underneath. // can return an object with arbitrary properties (although top/right/left/bottom are encouraged). // must have a `grid` property, a reference to this current grid. todo: avoid this // the returned object will be processed by gethitspan and gethitel. queryhit: function(leftoffset, topoffset) { }, // given position-level information about a date-related area within the grid, // should return an object with at least a start/end date. can provide other information as well. gethitspan: function(hit) { }, // given position-level information about a date-related area within the grid, // should return a jquery element that best represents it. passed to dayclick callback. gethitel: function(hit) { }, /* rendering ------------------------------------------------------------------------------------------------------------------*/ // sets the container element that the grid should render inside of. // does other dom-related initializations. setelement: function(el) { var _this = this; this.el = el; // attach a handler to the grid's root element. // jquery will take care of unregistering them when removeelement gets called. el.on('mousedown', function(ev) { if ( !$(ev.target).is('.fc-event-container *, .fc-more') && // not an an event element, or "more.." link !$(ev.target).closest('.fc-popover').length // not on a popover (like the "more.." events one) ) { _this.daymousedown(ev); } }); // attach event-element-related handlers. in grid.events // same garbage collection note as above. this.bindseghandlers(); this.bindglobalhandlers(); }, // removes the grid's container element from the dom. undoes any other dom-related attachments. // does not remove any content beforehand (doesn't clear events or call unrenderdates), unlike view removeelement: function() { this.unbindglobalhandlers(); this.el.remove(); // note: we don't null-out this.el for the same reasons we don't do it within view::removeelement }, // renders the basic structure of grid view before any content is rendered renderskeleton: function() { // subclasses should implement }, // renders the grid's date-related content (like areas that represent days/times). // assumes setrange has already been called and the skeleton has already been rendered. renderdates: function() { // subclasses should implement }, // unrenders the grid's date-related content unrenderdates: function() { // subclasses should implement }, /* handlers ------------------------------------------------------------------------------------------------------------------*/ // binds dom handlers to elements that reside outside the grid, such as the document bindglobalhandlers: function() { $(document).on('dragstart sortstart', this.externaldragstartproxy); // jqui }, // unbinds dom handlers from elements that reside outside the grid unbindglobalhandlers: function() { $(document).off('dragstart sortstart', this.externaldragstartproxy); // jqui }, // process a mousedown on an element that represents a day. for day clicking and selecting. daymousedown: function(ev) { var _this = this; var view = this.view; var isselectable = view.opt('selectable'); var dayclickhit; // null if invalid dayclick var selectionspan; // null if invalid selection // this listener tracks a mousedown on a day element, and a subsequent drag. // if the drag ends on the same day, it is a 'dayclick'. // if 'selectable' is enabled, this listener also detects selections. var draglistener = new hitdraglistener(this, { //distance: 5, // needs more work if we want dayclick to fire correctly scroll: view.opt('dragscroll'), dragstart: function() { view.unselect(); // since we could be rendering a new selection, we want to clear any old one }, hitover: function(hit, isorig, orighit) { if (orighit) { // click needs to have started on a hit dayclickhit = isorig ? hit : null; // single-hit selection is a day click if (isselectable) { selectionspan = _this.computeselection( _this.gethitspan(orighit), _this.gethitspan(hit) ); if (selectionspan) { _this.renderselection(selectionspan); } else if (selectionspan === false) { disablecursor(); } } } }, hitout: function() { dayclickhit = null; selectionspan = null; _this.unrenderselection(); enablecursor(); }, listenstop: function(ev) { if (dayclickhit) { view.triggerdayclick( _this.gethitspan(dayclickhit), _this.gethitel(dayclickhit), ev ); } if (selectionspan) { // the selection will already have been rendered. just report it view.reportselection(selectionspan, ev); } enablecursor(); } }); draglistener.mousedown(ev); // start listening, which will eventually initiate a dragstart }, /* event helper ------------------------------------------------------------------------------------------------------------------*/ // todo: should probably move this to grid.events, like we did event dragging / resizing // renders a mock event at the given event location, which contains zoned start/end properties. rendereventlocationhelper: function(eventlocation, sourceseg) { var fakeevent = this.fabricatehelperevent(eventlocation, sourceseg); this.renderhelper(fakeevent, sourceseg); // do the actual rendering }, // builds a fake event given zoned event date properties and a segment is should be inspired from. // the range's end can be null, in which case the mock event that is rendered will have a null end time. // `sourceseg` is the internal segment object involved in the drag. if null, something external is dragging. fabricatehelperevent: function(eventlocation, sourceseg) { var fakeevent = sourceseg ? createobject(sourceseg.event) : {}; // mask the original event object if possible fakeevent.start = eventlocation.start.clone(); fakeevent.end = eventlocation.end ? eventlocation.end.clone() : null; fakeevent.allday = null; // force it to be freshly computed by normalizeeventdates this.view.calendar.normalizeeventdates(fakeevent); // this extra classname will be useful for differentiating real events from mock events in css fakeevent.classname = (fakeevent.classname || []).concat('fc-helper'); // if something external is being dragged in, don't render a resizer if (!sourceseg) { fakeevent.editable = false; } return fakeevent; }, // renders a mock event. given zoned event date properties. renderhelper: function(eventlocation, sourceseg) { // subclasses must implement }, // unrenders a mock event unrenderhelper: function() { // subclasses must implement }, /* selection ------------------------------------------------------------------------------------------------------------------*/ // renders a visual indication of a selection. will highlight by default but can be overridden by subclasses. // given a span (unzoned start/end and other misc data) renderselection: function(span) { this.renderhighlight(span); }, // unrenders any visual indications of a selection. will unrender a highlight by default. unrenderselection: function() { this.unrenderhighlight(); }, // given the first and last date-spans of a selection, returns another date-span object. // subclasses can override and provide additional data in the span object. will be passed to renderselection(). // will return false if the selection is invalid and this should be indicated to the user. // will return null/undefined if a selection invalid but no error should be reported. computeselection: function(span0, span1) { var span = this.computeselectionspan(span0, span1); if (span && !this.view.calendar.isselectionspanallowed(span)) { return false; } return span; }, // given two spans, must return the combination of the two. // todo: do this separation of concerns (combining vs validation) for event dnd/resize too. computeselectionspan: function(span0, span1) { var dates = [ span0.start, span0.end, span1.start, span1.end ]; dates.sort(comparenumbers); // sorts chronologically. works with moments return { start: dates[0].clone(), end: dates[3].clone() }; }, /* highlight ------------------------------------------------------------------------------------------------------------------*/ // renders an emphasis on the given date range. given a span (unzoned start/end and other misc data) renderhighlight: function(span) { this.renderfill('highlight', this.spantosegs(span)); }, // unrenders the emphasis on a date range unrenderhighlight: function() { this.unrenderfill('highlight'); }, // generates an array of classnames for rendering the highlight. used by the fill system. highlightsegclasses: function() { return [ 'fc-highlight' ]; }, /* business hours ------------------------------------------------------------------------------------------------------------------*/ renderbusinesshours: function() { }, unrenderbusinesshours: function() { }, /* now indicator ------------------------------------------------------------------------------------------------------------------*/ getnowindicatorunit: function() { }, rendernowindicator: function(date) { }, unrendernowindicator: function() { }, /* fill system (highlight, background events, business hours) -------------------------------------------------------------------------------------------------------------------- todo: remove this system. like we did in timegrid */ // renders a set of rectangles over the given segments of time. // must return a subset of segs, the segs that were actually rendered. // responsible for populating this.elsbyfill. todo: better api for expressing this requirement renderfill: function(type, segs) { // subclasses must implement }, // unrenders a specific type of fill that is currently rendered on the grid unrenderfill: function(type) { var el = this.elsbyfill[type]; if (el) { el.remove(); delete this.elsbyfill[type]; } }, // renders and assigns an `el` property for each fill segment. generic enough to work with different types. // only returns segments that successfully rendered. // to be harnessed by renderfill (implemented by subclasses). // analagous to renderfgsegels. renderfillsegels: function(type, segs) { var _this = this; var segelmethod = this[type + 'segel']; var html = ''; var renderedsegs = []; var i; if (segs.length) { // build a large concatenation of segment html for (i = 0; i < segs.length; i++) { html += this.fillseghtml(type, segs[i]); } // grab individual elements from the combined html string. use each as the default rendering. // then, compute the 'el' for each segment. $(html).each(function(i, node) { var seg = segs[i]; var el = $(node); // allow custom filter methods per-type if (segelmethod) { el = segelmethod.call(_this, seg, el); } if (el) { // custom filters did not cancel the render el = $(el); // allow custom filter to return raw dom node // correct element type? (would be bad if a non-td were inserted into a table for example) if (el.is(_this.fillsegtag)) { seg.el = el; renderedsegs.push(seg); } } }); } return renderedsegs; }, fillsegtag: 'div', // subclasses can override // builds the html needed for one fill segment. generic enought o work with different types. fillseghtml: function(type, seg) { // custom hooks per-type var classesmethod = this[type + 'segclasses']; var cssmethod = this[type + 'segcss']; var classes = classesmethod ? classesmethod.call(this, seg) : []; var css = csstostr(cssmethod ? cssmethod.call(this, seg) : {}); return '<' + this.fillsegtag + (classes.length ? ' class="' + classes.join(' ') + '"' : '') + (css ? ' style="' + css + '"' : '') + ' />'; }, /* generic rendering utilities for subclasses ------------------------------------------------------------------------------------------------------------------*/ // computes html classnames for a single-day element getdayclasses: function(date) { var view = this.view; var today = view.calendar.getnow(); var classes = [ 'fc-' + dayids[date.day()] ]; if ( view.intervalduration.as('months') == 1 && date.month() != view.intervalstart.month() ) { classes.push('fc-other-month'); } if (date.issame(today, 'day')) { classes.push( 'fc-today', view.highlightstateclass ); } else if (date < today) { classes.push('fc-past'); } else { classes.push('fc-future'); } return classes; } }); ;; /* event-rendering and event-interaction methods for the abstract grid class ----------------------------------------------------------------------------------------------------------------------*/ grid.mixin({ mousedoverseg: null, // the segment object the user's mouse is over. null if over nothing isdraggingseg: false, // is a segment being dragged? boolean isresizingseg: false, // is a segment being resized? boolean isdraggingexternal: false, // jqui-dragging an external element? boolean segs: null, // the *event* segments currently rendered in the grid. todo: rename to `eventsegs` // renders the given events onto the grid renderevents: function(events) { var bgevents = []; var fgevents = []; var i; for (i = 0; i < events.length; i++) { (isbgevent(events[i]) ? bgevents : fgevents).push(events[i]); } this.segs = [].concat( // record all segs this.renderbgevents(bgevents), this.renderfgevents(fgevents) ); }, renderbgevents: function(events) { var segs = this.eventstosegs(events); // renderbgsegs might return a subset of segs, segs that were actually rendered return this.renderbgsegs(segs) || segs; }, renderfgevents: function(events) { var segs = this.eventstosegs(events); // renderfgsegs might return a subset of segs, segs that were actually rendered return this.renderfgsegs(segs) || segs; }, // unrenders all events currently rendered on the grid unrenderevents: function() { this.triggersegmouseout(); // trigger an eventmouseout if user's mouse is over an event this.unrenderfgsegs(); this.unrenderbgsegs(); this.segs = null; }, // retrieves all rendered segment objects currently rendered on the grid geteventsegs: function() { return this.segs || []; }, /* foreground segment rendering ------------------------------------------------------------------------------------------------------------------*/ // renders foreground event segments onto the grid. may return a subset of segs that were rendered. renderfgsegs: function(segs) { // subclasses must implement }, // unrenders all currently rendered foreground segments unrenderfgsegs: function() { // subclasses must implement }, // renders and assigns an `el` property for each foreground event segment. // only returns segments that successfully rendered. // a utility that subclasses may use. renderfgsegels: function(segs, disableresizing) { var view = this.view; var html = ''; var renderedsegs = []; var i; if (segs.length) { // don't build an empty html string // build a large concatenation of event segment html for (i = 0; i < segs.length; i++) { html += this.fgseghtml(segs[i], disableresizing); } // grab individual elements from the combined html string. use each as the default rendering. // then, compute the 'el' for each segment. an el might be null if the eventrender callback returned false. $(html).each(function(i, node) { var seg = segs[i]; var el = view.resolveeventel(seg.event, $(node)); if (el) { el.data('fc-seg', seg); // used by handlers seg.el = el; renderedsegs.push(seg); } }); } return renderedsegs; }, // generates the html for the default rendering of a foreground event segment. used by renderfgsegels() fgseghtml: function(seg, disableresizing) { // subclasses should implement }, /* background segment rendering ------------------------------------------------------------------------------------------------------------------*/ // renders the given background event segments onto the grid. // returns a subset of the segs that were actually rendered. renderbgsegs: function(segs) { return this.renderfill('bgevent', segs); }, // unrenders all the currently rendered background event segments unrenderbgsegs: function() { this.unrenderfill('bgevent'); }, // renders a background event element, given the default rendering. called by the fill system. bgeventsegel: function(seg, el) { return this.view.resolveeventel(seg.event, el); // will filter through eventrender }, // generates an array of classnames to be used for the default rendering of a background event. // called by the fill system. bgeventsegclasses: function(seg) { var event = seg.event; var source = event.source || {}; return [ 'fc-bgevent' ].concat( event.classname, source.classname || [] ); }, // generates a semicolon-separated css string to be used for the default rendering of a background event. // called by the fill system. bgeventsegcss: function(seg) { return { 'background-color': this.getsegskincss(seg)['background-color'] }; }, // generates an array of classnames to be used for the rendering business hours overlay. called by the fill system. businesshourssegclasses: function(seg) { return [ 'fc-nonbusiness', 'fc-bgevent' ]; }, /* handlers ------------------------------------------------------------------------------------------------------------------*/ // attaches event-element-related handlers to the container element and leverage bubbling bindseghandlers: function() { var _this = this; var view = this.view; $.each( { mouseenter: function(seg, ev) { _this.triggersegmouseover(seg, ev); }, mouseleave: function(seg, ev) { _this.triggersegmouseout(seg, ev); }, click: function(seg, ev) { return view.trigger('eventclick', this, seg.event, ev); // can return `false` to cancel }, mousedown: function(seg, ev) { if ($(ev.target).is('.fc-resizer') && view.iseventresizable(seg.event)) { _this.segresizemousedown(seg, ev, $(ev.target).is('.fc-start-resizer')); } else if (view.iseventdraggable(seg.event)) { _this.segdragmousedown(seg, ev); } } }, function(name, func) { // attach the handler to the container element and only listen for real event elements via bubbling _this.el.on(name, '.fc-event-container > *', function(ev) { var seg = $(this).data('fc-seg'); // grab segment data. put there by view::renderevents // only call the handlers if there is not a drag/resize in progress if (seg && !_this.isdraggingseg && !_this.isresizingseg) { return func.call(this, seg, ev); // `this` will be the event element } }); } ); }, // updates internal state and triggers handlers for when an event element is moused over triggersegmouseover: function(seg, ev) { if (!this.mousedoverseg) { this.mousedoverseg = seg; this.view.trigger('eventmouseover', seg.el[0], seg.event, ev); } }, // updates internal state and triggers handlers for when an event element is moused out. // can be given no arguments, in which case it will mouseout the segment that was previously moused over. triggersegmouseout: function(seg, ev) { ev = ev || {}; // if given no args, make a mock mouse event if (this.mousedoverseg) { seg = seg || this.mousedoverseg; // if given no args, use the currently moused-over segment this.mousedoverseg = null; this.view.trigger('eventmouseout', seg.el[0], seg.event, ev); } }, /* event dragging ------------------------------------------------------------------------------------------------------------------*/ // called when the user does a mousedown on an event, which might lead to dragging. // generic enough to work with any type of grid. segdragmousedown: function(seg, ev) { var _this = this; var view = this.view; var calendar = view.calendar; var el = seg.el; var event = seg.event; var droplocation; // zoned event date properties // a clone of the original element that will move with the mouse var mousefollower = new mousefollower(seg.el, { parentel: view.el, opacity: view.opt('dragopacity'), revertduration: view.opt('dragrevertduration'), zindex: 2 // one above the .fc-view }); // tracks mouse movement over the *view's* coordinate map. allows dragging and dropping between subcomponents // of the view. var draglistener = new hitdraglistener(view, { distance: 5, scroll: view.opt('dragscroll'), subjectel: el, subjectcenter: true, listenstart: function(ev) { mousefollower.hide(); // don't show until we know this is a real drag mousefollower.start(ev); }, dragstart: function(ev) { _this.triggersegmouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported _this.segdragstart(seg, ev); view.hideevent(event); // hide all event segments. our mousefollower will take over }, hitover: function(hit, isorig, orighit) { // starting hit could be forced (daygrid.limit) if (seg.hit) { orighit = seg.hit; } // since we are querying the parent view, might not belong to this grid droplocation = _this.computeeventdrop( orighit.component.gethitspan(orighit), hit.component.gethitspan(hit), event ); if (droplocation && !calendar.iseventspanallowed(_this.eventtospan(droplocation), event)) { disablecursor(); droplocation = null; } // if a valid drop location, have the subclass render a visual indication if (droplocation && view.renderdrag(droplocation, seg)) { mousefollower.hide(); // if the subclass is already using a mock event "helper", hide our own } else { mousefollower.show(); // otherwise, have the helper follow the mouse (no snapping) } if (isorig) { droplocation = null; // needs to have moved hits to be a valid drop } }, hitout: function() { // called before mouse moves to a different hit or moved out of all hits view.unrenderdrag(); // unrender whatever was done in renderdrag mousefollower.show(); // show in case we are moving out of all hits droplocation = null; }, hitdone: function() { // called after a hitout or before a dragstop enablecursor(); }, dragstop: function(ev) { // do revert animation if hasn't changed. calls a callback when finished (whether animation or not) mousefollower.stop(!droplocation, function() { view.unrenderdrag(); view.showevent(event); _this.segdragstop(seg, ev); if (droplocation) { view.reporteventdrop(event, droplocation, this.largeunit, el, ev); } }); }, listenstop: function() { mousefollower.stop(); // put in listenstop in case there was a mousedown but the drag never started } }); draglistener.mousedown(ev); // start listening, which will eventually lead to a dragstart }, // called before event segment dragging starts segdragstart: function(seg, ev) { this.isdraggingseg = true; this.view.trigger('eventdragstart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy }, // called after event segment dragging stops segdragstop: function(seg, ev) { this.isdraggingseg = false; this.view.trigger('eventdragstop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy }, // given the spans an event drag began, and the span event was dropped, calculates the new zoned start/end/allday // values for the event. subclasses may override and set additional properties to be used by renderdrag. // a falsy returned value indicates an invalid drop. // does not consider overlap/constraint. computeeventdrop: function(startspan, endspan, event) { var calendar = this.view.calendar; var dragstart = startspan.start; var dragend = endspan.start; var delta; var droplocation; // zoned event date properties if (dragstart.hastime() === dragend.hastime()) { delta = this.diffdates(dragend, dragstart); // if an all-day event was in a timed area and it was dragged to a different time, // guarantee an end and adjust start/end to have times if (event.allday && durationhastime(delta)) { droplocation = { start: event.start.clone(), end: calendar.geteventend(event), // will be an ambig day allday: false // for normalizeeventtimes }; calendar.normalizeeventtimes(droplocation); } // othewise, work off existing values else { droplocation = { start: event.start.clone(), end: event.end ? event.end.clone() : null, allday: event.allday // keep it the same }; } droplocation.start.add(delta); if (droplocation.end) { droplocation.end.add(delta); } } else { // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared droplocation = { start: dragend.clone(), end: null, // end should be cleared allday: !dragend.hastime() }; } return droplocation; }, // utility for apply dragopacity to a jquery set applydragopacity: function(els) { var opacity = this.view.opt('dragopacity'); if (opacity != null) { els.each(function(i, node) { // don't use jquery (will set an ie filter), do it the old fashioned way. // in ie8, a helper element will disappears if there's a filter. node.style.opacity = opacity; }); } }, /* external element dragging ------------------------------------------------------------------------------------------------------------------*/ // called when a jquery ui drag is initiated anywhere in the dom externaldragstart: function(ev, ui) { var view = this.view; var el; var accept; if (view.opt('droppable')) { // only listen if this setting is on el = $((ui ? ui.item : null) || ev.target); // test that the dragged element passes the dropaccept selector or filter function. // fyi, the default is "*" (matches all) accept = view.opt('dropaccept'); if ($.isfunction(accept) ? accept.call(el[0], el) : el.is(accept)) { if (!this.isdraggingexternal) { // prevent double-listening if fired twice this.listentoexternaldrag(el, ev, ui); } } } }, // called when a jquery ui drag starts and it needs to be monitored for dropping listentoexternaldrag: function(el, ev, ui) { var _this = this; var calendar = this.view.calendar; var meta = getdraggedelmeta(el); // extra data about event drop, including possible event to create var droplocation; // a null value signals an unsuccessful drag // listener that tracks mouse movement over date-associated pixel regions var draglistener = new hitdraglistener(this, { listenstart: function() { _this.isdraggingexternal = true; }, hitover: function(hit) { droplocation = _this.computeexternaldrop( hit.component.gethitspan(hit), // since we are querying the parent view, might not belong to this grid meta ); if ( // invalid hit? droplocation && !calendar.isexternalspanallowed(_this.eventtospan(droplocation), droplocation, meta.eventprops) ) { disablecursor(); droplocation = null; } if (droplocation) { _this.renderdrag(droplocation); // called without a seg parameter } }, hitout: function() { droplocation = null; // signal unsuccessful }, hitdone: function() { // called after a hitout or before a dragstop enablecursor(); _this.unrenderdrag(); }, dragstop: function() { if (droplocation) { // element was dropped on a valid hit _this.view.reportexternaldrop(meta, droplocation, el, ev, ui); } }, listenstop: function() { _this.isdraggingexternal = false; } }); draglistener.startdrag(ev); // start listening immediately }, // given a hit to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object), // returns the zoned start/end dates for the event that would result from the hypothetical drop. end might be null. // returning a null value signals an invalid drop hit. // does not consider overlap/constraint. computeexternaldrop: function(span, meta) { var calendar = this.view.calendar; var droplocation = { start: calendar.applytimezone(span.start), // simulate a zoned event start date end: null }; // if dropped on an all-day span, and element's metadata specified a time, set it if (meta.starttime && !droplocation.start.hastime()) { droplocation.start.time(meta.starttime); } if (meta.duration) { droplocation.end = droplocation.start.clone().add(meta.duration); } return droplocation; }, /* drag rendering (for both events and an external elements) ------------------------------------------------------------------------------------------------------------------*/ // renders a visual indication of an event or external element being dragged. // `droplocation` contains hypothetical start/end/allday values the event would have if dropped. end can be null. // `seg` is the internal segment object that is being dragged. if dragging an external element, `seg` is null. // a truthy returned value indicates this method has rendered a helper element. renderdrag: function(droplocation, seg) { // subclasses must implement }, // unrenders a visual indication of an event or external element being dragged unrenderdrag: function() { // subclasses must implement }, /* resizing ------------------------------------------------------------------------------------------------------------------*/ // called when the user does a mousedown on an event's resizer, which might lead to resizing. // generic enough to work with any type of grid. segresizemousedown: function(seg, ev, isstart) { var _this = this; var view = this.view; var calendar = view.calendar; var el = seg.el; var event = seg.event; var eventend = calendar.geteventend(event); var resizelocation; // zoned event date properties. falsy if invalid resize // tracks mouse movement over the *grid's* coordinate map var draglistener = new hitdraglistener(this, { distance: 5, scroll: view.opt('dragscroll'), subjectel: el, dragstart: function(ev) { _this.triggersegmouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported _this.segresizestart(seg, ev); }, hitover: function(hit, isorig, orighit) { var orighitspan = _this.gethitspan(orighit); var hitspan = _this.gethitspan(hit); resizelocation = isstart ? _this.computeeventstartresize(orighitspan, hitspan, event) : _this.computeeventendresize(orighitspan, hitspan, event); if (resizelocation) { if (!calendar.iseventspanallowed(_this.eventtospan(resizelocation), event)) { disablecursor(); resizelocation = null; } // no change? (todo: how does this work with timezones?) else if (resizelocation.start.issame(event.start) && resizelocation.end.issame(eventend)) { resizelocation = null; } } if (resizelocation) { view.hideevent(event); _this.rendereventresize(resizelocation, seg); } }, hitout: function() { // called before mouse moves to a different hit or moved out of all hits resizelocation = null; }, hitdone: function() { // resets the rendering to show the original event _this.unrendereventresize(); view.showevent(event); enablecursor(); }, dragstop: function(ev) { _this.segresizestop(seg, ev); if (resizelocation) { // valid date to resize to? view.reporteventresize(event, resizelocation, this.largeunit, el, ev); } } }); draglistener.mousedown(ev); // start listening, which will eventually lead to a dragstart }, // called before event segment resizing starts segresizestart: function(seg, ev) { this.isresizingseg = true; this.view.trigger('eventresizestart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy }, // called after event segment resizing stops segresizestop: function(seg, ev) { this.isresizingseg = false; this.view.trigger('eventresizestop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy }, // returns new date-information for an event segment being resized from its start computeeventstartresize: function(startspan, endspan, event) { return this.computeeventresize('start', startspan, endspan, event); }, // returns new date-information for an event segment being resized from its end computeeventendresize: function(startspan, endspan, event) { return this.computeeventresize('end', startspan, endspan, event); }, // returns new zoned date information for an event segment being resized from its start or end // `type` is either 'start' or 'end'. // does not consider overlap/constraint. computeeventresize: function(type, startspan, endspan, event) { var calendar = this.view.calendar; var delta = this.diffdates(endspan[type], startspan[type]); var resizelocation; // zoned event date properties var defaultduration; // build original values to work from, guaranteeing a start and end resizelocation = { start: event.start.clone(), end: calendar.geteventend(event), allday: event.allday }; // if an all-day event was in a timed area and was resized to a time, adjust start/end to have times if (resizelocation.allday && durationhastime(delta)) { resizelocation.allday = false; calendar.normalizeeventtimes(resizelocation); } resizelocation[type].add(delta); // apply delta to start or end // if the event was compressed too small, find a new reasonable duration for it if (!resizelocation.start.isbefore(resizelocation.end)) { defaultduration = this.minresizeduration || // todo: hack (event.allday ? calendar.defaultalldayeventduration : calendar.defaulttimedeventduration); if (type == 'start') { // resizing the start? resizelocation.start = resizelocation.end.clone().subtract(defaultduration); } else { // resizing the end? resizelocation.end = resizelocation.start.clone().add(defaultduration); } } return resizelocation; }, // renders a visual indication of an event being resized. // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag. rendereventresize: function(range, seg) { // subclasses must implement }, // unrenders a visual indication of an event being resized. unrendereventresize: function() { // subclasses must implement }, /* rendering utils ------------------------------------------------------------------------------------------------------------------*/ // compute the text that should be displayed on an event's element. // `range` can be the event object itself, or something range-like, with at least a `start`. // if event times are disabled, or the event has no time, will return a blank string. // if not specified, formatstr will default to the eventtimeformat setting, // and displayend will default to the displayeventend setting. geteventtimetext: function(range, formatstr, displayend) { if (formatstr == null) { formatstr = this.eventtimeformat; } if (displayend == null) { displayend = this.displayeventend; } if (this.displayeventtime && range.start.hastime()) { if (displayend && range.end) { return this.view.formatrange(range, formatstr); } else { return range.start.format(formatstr); } } return ''; }, // generic utility for generating the html classnames for an event segment's element getsegclasses: function(seg, isdraggable, isresizable) { var event = seg.event; var classes = [ 'fc-event', seg.isstart ? 'fc-start' : 'fc-not-start', seg.isend ? 'fc-end' : 'fc-not-end' ].concat( event.classname, event.source ? event.source.classname : [] ); if (isdraggable) { classes.push('fc-draggable'); } if (isresizable) { classes.push('fc-resizable'); } return classes; }, // utility for generating event skin-related css properties getsegskincss: function(seg) { var event = seg.event; var view = this.view; var source = event.source || {}; var eventcolor = event.color; var sourcecolor = source.color; var optioncolor = view.opt('eventcolor'); return { 'background-color': event.backgroundcolor || eventcolor || source.backgroundcolor || sourcecolor || view.opt('eventbackgroundcolor') || optioncolor, 'border-color': event.bordercolor || eventcolor || source.bordercolor || sourcecolor || view.opt('eventbordercolor') || optioncolor, color: event.textcolor || source.textcolor || view.opt('eventtextcolor') }; }, /* converting events -> eventrange -> eventspan -> eventsegs ------------------------------------------------------------------------------------------------------------------*/ // generates an array of segments for the given single event // can accept an event "location" as well (which only has start/end and no allday) eventtosegs: function(event) { return this.eventstosegs([ event ]); }, eventtospan: function(event) { return this.eventtospans(event)[0]; }, // generates spans (always unzoned) for the given event. // does not do any inverting for inverse-background events. // can accept an event "location" as well (which only has start/end and no allday) eventtospans: function(event) { var range = this.eventtorange(event); return this.eventrangetospans(range, event); }, // converts an array of event objects into an array of event segment objects. // a custom `segslicefunc` may be given for arbitrarily slicing up events. // doesn't guarantee an order for the resulting array. eventstosegs: function(allevents, segslicefunc) { var _this = this; var eventsbyid = groupeventsbyid(allevents); var segs = []; $.each(eventsbyid, function(id, events) { var ranges = []; var i; for (i = 0; i < events.length; i++) { ranges.push(_this.eventtorange(events[i])); } // inverse-background events (utilize only the first event in calculations) if (isinversebgevent(events[0])) { ranges = _this.invertranges(ranges); for (i = 0; i < ranges.length; i++) { segs.push.apply(segs, // append to _this.eventrangetosegs(ranges[i], events[0], segslicefunc)); } } // normal event ranges else { for (i = 0; i < ranges.length; i++) { segs.push.apply(segs, // append to _this.eventrangetosegs(ranges[i], events[i], segslicefunc)); } } }); return segs; }, // generates the unzoned start/end dates an event appears to occupy // can accept an event "location" as well (which only has start/end and no allday) eventtorange: function(event) { return { start: event.start.clone().stripzone(), end: ( event.end ? event.end.clone() : // derive the end from the start and allday. compute allday if necessary this.view.calendar.getdefaulteventend( event.allday != null ? event.allday : !event.start.hastime(), event.start ) ).stripzone() }; }, // given an event's range (unzoned start/end), and the event itself, // slice into segments (using the segslicefunc function if specified) eventrangetosegs: function(range, event, segslicefunc) { var spans = this.eventrangetospans(range, event); var segs = []; var i; for (i = 0; i < spans.length; i++) { segs.push.apply(segs, // append to this.eventspantosegs(spans[i], event, segslicefunc)); } return segs; }, // given an event's unzoned date range, return an array of "span" objects. // subclasses can override. eventrangetospans: function(range, event) { return [ $.extend({}, range) ]; // copy into a single-item array }, // given an event's span (unzoned start/end and other misc data), and the event itself, // slices into segments and attaches event-derived properties to them. eventspantosegs: function(span, event, segslicefunc) { var segs = segslicefunc ? segslicefunc(span) : this.spantosegs(span); var i, seg; for (i = 0; i < segs.length; i++) { seg = segs[i]; seg.event = event; seg.eventstartms = +span.start; // todo: not the best name after making spans unzoned seg.eventdurationms = span.end - span.start; } return segs; }, // produces a new array of range objects that will cover all the time not covered by the given ranges. // side effect: will mutate the given array and will use its date references. invertranges: function(ranges) { var view = this.view; var viewstart = view.start.clone(); // need a copy var viewend = view.end.clone(); // need a copy var inverseranges = []; var start = viewstart; // the end of the previous range. the start of the new range var i, range; // ranges need to be in order. required for our date-walking algorithm ranges.sort(compareranges); for (i = 0; i < ranges.length; i++) { range = ranges[i]; // add the span of time before the event (if there is any) if (range.start > start) { // compare millisecond time (skip any ambig logic) inverseranges.push({ start: start, end: range.start }); } start = range.end; } // add the span of time after the last event (if there is any) if (start < viewend) { // compare millisecond time (skip any ambig logic) inverseranges.push({ start: start, end: viewend }); } return inverseranges; }, sorteventsegs: function(segs) { segs.sort(proxy(this, 'compareeventsegs')); }, // a cmp function for determining which segments should take visual priority compareeventsegs: function(seg1, seg2) { return seg1.eventstartms - seg2.eventstartms || // earlier events go first seg2.eventdurationms - seg1.eventdurationms || // tie? longer events go first seg2.event.allday - seg1.event.allday || // tie? put all-day events first (booleans cast to 0/1) comparebyfieldspecs(seg1.event, seg2.event, this.view.eventorderspecs); } }); /* utilities ----------------------------------------------------------------------------------------------------------------------*/ function isbgevent(event) { // returns true if background or inverse-background var rendering = geteventrendering(event); return rendering === 'background' || rendering === 'inverse-background'; } fc.isbgevent = isbgevent; // export function isinversebgevent(event) { return geteventrendering(event) === 'inverse-background'; } function geteventrendering(event) { return firstdefined((event.source || {}).rendering, event.rendering); } function groupeventsbyid(events) { var eventsbyid = {}; var i, event; for (i = 0; i < events.length; i++) { event = events[i]; (eventsbyid[event._id] || (eventsbyid[event._id] = [])).push(event); } return eventsbyid; } // a cmp function for determining which non-inverted "ranges" (see above) happen earlier function compareranges(range1, range2) { return range1.start - range2.start; // earlier ranges go first } /* external-dragging-element data ----------------------------------------------------------------------------------------------------------------------*/ // require all html5 data-* attributes used by fullcalendar to have this prefix. // a value of '' will query attributes like data-event. a value of 'fc' will query attributes like data-fc-event. fc.dataattrprefix = ''; // given a jquery element that might represent a dragged fullcalendar event, returns an intermediate data structure // to be used for event object creation. // a defined `.eventprops`, even when empty, indicates that an event should be created. function getdraggedelmeta(el) { var prefix = fc.dataattrprefix; var eventprops; // properties for creating the event, not related to date/time var starttime; // a duration var duration; var stick; if (prefix) { prefix += '-'; } eventprops = el.data(prefix + 'event') || null; if (eventprops) { if (typeof eventprops === 'object') { eventprops = $.extend({}, eventprops); // make a copy } else { // something like 1 or true. still signal event creation eventprops = {}; } // pluck special-cased date/time properties starttime = eventprops.start; if (starttime == null) { starttime = eventprops.time; } // accept 'time' as well duration = eventprops.duration; stick = eventprops.stick; delete eventprops.start; delete eventprops.time; delete eventprops.duration; delete eventprops.stick; } // fallback to standalone attribute values for each of the date/time properties if (starttime == null) { starttime = el.data(prefix + 'start'); } if (starttime == null) { starttime = el.data(prefix + 'time'); } // accept 'time' as well if (duration == null) { duration = el.data(prefix + 'duration'); } if (stick == null) { stick = el.data(prefix + 'stick'); } // massage into correct data types starttime = starttime != null ? moment.duration(starttime) : null; duration = duration != null ? moment.duration(duration) : null; stick = boolean(stick); return { eventprops: eventprops, starttime: starttime, duration: duration, stick: stick }; } ;; /* a set of rendering and date-related methods for a visual component comprised of one or more rows of day columns. prerequisite: the object being mixed into needs to be a *grid* */ var daytablemixin = fc.daytablemixin = { breakonweeks: false, // should create a new row for each week? daydates: null, // whole-day dates for each column. left to right dayindices: null, // for each day from start, the offset daysperrow: null, rowcnt: null, colcnt: null, colheadformat: null, // populates internal variables used for date calculation and rendering updatedaytable: function() { var view = this.view; var date = this.start.clone(); var dayindex = -1; var dayindices = []; var daydates = []; var daysperrow; var firstday; var rowcnt; while (date.isbefore(this.end)) { // loop each day from start to end if (view.ishiddenday(date)) { dayindices.push(dayindex + 0.5); // mark that it's between indices } else { dayindex++; dayindices.push(dayindex); daydates.push(date.clone()); } date.add(1, 'days'); } if (this.breakonweeks) { // count columns until the day-of-week repeats firstday = daydates[0].day(); for (daysperrow = 1; daysperrow < daydates.length; daysperrow++) { if (daydates[daysperrow].day() == firstday) { break; } } rowcnt = math.ceil(daydates.length / daysperrow); } else { rowcnt = 1; daysperrow = daydates.length; } this.daydates = daydates; this.dayindices = dayindices; this.daysperrow = daysperrow; this.rowcnt = rowcnt; this.updatedaytablecols(); }, // computes and assigned the colcnt property and updates any options that may be computed from it updatedaytablecols: function() { this.colcnt = this.computecolcnt(); this.colheadformat = this.view.opt('columnformat') || this.computecolheadformat(); }, // determines how many columns there should be in the table computecolcnt: function() { return this.daysperrow; }, // computes the ambiguously-timed moment for the given cell getcelldate: function(row, col) { return this.daydates[ this.getcelldayindex(row, col) ].clone(); }, // computes the ambiguously-timed date range for the given cell getcellrange: function(row, col) { var start = this.getcelldate(row, col); var end = start.clone().add(1, 'days'); return { start: start, end: end }; }, // returns the number of day cells, chronologically, from the first of the grid (0-based) getcelldayindex: function(row, col) { return row * this.daysperrow + this.getcoldayindex(col); }, // returns the numner of day cells, chronologically, from the first cell in *any given row* getcoldayindex: function(col) { if (this.isrtl) { return this.colcnt - 1 - col; } else { return col; } }, // given a date, returns its chronolocial cell-index from the first cell of the grid. // if the date lies between cells (because of hiddendays), returns a floating-point value between offsets. // if before the first offset, returns a negative number. // if after the last offset, returns an offset past the last cell offset. // only works for *start* dates of cells. will not work for exclusive end dates for cells. getdatedayindex: function(date) { var dayindices = this.dayindices; var dayoffset = date.diff(this.start, 'days'); if (dayoffset < 0) { return dayindices[0] - 1; } else if (dayoffset >= dayindices.length) { return dayindices[dayindices.length - 1] + 1; } else { return dayindices[dayoffset]; } }, /* options ------------------------------------------------------------------------------------------------------------------*/ // computes a default column header formatting string if `colformat` is not explicitly defined computecolheadformat: function() { // if more than one week row, or if there are a lot of columns with not much space, // put just the day numbers will be in each cell if (this.rowcnt > 1 || this.colcnt > 10) { return 'ddd'; // "sat" } // multiple days, so full single date string won't be in title text else if (this.colcnt > 1) { return this.view.opt('dayofmonthformat'); // "sat 12/10" } // single day, so full single date string will probably be in title text else { return 'dddd'; // "saturday" } }, /* slicing ------------------------------------------------------------------------------------------------------------------*/ // slices up a date range into a segment for every week-row it intersects with slicerangebyrow: function(range) { var daysperrow = this.daysperrow; var normalrange = this.view.computedayrange(range); // make whole-day range, considering nextdaythreshold var rangefirst = this.getdatedayindex(normalrange.start); // inclusive first index var rangelast = this.getdatedayindex(normalrange.end.clone().subtract(1, 'days')); // inclusive last index var segs = []; var row; var rowfirst, rowlast; // inclusive day-index range for current row var segfirst, seglast; // inclusive day-index range for segment for (row = 0; row < this.rowcnt; row++) { rowfirst = row * daysperrow; rowlast = rowfirst + daysperrow - 1; // intersect segment's offset range with the row's segfirst = math.max(rangefirst, rowfirst); seglast = math.min(rangelast, rowlast); // deal with in-between indices segfirst = math.ceil(segfirst); // in-between starts round to next cell seglast = math.floor(seglast); // in-between ends round to prev cell if (segfirst <= seglast) { // was there any intersection with the current row? segs.push({ row: row, // normalize to start of row firstrowdayindex: segfirst - rowfirst, lastrowdayindex: seglast - rowfirst, // must be matching integers to be the segment's start/end isstart: segfirst === rangefirst, isend: seglast === rangelast }); } } return segs; }, // slices up a date range into a segment for every day-cell it intersects with. // todo: make more dry with slicerangebyrow somehow. slicerangebyday: function(range) { var daysperrow = this.daysperrow; var normalrange = this.view.computedayrange(range); // make whole-day range, considering nextdaythreshold var rangefirst = this.getdatedayindex(normalrange.start); // inclusive first index var rangelast = this.getdatedayindex(normalrange.end.clone().subtract(1, 'days')); // inclusive last index var segs = []; var row; var rowfirst, rowlast; // inclusive day-index range for current row var i; var segfirst, seglast; // inclusive day-index range for segment for (row = 0; row < this.rowcnt; row++) { rowfirst = row * daysperrow; rowlast = rowfirst + daysperrow - 1; for (i = rowfirst; i <= rowlast; i++) { // intersect segment's offset range with the row's segfirst = math.max(rangefirst, i); seglast = math.min(rangelast, i); // deal with in-between indices segfirst = math.ceil(segfirst); // in-between starts round to next cell seglast = math.floor(seglast); // in-between ends round to prev cell if (segfirst <= seglast) { // was there any intersection with the current row? segs.push({ row: row, // normalize to start of row firstrowdayindex: segfirst - rowfirst, lastrowdayindex: seglast - rowfirst, // must be matching integers to be the segment's start/end isstart: segfirst === rangefirst, isend: seglast === rangelast }); } } } return segs; }, /* header rendering ------------------------------------------------------------------------------------------------------------------*/ renderheadhtml: function() { var view = this.view; return '' + '
' + '' + '' + this.renderheadtrhtml() + '' + '
' + '
'; }, renderheadintrohtml: function() { return this.renderintrohtml(); // fall back to generic }, renderheadtrhtml: function() { return '' + '' + (this.isrtl ? '' : this.renderheadintrohtml()) + this.renderheaddatecellshtml() + (this.isrtl ? this.renderheadintrohtml() : '') + ''; }, renderheaddatecellshtml: function() { var htmls = []; var col, date; for (col = 0; col < this.colcnt; col++) { date = this.getcelldate(0, col); htmls.push(this.renderheaddatecellhtml(date)); } return htmls.join(''); }, // todo: when internalapiversion, accept an object for html attributes // (colspan should be no different) renderheaddatecellhtml: function(date, colspan, otherattrs) { var view = this.view; return '' + ' 1 ? ' colspan="' + colspan + '"' : '') + (otherattrs ? ' ' + otherattrs : '') + '>' + htmlescape(date.format(this.colheadformat)) + ''; }, /* background rendering ------------------------------------------------------------------------------------------------------------------*/ renderbgtrhtml: function(row) { return '' + '' + (this.isrtl ? '' : this.renderbgintrohtml(row)) + this.renderbgcellshtml(row) + (this.isrtl ? this.renderbgintrohtml(row) : '') + ''; }, 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 '' + '
' + '
' + '' + this.renderbgtrhtml(row) + '
' + '
' + '
' + '' + (this.numbersvisible ? '' + this.rendernumbertrhtml(row) + '' : '' ) + '
' + '
' + '
'; }, /* grid number rendering ------------------------------------------------------------------------------------------------------------------*/ rendernumbertrhtml: function(row) { return '' + '' + (this.isrtl ? '' : this.rendernumberintrohtml(row)) + this.rendernumbercellshtml(row) + (this.isrtl ? this.rendernumberintrohtml(row) : '') + ''; }, 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 = $( '
' + '
' + '
' ); trel = skeletonel.find('tr'); if (startcol > 0) { trel.append(''); } trel.append( seg.el.attr('colspan', endcol - startcol) ); if (endcol < colcnt) { trel.append(''); } this.bookendcells(trel); 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 '
' + '
' + (this.isrtl ? titlehtml + ' ' + timehtml : // put a natural space in between timehtml + ' ' + titlehtml // ) + '
' + (isresizablefromstart ? '
' : '' ) + (isresizablefromend ? '
' : '' ) + ''; }, // given a row # and an array of segments all in the same row, render a element, a skeleton that contains // the segments. returns object with a bunch of internal data about how the render was calculated. // note: modifies rowsegs rendersegrow: function(row, rowsegs) { var colcnt = this.colcnt; var seglevels = this.buildseglevels(rowsegs); // group into sub-arrays of levels var levelcnt = math.max(1, seglevels.length); // ensure at least one level var tbody = $(''); var segmatrix = []; // lookup for which segments are rendered into which level+col cells var cellmatrix = []; // lookup for all elements of the level+col matrix var lonecellmatrix = []; // lookup for elements that only take up a single column var i, levelsegs; var col; var tr; var j, seg; var td; // populates empty cells from the current column (`col`) to `endcol` function emptycellsuntil(endcol) { while (col < endcol) { // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell td = (lonecellmatrix[i - 1] || [])[col]; if (td) { td.attr( 'rowspan', parseint(td.attr('rowspan') || 1, 10) + 1 ); } else { td = $(''); tr.append(td); } cellmatrix[i][col] = td; lonecellmatrix[i][col] = td; col++; } } for (i = 0; i < levelcnt; i++) { // iterate through all levels levelsegs = seglevels[i]; col = 0; tr = $(''); segmatrix.push([]); cellmatrix.push([]); lonecellmatrix.push([]); // levelcnt might be 1 even though there are no actual levels. protect against this. // this single empty row is useful for styling. if (levelsegs) { for (j = 0; j < levelsegs.length; j++) { // iterate through segments in level seg = levelsegs[j]; emptycellsuntil(seg.leftcol); // create a container that occupies or more columns. append the event element. td = $('').append(seg.el); if (seg.leftcol != seg.rightcol) { td.attr('colspan', seg.rightcol - seg.leftcol + 1); } else { // a single-column segment lonecellmatrix[i][col] = td; } while (col <= seg.rightcol) { cellmatrix[i][col] = td; segmatrix[i][col] = seg; col++; } tr.append(td); } } emptycellsuntil(colcnt); // finish off the row this.bookendcells(tr); 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
wrapper around the var topel; // the element we want to match the top coordinate of var options; if (this.rowcnt == 1) { topel = view.el; // will cause the popover to cover any sort of header } else { topel = this.rowels.eq(row); // will align with top of row } options = { classname: 'fc-more-popover', content: this.rendersegpopovercontent(row, col, segs), parentel: this.el, top: topel.offset().top, autohide: true, // when the user clicks elsewhere, hide the popover viewportconstrain: view.opt('popoverviewportconstrain'), hide: function() { // kill everything when the popover is hidden _this.segpopover.removeelement(); _this.segpopover = null; _this.popoversegs = null; } }; // determine horizontal coordinate. // we use the morewrap instead of the to avoid border confusion. if (this.isrtl) { options.right = morewrap.offset().left + morewrap.outerwidth() + 1; // +1 to be over cell border } else { options.left = morewrap.offset().left - 1; // -1 to be over cell border } this.segpopover = new popover(options); this.segpopover.show(); }, // builds the inner dom contents of the segment popover rendersegpopovercontent: function(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 = '' + (islabeled ? '' + // for matchcellwidths htmlescape(slotdate.format(this.labelformat)) + '' : '' ) + ''; html += '' + (!isrtl ? axishtml : '') + '' + (isrtl ? axishtml : '') + ""; slottime.add(this.slotduration); } return html; }, /* options ------------------------------------------------------------------------------------------------------------------*/ // parses various options into properties of this object processoptions: function() { var view = this.view; var slotduration = view.opt('slotduration'); var snapduration = view.opt('snapduration'); 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 '
' + '
' + (timetext ? '
' + '' + htmlescape(timetext) + '' + '
' : '' ) + (event.title ? '
' + htmlescape(event.title) + '
' : '' ) + '
' + '
' + /* 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 '' + '' + '' + // needed for matchcellwidths htmlescape(view.opt('weeknumbertitle')) + '' + ''; } 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 '' + '' + '' + // needed for matchcellwidths this.getcelldate(row, 0).format('w') + '' + ''; } 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 '' + '' + '' + // needed for matchcellwidths htmlescape(weektext) + '' + ''; } else { 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 '' + '' + '' + // needed for matchcellwidths (view.opt('alldayhtml') || htmlescape(view.opt('alldaytext'))) + '' + ''; }, // 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 });