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.view.el, // attach to root of view. guarantees outside of scrollbars.
+ 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();
+
+ // the popover doesn't live within the grid's container element, and thus won't get the event
+ // delegated-handlers for free. attach event-related handlers to the popover.
+ this.bindSegHandlersToEl(this.segPopover.el);
+ },
+
+
+ // 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 = $(
+ '' +
+ ''
+ );
+ 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
+ slatContainerEl: null, // div that wraps all the slat rows
+ 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.slatContainerEl = this.el.find('.fc-slats');
+ this.slatEls = this.slatContainerEl.find('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('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;
+
+ if (colCoordCache.isLeftInBounds(leftOffset) && slatCoordCache.isTopInBounds(topOffset)) {
+ 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 || [])
+ );
+ }
+ },
+
+
+ getTotalSlatHeight: function() {
+ return this.slatContainerEl.outerHeight();
+ },
+
+
+ // 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
+
+ // returns mock event elements
+ // signal that a helper has been rendered
+ return this.renderEventLocationHelper(eventLocation, seg);
+ }
+ 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) {
+ return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
+ },
+
+
+ // 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) {
+ return this.renderHelperSegs(this.eventToSegs(event), sourceSeg); // returns mock event elements
+ },
+
+
+ // Unrenders any mock helper event
+ unrenderHelper: function() {
+ this.unrenderHelperSegs();
+ },
+
+
+ /* Business Hours
+ ------------------------------------------------------------------------------------------------------------------*/
+
+
+ renderBusinessHours: function() {
+ this.renderBusinessSegs(
+ this.buildBusinessHourSegs()
+ );
+ },
+
+
+ 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 helperEls = [];
+ 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')
+ });
+ }
+ helperEls.push(seg.el[0]);
+ }
+
+ this.helperSegs = segs;
+
+ return $(helperEls); // must return rendered helpers
+ },
+
+
+ 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(EmitterMixin, ListenerMixin, {
+
+ 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
+ selectedEvent: null,
+
+ eventOrderSpecs: null, // criteria for ordering events when they have same date/time
+
+ // classNames styled by jqui themes
+ widgetHeaderClass: null,
+ widgetContentClass: null,
+ highlightStateClass: null,
+
+ // for date utils, computed from options
+ nextDayThreshold: null,
+ isHiddenDayHash: null,
+
+ // 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.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'));
+ },
+
+
+ getAllDayHtml: function() {
+ return this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'));
+ },
+
+
+ /* Navigation
+ ------------------------------------------------------------------------------------------------------------------*/
+
+
+ // Generates HTML for an anchor to another view into the calendar.
+ // Will either generate an tag or a non-clickable tag, depending on enabled settings.
+ // `gotoOptions` can either be a moment input, or an object with the form:
+ // { date, type, forceOff }
+ // `type` is a view-type like "day" or "week". default value is "day".
+ // `attrs` and `innerHtml` are use to generate the rest of the HTML tag.
+ buildGotoAnchorHtml: function(gotoOptions, attrs, innerHtml) {
+ var date, type, forceOff;
+ var finalOptions;
+
+ if ($.isPlainObject(gotoOptions)) {
+ date = gotoOptions.date;
+ type = gotoOptions.type;
+ forceOff = gotoOptions.forceOff;
+ }
+ else {
+ date = gotoOptions; // a single moment input
+ }
+ date = FC.moment(date); // if a string, parse it
+
+ finalOptions = { // for serialization into the link
+ date: date.format('YYYY-MM-DD'),
+ type: type || 'day'
+ };
+
+ if (typeof attrs === 'string') {
+ innerHtml = attrs;
+ attrs = null;
+ }
+
+ attrs = attrs ? ' ' + attrsToStr(attrs) : ''; // will have a leading space
+ innerHtml = innerHtml || '';
+
+ if (!forceOff && this.opt('navLinks')) {
+ return '' +
+ innerHtml +
+ '';
+ }
+ else {
+ return '' +
+ innerHtml +
+ '';
+ }
+ },
+
+
+ /* 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, explicitScrollState) {
+ var _this = this;
+ var prevScrollState = null;
+
+ if (explicitScrollState != null && this.displaying) { // don't need prevScrollState if explicitScrollState
+ prevScrollState = this.queryScroll();
+ }
+
+ this.calendar.freezeContentHeight();
+
+ return syncThen(this.clear(), function() { // clear the content first
+ return (
+ _this.displaying =
+ syncThen(_this.displayView(date), function() { // displayView might return a promise
+
+ // caller of display() wants a specific scroll state?
+ if (explicitScrollState != null) {
+ // we make an assumption that this is NOT the initial render,
+ // and thus don't need forceScroll (is inconveniently asynchronous)
+ _this.setScroll(explicitScrollState);
+ }
+ else {
+ _this.forceScroll(_this.computeInitialScroll(prevScrollState));
+ }
+
+ _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 syncThen(displaying, 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() {
+ this.listenTo($(document), 'mousedown', this.handleDocumentMousedown);
+ this.listenTo($(document), 'touchstart', this.processUnselect);
+ },
+
+
+ // Unbinds DOM handlers from elements that reside outside the view container
+ unbindGlobalHandlers: function() {
+ this.stopListeningTo($(document));
+ },
+
+
+ // 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
+ ------------------------------------------------------------------------------------------------------------------*/
+
+
+ // 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() {
+ // subclasses must implement
+ },
+
+
+ // Sets the view's scroll state. Will accept the same format computeInitialScroll and queryScroll produce.
+ setScroll: function(scrollState) {
+ // subclasses must implement
+ },
+
+
+ // 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) {
+ return this.isEventStartEditable(event);
+ },
+
+
+ isEventStartEditable: function(event) {
+ return firstDefined(
+ event.startEditable,
+ (event.source || {}).startEditable,
+ this.opt('eventStartEditable'),
+ this.isEventGenerallyEditable(event)
+ );
+ },
+
+
+ isEventGenerallyEditable: function(event) {
+ return firstDefined(
+ event.editable,
+ (event.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`.
+ // Must return elements used for any mock events.
+ 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 (time range)
+ ------------------------------------------------------------------------------------------------------------------*/
+
+
+ // 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
+ },
+
+
+ /* Event Selection
+ ------------------------------------------------------------------------------------------------------------------*/
+
+
+ selectEvent: function(event) {
+ if (!this.selectedEvent || this.selectedEvent !== event) {
+ this.unselectEvent();
+ this.renderedEventSegEach(function(seg) {
+ seg.el.addClass('fc-selected');
+ }, event);
+ this.selectedEvent = event;
+ }
+ },
+
+
+ unselectEvent: function() {
+ if (this.selectedEvent) {
+ this.renderedEventSegEach(function(seg) {
+ seg.el.removeClass('fc-selected');
+ }, this.selectedEvent);
+ this.selectedEvent = null;
+ }
+ },
+
+
+ isEventSelected: function(event) {
+ // event references might change on refetchEvents(), while selectedEvent doesn't,
+ // so compare IDs
+ return this.selectedEvent && this.selectedEvent._id === event._id;
+ },
+
+
+ /* Mouse / Touch Unselecting (time range & event unselection)
+ ------------------------------------------------------------------------------------------------------------------*/
+ // TODO: move consistently to down/start or up/end?
+ // TODO: don't kill previous selection if touch scrolling
+
+
+ handleDocumentMousedown: function(ev) {
+ if (isPrimaryMouseButton(ev)) {
+ this.processUnselect(ev);
+ }
+ },
+
+
+ processUnselect: function(ev) {
+ this.processRangeUnselect(ev);
+ this.processEventUnselect(ev);
+ },
+
+
+ processRangeUnselect: function(ev) {
+ var ignore;
+
+ // is there a time-range selection?
+ if (this.isSelected && this.opt('unselectAuto')) {
+ // 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);
+ }
+ }
+ },
+
+
+ processEventUnselect: function(ev) {
+ if (this.selectedEvent) {
+ if (!$(ev.target).closest('.fc-selected').length) {
+ this.unselectEvent();
+ }
+ }
+ },
+
+
+ /* 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;
+ }
+
+});
+
+;;
+
+/*
+Embodies a div that has potential scrollbars
+*/
+var Scroller = FC.Scroller = Class.extend({
+
+ el: null, // the guaranteed outer element
+ scrollEl: null, // the element with the scrollbars
+ overflowX: null,
+ overflowY: null,
+
+
+ constructor: function(options) {
+ options = options || {};
+ this.overflowX = options.overflowX || options.overflow || 'auto';
+ this.overflowY = options.overflowY || options.overflow || 'auto';
+ },
+
+
+ render: function() {
+ this.el = this.renderEl();
+ this.applyOverflow();
+ },
+
+
+ renderEl: function() {
+ return (this.scrollEl = $(''));
+ },
+
+
+ // sets to natural height, unlocks overflow
+ clear: function() {
+ this.setHeight('auto');
+ this.applyOverflow();
+ },
+
+
+ destroy: function() {
+ this.el.remove();
+ },
+
+
+ // Overflow
+ // -----------------------------------------------------------------------------------------------------------------
+
+
+ applyOverflow: function() {
+ this.scrollEl.css({
+ 'overflow-x': this.overflowX,
+ 'overflow-y': this.overflowY
+ });
+ },
+
+
+ // Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'.
+ // Useful for preserving scrollbar widths regardless of future resizes.
+ // Can pass in scrollbarWidths for optimization.
+ lockOverflow: function(scrollbarWidths) {
+ var overflowX = this.overflowX;
+ var overflowY = this.overflowY;
+
+ scrollbarWidths = scrollbarWidths || this.getScrollbarWidths();
+
+ if (overflowX === 'auto') {
+ overflowX = (
+ scrollbarWidths.top || scrollbarWidths.bottom || // horizontal scrollbars?
+ // OR scrolling pane with massless scrollbars?
+ this.scrollEl[0].scrollWidth - 1 > this.scrollEl[0].clientWidth
+ // subtract 1 because of IE off-by-one issue
+ ) ? 'scroll' : 'hidden';
+ }
+
+ if (overflowY === 'auto') {
+ overflowY = (
+ scrollbarWidths.left || scrollbarWidths.right || // vertical scrollbars?
+ // OR scrolling pane with massless scrollbars?
+ this.scrollEl[0].scrollHeight - 1 > this.scrollEl[0].clientHeight
+ // subtract 1 because of IE off-by-one issue
+ ) ? 'scroll' : 'hidden';
+ }
+
+ this.scrollEl.css({ 'overflow-x': overflowX, 'overflow-y': overflowY });
+ },
+
+
+ // Getters / Setters
+ // -----------------------------------------------------------------------------------------------------------------
+
+
+ setHeight: function(height) {
+ this.scrollEl.height(height);
+ },
+
+
+ getScrollTop: function() {
+ return this.scrollEl.scrollTop();
+ },
+
+
+ setScrollTop: function(top) {
+ this.scrollEl.scrollTop(top);
+ },
+
+
+ getClientWidth: function() {
+ return this.scrollEl[0].clientWidth;
+ },
+
+
+ getClientHeight: function() {
+ return this.scrollEl[0].clientHeight;
+ },
+
+
+ getScrollbarWidths: function() {
+ return getScrollbarWidths(this.scrollEl);
+ }
+
+});
+
+;;
+
+var Calendar = FC.Calendar = Class.extend({
+
+ dirDefaults: null, // option defaults related to LTR or RTL
+ localeDefaults: null, // option defaults related to current locale
+ overrides: null, // option overrides given to the fullCalendar constructor
+ dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides.
+ 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() {
+ },
+
+
+ // Computes the flattened options hash for the calendar and assigns to `this.options`.
+ // Assumes this.overrides and this.dynamicOverrides have already been initialized.
+ populateOptionsHash: function() {
+ var locale, localeDefaults;
+ var isRTL, dirDefaults;
+
+ locale = firstDefined( // explicit locale option given?
+ this.dynamicOverrides.locale,
+ this.overrides.locale
+ );
+ localeDefaults = localeOptionHash[locale];
+ if (!localeDefaults) { // explicit locale option not given or invalid?
+ locale = Calendar.defaults.locale;
+ localeDefaults = localeOptionHash[locale] || {};
+ }
+
+ isRTL = firstDefined( // based on options computed so far, is direction RTL?
+ this.dynamicOverrides.isRTL,
+ this.overrides.isRTL,
+ localeDefaults.isRTL,
+ Calendar.defaults.isRTL
+ );
+ dirDefaults = isRTL ? Calendar.rtlDefaults : {};
+
+ this.dirDefaults = dirDefaults;
+ this.localeDefaults = localeDefaults;
+ this.options = mergeOptions([ // merge defaults and overrides. lowest to highest precedence
+ Calendar.defaults, // global defaults
+ dirDefaults,
+ localeDefaults,
+ this.overrides,
+ this.dynamicOverrides
+ ]);
+ populateInstanceComputableOptions(this.options); // fill in gaps with computed options
+ },
+
+
+ // 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.localeDefaults, // 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)
+ this.dynamicOverrides // dynamically set via setter. highest precedence
+ ]);
+ 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] ||
+ // view can decide to look up a certain key
+ (spec.buttonTextKey ? buttonText[spec.buttonTextKey] : null) ||
+ // a key like "month"
+ (spec.singleUnit ? buttonText[spec.singleUnit] : null);
+ }
+
+ // highest to lowest priority
+ spec.buttonTextOverride =
+ queryButtonText(this.dynamicOverrides) ||
+ 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.localeDefaults) ||
+ 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(EmitterMixin);
+
+
+function Calendar_constructor(element, overrides) {
+ var t = this;
+
+
+ // Exports
+ // -----------------------------------------------------------------------------------
+
+ t.render = render;
+ t.destroy = destroy;
+ t.refetchEvents = refetchEvents;
+ t.refetchEventSources = refetchEventSources;
+ 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; // getter/setter method
+ t.trigger = trigger;
+
+
+ // Options
+ // -----------------------------------------------------------------------------------
+
+ t.dynamicOverrides = {};
+ t.viewSpecCache = {};
+ t.optionHandlers = {}; // for Calendar.options.js
+ t.overrides = $.extend({}, overrides); // make a copy
+
+ t.populateOptionsHash(); // sets this.options
+
+
+
+ // Locale-data Internals
+ // -----------------------------------------------------------------------------------
+ // Apply overrides to the current locale's data
+
+ var localeData;
+
+ // Called immediately, and when any of the options change.
+ // Happens before any internal objects rebuild or rerender, because this is very core.
+ t.bindOptions([
+ 'locale', 'monthNames', 'monthNamesShort', 'dayNames', 'dayNamesShort', 'firstDay', 'weekNumberCalculation'
+ ], function(locale, monthNames, monthNamesShort, dayNames, dayNamesShort, firstDay, weekNumberCalculation) {
+
+ // normalize
+ if (weekNumberCalculation === 'iso') {
+ weekNumberCalculation = 'ISO'; // normalize
+ }
+
+ localeData = createObject( // make a cheap copy
+ getMomentLocaleData(locale) // will fall back to en
+ );
+
+ if (monthNames) {
+ localeData._months = monthNames;
+ }
+ if (monthNamesShort) {
+ localeData._monthsShort = monthNamesShort;
+ }
+ if (dayNames) {
+ localeData._weekdays = dayNames;
+ }
+ if (dayNamesShort) {
+ localeData._weekdaysShort = dayNamesShort;
+ }
+
+ if (firstDay == null && weekNumberCalculation === 'ISO') {
+ firstDay = 1;
+ }
+ if (firstDay != null) {
+ var _week = createObject(localeData._week); // _week: { dow: # }
+ _week.dow = firstDay;
+ localeData._week = _week;
+ }
+
+ if ( // whitelist certain kinds of input
+ weekNumberCalculation === 'ISO' ||
+ weekNumberCalculation === 'local' ||
+ typeof weekNumberCalculation === 'function'
+ ) {
+ localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it
+ }
+
+ // If the internal current date object already exists, move to new locale.
+ // We do NOT need to do this technique for event dates, because this happens when converting to "segments".
+ if (date) {
+ localizeMoment(date); // sets to localeData
+ }
+ });
+
+
+ // Calendar-specific Date Utilities
+ // -----------------------------------------------------------------------------------
+
+
+ t.defaultAllDayEventDuration = moment.duration(t.options.defaultAllDayEventDuration);
+ t.defaultTimedEventDuration = moment.duration(t.options.defaultTimedEventDuration);
+
+
+ // Builds a moment using the settings of the current calendar: timezone and locale.
+ // Accepts anything the vanilla moment() constructor accepts.
+ t.moment = function() {
+ var mom;
+
+ if (t.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 (t.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
}
+
+ localizeMoment(mom);
+
+ return mom;
+ };
+
+
+ // Updates the given moment's locale settings to the current calendar locale settings.
+ function localizeMoment(mom) {
+ mom._locale = localeData;
}
+ t.localizeMoment = localizeMoment;
+
+
+ // 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 t.options.timezone !== 'local' && t.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 = t.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(t.options.locale).humanize();
+ };
+
+
+ // Imports
+ // -----------------------------------------------------------------------------------
+
+
+ EventManager.call(t);
+ var isFetchNeeded = t.isFetchNeeded;
+ var fetchEvents = t.fetchEvents;
+ var fetchEventSources = t.fetchEventSources;
+
+
+
+ // Locals
+ // -----------------------------------------------------------------------------------
+
+
+ var _element = element[0];
+ var header;
+ 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
- function destroy() {
- element.remove();
- }
- function renderSection(position) {
- var e = $("");
- var buttonStr = options.header[position];
- if (buttonStr) {
- $.each(buttonStr.split(' '), function(i) {
- if (i > 0) {
- e.append("");
- }
- var prevButton;
- $.each(this.split(','), function(j, buttonName) {
- if (buttonName == 'title') {
- e.append("");
- if (prevButton) {
- prevButton.addClass(tm + '-corner-right');
- }
- prevButton = null;
- }else{
- var buttonClick;
- if (calendar[buttonName]) {
- buttonClick = calendar[buttonName]; // calendar method
- }
- else if (fcViews[buttonName]) {
- buttonClick = function() {
- button.removeClass(tm + '-state-hover'); // forget why
- calendar.changeView(buttonName);
- };
- }
- if (buttonClick) {
- var icon = options.theme ? smartProperty(options.buttonIcons, buttonName) : null; // why are we using smartProperty here?
- var text = smartProperty(options.buttonText, buttonName); // why are we using smartProperty here?
- var button = $(
- "" +
- (icon ?
- "" +
- "" +
- "" :
- text
- ) +
- ""
- )
- .click(function() {
- if (!button.hasClass(tm + '-state-disabled')) {
- buttonClick();
- }
- })
- .mousedown(function() {
- button
- .not('.' + tm + '-state-active')
- .not('.' + tm + '-state-disabled')
- .addClass(tm + '-state-down');
- })
- .mouseup(function() {
- button.removeClass(tm + '-state-down');
- })
- .hover(
- function() {
- button
- .not('.' + tm + '-state-active')
- .not('.' + tm + '-state-disabled')
- .addClass(tm + '-state-hover');
- },
- function() {
- button
- .removeClass(tm + '-state-hover')
- .removeClass(tm + '-state-down');
- }
- )
- .appendTo(e);
- disableTextSelection(button);
- if (!prevButton) {
- button.addClass(tm + '-corner-left');
- }
- prevButton = button;
- }
- }
- });
- if (prevButton) {
- prevButton.addClass(tm + '-corner-right');
- }
- });
- }
- return e;
+ // Main Rendering
+ // -----------------------------------------------------------------------------------
+
+
+ // compute the initial ambig-timezone date
+ if (t.options.defaultDate != null) {
+ date = t.moment(t.options.defaultDate).stripZone();
+ }
+ else {
+ date = t.getNow(); // getNow already returns unzoned
}
- function updateTitle(html) {
- element.find('h2')
- .html(html);
+ function render() {
+ if (!content) {
+ initialRender();
+ }
+ else if (elementVisible()) {
+ // mainly for the public API
+ calcSize();
+ renderView();
+ }
}
- function activateButton(buttonName) {
- element.find('span.fc-button-' + buttonName)
- .addClass(tm + '-state-active');
+ function initialRender() {
+ element.addClass('fc');
+
+ // event delegation for nav links
+ element.on('click.fc', 'a[data-goto]', function(ev) {
+ var anchorEl = $(this);
+ var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON
+ var date = t.moment(gotoOptions.date);
+ var viewType = gotoOptions.type;
+
+ // property like "navLinkDayClick". might be a string or a function
+ var customAction = currentView.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click');
+
+ if (typeof customAction === 'function') {
+ customAction(date, ev);
+ }
+ else {
+ if (typeof customAction === 'string') {
+ viewType = customAction;
+ }
+ zoomTo(date, viewType);
+ }
+ });
+
+ // called immediately, and upon option change
+ t.bindOption('theme', function(theme) {
+ tm = theme ? 'ui' : 'fc'; // affects a larger scope
+ element.toggleClass('ui-widget', theme);
+ element.toggleClass('fc-unthemed', !theme);
+ });
+
+ // called immediately, and upon option change.
+ // HACK: locale often affects isRTL, so we explicitly listen to that too.
+ t.bindOptions([ 'isRTL', 'locale' ], function(isRTL) {
+ element.toggleClass('fc-ltr', !isRTL);
+ element.toggleClass('fc-rtl', isRTL);
+ });
+
+ content = $("").prependTo(element);
+
+ header = t.header = new Header(t);
+ renderHeader();
+
+ renderView(t.options.defaultView);
+
+ if (t.options.handleWindowResize) {
+ windowResizeProxy = debounce(windowResize, t.options.windowResizeDelay); // prevents rapid calls
+ $(window).resize(windowResizeProxy);
+ }
+ }
+
+
+ // can be called repeatedly and Header will rerender
+ function renderHeader() {
+ header.render();
+ if (header.el) {
+ element.prepend(header.el);
+ }
}
- function deactivateButton(buttonName) {
- element.find('span.fc-button-' + buttonName)
- .removeClass(tm + '-state-active');
+ 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');
+
+ element.off('.fc'); // unbind nav link handlers
+
+ if (windowResizeProxy) {
+ $(window).unbind('resize', windowResizeProxy);
+ }
}
- function disableButton(buttonName) {
- element.find('span.fc-button-' + buttonName)
- .addClass(tm + '-state-disabled');
+ function elementVisible() {
+ return element.is(':visible');
}
- function enableButton(buttonName) {
- element.find('span.fc-button-' + buttonName)
- .removeClass(tm + '-state-disabled');
- }
+ // 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.
+ // Accepts an optional scroll state to restore to.
+ function renderView(viewType, explicitScrollState) {
+ ignoreWindowResize++;
+
+ // if viewType is changing, remove the old view's rendering
+ if (currentView && viewType && currentView.type !== viewType) {
+ freezeContentHeight(); // prevent a scroll jump when view element is removed
+ clearView();
+ }
-fc.sourceNormalizers = [];
-fc.sourceFetchers = [];
+ // 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));
-var ajaxDefaults = {
- dataType: 'json',
- cache: false
-};
+ currentView.setElement(
+ $("").appendTo(content)
+ );
+ header.activateButton(viewType);
+ }
-var eventGUID = 1;
+ 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 ||
+ !( // NOT within interval range signals an implicit date window change
+ date >= currentView.intervalStart &&
+ date < currentView.intervalEnd
+ )
+ ) {
+ if (elementVisible()) {
+
+ currentView.display(date, explicitScrollState); // 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--;
+ }
+
+
+ // Unrenders the current view and reflects this change in the Header.
+ // Unregsiters the `currentView`, but does not remove from viewByType hash.
+ function clearView() {
+ header.deactivateButton(currentView.type);
+ currentView.removeElement();
+ currentView = t.view = null;
+ }
+
+
+ // Destroys the view, including the view object. Then, re-instantiates it and renders it.
+ // Maintains the same scroll state.
+ // TODO: maintain any other user-manipulated state.
+ function reinitView() {
+ ignoreWindowResize++;
+ freezeContentHeight();
+
+ var viewType = currentView.type;
+ var scrollState = currentView.queryScroll();
+ clearView();
+ renderView(viewType, scrollState);
+
+ unfreezeContentHeight();
+ ignoreWindowResize--;
+ }
-function EventManager(options, _sources) {
- 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.normalizeEvent = normalizeEvent;
-
-
- // imports
- var trigger = t.trigger;
- var getView = t.getView;
- var reportEvents = t.reportEvents;
-
- // locals
- var stickySource = { events: [] };
- var sources = [ stickySource ];
- var rangeStart, rangeEnd;
- var currentFetchID = 0;
- var pendingSourceCnt = 0;
- var loadingLevel = 0;
- var cache = [];
+
+ // Resizing
+ // -----------------------------------------------------------------------------------
+
+
+ t.getSuggestedViewHeight = function() {
+ if (suggestedViewHeight === undefined) {
+ calcSize();
+ }
+ return suggestedViewHeight;
+ };
+
+
+ t.isHeightAuto = function() {
+ return t.options.contentHeight === 'auto' || t.options.height === 'auto';
+ };
- for (var i=0; i<_sources.length; i++) {
- _addEventSource(_sources[i]);
+ 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();
+ }
}
-
- /* Fetching
- -----------------------------------------------------------------------------*/
-
-
- function isFetchNeeded(start, end) {
- return !rangeStart || start < rangeStart || end > rangeEnd;
+ function _calcSize() { // assumes elementVisible
+ var contentHeightInput = t.options.contentHeight;
+ var heightInput = t.options.height;
+
+ if (typeof contentHeightInput === 'number') { // exists and not 'auto'
+ suggestedViewHeight = contentHeightInput;
+ }
+ else if (typeof contentHeightInput === 'function') { // exists and is a function
+ suggestedViewHeight = contentHeightInput();
+ }
+ else if (typeof heightInput === 'number') { // exists and not 'auto'
+ suggestedViewHeight = heightInput - queryHeaderHeight();
+ }
+ else if (typeof heightInput === 'function') { // exists and is a function
+ suggestedViewHeight = heightInput() - queryHeaderHeight();
+ }
+ else if (heightInput === 'parent') { // set to height of parent element
+ suggestedViewHeight = element.parent().height() - queryHeaderHeight();
+ }
+ else {
+ suggestedViewHeight = Math.round(content.width() / Math.max(t.options.aspectRatio, .5));
+ }
+ }
+
+
+ function queryHeaderHeight() {
+ return header.el ? header.el.outerHeight(true) : 0; // includes margin
}
- function fetchEvents(start, end) {
- rangeStart = start;
- rangeEnd = end;
- cache = [];
- var fetchID = ++currentFetchID;
- var len = sources.length;
- pendingSourceCnt = len;
- for (var i=0; i= currentView.intervalStart && now < currentView.intervalEnd) {
+ header.disableButton('today');
+ }
+ else {
+ header.enableButton('today');
}
}
+
+
+ /* Selection
+ -----------------------------------------------------------------------------*/
- function _addEventSource(source) {
- if ($.isFunction(source) || $.isArray(source)) {
- source = { events: source };
- }
- else if (typeof source == 'string') {
- source = { url: source };
- }
- if (typeof source == 'object') {
- normalizeSource(source);
- sources.push(source);
- return source;
- }
+
+ // 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 removeEventSource(source) {
- sources = $.grep(sources, function(src) {
- return !isSourcesEqual(src, source);
- });
- // remove all client events from that source
- cache = $.grep(cache, function(e) {
- return !isSourcesEqual(e.source, source);
- });
- reportEvents(cache);
+ function unselect() { // safe to be called before renderView
+ if (currentView) {
+ currentView.unselect();
+ }
}
- /* Manipulation
+ /* Date
-----------------------------------------------------------------------------*/
- function updateEvent(event) { // update an existing event
- var i, len = cache.length, e,
- defaultEventEnd = getView().defaultEventEnd, // getView???
- startDelta = event.start - event._start,
- endDelta = event.end ?
- (event.end - (event._end || defaultEventEnd(event))) // event._end would be null if event.end
- : 0; // was null and event was just resized
- for (i=0; i "September 2014"
+ monthYearFormat: function(dpOptions) {
+ return dpOptions.showMonthAfterYear ?
+ 'YYYY[' + dpOptions.yearSuffix + '] MMMM' :
+ 'MMMM YYYY[' + dpOptions.yearSuffix + ']';
}
- return d;
-}
+};
+
+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"
-function fixDate(d, check) { // force d to be on check's YMD, for daylight savings purposes
- if (+d) { // prevent infinite looping on invalid dates
- while (d.getDate() != check.getDate()) {
- d.setTime(+d + (d < check ? 1 : -1) * HOUR_MS);
+ // 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 locales
+ .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 locales
+ .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 locales
+ .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
}
-}
+};
-function addMinutes(d, n) {
- d.setMinutes(d.getMinutes() + n);
- return d;
-}
+// options that should be computed off live calendar options (considers override options)
+// TODO: best place for this? related to locale?
+// TODO: flipping text based on isRTL is a bad idea because the CSS `direction` might want to handle it
+var instanceComputableOptions = {
-function clearTime(d) {
- d.setHours(0);
- d.setMinutes(0);
- d.setSeconds(0);
- d.setMilliseconds(0);
- return d;
-}
+ // 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';
+ },
-function cloneDate(d, dontKeepTime) {
- if (dontKeepTime) {
- return clearTime(new Date(+d));
+ // Produces format strings for results like "Wk5"
+ smallWeekFormat: function(options) {
+ return options.isRTL ?
+ 'w[' + options.weekNumberTitle + ']' :
+ '[' + options.weekNumberTitle + ']w';
}
- return new Date(+d);
-}
+};
-function zeroDate() { // returns a Date with time 00:00:00 and dateOfMonth=1
- var i=0, d;
- do {
- d = new Date(1970, i++, 1);
- } while (d.getHours()); // != 0
- return d;
+function populateInstanceComputableOptions(options) {
+ $.each(instanceComputableOptions, function(name, func) {
+ if (options[name] == null) {
+ options[name] = func(options);
+ }
+ });
}
-function dayDiff(d1, d2) { // d1 - d2
- return Math.round((cloneDate(d1, true) - cloneDate(d2, true)) / DAY_MS);
+// Returns moment's internal locale data. If doesn't exist, returns English.
+function getMomentLocaleData(localeCode) {
+ return moment.localeData(localeCode) || moment.localeData('en');
}
-function setYMD(date, y, m, d) {
- if (y !== undefined && y != date.getFullYear()) {
- date.setDate(1);
- date.setMonth(0);
- date.setFullYear(y);
- }
- if (m !== undefined && m != date.getMonth()) {
- date.setDate(1);
- date.setMonth(m);
- }
- if (d !== undefined) {
- date.setDate(d);
- }
-}
+// Initialize English by forcing computation of moment-derived options.
+// Also, sets it as the default.
+FC.locale('en', Calendar.englishDefaults);
+;;
+/* Top toolbar area with buttons and title
+----------------------------------------------------------------------------------------------------------------------*/
+// TODO: rename all header-related things to "toolbar"
-/* Date Parsing
------------------------------------------------------------------------------*/
+function Header(calendar) {
+ 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;
+ t.el = null; // mirrors local `el`
+
+ // locals
+ var el;
+ var viewsWithButtons = [];
+ var tm;
-function parseDate(s, ignoreTimezone) { // ignoreTimezone defaults to true
- if (typeof s == 'object') { // already a Date object
- return s;
- }
- if (typeof s == 'number') { // a UNIX timestamp
- return new Date(s * 1000);
- }
- if (typeof s == 'string') {
- if (s.match(/^\d+(\.\d+)?$/)) { // a UNIX timestamp
- return new Date(parseFloat(s) * 1000);
- }
- if (ignoreTimezone === undefined) {
- ignoreTimezone = true;
- }
- return parseISO8601(s, ignoreTimezone) || (s ? new Date(s) : null);
- }
- // TODO: never return invalid dates (like from new Date()), return null instead
- return null;
-}
+ // can be called repeatedly and will rerender
+ function render() {
+ var options = calendar.options;
+ var sections = options.header;
+ tm = options.theme ? 'ui' : 'fc';
-function parseISO8601(s, ignoreTimezone) { // ignoreTimezone defaults to false
- // derived from http://delete.me.uk/2005/03/iso8601.html
- // TODO: for a know glitch/feature, read tests/issue_206_parseDate_dst.html
- var m = s.match(/^([0-9]{4})(-([0-9]{2})(-([0-9]{2})([T ]([0-9]{2}):([0-9]{2})(:([0-9]{2})(\.([0-9]+))?)?(Z|(([-+])([0-9]{2})(:?([0-9]{2}))?))?)?)?)?$/);
- if (!m) {
- return null;
- }
- var date = new Date(m[1], 0, 1);
- if (ignoreTimezone || !m[13]) {
- var check = new Date(m[1], 0, 1, 9, 0);
- if (m[3]) {
- date.setMonth(m[3] - 1);
- check.setMonth(m[3] - 1);
- }
- if (m[5]) {
- date.setDate(m[5]);
- check.setDate(m[5]);
- }
- fixDate(date, check);
- if (m[7]) {
- date.setHours(m[7]);
- }
- if (m[8]) {
- date.setMinutes(m[8]);
- }
- if (m[10]) {
- date.setSeconds(m[10]);
+ if (sections) {
+ if (!el) {
+ el = this.el = $("");
+ }
+ else {
+ el.empty();
+ }
+ el.append(renderSection('left'))
+ .append(renderSection('right'))
+ .append(renderSection('center'))
+ .append('');
}
- if (m[12]) {
- date.setMilliseconds(Number("0." + m[12]) * 1000);
+ else {
+ removeElement();
}
- fixDate(date, check);
- }else{
- date.setUTCFullYear(
- m[1],
- m[3] ? m[3] - 1 : 0,
- m[5] || 1
- );
- date.setUTCHours(
- m[7] || 0,
- m[8] || 0,
- m[10] || 0,
- m[12] ? Number("0." + m[12]) * 1000 : 0
- );
- if (m[14]) {
- var offset = Number(m[16]) * 60 + (m[18] ? Number(m[18]) : 0);
- offset *= m[15] == '-' ? 1 : -1;
- date = new Date(+date + (offset * 60 * 1000));
+ }
+
+
+ function removeElement() {
+ if (el) {
+ el.remove();
+ el = t.el = null;
}
}
- return date;
-}
+
+
+ function renderSection(position) {
+ var sectionEl = $('');
+ var options = calendar.options;
+ 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 = (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) {
-function parseTime(s) { // returns minutes since start of day
- if (typeof s == 'number') { // an hour
- return s * 60;
- }
- if (typeof s == 'object') { // a Date object
- return s.getHours() * 60 + s.getMinutes();
- }
- var m = s.match(/(\d+)(?::(\d+))?\s*(\w+)?/);
- if (m) {
- var h = parseInt(m[1], 10);
- if (m[3]) {
- h %= 12;
- if (m[3].toLowerCase().charAt(0) == 'p') {
- h += 12;
- }
- }
- return h * 60 + (m[2] ? parseInt(m[2], 10) : 0);
- }
-}
+ 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);
+ }
-/* Date Formatting
------------------------------------------------------------------------------*/
-// TODO: use same function formatDate(date, [date2], format, [options])
+ 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')) {
-function formatDate(date, format, options) {
- return formatDates(date, null, format, options);
-}
+ 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
+ }
+ );
-function formatDates(date1, date2, format, options) {
- options = options || defaults;
- var date = date1,
- otherDate = date2,
- i, len = format.length, c,
- i2, formatter,
- res = '';
- for (i=0; i 1) {
+ groupEl = $('');
+ if (isOnlyButtons) {
+ groupEl.addClass('fc-button-group');
}
- i = i2;
- break;
+ groupEl.append(groupChildren);
+ sectionEl.append(groupEl);
}
- }
- }
- else if (c == '[') {
- for (i2=i+1; i2i; i2--) {
- if (formatter = dateFormatters[format.substring(i, i2)]) {
- if (date) {
- res += formatter(date, options);
- }
- i = i2 - 1;
- break;
- }
- }
- if (i2 == i) {
- if (date) {
- res += c;
- }
- }
+ }
+
+
+ function deactivateButton(buttonName) {
+ if (el) {
+ el.find('.fc-' + buttonName + '-button')
+ .removeClass(tm + '-state-active');
}
}
- return res;
-};
-
-
-var dateFormatters = {
- s : function(d) { return d.getSeconds() },
- ss : function(d) { return zeroPad(d.getSeconds()) },
- m : function(d) { return d.getMinutes() },
- mm : function(d) { return zeroPad(d.getMinutes()) },
- h : function(d) { return d.getHours() % 12 || 12 },
- hh : function(d) { return zeroPad(d.getHours() % 12 || 12) },
- H : function(d) { return d.getHours() },
- HH : function(d) { return zeroPad(d.getHours()) },
- d : function(d) { return d.getDate() },
- dd : function(d) { return zeroPad(d.getDate()) },
- ddd : function(d,o) { return o.dayNamesShort[d.getDay()] },
- dddd: function(d,o) { return o.dayNames[d.getDay()] },
- M : function(d) { return d.getMonth() + 1 },
- MM : function(d) { return zeroPad(d.getMonth() + 1) },
- MMM : function(d,o) { return o.monthNamesShort[d.getMonth()] },
- MMMM: function(d,o) { return o.monthNames[d.getMonth()] },
- yy : function(d) { return (d.getFullYear()+'').substring(2) },
- yyyy: function(d) { return d.getFullYear() },
- t : function(d) { return d.getHours() < 12 ? 'a' : 'p' },
- tt : function(d) { return d.getHours() < 12 ? 'am' : 'pm' },
- T : function(d) { return d.getHours() < 12 ? 'A' : 'P' },
- TT : function(d) { return d.getHours() < 12 ? 'AM' : 'PM' },
- u : function(d) { return formatDate(d, "yyyy-MM-dd'T'HH:mm:ss'Z'") },
- S : function(d) {
- var date = d.getDate();
- if (date > 10 && date < 20) {
- return 'th';
- }
- return ['st', 'nd', 'rd'][date%10-1] || 'th';
- },
- w : function(d, o) { // local
- return o.weekNumberCalculation(d);
- },
- W : function(d) { // ISO
- return iso8601Week(d);
+
+
+ function disableButton(buttonName) {
+ if (el) {
+ el.find('.fc-' + buttonName + '-button')
+ .prop('disabled', true)
+ .addClass(tm + '-state-disabled');
+ }
+ }
+
+
+ function enableButton(buttonName) {
+ if (el) {
+ el.find('.fc-' + buttonName + '-button')
+ .prop('disabled', false)
+ .removeClass(tm + '-state-disabled');
+ }
}
-};
-fc.dateFormatters = dateFormatters;
-
-/* thanks jQuery UI (https://github.com/jquery/jquery-ui/blob/master/ui/jquery.ui.datepicker.js)
- *
- * Set as calculateWeek to determine the week of the year based on the ISO 8601 definition.
- * `date` - the date to get the week for
- * `number` - the number of the week within the year that contains this date
- */
-function iso8601Week(date) {
- var time;
- var checkDate = new Date(date.getTime());
- // Find Thursday of this week starting on Monday
- checkDate.setDate(checkDate.getDate() + 4 - (checkDate.getDay() || 7));
+ function getViewsWithButtons() {
+ return viewsWithButtons;
+ }
- time = checkDate.getTime();
- checkDate.setMonth(0); // Compare with Jan 1
- checkDate.setDate(1);
- return Math.floor(Math.round((time - checkDate) / 86400000) / 7) + 1;
}
-
;;
-fc.applyAll = applyAll;
+FC.sourceNormalizers = [];
+FC.sourceFetchers = [];
+var ajaxDefaults = {
+ dataType: 'json',
+ cache: false
+};
-/* Event Date Math
------------------------------------------------------------------------------*/
-
-
-function exclEndDay(event) {
- if (event.end) {
- return _exclEndDay(event.end, event.allDay);
- }else{
- return addDays(cloneDate(event.start), 1);
- }
-}
-
-
-function _exclEndDay(end, allDay) {
- end = cloneDate(end);
- return allDay || end.getHours() || end.getMinutes() ? addDays(end, 1) : clearTime(end);
- // why don't we check for seconds/ms too?
-}
-
+var eventGUID = 1;
-/* Event Element Binding
------------------------------------------------------------------------------*/
+function EventManager() { // assumed to be a calendar
+ var t = this;
+
+
+ // exports
+ t.isFetchNeeded = isFetchNeeded;
+ t.fetchEvents = fetchEvents;
+ t.fetchEventSources = fetchEventSources;
+ t.getEventSources = getEventSources;
+ t.getEventSourceById = getEventSourceById;
+ t.getEventSourcesByMatchArray = getEventSourcesByMatchArray;
+ t.getEventSourcesByMatch = getEventSourcesByMatch;
+ t.addEventSource = addEventSource;
+ t.removeEventSource = removeEventSource;
+ t.removeEventSources = removeEventSources;
+ 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 pendingSourceCnt = 0; // outstanding fetch requests, max one per source
+ var cache = []; // holds events that have already been expanded
-function lazySegBind(container, segs, bindHandlers) {
- container.unbind('mouseover').mouseover(function(ev) {
- var parent=ev.target, e,
- i, seg;
- while (parent != this) {
- e = parent;
- parent = parent.parentNode;
- }
- if ((i = e._fci) !== undefined) {
- e._fci = undefined;
- seg = segs[i];
- bindHandlers(seg.event, seg.element, seg);
- $(ev.target).trigger(ev);
+ $.each(
+ (t.options.events ? [ t.options.events ] : []).concat(t.options.eventSources || []),
+ function(i, sourceInput) {
+ var source = buildEventSource(sourceInput);
+ if (source) {
+ sources.push(source);
+ }
}
- ev.stopPropagation();
- });
-}
-
-
-
-/* Element Dimensions
------------------------------------------------------------------------------*/
+ );
+
+
+
+ /* Fetching
+ -----------------------------------------------------------------------------*/
-function setOuterWidth(element, width, includeMargins) {
- for (var i=0, e; i rangeEnd; // is part of the new range outside of the old range?
}
-}
-
-
-function setOuterHeight(element, height, includeMargins) {
- for (var i=0, e; i=0; i--) {
- res = obj[parts[i].toLowerCase()];
- if (res !== undefined) {
- return res;
+ t.pushLoading();
+ $.ajax($.extend({}, ajaxDefaults, source, {
+ data: data,
+ success: function(events) {
+ events = events || [];
+ var res = applyAll(success, this, arguments);
+ if ($.isArray(res)) {
+ events = res;
+ }
+ callback(events);
+ },
+ error: function() {
+ applyAll(error, this, arguments);
+ callback();
+ },
+ complete: function() {
+ applyAll(complete, this, arguments);
+ t.popLoading();
+ }
+ }));
+ }else{
+ callback();
+ }
}
}
- return obj[''];
-}
-
-
-function htmlEscape(s) {
- return s.replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/'/g, ''')
- .replace(/"/g, '"')
- .replace(/\n/g, ' ');
-}
+
+
+
+ /* Sources
+ -----------------------------------------------------------------------------*/
-function disableTextSelection(element) {
- element
- .attr('unselectable', 'on')
- .css('MozUserSelect', 'none')
- .bind('selectstart.ui', function() { return false; });
-}
+ function addEventSource(sourceInput) {
+ var source = buildEventSource(sourceInput);
+ if (source) {
+ sources.push(source);
+ fetchEventSources([ source ], 'add'); // will eventually call reportEvents
+ }
+ }
-/*
-function enableTextSelection(element) {
- element
- .attr('unselectable', 'off')
- .css('MozUserSelect', '')
- .unbind('selectstart.ui');
-}
-*/
+ function buildEventSource(sourceInput) { // will return undefined if invalid source
+ var normalizers = FC.sourceNormalizers;
+ var source;
+ var i;
+ if ($.isFunction(sourceInput) || $.isArray(sourceInput)) {
+ source = { events: sourceInput };
+ }
+ else if (typeof sourceInput === 'string') {
+ source = { url: sourceInput };
+ }
+ else if (typeof sourceInput === 'object') {
+ source = $.extend({}, sourceInput); // shallow copy
+ }
-function markFirstLast(e) {
- e.children()
- .removeClass('fc-first fc-last')
- .filter(':first-child')
- .addClass('fc-first')
- .end()
- .filter(':last-child')
- .addClass('fc-last');
-}
+ if (source) {
+ // TODO: repeat code, same code for event classNames
+ if (source.className) {
+ if (typeof source.className === 'string') {
+ source.className = source.className.split(/\s+/);
+ }
+ // otherwise, assumed to be an array
+ }
+ else {
+ source.className = [];
+ }
-function setDayID(cell, date) {
- cell.each(function(i, _cell) {
- _cell.className = _cell.className.replace(/^fc-\w*/, 'fc-' + dayIDs[date.getDay()]);
- // TODO: make a way that doesn't rely on order of classes
- });
-}
+ // for array sources, we convert to standard Event Objects up front
+ if ($.isArray(source.events)) {
+ source.origArray = source.events; // for removeEventSource
+ source.events = $.map(source.events, function(eventInput) {
+ return buildEventFromInput(eventInput, source);
+ });
+ }
+ for (i=0; i")
- .appendTo(element);
+ function clientEvents(filter) {
+ if ($.isFunction(filter)) {
+ return $.grep(cache, filter);
+ }
+ else if (filter != null) { // not null, not undefined. an event ID
+ filter += '';
+ return $.grep(cache, function(e) {
+ return e._id == filter;
+ });
+ }
+ return cache; // else, return all
}
-
-
- function buildTable() {
- var html = buildTableHTML();
- if (table) {
- table.remove();
- }
- table = $(html).appendTo(element);
- head = table.find('thead');
- headCells = head.find('.fc-day-header');
- body = table.find('tbody');
- bodyRows = body.find('tr');
- bodyCells = body.find('.fc-day');
- bodyFirstCells = bodyRows.find('td:first-child');
+ // Makes sure all array event sources have their internal event objects
+ // converted over to the Calendar's current timezone.
+ t.rezoneArrayEventSources = function() {
+ var i;
+ var events;
+ var j;
- firstRowCellInners = bodyRows.eq(0).find('.fc-day > div');
- firstRowCellContentInners = bodyRows.eq(0).find('.fc-day-content > div');
-
- markFirstLast(head.add(head.find('tr'))); // marks first+last tr/th's
- markFirstLast(bodyRows); // marks first+last td's
- bodyRows.eq(0).addClass('fc-first');
- bodyRows.filter(':last').addClass('fc-last');
-
- bodyCells.each(function(i, _cell) {
- var date = cellToDate(
- Math.floor(i / colCnt),
- i % colCnt
- );
- trigger('dayRender', t, date, $(_cell));
- });
+ for (i = 0; i < sources.length; i++) {
+ events = sources[i].events;
+ if ($.isArray(events)) {
- dayBind(bodyCells);
- }
+ for (j = 0; j < events.length; j++) {
+ rezoneEventDates(events[j]);
+ }
+ }
+ }
+ };
+ function rezoneEventDates(event) {
+ event.start = t.moment(event.start);
+ if (event.end) {
+ event.end = t.moment(event.end);
+ }
+ backupEventDates(event);
+ }
+
+
+ /* Event Normalization
+ -----------------------------------------------------------------------------*/
- /* HTML Building
- -----------------------------------------------------------*/
+ // Given a raw object with key/value properties, returns an "abstract" Event object.
+ // An "abstract" event is an event that, if recurring, will not have been expanded yet.
+ // Will return `false` when input is invalid.
+ // `source` is optional
+ function buildEventFromInput(input, source) {
+ var out = {};
+ var start, end;
+ var allDay;
+ if (t.options.eventDataTransform) {
+ input = t.options.eventDataTransform(input);
+ }
+ if (source && source.eventDataTransform) {
+ input = source.eventDataTransform(input);
+ }
- function buildTableHTML() {
- var html =
- "" +
- buildHeadHTML() +
- buildBodyHTML() +
- " ";
+ // Copy all properties over to the resulting object.
+ // The special-case properties will be copied over afterwards.
+ $.extend(out, input);
- return html;
- }
+ if (source) {
+ out.source = source;
+ }
+ out._id = input._id || (input.id === undefined ? '_fc' + eventGUID++ : input.id + '');
- function buildHeadHTML() {
- var headerClass = tm + "-widget-header";
- var html = '';
- var col;
- var date;
+ if (input.className) {
+ if (typeof input.className == 'string') {
+ out.className = input.className.split(/\s+/);
+ }
+ else { // assumed to be an array
+ out.className = input.className;
+ }
+ }
+ else {
+ out.className = [];
+ }
- html += "";
+ start = input.start || input.date; // "date" is an alias for "start"
+ end = input.end;
- if (showWeekNumbers) {
- html +=
- "";
+ // parse as a time (Duration) if applicable
+ if (isTimeString(start)) {
+ start = moment.duration(start);
+ }
+ if (isTimeString(end)) {
+ end = moment.duration(end);
}
- for (col=0; col ";
+ if (start) {
+ start = t.moment(start);
+ if (!start.isValid()) {
+ return false;
+ }
+ }
- return html;
- }
+ if (end) {
+ end = t.moment(end);
+ if (!end.isValid()) {
+ end = null; // let defaults take over
+ }
+ }
+
+ allDay = input.allDay;
+ if (allDay === undefined) { // still undefined? fallback to default
+ allDay = firstDefined(
+ source ? source.allDayDefault : undefined,
+ t.options.allDayDefault
+ );
+ // still undefined? normalizeEventDates will calculate it
+ }
+ assignDatesToEvent(start, end, allDay, out);
+ }
- function buildBodyHTML() {
- var contentClass = tm + "-widget-content";
- var html = '';
- var row;
- var col;
- var date;
+ t.normalizeEvent(out); // hook for external use. a prototype method
+
+ return out;
+ }
+ t.buildEventFromInput = buildEventFromInput;
- html += "";
- for (row=0; row";
- if (showWeekNumbers) {
- date = cellToDate(row, 0);
- html +=
- "" +
- " " +
- htmlEscape(formatDate(date, weekNumberFormat)) +
- " " +
- " | ";
- }
+ // Ensures proper values for allDay/start/end. Accepts an Event object, or a plain object with event-ish properties.
+ // NOTE: Will modify the given object.
+ function normalizeEventDates(eventProps) {
- for (col=0; col";
+ if (eventProps.end && !eventProps.end.isAfter(eventProps.start)) {
+ eventProps.end = null;
}
- html += "";
-
- return html;
+ if (!eventProps.end) {
+ if (t.options.forceEventDuration) {
+ eventProps.end = t.getDefaultEventEnd(eventProps.allDay, eventProps.start);
+ }
+ else {
+ eventProps.end = null;
+ }
+ }
}
- function buildCellHTML(date) {
- var contentClass = tm + "-widget-content";
- var month = t.start.getMonth();
- var today = clearTime(new Date());
- var html = '';
- var classNames = [
- 'fc-day',
- 'fc-' + dayIDs[date.getDay()],
- contentClass
- ];
-
- if (date.getMonth() != month) {
- classNames.push('fc-other-month');
+ // Ensures the allDay property exists and the timeliness of the start/end dates are consistent
+ function normalizeEventTimes(eventProps) {
+ if (eventProps.allDay == null) {
+ eventProps.allDay = !(eventProps.start.hasTime() || (eventProps.end && eventProps.end.hasTime()));
}
- if (+date == +today) {
- classNames.push(
- 'fc-today',
- tm + '-state-highlight'
- );
- }
- else if (date < today) {
- classNames.push('fc-past');
+
+ if (eventProps.allDay) {
+ eventProps.start.stripTime();
+ if (eventProps.end) {
+ // TODO: consider nextDayThreshold here? If so, will require a lot of testing and adjustment
+ eventProps.end.stripTime();
+ }
}
else {
- classNames.push('fc-future');
+ if (!eventProps.start.hasTime()) {
+ eventProps.start = t.applyTimezone(eventProps.start.time(0)); // will assign a 00:00 time
+ }
+ if (eventProps.end && !eventProps.end.hasTime()) {
+ eventProps.end = t.applyTimezone(eventProps.end.time(0)); // will assign a 00:00 time
+ }
}
+ }
- html +=
- "" +
- "";
- if (showNumbers) {
- html += " " + date.getDate() + " ";
- }
+ // If the given event is a recurring event, break it down into an array of individual instances.
+ // If not a recurring event, return an array with the single original event.
+ // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array.
+ // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours).
+ function expandEvent(abstractEvent, _rangeStart, _rangeEnd) {
+ var events = [];
+ var dowHash;
+ var dow;
+ var i;
+ var date;
+ var startTime, endTime;
+ var start, end;
+ var event;
- html +=
- " " +
- " " +
- " | ";
+ _rangeStart = _rangeStart || rangeStart;
+ _rangeEnd = _rangeEnd || rangeEnd;
- return html;
- }
+ if (abstractEvent) {
+ if (abstractEvent._recurring) {
+ // make a boolean hash as to whether the event occurs on each day-of-week
+ if ((dow = abstractEvent.dow)) {
+ dowHash = {};
+ for (i = 0; i < dow.length; i++) {
+ dowHash[dow[i]] = true;
+ }
+ }
+ // iterate through every day in the current range
+ date = _rangeStart.clone().stripTime(); // holds the date of the current day
+ while (date.isBefore(_rangeEnd)) {
- /* Dimensions
- -----------------------------------------------------------*/
-
-
- function setHeight(height) {
- viewHeight = height;
-
- var bodyHeight = viewHeight - head.height();
- var rowHeight;
- var rowHeightLast;
- var cell;
-
- if (opt('weekMode') == 'variable') {
- rowHeight = rowHeightLast = Math.floor(bodyHeight / (rowCnt==1 ? 2 : 6));
- }else{
- rowHeight = Math.floor(bodyHeight / rowCnt);
- rowHeightLast = bodyHeight - rowHeight * (rowCnt-1);
- }
-
- bodyFirstCells.each(function(i, _cell) {
- if (i < rowCnt) {
- cell = $(_cell);
- cell.find('> div').css(
- 'min-height',
- (i==rowCnt-1 ? rowHeightLast : rowHeight) - vsides(cell)
- );
- }
- });
-
- }
-
-
- function setWidth(width) {
- viewWidth = width;
- colPositions.clear();
- colContentPositions.clear();
+ if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week
- weekNumberWidth = 0;
- if (showWeekNumbers) {
- weekNumberWidth = head.find('th.fc-week-number').outerWidth();
- }
+ startTime = abstractEvent.start; // the stored start and end properties are times (Durations)
+ endTime = abstractEvent.end; // "
+ start = date.clone();
+ end = null;
- colWidth = Math.floor((viewWidth - weekNumberWidth) / colCnt);
- setOuterWidth(headCells.slice(0, -1), colWidth);
- }
-
-
-
- /* Day clicking and binding
- -----------------------------------------------------------*/
-
-
- function dayBind(days) {
- days.click(dayClick)
- .mousedown(daySelectionMousedown);
- }
-
-
- function dayClick(ev) {
- if (!opt('selectable')) { // if selectable, SelectionManager will worry about dayClick
- var date = parseISO8601($(this).data('date'));
- trigger('dayClick', this, date, true, ev);
+ if (startTime) {
+ start = start.time(startTime);
+ }
+ if (endTime) {
+ end = date.clone().time(endTime);
+ }
+
+ event = $.extend({}, abstractEvent); // make a copy of the original
+ assignDatesToEvent(
+ start, end,
+ !startTime && !endTime, // allDay?
+ event
+ );
+ events.push(event);
+ }
+
+ date.add(1, 'days');
+ }
+ }
+ else {
+ events.push(abstractEvent); // return the original event. will be a one-item array
+ }
}
+
+ return events;
}
-
-
-
- /* Semi-transparent Overlay Helpers
- ------------------------------------------------------*/
- // TODO: should be consolidated with AgendaView's methods
+ t.expandEvent = expandEvent;
- function renderDayOverlay(overlayStart, overlayEnd, refreshCoordinateGrid) { // overlayEnd is exclusive
- if (refreshCoordinateGrid) {
- coordinateGrid.build();
+ /* Event Modification Math
+ -----------------------------------------------------------------------------------------*/
+
+
+ // Modifies an event and all related events by applying the given properties.
+ // Special date-diffing logic is used for manipulation of dates.
+ // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end.
+ // All date comparisons are done against the event's pristine _start and _end dates.
+ // Returns an object with delta information and a function to undo all operations.
+ // For making computations in a granularity greater than day/time, specify largeUnit.
+ // NOTE: The given `newProps` might be mutated for normalization purposes.
+ function mutateEvent(event, newProps, largeUnit) {
+ var miscProps = {};
+ var oldProps;
+ var clearEnd;
+ var startDelta;
+ var endDelta;
+ var durationDelta;
+ var undoFunc;
+
+ // diffs the dates in the appropriate way, returning a duration
+ function diffDates(date1, date0) { // date1 - date0
+ if (largeUnit) {
+ return diffByUnit(date1, date0, largeUnit);
+ }
+ else if (newProps.allDay) {
+ return diffDay(date1, date0);
+ }
+ else {
+ return diffDayTime(date1, date0);
+ }
}
- var segments = rangeToSegments(overlayStart, overlayEnd);
+ newProps = newProps || {};
- for (var i=0; i= eventStart && innerSpan.end <= eventEnd;
+};
- var start = addDays(cloneDate(date), -((date.getDay() - opt('firstDay') + 7) % 7));
- var end = addDays(cloneDate(start), 7);
- var visStart = cloneDate(start);
- skipHiddenDays(visStart);
+// 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;
- var visEnd = cloneDate(end);
- skipHiddenDays(visEnd, -1, true);
+ 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);
+ }
+ }
- var colCnt = getCellsPerWeek();
+ return peerEvents;
+};
- t.title = formatDates(
- visStart,
- addDays(cloneDate(visEnd), -1),
- opt('titleFormat')
- );
- t.start = start;
- t.end = end;
- t.visStart = visStart;
- t.visEnd = visEnd;
+// 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;
+}
- renderAgenda(colCnt);
- }
-}
+/* Overlapping / Constraining
+-----------------------------------------------------------------------------------------*/
-;;
-fcViews.agendaDay = AgendaDayView;
+// Determines if the given event can be relocated to the given span (unzoned start/end with other misc data)
+Calendar.prototype.isEventSpanAllowed = function(span, event) {
+ var source = event.source || {};
+ var constraint = firstDefined(
+ event.constraint,
+ source.constraint,
+ this.options.eventConstraint
+ );
-function AgendaDayView(element, calendar) {
- var t = this;
-
-
- // exports
- t.render = render;
-
-
- // imports
- AgendaView.call(t, element, calendar, 'agendaDay');
- var opt = t.opt;
- var renderAgenda = t.renderAgenda;
- var skipHiddenDays = t.skipHiddenDays;
- var formatDate = calendar.formatDate;
-
-
- function render(date, delta) {
+ var overlap = firstDefined(
+ event.overlap,
+ source.overlap,
+ this.options.eventOverlap
+ );
- if (delta) {
- addDays(date, delta);
- }
- skipHiddenDays(date, delta < 0 ? -1 : 1);
+ return this.isSpanAllowed(span, constraint, overlap, event) &&
+ (!this.options.eventAllow || this.options.eventAllow(span, event) !== false);
+};
- var start = cloneDate(date, true);
- var end = addDays(cloneDate(start), 1);
- t.title = formatDate(date, opt('titleFormat'));
+// Determines if an external event can be relocated to the given span (unzoned start/end with other misc data)
+Calendar.prototype.isExternalSpanAllowed = function(eventSpan, eventLocation, eventProps) {
+ var eventInput;
+ var event;
- t.start = t.visStart = start;
- t.end = t.visEnd = end;
+ // note: very similar logic is in View's reportExternalDrop
+ if (eventProps) {
+ eventInput = $.extend({}, eventProps, eventLocation);
+ event = this.expandEvent(
+ this.buildEventFromInput(eventInput)
+ )[0];
+ }
- renderAgenda(1);
+ if (event) {
+ return this.isEventSpanAllowed(eventSpan, event);
}
-
+ else { // treat it as a selection
-}
+ return this.isSelectionSpanAllowed(eventSpan);
+ }
+};
-;;
-setDefaults({
- allDaySlot: true,
- allDayText: 'all-day',
- firstHour: 6,
- slotMinutes: 30,
- defaultEventMinutes: 120,
- axisFormat: 'h(:mm)tt',
- timeFormat: {
- agenda: 'h:mm{ - h:mm}'
- },
- dragOpacity: {
- agenda: .5
- },
- minTime: 0,
- maxTime: 24,
- slotEventOverlap: true
-});
+// Determines the given span (unzoned start/end with other misc data) can be selected.
+Calendar.prototype.isSelectionSpanAllowed = function(span) {
+ return this.isSpanAllowed(span, this.options.selectConstraint, this.options.selectOverlap) &&
+ (!this.options.selectAllow || this.options.selectAllow(span) !== false);
+};
-// TODO: make it work in quirks mode (event corners, all-day height)
-// TODO: test liquid width, especially in IE6
+// Returns true if the given span (caused by an event drop/resize or a selection) is allowed to exist
+// according to the constraint/overlap settings.
+// `event` is not required if checking a selection.
+Calendar.prototype.isSpanAllowed = function(span, constraint, overlap, event) {
+ var constraintEvents;
+ var anyContainment;
+ var peerEvents;
+ var i, peerEvent;
+ var peerOverlap;
+
+ // the range must be fully contained by at least one of produced constraint events
+ if (constraint != null) {
+
+ // not treated as an event! intermediate data structure
+ // TODO: use ranges in the future
+ constraintEvents = this.constraintToEvents(constraint);
+ if (constraintEvents) { // not invalid
+
+ anyContainment = false;
+ for (i = 0; i < constraintEvents.length; i++) {
+ if (this.spanContainsSpan(constraintEvents[i], span)) {
+ anyContainment = true;
+ break;
+ }
+ }
+ if (!anyContainment) {
+ return false;
+ }
+ }
+ }
-function AgendaView(element, calendar, viewName) {
- var t = this;
-
-
- // exports
- t.renderAgenda = renderAgenda;
- t.setWidth = setWidth;
- t.setHeight = setHeight;
- t.afterRender = afterRender;
- t.defaultEventEnd = defaultEventEnd;
- t.timePosition = timePosition;
- t.getIsCellAllDay = getIsCellAllDay;
- t.allDayRow = getAllDayRow;
- t.getCoordinateGrid = function() { return coordinateGrid }; // specifically for AgendaEventRenderer
- t.getHoverListener = function() { return hoverListener };
- t.colLeft = colLeft;
- t.colRight = colRight;
- t.colContentLeft = colContentLeft;
- t.colContentRight = colContentRight;
- t.getDaySegmentContainer = function() { return daySegmentContainer };
- t.getSlotSegmentContainer = function() { return slotSegmentContainer };
- t.getMinMinute = function() { return minMinute };
- t.getMaxMinute = function() { return maxMinute };
- t.getSlotContainer = function() { return slotContainer };
- t.getRowCnt = function() { return 1 };
- t.getColCnt = function() { return colCnt };
- t.getColWidth = function() { return colWidth };
- t.getSnapHeight = function() { return snapHeight };
- t.getSnapMinutes = function() { return snapMinutes };
- t.defaultSelectionEnd = defaultSelectionEnd;
- t.renderDayOverlay = renderDayOverlay;
- t.renderSelection = renderSelection;
- t.clearSelection = clearSelection;
- t.reportDayClick = reportDayClick; // selection mousedown hack
- t.dragStart = dragStart;
- t.dragStop = dragStop;
-
-
- // imports
- View.call(t, element, calendar, viewName);
- OverlayManager.call(t);
- SelectionManager.call(t);
- AgendaEventRenderer.call(t);
- var opt = t.opt;
- var trigger = t.trigger;
- var renderOverlay = t.renderOverlay;
- var clearOverlays = t.clearOverlays;
- var reportSelection = t.reportSelection;
- var unselect = t.unselect;
- var daySelectionMousedown = t.daySelectionMousedown;
- var slotSegHtml = t.slotSegHtml;
- var cellToDate = t.cellToDate;
- var dateToCell = t.dateToCell;
- var rangeToSegments = t.rangeToSegments;
- var formatDate = calendar.formatDate;
-
-
- // locals
-
- var dayTable;
- var dayHead;
- var dayHeadCells;
- var dayBody;
- var dayBodyCells;
- var dayBodyCellInners;
- var dayBodyCellContentInners;
- var dayBodyFirstCell;
- var dayBodyFirstCellStretcher;
- var slotLayer;
- var daySegmentContainer;
- var allDayTable;
- var allDayRow;
- var slotScroller;
- var slotContainer;
- var slotSegmentContainer;
- var slotTable;
- var selectionHelper;
-
- var viewWidth;
- var viewHeight;
- var axisWidth;
- var colWidth;
- var gutterWidth;
- var slotHeight; // TODO: what if slotHeight changes? (see issue 650)
-
- var snapMinutes;
- var snapRatio; // ratio of number of "selection" slots to normal slots. (ex: 1, 2, 4)
- var snapHeight; // holds the pixel hight of a "selection" slot
-
- var colCnt;
- var slotCnt;
- var coordinateGrid;
- var hoverListener;
- var colPositions;
- var colContentPositions;
- var slotTopCache = {};
-
- var tm;
- var rtl;
- var minMinute, maxMinute;
- var colFormat;
- var showWeekNumbers;
- var weekNumberTitle;
- var weekNumberFormat;
-
+ peerEvents = this.getPeerEvents(span, event);
-
- /* Rendering
- -----------------------------------------------------------------------------*/
-
-
- disableTextSelection(element.addClass('fc-agenda'));
-
-
- function renderAgenda(c) {
- colCnt = c;
- updateOptions();
+ for (i = 0; i < peerEvents.length; i++) {
+ peerEvent = peerEvents[i];
- if (!dayTable) { // first time rendering?
- buildSkeleton(); // builds day table, slot area, events containers
- }
- else {
- buildDayTable(); // rebuilds day table
- }
- }
-
-
- function updateOptions() {
+ // there needs to be an actual intersection before disallowing anything
+ if (this.eventIntersectsRange(peerEvent, span)) {
- tm = opt('theme') ? 'ui' : 'fc';
- rtl = opt('isRTL')
- minMinute = parseTime(opt('minTime'));
- maxMinute = parseTime(opt('maxTime'));
- colFormat = opt('columnFormat');
+ // evaluate overlap for the given range and short-circuit if necessary
+ if (overlap === false) {
+ return false;
+ }
+ // if the event's overlap is a test function, pass the peer event in question as the first param
+ else if (typeof overlap === 'function' && !overlap(peerEvent, event)) {
+ return false;
+ }
- // week # options. (TODO: bad, logic also in other views)
- showWeekNumbers = opt('weekNumbers');
- weekNumberTitle = opt('weekNumberTitle');
- if (opt('weekNumberCalculation') != 'iso') {
- weekNumberFormat = "w";
- }
- else {
- weekNumberFormat = "W";
+ // if we are computing if the given range is allowable for an event, consider the other event's
+ // EventObject-specific or Source-specific `overlap` property
+ if (event) {
+ peerOverlap = firstDefined(
+ peerEvent.overlap,
+ (peerEvent.source || {}).overlap
+ // we already considered the global `eventOverlap`
+ );
+ if (peerOverlap === false) {
+ return false;
+ }
+ // if the peer event's overlap is a test function, pass the subject event as the first param
+ if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) {
+ return false;
+ }
+ }
}
-
- snapMinutes = opt('snapMinutes') || opt('slotMinutes');
}
+ return true;
+};
- /* Build DOM
- -----------------------------------------------------------------------*/
+// Given an event input from the API, produces an array of event objects. Possible event inputs:
+// 'businessHours'
+// An event ID (number or string)
+// An object with specific start/end dates or a recurring event (like what businessHours accepts)
+Calendar.prototype.constraintToEvents = function(constraintInput) {
+ if (constraintInput === 'businessHours') {
+ return this.getCurrentBusinessHourEvents();
+ }
- function buildSkeleton() {
- var headerClass = tm + "-widget-header";
- var contentClass = tm + "-widget-content";
- var s;
- var d;
- var i;
- var maxd;
- var minutes;
- var slotNormal = opt('slotMinutes') % 15 == 0;
-
- buildDayTable();
-
- slotLayer =
- $("")
- .appendTo(element);
-
- if (opt('allDaySlot')) {
-
- daySegmentContainer =
- $("")
- .appendTo(slotLayer);
-
- s =
- "" +
- "" +
- "" +
- "" +
- "" +
- " | " +
- "" +
- " " +
- " ";
- allDayTable = $(s).appendTo(slotLayer);
- allDayRow = allDayTable.find('tr');
-
- dayBind(allDayRow.find('td'));
-
- slotLayer.append(
- ""
- );
-
- }else{
-
- daySegmentContainer = $([]); // in jQuery 1.4, we can just do $()
-
+ if (typeof constraintInput === 'object') {
+ if (constraintInput.start != null) { // needs to be event-like input
+ return this.expandEvent(this.buildEventFromInput(constraintInput));
}
-
- slotScroller =
- $("")
- .appendTo(slotLayer);
-
- slotContainer =
- $("")
- .appendTo(slotScroller);
-
- slotSegmentContainer =
- $("")
- .appendTo(slotContainer);
-
- s =
- "" +
- "";
- d = zeroDate();
- maxd = addMinutes(cloneDate(d), maxMinute);
- addMinutes(d, minMinute);
- slotCnt = 0;
- for (i=0; d < maxd; i++) {
- minutes = d.getMinutes();
- s +=
- "" +
- "" +
- "" +
- " " +
- " | " +
- " ";
- addMinutes(d, opt('slotMinutes'));
- slotCnt++;
+ else {
+ return null; // invalid
}
- s +=
- "" +
- " ";
- slotTable = $(s).appendTo(slotContainer);
-
- slotBind(slotTable.find('td'));
}
+ return this.clientEvents(constraintInput); // probably an ID
+};
- /* Build Day Table
- -----------------------------------------------------------------------*/
+// Does the event's date range intersect with the given range?
+// start/end already assumed to have stripped zones :(
+Calendar.prototype.eventIntersectsRange = function(event, range) {
+ var eventStart = event.start.clone().stripZone();
+ var eventEnd = this.getEventEnd(event).stripZone();
+ return range.start < eventEnd && range.end > eventStart;
+};
- function buildDayTable() {
- var html = buildDayTableHTML();
- if (dayTable) {
- dayTable.remove();
- }
- dayTable = $(html).appendTo(element);
+/* Business Hours
+-----------------------------------------------------------------------------------------*/
- dayHead = dayTable.find('thead');
- dayHeadCells = dayHead.find('th').slice(1, -1); // exclude gutter
- dayBody = dayTable.find('tbody');
- dayBodyCells = dayBody.find('td').slice(0, -1); // exclude gutter
- dayBodyCellInners = dayBodyCells.find('> div');
- dayBodyCellContentInners = dayBodyCells.find('.fc-day-content > div');
+var BUSINESS_HOUR_EVENT_DEFAULTS = {
+ id: '_fcBusinessHours', // will relate events from different calls to expandEvent
+ start: '09:00',
+ end: '17:00',
+ dow: [ 1, 2, 3, 4, 5 ], // monday - friday
+ rendering: 'inverse-background'
+ // classNames are defined in businessHoursSegClasses
+};
- dayBodyFirstCell = dayBodyCells.eq(0);
- dayBodyFirstCellStretcher = dayBodyCellInners.eq(0);
-
- markFirstLast(dayHead.add(dayHead.find('tr')));
- markFirstLast(dayBody.add(dayBody.find('tr')));
+// Return events objects for business hours within the current view.
+// Abuse of our event system :(
+Calendar.prototype.getCurrentBusinessHourEvents = function(wholeDay) {
+ return this.computeBusinessHourEvents(wholeDay, this.options.businessHours);
+};
- // TODO: now that we rebuild the cells every time, we should call dayRender
+// Given a raw input value from options, return events objects for business hours within the current view.
+Calendar.prototype.computeBusinessHourEvents = function(wholeDay, input) {
+ if (input === true) {
+ return this.expandBusinessHourEvents(wholeDay, [ {} ]);
}
-
-
- function buildDayTableHTML() {
- var html =
- "" +
- buildDayTableHeadHTML() +
- buildDayTableBodyHTML() +
- " ";
-
- return html;
+ else if ($.isPlainObject(input)) {
+ return this.expandBusinessHourEvents(wholeDay, [ input ]);
}
+ else if ($.isArray(input)) {
+ return this.expandBusinessHourEvents(wholeDay, input, true);
+ }
+ else {
+ return [];
+ }
+};
+// inputs expected to be an array of objects.
+// if ignoreNoDow is true, will ignore entries that don't specify a day-of-week (dow) key.
+Calendar.prototype.expandBusinessHourEvents = function(wholeDay, inputs, ignoreNoDow) {
+ var view = this.getView();
+ var events = [];
+ var i, input;
- function buildDayTableHeadHTML() {
- var headerClass = tm + "-widget-header";
- var date;
- var html = '';
- var weekText;
- var col;
-
- html +=
- "" +
- "";
+ for (i = 0; i < inputs.length; i++) {
+ input = inputs[i];
- if (showWeekNumbers) {
- date = cellToDate(0, 0);
- weekText = formatDate(date, weekNumberFormat);
- if (rtl) {
- weekText += weekNumberTitle;
- }
- else {
- weekText = weekNumberTitle + weekText;
- }
- html +=
- "";
- }
- else {
- html += "";
+ if (ignoreNoDow && !input.dow) {
+ continue;
}
- for (col=0; col" +
- htmlEscape(formatDate(date, colFormat)) +
- "";
- }
+ // give defaults. will make a copy
+ input = $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, input);
- html +=
- "" +
- " " +
- "";
+ // if a whole-day series is requested, clear the start/end times
+ if (wholeDay) {
+ input.start = null;
+ input.end = null;
+ }
- return html;
+ events.push.apply(events, // append
+ this.expandEvent(
+ this.buildEventFromInput(input),
+ view.start,
+ view.end
+ )
+ );
}
+ return events;
+};
- function buildDayTableBodyHTML() {
- var headerClass = tm + "-widget-header"; // TODO: make these when updateOptions() called
- var contentClass = tm + "-widget-content";
- var date;
- var today = clearTime(new Date());
- var col;
- var cellsHTML;
- var cellHTML;
- var classNames;
- var html = '';
-
- html +=
- "" +
- "" +
- "";
+;;
- cellsHTML = '';
+/* 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.
- for (col=0; col" +
- "" +
- "";
+ dayNumbersVisible: false, // display day numbers on each day cell?
+ colWeekNumbersVisible: false, // display week numbers along the side?
+ cellWeekNumbersVisible: false, // display week numbers in day cell?
- cellsHTML += cellHTML;
- }
+ weekNumberWidth: null, // width of all the week-number cells running down the side
- html += cellsHTML;
- html +=
- " | " +
- " " +
- "";
+ headContainerEl: null, // div that hold's the dayGrid's rendered date header
+ headRowEl: null, // the fake row element of the day-of-week header
- return html;
- }
+ initialize: function() {
+ this.dayGrid = this.instantiateDayGrid();
- // TODO: data-date on the cells
+ this.scroller = new Scroller({
+ overflowX: 'hidden',
+ overflowY: 'auto'
+ });
+ },
-
-
- /* Dimensions
- -----------------------------------------------------------------------*/
-
- function setHeight(height) {
- if (height === undefined) {
- height = viewHeight;
- }
- viewHeight = height;
- slotTopCache = {};
-
- var headHeight = dayBody.position().top;
- var allDayHeight = slotScroller.position().top; // including divider
- var bodyHeight = Math.min( // total body height, including borders
- height - headHeight, // when scrollbars
- slotTable.height() + allDayHeight + 1 // when no scrollbars. +1 for bottom border
- );
+ // 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);
- dayBodyFirstCellStretcher
- .height(bodyHeight - vsides(dayBodyFirstCell));
-
- slotLayer.css('top', headHeight);
-
- slotScroller.height(bodyHeight - allDayHeight - 1);
-
- // the stylesheet guarantees that the first row has no border.
- // this allows .height() to work well cross-browser.
- slotHeight = slotTable.find('tr:first').height() + 1; // +1 for bottom border
+ return new subclass(this);
+ },
- snapRatio = opt('slotMinutes') / snapMinutes;
- snapHeight = slotHeight / snapRatio;
- }
-
-
- function setWidth(width) {
- viewWidth = width;
- colPositions.clear();
- colContentPositions.clear();
- var axisFirstCells = dayHead.find('th:first');
- if (allDayTable) {
- axisFirstCells = axisFirstCells.add(allDayTable.find('th:first'));
- }
- axisFirstCells = axisFirstCells.add(slotTable.find('th:first'));
-
- axisWidth = 0;
- setOuterWidth(
- axisFirstCells
- .width('')
- .each(function(i, _cell) {
- axisWidth = Math.max(axisWidth, $(_cell).outerWidth());
- }),
- axisWidth
- );
-
- var gutterCells = dayTable.find('.fc-agenda-gutter');
- if (allDayTable) {
- gutterCells = gutterCells.add(allDayTable.find('th.fc-agenda-gutter'));
- }
+ // Sets the display range and computes all necessary dates
+ setRange: function(range) {
+ View.prototype.setRange.call(this, range); // call the super-method
- var slotTableWidth = slotScroller[0].clientWidth; // needs to be done after axisWidth (for IE7)
-
- gutterWidth = slotScroller.width() - slotTableWidth;
- if (gutterWidth) {
- setOuterWidth(gutterCells, gutterWidth);
- gutterCells
- .show()
- .prev()
- .removeClass('fc-last');
- }else{
- gutterCells
- .hide()
- .prev()
- .addClass('fc-last');
- }
-
- colWidth = Math.floor((slotTableWidth - axisWidth) / colCnt);
- setOuterWidth(dayHeadCells.slice(0, -1), colWidth);
- }
-
+ this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange
+ this.dayGrid.setRange(range);
+ },
- /* Scrolling
- -----------------------------------------------------------------------*/
+ // 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);
- function resetScroll() {
- var d0 = zeroDate();
- var scrollDate = cloneDate(d0);
- scrollDate.setHours(opt('firstHour'));
- var top = timePosition(d0, scrollDate) + 1; // +1 for the border
- function scroll() {
- slotScroller.scrollTop(top);
+ // 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
+ }
}
- scroll();
- setTimeout(scroll, 0); // overrides any previous scroll state made by the browser
- }
-
- function afterRender() { // after the view has been freshly rendered and sized
- resetScroll();
- }
-
-
-
- /* Slot/Day clicking and binding
- -----------------------------------------------------------------------*/
-
+ return range;
+ },
- function dayBind(cells) {
- cells.click(slotClick)
- .mousedown(daySelectionMousedown);
- }
+ // Renders the view into `this.el`, which should already be assigned
+ renderDates: function() {
- function slotBind(cells) {
- cells.click(slotClick)
- .mousedown(slotSelectionMousedown);
- }
-
-
- function slotClick(ev) {
- if (!opt('selectable')) { // if selectable, SelectionManager will worry about dayClick
- var col = Math.min(colCnt-1, Math.floor((ev.pageX - dayTable.offset().left - axisWidth) / colWidth));
- var date = cellToDate(0, col);
- var rowMatch = this.parentNode.className.match(/fc-slot(\d+)/); // TODO: maybe use data
- if (rowMatch) {
- var mins = parseInt(rowMatch[1]) * opt('slotMinutes');
- var hours = Math.floor(mins/60);
- date.setHours(hours);
- date.setMinutes(mins%60 + minMinute);
- trigger('dayClick', dayBodyCells[col], date, false, ev);
- }else{
- trigger('dayClick', dayBodyCells[col], date, true, ev);
+ this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible
+ if (this.opt('weekNumbers')) {
+ if (this.opt('weekNumbersWithinDays')) {
+ this.cellWeekNumbersVisible = true;
+ this.colWeekNumbersVisible = false;
}
+ else {
+ this.cellWeekNumbersVisible = false;
+ this.colWeekNumbersVisible = true;
+ };
}
- }
-
-
-
- /* Semi-transparent Overlay Helpers
- -----------------------------------------------------*/
- // TODO: should be consolidated with BasicView's methods
+ this.dayGrid.numbersVisible = this.dayNumbersVisible ||
+ this.cellWeekNumbersVisible || this.colWeekNumbersVisible;
+ this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml());
+ this.renderHead();
- function renderDayOverlay(overlayStart, overlayEnd, refreshCoordinateGrid) { // overlayEnd is exclusive
+ this.scroller.render();
+ var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container');
+ var dayGridEl = $('').appendTo(dayGridContainerEl);
+ this.el.find('.fc-body > tr > td').append(dayGridContainerEl);
- if (refreshCoordinateGrid) {
- coordinateGrid.build();
- }
+ this.dayGrid.setElement(dayGridEl);
+ this.dayGrid.renderDates(this.hasRigidRows());
+ },
- var segments = rangeToSegments(overlayStart, overlayEnd);
- for (var i=0; i' +
+ '' +
+ '' +
+ '' +
+ ' ' +
+ '' +
+ '' +
+ '' +
+ ' | ' +
+ ' ' +
+ '' +
+ '';
+ },
- function realCellToDate(cell) { // ugh "real" ... but blame it on our abuse of the "cell" system
- var d = cellToDate(0, cell.col);
- var slotIndex = cell.row;
- if (opt('allDaySlot')) {
- slotIndex--;
- }
- if (slotIndex >= 0) {
- addMinutes(d, minMinute + slotIndex * snapMinutes);
- }
- return d;
- }
-
-
- // get the Y coordinate of the given time on the given day (both Date objects)
- function timePosition(day, time) { // both date objects. day holds 00:00 of current day
- day = cloneDate(day, true);
- if (time < addMinutes(cloneDate(day), minMinute)) {
- return 0;
- }
- if (time >= addMinutes(cloneDate(day), maxMinute)) {
- return slotTable.height();
- }
- var slotMinutes = opt('slotMinutes'),
- minutes = time.getHours()*60 + time.getMinutes() - minMinute,
- slotI = Math.floor(minutes / slotMinutes),
- slotTop = slotTopCache[slotI];
- if (slotTop === undefined) {
- slotTop = slotTopCache[slotI] =
- slotTable.find('tr').eq(slotI).find('td div')[0].offsetTop;
- // .eq() is faster than ":eq()" selector
- // [0].offsetTop is faster than .position().top (do we really need this optimization?)
- // a better optimization would be to cache all these divs
- }
- return Math.max(0, Math.round(
- slotTop - 1 + slotHeight * ((minutes % slotMinutes) / slotMinutes)
- ));
- }
-
-
- function getAllDayRow(index) {
- return allDayRow;
- }
-
-
- function defaultEventEnd(event) {
- var start = cloneDate(event.start);
- if (event.allDay) {
- return start;
- }
- return addMinutes(start, opt('defaultEventMinutes'));
- }
-
-
-
- /* Selection
- ---------------------------------------------------------------------------------*/
-
-
- function defaultSelectionEnd(startDate, allDay) {
- if (allDay) {
- return cloneDate(startDate);
- }
- return addMinutes(cloneDate(startDate), opt('slotMinutes'));
- }
-
-
- function renderSelection(startDate, endDate, allDay) { // only for all-day
- if (allDay) {
- if (opt('allDaySlot')) {
- renderDayOverlay(startDate, addDays(cloneDate(endDate), 1), true);
- }
- }else{
- renderSlotSelection(startDate, endDate);
- }
- }
-
-
- function renderSlotSelection(startDate, endDate) {
- var helperOption = opt('selectHelper');
- coordinateGrid.build();
- if (helperOption) {
- var col = dateToCell(startDate).col;
- if (col >= 0 && col < colCnt) { // only works when times are on same day
- var rect = coordinateGrid.rect(0, col, 0, col, slotContainer); // only for horizontal coords
- var top = timePosition(startDate, startDate);
- var bottom = timePosition(startDate, endDate);
- if (bottom > top) { // protect against selections that are entirely before or after visible range
- rect.top = top;
- rect.height = bottom - top;
- rect.left += 2;
- rect.width -= 5;
- if ($.isFunction(helperOption)) {
- var helperRes = helperOption(startDate, endDate);
- if (helperRes) {
- rect.position = 'absolute';
- selectionHelper = $(helperRes)
- .css(rect)
- .appendTo(slotContainer);
- }
- }else{
- rect.isStart = true; // conside rect a "seg" now
- rect.isEnd = true; //
- selectionHelper = $(slotSegHtml(
- {
- title: '',
- start: startDate,
- end: endDate,
- className: ['fc-select-helper'],
- editable: false
- },
- rect
- ));
- selectionHelper.css('opacity', opt('dragOpacity'));
- }
- if (selectionHelper) {
- slotBind(selectionHelper);
- slotContainer.append(selectionHelper);
- setOuterWidth(selectionHelper, rect.width, true); // needs to be after appended
- setOuterHeight(selectionHelper, rect.height, true);
- }
- }
- }
- }else{
- renderSlotOverlay(startDate, endDate);
- }
- }
-
-
- function clearSelection() {
- clearOverlays();
- if (selectionHelper) {
- selectionHelper.remove();
- selectionHelper = null;
- }
- }
-
-
- function slotSelectionMousedown(ev) {
- if (ev.which == 1 && opt('selectable')) { // ev.which==1 means left mouse button
- unselect(ev);
- var dates;
- hoverListener.start(function(cell, origCell) {
- clearSelection();
- if (cell && cell.col == origCell.col && !getIsCellAllDay(cell)) {
- var d1 = realCellToDate(origCell);
- var d2 = realCellToDate(cell);
- dates = [
- d1,
- addMinutes(cloneDate(d1), snapMinutes), // calculate minutes depending on selection slot minutes
- d2,
- addMinutes(cloneDate(d2), snapMinutes)
- ].sort(dateCompare);
- renderSlotSelection(dates[0], dates[3]);
- }else{
- dates = null;
- }
- }, ev);
- $(document).one('mouseup', function(ev) {
- hoverListener.stop();
- if (dates) {
- if (+dates[0] == +dates[1]) {
- reportDayClick(dates[0], false, ev);
- }
- reportSelection(dates[0], dates[3], false, ev);
- }
- });
+
+ // 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 '';
+ },
- function reportDayClick(date, allDay, ev) {
- trigger('dayClick', dayBodyCells[dateToCell(date).col], date, allDay, ev);
- }
-
-
-
- /* External Dragging
- --------------------------------------------------------------------------------*/
-
-
- function dragStart(_dragElement, ev, ui) {
- hoverListener.start(function(cell) {
- clearOverlays();
- if (cell) {
- if (getIsCellAllDay(cell)) {
- renderCellOverlay(cell.row, cell.col, cell.row, cell.col);
- }else{
- var d1 = realCellToDate(cell);
- var d2 = addMinutes(cloneDate(d1), opt('defaultEventMinutes'));
- renderSlotOverlay(d1, d2);
- }
- }
- }, ev);
- }
-
-
- function dragStop(_dragElement, ev, ui) {
- var cell = hoverListener.stop();
- clearOverlays();
- if (cell) {
- trigger('drop', _dragElement, realCellToDate(cell), getIsCellAllDay(cell), ev, ui);
+ // 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.colWeekNumbersVisible) {
+ // 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;
+ var scrollbarWidths;
-function AgendaEventRenderer() {
- var t = this;
-
-
- // exports
- t.renderEvents = renderEvents;
- t.clearEvents = clearEvents;
- t.slotSegHtml = slotSegHtml;
-
-
- // imports
- DayEventRenderer.call(t);
- var opt = t.opt;
- var trigger = t.trigger;
- var isEventDraggable = t.isEventDraggable;
- var isEventResizable = t.isEventResizable;
- var eventEnd = t.eventEnd;
- var eventElementHandlers = t.eventElementHandlers;
- var setHeight = t.setHeight;
- var getDaySegmentContainer = t.getDaySegmentContainer;
- var getSlotSegmentContainer = t.getSlotSegmentContainer;
- var getHoverListener = t.getHoverListener;
- var getMaxMinute = t.getMaxMinute;
- var getMinMinute = t.getMinMinute;
- var timePosition = t.timePosition;
- var getIsCellAllDay = t.getIsCellAllDay;
- var colContentLeft = t.colContentLeft;
- var colContentRight = t.colContentRight;
- var cellToDate = t.cellToDate;
- var getColCnt = t.getColCnt;
- var getColWidth = t.getColWidth;
- var getSnapHeight = t.getSnapHeight;
- var getSnapMinutes = t.getSnapMinutes;
- var getSlotContainer = t.getSlotContainer;
- var reportEventElement = t.reportEventElement;
- var showEvents = t.showEvents;
- var hideEvents = t.hideEvents;
- var eventDrop = t.eventDrop;
- var eventResize = t.eventResize;
- var renderDayOverlay = t.renderDayOverlay;
- var clearOverlays = t.clearOverlays;
- var renderDayEvents = t.renderDayEvents;
- var calendar = t.calendar;
- var formatDate = calendar.formatDate;
- var formatDates = calendar.formatDates;
-
-
- // overrides
- t.draggableDayEvent = draggableDayEvent;
+ // reset all heights to be natural
+ this.scroller.clear();
+ uncompensateScroll(this.headRowEl);
-
-
- /* Rendering
- ----------------------------------------------------------------------------*/
-
+ this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
- function renderEvents(events, modifiedEventId) {
- var i, len=events.length,
- dayEvents=[],
- slotEvents=[];
- for (i=0; i start && eventStart < end) {
- if (eventStart < start) {
- segStart = cloneDate(start);
- isStart = false;
- }else{
- segStart = eventStart;
- isStart = true;
- }
- if (eventEnd > end) {
- segEnd = cloneDate(end);
- isEnd = false;
- }else{
- segEnd = eventEnd;
- isEnd = true;
- }
- segs.push({
- event: event,
- start: segStart,
- end: segEnd,
- isStart: isStart,
- isEnd: isEnd
- });
- }
+ // 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
}
- return segs.sort(compareSlotSegs);
- }
+ else {
+ distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows
+ }
+ },
- function slotEventEnd(event) {
- if (event.end) {
- return cloneDate(event.end);
- }else{
- return addMinutes(cloneDate(event.start), opt('defaultEventMinutes'));
- }
- }
-
-
- // renders events in the 'time slots' at the bottom
- // TODO: when we refactor this, when user returns `false` eventRender, don't have empty space
- // TODO: refactor will include using pixels to detect collisions instead of dates (handy for seg cmp)
-
- function renderSlotSegs(segs, modifiedEventId) {
-
- var i, segCnt=segs.length, seg,
- event,
- top,
- bottom,
- columnLeft,
- columnRight,
- columnWidth,
- width,
- left,
- right,
- html = '',
- eventElements,
- eventElement,
- triggerRes,
- titleElement,
- height,
- slotSegmentContainer = getSlotSegmentContainer(),
- isRTL = opt('isRTL');
-
- // calculate position/dimensions, create html
- for (i=0; i" +
- "" +
- " " +
- htmlEscape(formatDates(event.start, event.end, opt('timeFormat'))) +
- " " +
- " " +
- htmlEscape(event.title || '') +
- " " +
- " " +
- "";
- if (seg.isEnd && isEventResizable(event)) {
- html +=
- "= ";
- }
- html +=
- "" + (url ? "a" : "div") + ">";
- return html;
- }
-
-
- function bindSlotSeg(event, eventElement, seg) {
- var timeElement = eventElement.find('div.fc-event-time');
- if (isEventDraggable(event)) {
- draggableSlotEvent(event, eventElement, timeElement);
- }
- if (seg.isEnd && isEventResizable(event)) {
- resizableSlotEvent(event, eventElement, timeElement);
- }
- eventElementHandlers(event, eventElement);
- }
-
-
-
- /* Dragging
- -----------------------------------------------------------------------------------*/
-
-
- // when event starts out FULL-DAY
- // overrides DayEventRenderer's version because it needs to account for dragging elements
- // to and from the slot area.
-
- function draggableDayEvent(event, eventElement, seg) {
- var isStart = seg.isStart;
- var origWidth;
- var revert;
- var allDay = true;
- var dayDelta;
- var hoverListener = getHoverListener();
- var colWidth = getColWidth();
- var snapHeight = getSnapHeight();
- var snapMinutes = getSnapMinutes();
- var minMinute = getMinMinute();
- eventElement.draggable({
- opacity: opt('dragOpacity', 'month'), // use whatever the month view was using
- revertDuration: opt('dragRevertDuration'),
- start: function(ev, ui) {
- trigger('eventDragStart', eventElement, event, ev, ui);
- hideEvents(event, eventElement);
- origWidth = eventElement.width();
- hoverListener.start(function(cell, origCell) {
- clearOverlays();
- if (cell) {
- revert = false;
- var origDate = cellToDate(0, origCell.col);
- var date = cellToDate(0, cell.col);
- dayDelta = dayDiff(date, origDate);
- if (!cell.row) {
- // on full-days
- renderDayOverlay(
- addDays(cloneDate(event.start), dayDelta),
- addDays(exclEndDay(event), dayDelta)
- );
- resetElement();
- }else{
- // mouse is over bottom slots
- if (isStart) {
- if (allDay) {
- // convert event to temporary slot-event
- eventElement.width(colWidth - 10); // don't use entire width
- setOuterHeight(
- eventElement,
- snapHeight * Math.round(
- (event.end ? ((event.end - event.start) / MINUTE_MS) : opt('defaultEventMinutes')) /
- snapMinutes
- )
- );
- eventElement.draggable('option', 'grid', [colWidth, 1]);
- allDay = false;
- }
- }else{
- revert = true;
- }
- }
- revert = revert || (allDay && !dayDelta);
- }else{
- resetElement();
- revert = true;
- }
- eventElement.draggable('option', 'revert', revert);
- }, ev, 'drag');
- },
- stop: function(ev, ui) {
- hoverListener.stop();
- clearOverlays();
- trigger('eventDragStop', eventElement, event, ev, ui);
- if (revert) {
- // hasn't moved or is out of bounds (draggable has already reverted)
- resetElement();
- eventElement.css('filter', ''); // clear IE opacity side-effects
- showEvents(event, eventElement);
- }else{
- // changed!
- var minuteDelta = 0;
- if (!allDay) {
- minuteDelta = Math.round((eventElement.offset().top - getSlotContainer().offset().top) / snapHeight)
- * snapMinutes
- + minMinute
- - (event.start.getHours() * 60 + event.start.getMinutes());
- }
- eventDrop(this, event, dayDelta, minuteDelta, allDay, ev, ui);
- }
- }
- });
- function resetElement() {
- if (!allDay) {
- eventElement
- .width(origWidth)
- .height('')
- .draggable('option', 'grid', null);
- allDay = true;
- }
- }
- }
-
-
- // when event starts out IN TIMESLOTS
-
- function draggableSlotEvent(event, eventElement, timeElement) {
- var coordinateGrid = t.getCoordinateGrid();
- var colCnt = getColCnt();
- var colWidth = getColWidth();
- var snapHeight = getSnapHeight();
- var snapMinutes = getSnapMinutes();
-
- // states
- var origPosition; // original position of the element, not the mouse
- var origCell;
- var isInBounds, prevIsInBounds;
- var isAllDay, prevIsAllDay;
- var colDelta, prevColDelta;
- var dayDelta; // derived from colDelta
- var minuteDelta, prevMinuteDelta;
-
- eventElement.draggable({
- scroll: false,
- grid: [ colWidth, snapHeight ],
- axis: colCnt==1 ? 'y' : false,
- opacity: opt('dragOpacity'),
- revertDuration: opt('dragRevertDuration'),
- start: function(ev, ui) {
-
- trigger('eventDragStart', eventElement, event, ev, ui);
- hideEvents(event, eventElement);
-
- coordinateGrid.build();
-
- // initialize states
- origPosition = eventElement.position();
- origCell = coordinateGrid.cell(ev.pageX, ev.pageY);
- isInBounds = prevIsInBounds = true;
- isAllDay = prevIsAllDay = getIsCellAllDay(origCell);
- colDelta = prevColDelta = 0;
- dayDelta = 0;
- minuteDelta = prevMinuteDelta = 0;
+ setScroll: function(top) {
+ this.scroller.setScrollTop(top);
+ },
- },
- drag: function(ev, ui) {
-
- // NOTE: this `cell` value is only useful for determining in-bounds and all-day.
- // Bad for anything else due to the discrepancy between the mouse position and the
- // element position while snapping. (problem revealed in PR #55)
- //
- // PS- the problem exists for draggableDayEvent() when dragging an all-day event to a slot event.
- // We should overhaul the dragging system and stop relying on jQuery UI.
- var cell = coordinateGrid.cell(ev.pageX, ev.pageY);
-
- // update states
- isInBounds = !!cell;
- if (isInBounds) {
- isAllDay = getIsCellAllDay(cell);
-
- // calculate column delta
- colDelta = Math.round((ui.position.left - origPosition.left) / colWidth);
- if (colDelta != prevColDelta) {
- // calculate the day delta based off of the original clicked column and the column delta
- var origDate = cellToDate(0, origCell.col);
- var col = origCell.col + colDelta;
- col = Math.max(0, col);
- col = Math.min(colCnt-1, col);
- var date = cellToDate(0, col);
- dayDelta = dayDiff(date, origDate);
- }
- // calculate minute delta (only if over slots)
- if (!isAllDay) {
- minuteDelta = Math.round((ui.position.top - origPosition.top) / snapHeight) * snapMinutes;
- }
- }
+ /* Hit Areas
+ ------------------------------------------------------------------------------------------------------------------*/
+ // forward all hit-related method calls to dayGrid
- // any state changes?
- if (
- isInBounds != prevIsInBounds ||
- isAllDay != prevIsAllDay ||
- colDelta != prevColDelta ||
- minuteDelta != prevMinuteDelta
- ) {
- updateUI();
+ prepareHits: function() {
+ this.dayGrid.prepareHits();
+ },
- // update previous states for next time
- prevIsInBounds = isInBounds;
- prevIsAllDay = isAllDay;
- prevColDelta = colDelta;
- prevMinuteDelta = minuteDelta;
- }
- // if out-of-bounds, revert when done, and vice versa.
- eventElement.draggable('option', 'revert', !isInBounds);
+ releaseHits: function() {
+ this.dayGrid.releaseHits();
+ },
- },
- stop: function(ev, ui) {
- clearOverlays();
- trigger('eventDragStop', eventElement, event, ev, ui);
+ queryHit: function(left, top) {
+ return this.dayGrid.queryHit(left, top);
+ },
- if (isInBounds && (isAllDay || dayDelta || minuteDelta)) { // changed!
- eventDrop(this, event, dayDelta, isAllDay ? 0 : minuteDelta, isAllDay, ev, ui);
- }
- else { // either no change or out-of-bounds (draggable has already reverted)
- // reset states for next time, and for updateUI()
- isInBounds = true;
- isAllDay = false;
- colDelta = 0;
- dayDelta = 0;
- minuteDelta = 0;
+ getHitSpan: function(hit) {
+ return this.dayGrid.getHitSpan(hit);
+ },
- updateUI();
- eventElement.css('filter', ''); // clear IE opacity side-effects
- // sometimes fast drags make event revert to wrong position, so reset.
- // also, if we dragged the element out of the area because of snapping,
- // but the *mouse* is still in bounds, we need to reset the position.
- eventElement.css(origPosition);
+ getHitEl: function(hit) {
+ return this.dayGrid.getHitEl(hit);
+ },
- showEvents(event, eventElement);
- }
- }
- });
- function updateUI() {
- clearOverlays();
- if (isInBounds) {
- if (isAllDay) {
- timeElement.hide();
- eventElement.draggable('option', 'grid', null); // disable grid snapping
- renderDayOverlay(
- addDays(cloneDate(event.start), dayDelta),
- addDays(exclEndDay(event), dayDelta)
- );
- }
- else {
- updateTimeText(minuteDelta);
- timeElement.css('display', ''); // show() was causing display=inline
- eventElement.draggable('option', 'grid', [colWidth, snapHeight]); // re-enable grid snapping
- }
- }
- }
+ /* Events
+ ------------------------------------------------------------------------------------------------------------------*/
- function updateTimeText(minuteDelta) {
- var newStart = addMinutes(cloneDate(event.start), minuteDelta);
- var newEnd;
- if (event.end) {
- newEnd = addMinutes(cloneDate(event.end), minuteDelta);
- }
- timeElement.text(formatDates(newStart, newEnd, opt('timeFormat')));
- }
- }
-
-
-
- /* Resizing
- --------------------------------------------------------------------------------------*/
-
-
- function resizableSlotEvent(event, eventElement, timeElement) {
- var snapDelta, prevSnapDelta;
- var snapHeight = getSnapHeight();
- var snapMinutes = getSnapMinutes();
- eventElement.resizable({
- handles: {
- s: '.ui-resizable-handle'
- },
- grid: snapHeight,
- start: function(ev, ui) {
- snapDelta = prevSnapDelta = 0;
- hideEvents(event, eventElement);
- trigger('eventResizeStart', this, event, ev, ui);
- },
- resize: function(ev, ui) {
- // don't rely on ui.size.height, doesn't take grid into account
- snapDelta = Math.round((Math.max(snapHeight, eventElement.height()) - ui.originalSize.height) / snapHeight);
- if (snapDelta != prevSnapDelta) {
- timeElement.text(
- formatDates(
- event.start,
- (!snapDelta && !event.end) ? null : // no change, so don't display time range
- addMinutes(eventEnd(event), snapMinutes*snapDelta),
- opt('timeFormat')
- )
- );
- prevSnapDelta = snapDelta;
- }
- },
- stop: function(ev, ui) {
- trigger('eventResizeStop', this, event, ev, ui);
- if (snapDelta) {
- eventResize(this, event, 0, snapMinutes*snapDelta, ev, ui);
- }else{
- showEvents(event, eventElement);
- // BUG: if event was really short, need to put title back in span
- }
- }
- });
- }
-
+ // 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();
+ },
-/* Agenda Event Segment Utilities
------------------------------------------------------------------------------*/
+ // Unrenders all event elements and clears internal segment data
+ unrenderEvents: function() {
+ this.dayGrid.unrenderEvents();
-// Sets the seg.backwardCoord and seg.forwardCoord on each segment and returns a new
-// list in the order they should be placed into the DOM (an implicit z-index).
-function placeSlotSegs(segs) {
- var levels = buildSlotSegLevels(segs);
- var level0 = levels[0];
- var i;
+ // we DON'T need to call updateHeight() because
+ // a renderEvents() call always happens after this, which will eventually call updateHeight()
+ },
- computeForwardSlotSegs(levels);
- if (level0) {
+ /* Dragging (for both events and external elements)
+ ------------------------------------------------------------------------------------------------------------------*/
- for (i=0; i | ';
+ }
- // figure out the child's maximum forward path
- computeSlotSegPressures(forwardSeg);
+ return '';
+ },
- // either use the existing maximum, or use the child's forward pressure
- // plus one (for the forwardSeg itself)
- forwardPressure = Math.max(
- forwardPressure,
- 1 + forwardSeg.forwardPressure
- );
+
+ // Generates the HTML that goes before the day bg cells for each day-row
+ renderBgIntroHtml: function() {
+ var view = this.view;
+
+ if (view.colWeekNumbersVisible) {
+ return ' | ';
}
- seg.forwardPressure = forwardPressure;
+ 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.colWeekNumbersVisible) {
+ return ' | ';
+ }
+
+ return '';
}
-}
+};
-// 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.
-function computeSlotSegCoords(seg, seriesBackwardPressure, seriesBackwardCoord) {
- var forwardSegs = seg.forwardSegs;
- var i;
+;;
- if (seg.forwardCoord === undefined) { // not already computed
+/* A month view with day cells running in rows (one-per-week) and columns
+----------------------------------------------------------------------------------------------------------------------*/
- if (!forwardSegs.length) {
+var MonthView = FC.MonthView = BasicView.extend({
- // if there are no forward segments, this segment should butt up against the edge
- seg.forwardCoord = 1;
+ // 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');
}
- else {
- // sort highest pressure first
- forwardSegs.sort(compareForwardSlotSegs);
+ return range;
+ },
- // this segment's forwardCoord will be calculated from the backwardCoord of the
- // highest-pressure forward segment.
- computeSlotSegCoords(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
+ // Overrides the default BasicView behavior to have special multi-week auto-height logic
+ setGridHeight: function(height, isAuto) {
- // use this segment's coordinates to computed the coordinates of the less-pressurized
- // forward segments
- for (i=0; i underneath
+ bottomRuleEl: 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
}
- }
- return results;
-}
+ this.scroller = new Scroller({
+ overflowX: 'hidden',
+ overflowY: 'auto'
+ });
+ },
-// Do these segments occupy the same vertical space?
-function isSlotSegCollision(seg1, seg2) {
- return seg1.end > seg2.start && seg1.start < seg2.end;
-}
+ // Instantiates the TimeGrid object this view needs. Draws from this.timeGridClass
+ instantiateTimeGrid: function() {
+ var subclass = this.timeGridClass.extend(agendaTimeGridMethods);
+ return new subclass(this);
+ },
-// A cmp function for determining which forward segment to rely on more when computing coordinates.
-function compareForwardSlotSegs(seg1, seg2) {
- // put higher-pressure first
- return seg2.forwardPressure - seg1.forwardPressure ||
- // put segments that are closer to initial edge first (and favor ones with no coords yet)
- (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) ||
- // do normal sorting...
- compareSlotSegs(seg1, seg2);
-}
+ // Instantiates the DayGrid object this view might need. Draws from this.dayGridClass
+ instantiateDayGrid: function() {
+ var subclass = this.dayGridClass.extend(agendaDayGridMethods);
-// A cmp function for determining which segment should be closer to the initial edge
-// (the left edge on a left-to-right calendar).
-function compareSlotSegs(seg1, seg2) {
- return seg1.start - seg2.start || // earlier start time goes first
- (seg2.end - seg2.start) - (seg1.end - seg1.start) || // tie? longer-duration goes first
- (seg1.event.title || '').localeCompare(seg2.event.title); // tie? alphabetically by title
-}
+ return new subclass(this);
+ },
-;;
+ /* Rendering
+ ------------------------------------------------------------------------------------------------------------------*/
-function View(element, calendar, viewName) {
- var t = this;
-
-
- // exports
- t.element = element;
- t.calendar = calendar;
- t.name = viewName;
- t.opt = opt;
- t.trigger = trigger;
- t.isEventDraggable = isEventDraggable;
- t.isEventResizable = isEventResizable;
- t.setEventData = setEventData;
- t.clearEventData = clearEventData;
- t.eventEnd = eventEnd;
- t.reportEventElement = reportEventElement;
- t.triggerEventDestroy = triggerEventDestroy;
- t.eventElementHandlers = eventElementHandlers;
- t.showEvents = showEvents;
- t.hideEvents = hideEvents;
- t.eventDrop = eventDrop;
- t.eventResize = eventResize;
- // t.title
- // t.start, t.end
- // t.visStart, t.visEnd
-
-
- // imports
- var defaultEventEnd = t.defaultEventEnd;
- var normalizeEvent = calendar.normalizeEvent; // in EventManager
- var reportEventChange = calendar.reportEventChange;
-
-
- // locals
- var eventsByID = {}; // eventID mapped to array of events (there can be multiple b/c of repeating events)
- var eventElementsByID = {}; // eventID mapped to array of jQuery elements
- var eventElementCouples = []; // array of objects, { event, element } // TODO: unify with segment system
- var options = calendar.options;
-
-
-
- function opt(name, viewNameOverride) {
- var v = options[name];
- if ($.isPlainObject(v)) {
- return smartProperty(v, viewNameOverride || viewName);
+ // 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);
}
- return v;
- }
+ },
-
- function trigger(name, thisObj) {
- return calendar.trigger.apply(
- calendar,
- [name, thisObj || t].concat(Array.prototype.slice.call(arguments, 2), [t])
- );
- }
-
+ // Renders the view into `this.el`, which has already been assigned
+ renderDates: function() {
- /* Event Editable Boolean Calculations
- ------------------------------------------------------------------------------*/
+ this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml());
+ this.renderHead();
-
- function isEventDraggable(event) {
- var source = event.source || {};
- return firstDefined(
- event.startEditable,
- source.startEditable,
- opt('eventStartEditable'),
- event.editable,
- source.editable,
- opt('editable')
- )
- && !opt('disableDragging'); // deprecated
- }
-
-
- function isEventResizable(event) { // but also need to make sure the seg.isEnd == true
- var source = event.source || {};
- return firstDefined(
- event.durationEditable,
- source.durationEditable,
- opt('eventDurationEditable'),
- event.editable,
- source.editable,
- opt('editable')
- )
- && !opt('disableResizing'); // deprecated
- }
-
-
-
- /* Event Data
- ------------------------------------------------------------------------------*/
-
-
- function setEventData(events) { // events are already normalized at this point
- eventsByID = {};
- var i, len=events.length, event;
- for (i=0; i').appendTo(timeGridWrapEl);
+ this.el.find('.fc-body > tr > td').append(timeGridWrapEl);
+ this.timeGrid.setElement(timeGridEl);
+ this.timeGrid.renderDates();
- function clearEventData() {
- eventsByID = {};
- eventElementsByID = {};
- eventElementCouples = [];
- }
-
-
- // returns a Date object for an event's end
- function eventEnd(event) {
- return event.end ? cloneDate(event.end) : defaultEventEnd(event);
- }
-
-
-
- /* Event Elements
- ------------------------------------------------------------------------------*/
-
-
- // report when view creates an element for an event
- function reportEventElement(event, element) {
- eventElementCouples.push({ event: event, element: element });
- if (eventElementsByID[event._id]) {
- eventElementsByID[event._id].push(element);
- }else{
- eventElementsByID[event._id] = [element];
- }
- }
+ // 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();
- function triggerEventDestroy() {
- $.each(eventElementCouples, function(i, couple) {
- t.trigger('eventDestroy', couple.event, couple.event, couple.element);
- });
- }
-
-
- // attaches eventClick, eventMouseover, eventMouseout
- function eventElementHandlers(event, eventElement) {
- eventElement
- .click(function(ev) {
- if (!eventElement.hasClass('ui-draggable-dragging') &&
- !eventElement.hasClass('ui-resizable-resizing')) {
- return trigger('eventClick', this, event, ev);
- }
- })
- .hover(
- function(ev) {
- trigger('eventMouseover', this, event, ev);
- },
- function(ev) {
- trigger('eventMouseout', this, event, ev);
- }
- );
- // TODO: don't fire eventMouseover/eventMouseout *while* dragging is occuring (on subject element)
- // TODO: same for resizing
- }
-
-
- function showEvents(event, exceptElement) {
- eachEventElement(event, exceptElement, 'show');
- }
-
-
- function hideEvents(event, exceptElement) {
- eachEventElement(event, exceptElement, 'hide');
- }
-
-
- function eachEventElement(event, exceptElement, funcName) {
- // NOTE: there may be multiple events per ID (repeating events)
- // and multiple segments per event
- var elements = eventElementsByID[event._id],
- i, len = elements.length;
- for (i=0; i dividing the two grids
+ this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight();
}
- }
-
-
- function elongateEvents(events, dayDelta, minuteDelta) {
- minuteDelta = minuteDelta || 0;
- for (var e, len=events.length, i=0; i bool)
- var cellsPerWeek;
- var dayToCellMap = []; // hash from dayIndex -> cellIndex, for one week
- var cellToDayMap = []; // hash from cellIndex -> dayIndex, for one week
- var isRTL = opt('isRTL');
-
-
- // initialize important internal variables
- (function() {
-
- if (opt('weekends') === false) {
- hiddenDays.push(0, 6); // 0=sunday, 6=saturday
+ if (this.dayGrid) {
+ this.dayGrid.unrenderDates();
+ this.dayGrid.removeElement();
}
- // Loop through a hypothetical week and determine which
- // days-of-week are hidden. Record in both hashes (one is the reverse of the other).
- for (var dayIndex=0, cellIndex=0; dayIndex<7; dayIndex++) {
- dayToCellMap[dayIndex] = cellIndex;
- isHiddenDayHash[dayIndex] = $.inArray(dayIndex, hiddenDays) != -1;
- if (!isHiddenDayHash[dayIndex]) {
- cellToDayMap[cellIndex] = dayIndex;
- cellIndex++;
- }
- }
+ this.scroller.destroy();
+ },
- cellsPerWeek = cellIndex;
- if (!cellsPerWeek) {
- throw 'invalid hiddenDays'; // all days were hidden? bad.
- }
- })();
+ // 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 ?
+ '' +
+ '' :
+ ''
+ ) +
+ ' | ' +
+ '
' +
+ '' +
+ '
';
+ },
- // Is the current day hidden?
- // `day` is a day-of-week index (0-6), or a Date object
- function isHiddenDay(day) {
- if (typeof day == 'object') {
- day = day.getDay();
+ // 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 isHiddenDayHash[day];
- }
+ return '';
+ },
- function getCellsPerWeek() {
- return cellsPerWeek;
- }
+ /* Business Hours
+ ------------------------------------------------------------------------------------------------------------------*/
- // Keep incrementing the current day until it is no longer a hidden day.
- // 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)
- function skipHiddenDays(date, inc, isExclusive) {
- inc = inc || 1;
- while (
- isHiddenDayHash[ ( date.getDay() + (isExclusive ? inc : 0) + 7 ) % 7 ]
- ) {
- addDays(date, inc);
+ renderBusinessHours: function() {
+ this.timeGrid.renderBusinessHours();
+
+ if (this.dayGrid) {
+ this.dayGrid.renderBusinessHours();
}
- }
+ },
- //
- // TRANSFORMATIONS: cell -> cell offset -> day offset -> date
- //
+ unrenderBusinessHours: function() {
+ this.timeGrid.unrenderBusinessHours();
- // cell -> date (combines all transformations)
- // Possible arguments:
- // - row, col
- // - { row:#, col: # }
- function cellToDate() {
- var cellOffset = cellToCellOffset.apply(null, arguments);
- var dayOffset = cellOffsetToDayOffset(cellOffset);
- var date = dayOffsetToDate(dayOffset);
- return date;
- }
+ if (this.dayGrid) {
+ this.dayGrid.unrenderBusinessHours();
+ }
+ },
- // cell -> cell offset
- // Possible arguments:
- // - row, col
- // - { row:#, col:# }
- function cellToCellOffset(row, col) {
- var colCnt = t.getColCnt();
- // rtl variables. wish we could pre-populate these. but where?
- var dis = isRTL ? -1 : 1;
- var dit = isRTL ? colCnt - 1 : 0;
+ /* Now Indicator
+ ------------------------------------------------------------------------------------------------------------------*/
- if (typeof row == 'object') {
- col = row.col;
- row = row.row;
- }
- var cellOffset = row * colCnt + (col * dis + dit); // column, adjusted for RTL (dis & dit)
- return cellOffset;
- }
+ getNowIndicatorUnit: function() {
+ return this.timeGrid.getNowIndicatorUnit();
+ },
- // cell offset -> day offset
- function cellOffsetToDayOffset(cellOffset) {
- var day0 = t.visStart.getDay(); // first date's day of week
- cellOffset += dayToCellMap[day0]; // normlize cellOffset to beginning-of-week
- return Math.floor(cellOffset / cellsPerWeek) * 7 // # of days from full weeks
- + cellToDayMap[ // # of days from partial last week
- (cellOffset % cellsPerWeek + cellsPerWeek) % cellsPerWeek // crazy math to handle negative cellOffsets
- ]
- - day0; // adjustment for beginning-of-week normalization
- }
- // day offset -> date (JavaScript Date object)
- function dayOffsetToDate(dayOffset) {
- var date = cloneDate(t.visStart);
- addDays(date, dayOffset);
- return date;
- }
+ renderNowIndicator: function(date) {
+ this.timeGrid.renderNowIndicator(date);
+ },
- //
- // TRANSFORMATIONS: date -> day offset -> cell offset -> cell
- //
+ unrenderNowIndicator: function() {
+ this.timeGrid.unrenderNowIndicator();
+ },
- // date -> cell (combines all transformations)
- function dateToCell(date) {
- var dayOffset = dateToDayOffset(date);
- var cellOffset = dayOffsetToCellOffset(dayOffset);
- var cell = cellOffsetToCell(cellOffset);
- return cell;
- }
- // date -> day offset
- function dateToDayOffset(date) {
- return dayDiff(date, t.visStart);
- }
+ /* Dimensions
+ ------------------------------------------------------------------------------------------------------------------*/
- // day offset -> cell offset
- function dayOffsetToCellOffset(dayOffset) {
- var day0 = t.visStart.getDay(); // first date's day of week
- dayOffset += day0; // normalize dayOffset to beginning-of-week
- return Math.floor(dayOffset / 7) * cellsPerWeek // # of cells from full weeks
- + dayToCellMap[ // # of cells from partial last week
- (dayOffset % 7 + 7) % 7 // crazy math to handle negative dayOffsets
- ]
- - dayToCellMap[day0]; // adjustment for beginning-of-week normalization
- }
- // cell offset -> cell (object with row & col keys)
- function cellOffsetToCell(cellOffset) {
- var colCnt = t.getColCnt();
+ updateSize: function(isResize) {
+ this.timeGrid.updateSize(isResize);
- // rtl variables. wish we could pre-populate these. but where?
- var dis = isRTL ? -1 : 1;
- var dit = isRTL ? colCnt - 1 : 0;
+ View.prototype.updateSize.call(this, isResize); // call the super-method
+ },
- var row = Math.floor(cellOffset / colCnt);
- var col = ((cellOffset % colCnt + colCnt) % colCnt) * dis + dit; // column, adjusted for RTL (dis & dit)
- return {
- row: row,
- col: col
- };
- }
+ // 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'));
+ },
- //
- // Converts a date range into an array of segment objects.
- // "Segments" are horizontal stretches of time, sliced up by row.
- // A segment object has the following properties:
- // - row
- // - cols
- // - isStart
- // - isEnd
- //
- function rangeToSegments(startDate, endDate) {
- var rowCnt = t.getRowCnt();
- var colCnt = t.getColCnt();
- var segments = []; // array of segments to return
- // day offset for given date range
- var rangeDayOffsetStart = dateToDayOffset(startDate);
- var rangeDayOffsetEnd = dateToDayOffset(endDate); // exclusive
+ // Adjusts the vertical dimensions of the view to the specified values
+ setHeight: function(totalHeight, isAuto) {
+ var eventLimit;
+ var scrollerHeight;
+ var scrollbarWidths;
+
+ // reset all dimensions back to the original state
+ this.bottomRuleEl.hide(); // .show() will be called later if this
is necessary
+ this.scroller.clear(); // sets height to 'auto' and clears overflow
+ uncompensateScroll(this.noScrollRowEls);
- // first and last cell offset for the given date range
- // "last" implies inclusivity
- var rangeCellOffsetFirst = dayOffsetToCellOffset(rangeDayOffsetStart);
- var rangeCellOffsetLast = dayOffsetToCellOffset(rangeDayOffsetEnd) - 1;
+ // limit number of events in the all-day area
+ if (this.dayGrid) {
+ this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
- // loop through all the rows in the view
- for (var row=0; row") : finalContainer;
+ releaseHits: function() {
+ this.timeGrid.releaseHits();
+ if (this.dayGrid) {
+ this.dayGrid.releaseHits();
+ }
+ },
- var segments = buildSegments(events);
- var html;
- var elements;
- // calculate the desired `left` and `width` properties on each segment object
- calculateHorizontals(segments);
+ queryHit: function(left, top) {
+ var hit = this.timeGrid.queryHit(left, top);
- // build the HTML string. relies on `left` property
- html = buildHTML(segments);
+ if (!hit && this.dayGrid) {
+ hit = this.dayGrid.queryHit(left, top);
+ }
- // render the HTML. innerHTML is considerably faster than jQuery's .html()
- renderContainer[0].innerHTML = html;
+ return hit;
+ },
- // retrieve the individual elements
- elements = renderContainer.children();
- // if we were appending, and thus using a temporary container,
- // re-attach elements to the real container.
- if (doAppend) {
- finalContainer.append(elements);
- }
+ getHitSpan: function(hit) {
+ // TODO: hit.component is set as a hack to identify where the hit came from
+ return hit.component.getHitSpan(hit);
+ },
- // assigns each element to `segment.event`, after filtering them through user callbacks
- resolveElements(segments, elements);
- // Calculate the left and right padding+margin for each element.
- // We need this for setting each element's desired outer width, because of the W3C box model.
- // It's important we do this in a separate pass from acually setting the width on the DOM elements
- // because alternating reading/writing dimensions causes reflow for every iteration.
- segmentElementEach(segments, function(segment, element) {
- segment.hsides = hsides(element, true); // include margins = `true`
- });
+ getHitEl: function(hit) {
+ // TODO: hit.component is set as a hack to identify where the hit came from
+ return hit.component.getHitEl(hit);
+ },
- // Set the width of each element
- segmentElementEach(segments, function(segment, element) {
- element.width(
- Math.max(0, segment.outerWidth - segment.hsides)
- );
- });
- // Grab each element's outerHeight (setVerticals uses this).
- // To get an accurate reading, it's important to have each element's width explicitly set already.
- segmentElementEach(segments, function(segment, element) {
- segment.outerHeight = element.outerHeight(true); // include margins = `true`
- });
+ /* Events
+ ------------------------------------------------------------------------------------------------------------------*/
- // Set the top coordinate on each element (requires segment.outerHeight)
- setVerticals(segments, doRowHeights);
- return segments;
- }
+ // 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]);
+ }
+ }
- // Generate an array of "segments" for all events.
- function buildSegments(events) {
- var segments = [];
- for (var i=0; i" +
- "";
- if (!event.allDay && segment.isStart) {
- html +=
- "" +
- htmlEscape(
- formatDates(event.start, event.end, opt('timeFormat'))
- ) +
- "";
- }
- html +=
- "" +
- htmlEscape(event.title || '') +
- "" +
- "
";
- if (segment.isEnd && isEventResizable(event)) {
- html +=
- "" +
- " " + // makes hit area a lot better for IE6/7
- "
";
+ unrenderDrag: function() {
+ this.timeGrid.unrenderDrag();
+ if (this.dayGrid) {
+ this.dayGrid.unrenderDrag();
}
- html += "" + (url ? "a" : "div") + ">";
-
- // TODO:
- // When these elements are initially rendered, they will be briefly visibile on the screen,
- // even though their widths/heights are not set.
- // SOLUTION: initially set them as visibility:hidden ?
+ },
- return html;
- }
+ /* Selection
+ ------------------------------------------------------------------------------------------------------------------*/
- // Associate each segment (an object) with an element (a jQuery object),
- // by setting each `segment.element`.
- // Run each element through the `eventRender` filter, which allows developers to
- // modify an existing element, supply a new one, or cancel rendering.
- function resolveElements(segments, elements) {
- for (var i=0; i