/*! * FullCalendar v2.0.0-beta2 * Docs & License: http://arshaw.com/fullcalendar/ * (c) 2013 Adam Shaw */ (function(factory) { if (typeof define === 'function' && define.amd) { define([ 'jquery', 'moment' ], factory); } else { factory(jQuery, moment); } })(function($, moment) { ;; var defaults = { lang: 'en', defaultTimedEventDuration: '02:00:00', defaultAllDayEventDuration: { days: 1 }, forceEventDuration: false, nextDayThreshold: '09:00:00', // 9am // display defaultView: 'month', aspectRatio: 1.35, header: { left: 'title', center: '', right: 'today prev,next' }, weekends: true, weekNumbers: false, weekNumberTitle: 'W', weekNumberCalculation: 'local', //editable: false, // event ajax lazyFetching: true, startParam: 'start', endParam: 'end', timezoneParam: 'timezone', //allDayDefault: undefined, // time formats titleFormat: { month: 'MMMM YYYY', // like "September 1986". each language will override this week: 'll', // like "Sep 4 1986" day: 'LL' // like "September 4 1986" }, columnFormat: { month: 'ddd', // like "Sat" week: generateWeekColumnFormat, day: 'dddd' // like "Saturday" }, timeFormat: { // for event elements 'default': generateShortTimeFormat }, // locale isRTL: false, buttonText: { prev: "prev", next: "next", prevYear: "prev year", nextYear: "next year", today: 'today', month: 'month', week: 'week', day: 'day' }, buttonIcons: { prev: 'left-single-arrow', next: 'right-single-arrow', prevYear: 'left-double-arrow', nextYear: 'right-double-arrow' }, // jquery-ui theming theme: false, themeButtonIcons: { prev: 'circle-triangle-w', next: 'circle-triangle-e', prevYear: 'seek-prev', nextYear: 'seek-next' }, //selectable: false, unselectAuto: true, dropAccept: '*', handleWindowResize: true }; function generateShortTimeFormat(options, langData) { return langData.longDateFormat('LT') .replace(':mm', '(:mm)') .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand } function generateWeekColumnFormat(options, langData) { var format = langData.longDateFormat('L'); // for the format like "MM/DD/YYYY" format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, ''); // strip the year off the edge, as well as other misc non-whitespace chars if (options.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; } var langOptionHash = { en: { columnFormat: { week: 'ddd M/D' // override for english. different from the generated default, which is MM/DD } } }; // right-to-left defaults var rtlDefaults = { header: { left: 'next,prev today', center: '', right: 'title' }, buttonIcons: { prev: 'right-single-arrow', next: 'left-single-arrow', prevYear: 'right-double-arrow', nextYear: 'left-double-arrow' }, themeButtonIcons: { prev: 'circle-triangle-e', next: 'circle-triangle-w', nextYear: 'seek-prev', prevYear: 'seek-next' } }; ;; var fc = $.fullCalendar = { version: "2.0.0-beta2" }; var fcViews = fc.views = {}; $.fn.fullCalendar = function(options) { var args = Array.prototype.slice.call(arguments, 1); // for a possible method call var res = this; // what this function will return (this jQuery object by default) this.each(function(i, _element) { // loop each DOM element involved var element = $(_element); var calendar = element.data('fullCalendar'); // get the existing calendar object (if any) var singleRes; // the returned value of this single method call // a method call if (typeof options === 'string') { if (calendar && $.isFunction(calendar[options])) { singleRes = calendar[options].apply(calendar, args); if (!i) { res = singleRes; // record the first method call result } if (options === 'destroy') { // for the destroy method, must remove Calendar object data element.removeData('fullCalendar'); } } } // a new calendar initialization else if (!calendar) { // don't initialize twice calendar = new Calendar(element, options); element.data('fullCalendar', calendar); calendar.render(); } }); return res; }; // function for adding/overriding defaults function setDefaults(d) { mergeOptions(defaults, d); } // Recursively combines option hash-objects. // Better than `$.extend(true, ...)` because arrays are not traversed/copied. // // called like: // mergeOptions(target, obj1, obj2, ...) // function mergeOptions(target) { function mergeIntoTarget(name, value) { if ($.isPlainObject(value) && $.isPlainObject(target[name]) && !isForcedAtomicOption(name)) { // merge into a new object to avoid destruction target[name] = mergeOptions({}, target[name], value); // combine. `value` object takes precedence } else if (value !== undefined) { // only use values that are set and not undefined target[name] = value; } } for (var i=1; i") .prependTo(element); header = new Header(t, options); headerElement = header.render(); if (headerElement) { element.prepend(headerElement); } changeView(options.defaultView); if (options.handleWindowResize) { $(window).resize(windowResize); } // needed for IE in a 0x0 iframe, b/c when it is resized, never triggers a windowResize if (!bodyVisible()) { lateRender(); } } // called when we know the calendar couldn't be rendered when it was initialized, // but we think it's ready now function lateRender() { setTimeout(function() { // IE7 needs this so dimensions are calculated correctly if (!currentView.start && bodyVisible()) { // !currentView.start makes sure this never happens more than once renderView(); } },0); } function destroy() { if (currentView) { trigger('viewDestroy', currentView, currentView, currentView.element); currentView.triggerEventDestroy(); } $(window).unbind('resize', windowResize); header.destroy(); content.remove(); element.removeClass('fc fc-rtl ui-widget'); } function elementVisible() { return element.is(':visible'); } function bodyVisible() { return $('body').is(':visible'); } // View Rendering // ----------------------------------------------------------------------------------- function changeView(newViewName) { if (!currentView || newViewName != currentView.name) { _changeView(newViewName); } } function _changeView(newViewName) { ignoreWindowResize++; if (currentView) { trigger('viewDestroy', currentView, currentView, currentView.element); unselect(); currentView.triggerEventDestroy(); // trigger 'eventDestroy' for each event freezeContentHeight(); currentView.element.remove(); header.deactivateButton(currentView.name); } header.activateButton(newViewName); currentView = new fcViews[newViewName]( $("
") .appendTo(content), t // the calendar object ); renderView(); unfreezeContentHeight(); ignoreWindowResize--; } function renderView(inc) { if ( !currentView.start || // never rendered before inc || // explicit date window change !date.isWithin(currentView.intervalStart, currentView.intervalEnd) // implicit date window change ) { if (elementVisible()) { _renderView(inc); } } } function _renderView(inc) { // assumes elementVisible ignoreWindowResize++; if (currentView.start) { // already been rendered? trigger('viewDestroy', currentView, currentView, currentView.element); unselect(); clearEvents(); } freezeContentHeight(); if (inc) { date = currentView.incrementDate(date, inc); } currentView.render(date.clone()); // the view's render method ONLY renders the skeleton, nothing else setSize(); unfreezeContentHeight(); (currentView.afterRender || noop)(); updateTitle(); updateTodayButton(); trigger('viewRender', currentView, currentView, currentView.element); ignoreWindowResize--; getAndRenderEvents(); } // Resizing // ----------------------------------------------------------------------------------- function updateSize() { if (elementVisible()) { unselect(); clearEvents(); calcSize(); setSize(); renderEvents(); } } function calcSize() { // assumes elementVisible if (options.contentHeight) { suggestedViewHeight = options.contentHeight; } else if (options.height) { suggestedViewHeight = options.height - (headerElement ? headerElement.height() : 0) - vsides(content); } else { suggestedViewHeight = Math.round(content.width() / Math.max(options.aspectRatio, .5)); } } function setSize() { // assumes elementVisible if (suggestedViewHeight === undefined) { calcSize(); // for first time // NOTE: we don't want to recalculate on every renderView because // it could result in oscillating heights due to scrollbars. } ignoreWindowResize++; currentView.setHeight(suggestedViewHeight); currentView.setWidth(content.width()); ignoreWindowResize--; elementOuterWidth = element.outerWidth(); } function windowResize() { if (!ignoreWindowResize) { if (currentView.start) { // view has already been rendered var uid = ++resizeUID; setTimeout(function() { // add a delay if (uid == resizeUID && !ignoreWindowResize && elementVisible()) { if (elementOuterWidth != (elementOuterWidth = element.outerWidth())) { ignoreWindowResize++; // in case the windowResize callback changes the height updateSize(); currentView.trigger('windowResize', _element); ignoreWindowResize--; } } }, 200); }else{ // calendar must have been initialized in a 0x0 iframe that has just been resized lateRender(); } } } /* Event Fetching/Rendering -----------------------------------------------------------------------------*/ // TODO: going forward, most of this stuff should be directly handled by the view function refetchEvents() { // can be called as an API method clearEvents(); fetchAndRenderEvents(); } function rerenderEvents(modifiedEventID) { // can be called as an API method clearEvents(); renderEvents(modifiedEventID); } function renderEvents(modifiedEventID) { // TODO: remove modifiedEventID hack if (elementVisible()) { currentView.renderEvents(events, modifiedEventID); // actually render the DOM elements currentView.trigger('eventAfterAllRender'); } } function clearEvents() { currentView.triggerEventDestroy(); // trigger 'eventDestroy' for each event currentView.clearEvents(); // actually remove the DOM elements currentView.clearEventData(); // for View.js, TODO: unify with clearEvents } function getAndRenderEvents() { if (!options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) { fetchAndRenderEvents(); } else { renderEvents(); } } function fetchAndRenderEvents() { fetchEvents(currentView.start, currentView.end); // ... will call reportEvents // ... which will call renderEvents } // called when event data arrives function reportEvents(_events) { events = _events; renderEvents(); } // called when a single event's data has been changed function reportEventChange(eventID) { rerenderEvents(eventID); } /* Header Updating -----------------------------------------------------------------------------*/ function updateTitle() { header.updateTitle(currentView.title); } function updateTodayButton() { var now = t.getNow(); if (now.isWithin(currentView.intervalStart, currentView.intervalEnd)) { header.disableButton('today'); } else { header.enableButton('today'); } } /* Selection -----------------------------------------------------------------------------*/ function select(start, end) { currentView.select(start, end); } function unselect() { // safe to be called before renderView if (currentView) { currentView.unselect(); } } /* Date -----------------------------------------------------------------------------*/ function prev() { renderView(-1); } function next() { renderView(1); } function prevYear() { date.add('years', -1); renderView(); } function nextYear() { date.add('years', 1); renderView(); } function today() { date = t.getNow(); renderView(); } function gotoDate(dateInput) { date = t.moment(dateInput); renderView(); } function incrementDate() { date.add.apply(date, arguments); renderView(); } function getDate() { return date.clone(); } /* Height "Freezing" -----------------------------------------------------------------------------*/ function freezeContentHeight() { content.css({ width: '100%', height: content.height(), overflow: 'hidden' }); } function unfreezeContentHeight() { content.css({ width: '', height: '', overflow: '' }); } /* Misc -----------------------------------------------------------------------------*/ function getCalendar() { return t; } function getView() { return currentView; } function option(name, value) { if (value === undefined) { return options[name]; } if (name == 'height' || name == 'contentHeight' || name == 'aspectRatio') { options[name] = value; updateSize(); } } function trigger(name, thisObj) { if (options[name]) { return options[name].apply( thisObj || _element, Array.prototype.slice.call(arguments, 2) ); } } /* External Dragging ------------------------------------------------------------------------*/ if (options.droppable) { // TODO: unbind on destroy $(document) .bind('dragstart', function(ev, ui) { var _e = ev.target; var e = $(_e); if (!e.parents('.fc').length) { // not already inside a calendar var accept = options.dropAccept; if ($.isFunction(accept) ? accept.call(_e, e) : e.is(accept)) { _dragElement = _e; currentView.dragStart(_dragElement, ev, ui); } } }) .bind('dragstop', function(ev, ui) { if (_dragElement) { currentView.dragStop(_dragElement, ev, ui); _dragElement = null; } }); } } ;; function Header(calendar, options) { var t = this; // exports t.render = render; t.destroy = destroy; t.updateTitle = updateTitle; t.activateButton = activateButton; t.deactivateButton = deactivateButton; t.disableButton = disableButton; t.enableButton = enableButton; // locals var element = $([]); var tm; function render() { tm = options.theme ? 'ui' : 'fc'; var sections = options.header; if (sections) { element = $("") .append( $("") .append(renderSection('left')) .append(renderSection('center')) .append(renderSection('right')) ); return element; } } function destroy() { element.remove(); } function renderSection(position) { var e = $(""; if (showWeekNumbers) { html += ""; } for (col=0; col" + htmlEscape(formatDate(date, colFormat)) + ""; } html += ""; return html; } function buildBodyHTML() { var contentClass = tm + "-widget-content"; var html = ''; var row; var col; var date; html += ""; for (row=0; row" + "
" + htmlEscape(calculateWeekNumber(date)) + "
" + ""; } for (col=0; col" + "
"; if (showNumbers) { html += "
" + date.date() + "
"; } html += "
" + "
 
" + "
" + "
" + ""; return html; } /* Dimensions -----------------------------------------------------------*/ function setHeight(height) { viewHeight = height; var bodyHeight = Math.max(viewHeight - head.height(), 0); 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(); weekNumberWidth = 0; if (showWeekNumbers) { weekNumberWidth = head.find('th.fc-week-number').outerWidth(); } 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 = calendar.moment($(this).data('date')); trigger('dayClick', this, date, ev); } } /* Semi-transparent Overlay Helpers ------------------------------------------------------*/ // TODO: should be consolidated with AgendaView's methods function renderDayOverlay(overlayStart, overlayEnd, refreshCoordinateGrid) { // overlayEnd is exclusive if (refreshCoordinateGrid) { coordinateGrid.build(); } var segments = rangeToSegments(overlayStart, overlayEnd); for (var i=0; i") .appendTo(element); if (opt('allDaySlot')) { daySegmentContainer = $("
") .appendTo(slotLayer); s = "
"); 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) { // smartProperty allows different text per view button (ex: "Agenda Week" vs "Basic Week") var themeIcon = smartProperty(options.themeButtonIcons, buttonName); var normalIcon = smartProperty(options.buttonIcons, buttonName); var text = smartProperty(options.buttonText, buttonName); var html; if (themeIcon && options.theme) { html = ""; } else if (normalIcon && !options.theme) { html = ""; } else { html = htmlEscape(text || buttonName); } var button = $( "" + html + "" ) .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; } function updateTitle(html) { element.find('h2') .html(html); } function activateButton(buttonName) { element.find('span.fc-button-' + buttonName) .addClass(tm + '-state-active'); } function deactivateButton(buttonName) { element.find('span.fc-button-' + buttonName) .removeClass(tm + '-state-active'); } function disableButton(buttonName) { element.find('span.fc-button-' + buttonName) .addClass(tm + '-state-disabled'); } function enableButton(buttonName) { element.find('span.fc-button-' + buttonName) .removeClass(tm + '-state-disabled'); } } ;; fc.sourceNormalizers = []; fc.sourceFetchers = []; var ajaxDefaults = { dataType: 'json', cache: false }; var eventGUID = 1; function EventManager(options) { // assumed to be a calendar var t = this; // exports t.isFetchNeeded = isFetchNeeded; t.fetchEvents = fetchEvents; t.addEventSource = addEventSource; t.removeEventSource = removeEventSource; t.updateEvent = updateEvent; t.renderEvent = renderEvent; t.removeEvents = removeEvents; t.clientEvents = clientEvents; t.mutateEvent = mutateEvent; // imports var trigger = t.trigger; var getView = t.getView; var reportEvents = t.reportEvents; var getEventEnd = t.getEventEnd; // locals var stickySource = { events: [] }; var sources = [ stickySource ]; var rangeStart, rangeEnd; var currentFetchID = 0; var pendingSourceCnt = 0; var loadingLevel = 0; var cache = []; var _sources = options.eventSources || []; if (options.events) { _sources.push(options.events); } for (var i=0; i<_sources.length; i++) { _addEventSource(_sources[i]); } /* Fetching -----------------------------------------------------------------------------*/ function isFetchNeeded(start, end) { return !rangeStart || // nothing has been fetched yet? // or, a part of the new range is outside of the old range? (after normalizing) start.clone().stripZone() < rangeStart.clone().stripZone() || end.clone().stripZone() > rangeEnd.clone().stripZone(); } function fetchEvents(start, end) { rangeStart = start; rangeEnd = end; cache = []; var fetchID = ++currentFetchID; var len = sources.length; pendingSourceCnt = len; for (var i=0; i=0; i--) { res = obj[parts[i].toLowerCase()]; if (res !== undefined) { return res; } } return obj['default']; } function htmlEscape(s) { return (s + '').replace(/&/g, '&') .replace(//g, '>') .replace(/'/g, ''') .replace(/"/g, '"') .replace(/\n/g, '
'); } function stripHTMLEntities(text) { return text.replace(/&.*?;/g, ''); } function disableTextSelection(element) { element .attr('unselectable', 'on') .css('MozUserSelect', 'none') .bind('selectstart.ui', function() { return false; }); } /* function enableTextSelection(element) { element .attr('unselectable', 'off') .css('MozUserSelect', '') .unbind('selectstart.ui'); } */ function markFirstLast(e) { // TODO: use CSS selectors instead e.children() .removeClass('fc-first fc-last') .filter(':first-child') .addClass('fc-first') .end() .filter(':last-child') .addClass('fc-last'); } function getSkinCss(event, opt) { var source = event.source || {}; var eventColor = event.color; var sourceColor = source.color; var optionColor = opt('eventColor'); var backgroundColor = event.backgroundColor || eventColor || source.backgroundColor || sourceColor || opt('eventBackgroundColor') || optionColor; var borderColor = event.borderColor || eventColor || source.borderColor || sourceColor || opt('eventBorderColor') || optionColor; var textColor = event.textColor || source.textColor || opt('eventTextColor'); var statements = []; if (backgroundColor) { statements.push('background-color:' + backgroundColor); } if (borderColor) { statements.push('border-color:' + borderColor); } if (textColor) { statements.push('color:' + textColor); } return statements.join(';'); } function applyAll(functions, thisObj, args) { if ($.isFunction(functions)) { functions = [ functions ]; } if (functions) { var i; var ret; for (i=0; i= a[1] && a[0] < a[2]; }; // Make these query methods work with ambiguous moments $.each([ 'isBefore', 'isAfter', 'isSame' ], function(i, methodName) { FCMoment.prototype[methodName] = function(input, units) { var a = commonlyAmbiguate([ this, input ]); return moment.fn[methodName].call(a[0], a[1], units); }; }); // Misc Internals // ------------------------------------------------------------------------------------------------- // transfers our internal _ambig properties from one moment to another function transferAmbigs(src, dest) { if (src._ambigTime) { dest._ambigTime = true; } else if (dest._ambigTime) { delete dest._ambigTime; } if (src._ambigZone) { dest._ambigZone = true; } else if (dest._ambigZone) { delete dest._ambigZone; } } // given an array of moment-like inputs, return a parallel array w/ moments similarly ambiguated. // for example, of one moment has ambig time, but not others, all moments will have their time stripped. function commonlyAmbiguate(inputs) { var outputs = []; var anyAmbigTime = false; var anyAmbigZone = false; var i; for (i=0; i "MMMM D YYYY" formatStr = date1.lang().longDateFormat(formatStr) || formatStr; // BTW, this is not important for `formatDate` because it is impossible to put custom tokens // or non-zero areas in Moment's localized format strings. separator = separator || ' - '; return formatRangeWithChunks( date1, date2, getFormatStringChunks(formatStr), separator, isRTL ); } fc.formatRange = formatRange; // expose function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { var chunkStr; // the rendering of the chunk var leftI; var leftStr = ''; var rightI; var rightStr = ''; var middleI; var middleStr1 = ''; var middleStr2 = ''; var middleStr = ''; // Start at the leftmost side of the formatting string and continue until you hit a token // that is not the same between dates. for (leftI=0; leftIleftI; rightI--) { chunkStr = formatSimilarChunk(date1, date2, chunks[rightI]); if (chunkStr === false) { break; } rightStr = chunkStr + rightStr; } // The area in the middle is different for both of the dates. // Collect them distinctly so we can jam them together later. for (middleI=leftI; middleI<=rightI; middleI++) { middleStr1 += formatDateWithChunk(date1, chunks[middleI]); middleStr2 += formatDateWithChunk(date2, chunks[middleI]); } if (middleStr1 || middleStr2) { if (isRTL) { middleStr = middleStr2 + separator + middleStr1; } else { middleStr = middleStr1 + separator + middleStr2; } } return leftStr + middleStr + rightStr; } var similarUnitMap = { Y: 'year', M: 'month', D: 'day', // day of month d: 'day' // day of week }; // don't go any further than day, because we don't want to break apart times like "12:30:00" // TODO: week maybe? // Given a formatting chunk, and given that both dates are similar in the regard the // formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`. function formatSimilarChunk(date1, date2, chunk) { var token; var unit; if (typeof chunk === 'string') { // a literal string return chunk; } else if ((token = chunk.token)) { unit = similarUnitMap[token.charAt(0)]; // are the dates the same for this unit of measurement? if (unit && date1.isSame(date2, unit)) { return momentFormat(date1, token); // would be the same if we used `date2` // BTW, don't support custom tokens } } return false; // the chunk is NOT the same for the two dates // BTW, don't support splitting on non-zero areas } // Chunking Utils // ------------------------------------------------------------------------------------------------- var formatStringChunkCache = {}; function getFormatStringChunks(formatStr) { if (formatStr in formatStringChunkCache) { return formatStringChunkCache[formatStr]; } return (formatStringChunkCache[formatStr] = chunkFormatString(formatStr)); } // Break the formatting string into an array of chunks function chunkFormatString(formatStr) { var chunks = []; var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|((\w)\4*o?T?)|([^\w\[\(]+)/g; // TODO: more descrimination var match; while ((match = chunker.exec(formatStr))) { if (match[1]) { // a literal string instead [ ... ] chunks.push(match[1]); } else if (match[2]) { // non-zero formatting inside ( ... ) chunks.push({ maybe: chunkFormatString(match[2]) }); } else if (match[3]) { // a formatting token chunks.push({ token: match[3] }); } else if (match[5]) { // an unenclosed literal string chunks.push(match[5]); } } return chunks; } ;; fcViews.month = MonthView; function MonthView(element, calendar) { var t = this; // exports t.incrementDate = incrementDate; t.render = render; // imports BasicView.call(t, element, calendar, 'month'); function incrementDate(date, delta) { return date.clone().stripTime().add('months', delta).startOf('month'); } function render(date) { t.intervalStart = date.clone().stripTime().startOf('month'); t.intervalEnd = t.intervalStart.clone().add('months', 1); t.start = t.intervalStart.clone().startOf('week'); t.start = t.skipHiddenDays(t.start); t.end = t.intervalEnd.clone().add('days', (7 - t.intervalEnd.weekday()) % 7); t.end = t.skipHiddenDays(t.end, -1, true); var rowCnt = Math.ceil( // need to ceil in case there are hidden days t.end.diff(t.start, 'weeks', true) // returnfloat=true ); if (t.opt('weekMode') == 'fixed') { t.end.add('weeks', 6 - rowCnt); rowCnt = 6; } t.title = calendar.formatDate(t.intervalStart, t.opt('titleFormat')); t.renderBasic(rowCnt, t.getCellsPerWeek(), true); } } ;; fcViews.basicWeek = BasicWeekView; function BasicWeekView(element, calendar) { // TODO: do a WeekView mixin var t = this; // exports t.incrementDate = incrementDate; t.render = render; // imports BasicView.call(t, element, calendar, 'basicWeek'); function incrementDate(date, delta) { return date.clone().stripTime().add('weeks', delta).startOf('week'); } function render(date) { t.intervalStart = date.clone().stripTime().startOf('week'); t.intervalEnd = t.intervalStart.clone().add('weeks', 1); t.start = t.skipHiddenDays(t.intervalStart); t.end = t.skipHiddenDays(t.intervalEnd, -1, true); t.title = calendar.formatRange( t.start, t.end.clone().subtract(1), // make inclusive by subtracting 1 ms t.opt('titleFormat'), ' \u2014 ' // emphasized dash ); t.renderBasic(1, t.getCellsPerWeek(), false); } } ;; fcViews.basicDay = BasicDayView; function BasicDayView(element, calendar) { // TODO: make a DayView mixin var t = this; // exports t.incrementDate = incrementDate; t.render = render; // imports BasicView.call(t, element, calendar, 'basicDay'); function incrementDate(date, delta) { var out = date.clone().stripTime().add('days', delta); out = t.skipHiddenDays(out, delta < 0 ? -1 : 1); return out; } function render(date) { t.start = t.intervalStart = date.clone().stripTime(); t.end = t.intervalEnd = t.start.clone().add('days', 1); t.title = calendar.formatDate(t.start, t.opt('titleFormat')); t.renderBasic(1, 1, false); } } ;; setDefaults({ weekMode: 'fixed' }); function BasicView(element, calendar, viewName) { var t = this; // exports t.renderBasic = renderBasic; t.setHeight = setHeight; t.setWidth = setWidth; t.renderDayOverlay = renderDayOverlay; t.defaultSelectionEnd = defaultSelectionEnd; t.renderSelection = renderSelection; t.clearSelection = clearSelection; t.reportDayClick = reportDayClick; // for selection (kinda hacky) t.dragStart = dragStart; t.dragStop = dragStop; t.getHoverListener = function() { return hoverListener; }; t.colLeft = colLeft; t.colRight = colRight; t.colContentLeft = colContentLeft; t.colContentRight = colContentRight; t.getIsCellAllDay = function() { return true; }; t.allDayRow = allDayRow; t.getRowCnt = function() { return rowCnt; }; t.getColCnt = function() { return colCnt; }; t.getColWidth = function() { return colWidth; }; t.getDaySegmentContainer = function() { return daySegmentContainer; }; // imports View.call(t, element, calendar, viewName); OverlayManager.call(t); SelectionManager.call(t); BasicEventRenderer.call(t); var opt = t.opt; var trigger = t.trigger; var renderOverlay = t.renderOverlay; var clearOverlays = t.clearOverlays; var daySelectionMousedown = t.daySelectionMousedown; var cellToDate = t.cellToDate; var dateToCell = t.dateToCell; var rangeToSegments = t.rangeToSegments; var formatDate = calendar.formatDate; var calculateWeekNumber = calendar.calculateWeekNumber; // locals var table; var head; var headCells; var body; var bodyRows; var bodyCells; var bodyFirstCells; var firstRowCellInners; var firstRowCellContentInners; var daySegmentContainer; var viewWidth; var viewHeight; var colWidth; var weekNumberWidth; var rowCnt, colCnt; var showNumbers; var coordinateGrid; var hoverListener; var colPositions; var colContentPositions; var tm; var colFormat; var showWeekNumbers; /* Rendering ------------------------------------------------------------*/ disableTextSelection(element.addClass('fc-grid')); function renderBasic(_rowCnt, _colCnt, _showNumbers) { rowCnt = _rowCnt; colCnt = _colCnt; showNumbers = _showNumbers; updateOptions(); if (!body) { buildEventContainer(); } buildTable(); } function updateOptions() { tm = opt('theme') ? 'ui' : 'fc'; colFormat = opt('columnFormat'); showWeekNumbers = opt('weekNumbers'); } function buildEventContainer() { daySegmentContainer = $("
") .appendTo(element); } 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'); 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)); }); dayBind(bodyCells); } /* HTML Building -----------------------------------------------------------*/ function buildTableHTML() { var html = "" + buildHeadHTML() + buildBodyHTML() + "
"; return html; } function buildHeadHTML() { var headerClass = tm + "-widget-header"; var html = ''; var col; var date; html += "
" + htmlEscape(opt('weekNumberTitle')) + "
" + "" + "" + "" + "" + "" + "
" + ( opt('allDayHTML') || htmlEscape(opt('allDayText')) ) + "" + "
" + "
 
"; allDayTable = $(s).appendTo(slotLayer); allDayRow = allDayTable.find('tr'); dayBind(allDayRow.find('td')); slotLayer.append( "
" + "
" + "
" ); }else{ daySegmentContainer = $([]); // in jQuery 1.4, we can just do $() } slotScroller = $("
") .appendTo(slotLayer); slotContainer = $("
") .appendTo(slotScroller); slotSegmentContainer = $("
") .appendTo(slotContainer); s = "" + ""; slotTime = moment.duration(+minTime); // i wish there was .clone() for durations slotCnt = 0; while (slotTime < maxTime) { slotDate = t.start.clone().time(slotTime); // will be in UTC but that's good. to avoid DST issues minutes = slotDate.minutes(); s += "" + "" + "" + ""; slotTime.add(slotDuration); slotCnt++; } s += "" + "
" + ((!slotNormal || !minutes) ? htmlEscape(formatDate(slotDate, opt('axisFormat'))) : ' ' ) + "" + "
 
" + "
"; slotTable = $(s).appendTo(slotContainer); slotBind(slotTable.find('td')); } /* Build Day Table -----------------------------------------------------------------------*/ function buildDayTable() { var html = buildDayTableHTML(); if (dayTable) { dayTable.remove(); } dayTable = $(html).appendTo(element); 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'); dayBodyFirstCell = dayBodyCells.eq(0); dayBodyFirstCellStretcher = dayBodyCellInners.eq(0); markFirstLast(dayHead.add(dayHead.find('tr'))); markFirstLast(dayBody.add(dayBody.find('tr'))); // TODO: now that we rebuild the cells every time, we should call dayRender } function buildDayTableHTML() { var html = "" + buildDayTableHeadHTML() + buildDayTableBodyHTML() + "
"; return html; } function buildDayTableHeadHTML() { var headerClass = tm + "-widget-header"; var date; var html = ''; var weekText; var col; html += "" + ""; if (opt('weekNumbers')) { date = cellToDate(0, 0); weekText = calculateWeekNumber(date); if (rtl) { weekText += opt('weekNumberTitle'); } else { weekText = opt('weekNumberTitle') + weekText; } html += "" + htmlEscape(weekText) + ""; } else { html += " "; } for (col=0; col" + htmlEscape(formatDate(date, colFormat)) + ""; } html += " " + "" + ""; return html; } function buildDayTableBodyHTML() { var headerClass = tm + "-widget-header"; // TODO: make these when updateOptions() called var contentClass = tm + "-widget-content"; var date; var today = calendar.getNow().stripTime(); var col; var cellsHTML; var cellHTML; var classNames; var html = ''; html += "" + "" + " "; cellsHTML = ''; for (col=0; col" + "
" + "
" + "
 
" + "
" + "
" + ""; cellsHTML += cellHTML; } html += cellsHTML; html += " " + "" + ""; return html; } // TODO: data-date on the cells /* 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 ); 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. var slotHeight0 = slotTable.find('tr:first').height() + 1; // +1 for bottom border var slotHeight1 = slotTable.find('tr:eq(1)').height(); // HACK: i forget why we do this, but i think a cross-browser issue slotHeight = (slotHeight0 + slotHeight1) / 2; snapRatio = slotDuration / snapDuration; 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')); } 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); } /* Scrolling -----------------------------------------------------------------------*/ function resetScroll() { var top = computeTimeTop( moment.duration(opt('scrollTime')) ) + 1; // +1 for the border function scroll() { slotScroller.scrollTop(top); } 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 -----------------------------------------------------------------------*/ function dayBind(cells) { cells.click(slotClick) .mousedown(daySelectionMousedown); } 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 match = this.parentNode.className.match(/fc-slot(\d+)/); // TODO: maybe use data if (match) { var slotIndex = parseInt(match[1]); date.add(minTime + slotIndex * slotDuration); date = calendar.rezoneDate(date); trigger( 'dayClick', dayBodyCells[col], date, ev ); }else{ trigger( 'dayClick', dayBodyCells[col], date, ev ); } } } /* Semi-transparent Overlay Helpers -----------------------------------------------------*/ // TODO: should be consolidated with BasicView's methods function renderDayOverlay(overlayStart, overlayEnd, refreshCoordinateGrid) { // overlayEnd is exclusive if (refreshCoordinateGrid) { coordinateGrid.build(); } var segments = rangeToSegments(overlayStart, overlayEnd); for (var i=0; i= 0) { date.time(moment.duration(minTime + slotIndex * slotDuration)); date = calendar.rezoneDate(date); } return date; } function computeDateTop(date, startOfDayDate) { return computeTimeTop( moment.duration( date.clone().stripZone() - startOfDayDate.clone().stripTime() ) ); } function computeTimeTop(time) { // time is a duration if (time < minTime) { return 0; } if (time >= maxTime) { return slotTable.height(); } var slots = (time - minTime) / slotDuration; var slotIndex = Math.floor(slots); var slotPartial = slots - slotIndex; var slotTop = slotTopCache[slotIndex]; // find the position of the corresponding // need to use this tecnhique because not all rows are rendered at same height sometimes. if (slotTop === undefined) { slotTop = slotTopCache[slotIndex] = slotTable.find('tr').eq(slotIndex).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 } var top = slotTop - 1 + // because first row doesn't have a top border slotPartial * slotHeight; // part-way through the row top = Math.max(top, 0); return top; } /* Selection ---------------------------------------------------------------------------------*/ function defaultSelectionEnd(start) { if (start.hasTime()) { return start.clone().add(slotDuration); } else { return start.clone().add('days', 1); } } function renderSelection(start, end) { if (start.hasTime() || end.hasTime()) { renderSlotSelection(start, end); } else if (opt('allDaySlot')) { renderDayOverlay(start, end, true); // true for refreshing coordinate grid } } 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 = computeDateTop(startDate, startDate); var bottom = computeDateTop(endDate, startDate); 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, d1.clone().add(snapDuration), // calculate minutes depending on selection slot minutes d2, d2.clone().add(snapDuration) ].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], ev); } reportSelection(dates[0], dates[3], ev); } }); } } function reportDayClick(date, ev) { trigger('dayClick', dayBodyCells[dateToCell(date).col], date, ev); } /* External Dragging --------------------------------------------------------------------------------*/ function dragStart(_dragElement, ev, ui) { hoverListener.start(function(cell) { clearOverlays(); if (cell) { var d1 = realCellToDate(cell); var d2 = d1.clone(); if (d1.hasTime()) { d2.add(calendar.defaultTimedEventDuration); renderSlotOverlay(d1, d2); } else { d2.add(calendar.defaultAllDayEventDuration); renderDayOverlay(d1, d2); } } }, ev); } function dragStop(_dragElement, ev, ui) { var cell = hoverListener.stop(); clearOverlays(); if (cell) { trigger( 'drop', _dragElement, realCellToDate(cell), ev, ui ); } } } ;; 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 eventElementHandlers = t.eventElementHandlers; var setHeight = t.setHeight; var getDaySegmentContainer = t.getDaySegmentContainer; var getSlotSegmentContainer = t.getSlotSegmentContainer; var getHoverListener = t.getHoverListener; var computeDateTop = t.computeDateTop; 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 getSnapDuration = t.getSnapDuration; var getSlotHeight = t.getSlotHeight; var getSlotDuration = t.getSlotDuration; 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 getMinTime = t.getMinTime; var getMaxTime = t.getMaxTime; var calendar = t.calendar; var formatDate = calendar.formatDate; var formatRange = calendar.formatRange; var getEventEnd = calendar.getEventEnd; // overrides t.draggableDayEvent = draggableDayEvent; /* Rendering ----------------------------------------------------------------------------*/ function renderEvents(events, modifiedEventId) { var i, len=events.length, dayEvents=[], slotEvents=[]; for (i=0; i rangeStart && eventStart < rangeEnd) { if (eventStart < rangeStart) { segStart = rangeStart.clone(); isStart = false; } else { segStart = eventStart; isStart = true; } if (eventEnd > rangeEnd) { segEnd = rangeEnd.clone(); isEnd = false; } else { segEnd = eventEnd; isEnd = true; } segs.push({ event: event, start: segStart, end: segEnd, isStart: isStart, isEnd: isEnd }); } } return segs.sort(compareSlotSegs); } // 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" + "
" + "
"; if (event.end) { html += htmlEscape(formatRange(event.start, event.end, opt('timeFormat'))); }else{ html += htmlEscape(formatDate(event.start, opt('timeFormat'))); } html += "
" + "
" + htmlEscape(event.title || '') + "
" + "
" + "
"; if (seg.isEnd && isEventResizable(event)) { html += "
=
"; } html += ""; 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 minTime = getMinTime(); var slotDuration = getSlotDuration(); var slotHeight = getSlotHeight(); var snapDuration = getSnapDuration(); var snapHeight = getSnapHeight(); 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 = date.diff(origDate, 'days'); if (!cell.row) { // on full-days renderDayOverlay( event.start.clone().add('days', dayDelta), getEventEnd(event).add('days', 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, calendar.defaultTimedEventDuration / slotDuration * slotHeight); // the default height 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 eventStart = event.start.clone().add('days', dayDelta); // already assumed to have a stripped time var snapTime; var snapIndex; if (!allDay) { snapIndex = Math.round((eventElement.offset().top - getSlotContainer().offset().top) / snapHeight); // why not use ui.offset.top? snapTime = moment.duration(minTime + snapIndex * snapDuration); eventStart = calendar.rezoneDate(eventStart.clone().time(snapTime)); } eventDrop( this, // el event, eventStart, 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 snapDuration = getSnapDuration(); // 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 snapDelta, prevSnapDelta; // the number of snaps away from the original position // newly computed var eventStart, eventEnd; 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; snapDelta = prevSnapDelta = 0; eventStart = null; eventEnd = null; }, 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 = date.diff(origDate, 'days'); } // calculate minute delta (only if over slots) if (!isAllDay) { snapDelta = Math.round((ui.position.top - origPosition.top) / snapHeight); } } // any state changes? if ( isInBounds != prevIsInBounds || isAllDay != prevIsAllDay || colDelta != prevColDelta || snapDelta != prevSnapDelta ) { // compute new dates if (isAllDay) { eventStart = event.start.clone().stripTime().add('days', dayDelta); eventEnd = eventStart.clone().add(calendar.defaultAllDayEventDuration); } else { eventStart = event.start.clone().add(snapDelta * snapDuration).add('days', dayDelta); eventEnd = getEventEnd(event).add(snapDelta * snapDuration).add('days', dayDelta); } updateUI(); // update previous states for next time prevIsInBounds = isInBounds; prevIsAllDay = isAllDay; prevColDelta = colDelta; prevSnapDelta = snapDelta; } // if out-of-bounds, revert when done, and vice versa. eventElement.draggable('option', 'revert', !isInBounds); }, stop: function(ev, ui) { clearOverlays(); trigger('eventDragStop', eventElement, event, ev, ui); if (isInBounds && (isAllDay || dayDelta || snapDelta)) { // changed! eventDrop( this, // el event, eventStart, 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; snapDelta = 0; 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); showEvents(event, eventElement); } } }); function updateUI() { clearOverlays(); if (isInBounds) { if (isAllDay) { timeElement.hide(); eventElement.draggable('option', 'grid', null); // disable grid snapping renderDayOverlay(eventStart, eventEnd); } else { updateTimeText(); timeElement.css('display', ''); // show() was causing display=inline eventElement.draggable('option', 'grid', [colWidth, snapHeight]); // re-enable grid snapping } } } function updateTimeText() { var text; if (eventStart) { // must of had a state change if (event.end) { text = formatRange(eventStart, eventEnd, opt('timeFormat')); } else { text = formatDate(eventStart, opt('timeFormat')); } timeElement.text(text); } } } /* Resizing --------------------------------------------------------------------------------------*/ function resizableSlotEvent(event, eventElement, timeElement) { var snapDelta, prevSnapDelta; var snapHeight = getSnapHeight(); var snapDuration = getSnapDuration(); var eventEnd; 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) { eventEnd = getEventEnd(event).add(snapDuration * snapDelta); var text; if (snapDelta || event.end) { text = formatRange( event.start, eventEnd, opt('timeFormat') ); } else { text = formatDate(event.start, opt('timeFormat')); } timeElement.text(text); prevSnapDelta = snapDelta; } }, stop: function(ev, ui) { trigger('eventResizeStop', this, event, ev, ui); if (snapDelta) { eventResize( this, event, eventEnd, ev, ui ); } else { showEvents(event, eventElement); // BUG: if event was really short, need to put title back in span } } }); } } /* Agenda Event Segment Utilities -----------------------------------------------------------------------------*/ // 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; computeForwardSlotSegs(levels); if (level0) { for (i=0; i seg2.start && seg1.start < seg2.end; } // 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); } // 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 } ;; 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.clearEventData = clearEventData; t.reportEventElement = reportEventElement; t.triggerEventDestroy = triggerEventDestroy; t.eventElementHandlers = eventElementHandlers; t.showEvents = showEvents; t.hideEvents = hideEvents; t.eventDrop = eventDrop; t.eventResize = eventResize; // t.start, t.end // moments with ambiguous-time // t.intervalStart, t.intervalEnd // moments with ambiguous-time // imports var reportEventChange = calendar.reportEventChange; // locals 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; var nextDayThreshold = moment.duration(options.nextDayThreshold); function opt(name, viewNameOverride) { var v = options[name]; if ($.isPlainObject(v) && !isForcedAtomicOption(name)) { return smartProperty(v, viewNameOverride || viewName); } return v; } function trigger(name, thisObj) { return calendar.trigger.apply( calendar, [name, thisObj || t].concat(Array.prototype.slice.call(arguments, 2), [t]) ); } /* Event Editable Boolean Calculations ------------------------------------------------------------------------------*/ function isEventDraggable(event) { var source = event.source || {}; return firstDefined( event.startEditable, source.startEditable, opt('eventStartEditable'), event.editable, source.editable, opt('editable') ); } 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') ); } /* Event Data ------------------------------------------------------------------------------*/ function clearEventData() { eventElementsByID = {}; eventElementCouples = []; } /* 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]; } } 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