function Calendar(element, instanceOptions) { var t = this; // Build options object // ----------------------------------------------------------------------------------- // Precedence (lowest to highest): defaults, rtlDefaults, langOptions, instanceOptions instanceOptions = instanceOptions || {}; var options = mergeOptions({}, defaults, instanceOptions); var langOptions; // determine language options if (options.lang in langOptionHash) { langOptions = langOptionHash[options.lang]; } else { langOptions = langOptionHash[defaults.lang]; } if (langOptions) { // if language options exist, rebuild... options = mergeOptions({}, defaults, langOptions, instanceOptions); } if (options.isRTL) { // is isRTL, rebuild... options = mergeOptions({}, defaults, rtlDefaults, langOptions || {}, instanceOptions); } // Exports // ----------------------------------------------------------------------------------- t.options = options; t.render = render; t.destroy = destroy; t.refetchEvents = refetchEvents; t.reportEvents = reportEvents; t.reportEventChange = reportEventChange; t.rerenderEvents = renderEvents; // `renderEvents` serves as a rerender. an API method t.changeView = changeView; t.select = select; t.unselect = unselect; t.prev = prev; t.next = next; t.prevYear = prevYear; t.nextYear = nextYear; t.today = today; t.gotoDate = gotoDate; t.incrementDate = incrementDate; t.zoomTo = zoomTo; t.getDate = getDate; t.getCalendar = getCalendar; t.getView = getView; t.option = option; t.trigger = trigger; // Language-data Internals // ----------------------------------------------------------------------------------- // Apply overrides to the current language's data // Returns moment's internal locale data. If doesn't exist, returns English. // Works with moment-pre-2.8 function getLocaleData(langCode) { var f = moment.localeData || moment.langData; return f.call(moment, langCode) || f.call(moment, 'en'); // the newer localData could return null, so fall back to en } var localeData = createObject(getLocaleData(options.lang)); // make a cheap copy if (options.monthNames) { localeData._months = options.monthNames; } if (options.monthNamesShort) { localeData._monthsShort = options.monthNamesShort; } if (options.dayNames) { localeData._weekdays = options.dayNames; } if (options.dayNamesShort) { localeData._weekdaysShort = options.dayNamesShort; } if (options.firstDay != null) { var _week = createObject(localeData._week); // _week: { dow: # } _week.dow = options.firstDay; localeData._week = _week; } // Calendar-specific Date Utilities // ----------------------------------------------------------------------------------- t.defaultAllDayEventDuration = moment.duration(options.defaultAllDayEventDuration); t.defaultTimedEventDuration = moment.duration(options.defaultTimedEventDuration); // Builds a moment using the settings of the current calendar: timezone and language. // Accepts anything the vanilla moment() constructor accepts. t.moment = function() { var mom; if (options.timezone === 'local') { mom = fc.moment.apply(null, arguments); // Force the moment to be local, because fc.moment doesn't guarantee it. if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone mom.local(); } } else if (options.timezone === 'UTC') { mom = fc.moment.utc.apply(null, arguments); // process as UTC } else { mom = fc.moment.parseZone.apply(null, arguments); // let the input decide the zone } if ('_locale' in mom) { // moment 2.8 and above mom._locale = localeData; } else { // pre-moment-2.8 mom._lang = localeData; } return mom; }; // Returns a boolean about whether or not the calendar knows how to calculate // the timezone offset of arbitrary dates in the current timezone. t.getIsAmbigTimezone = function() { return options.timezone !== 'local' && options.timezone !== 'UTC'; }; // Returns a copy of the given date in the current timezone of it is ambiguously zoned. // This will also give the date an unambiguous time. t.rezoneDate = function(date) { return t.moment(date.toArray()); }; // Returns a moment for the current date, as defined by the client's computer, // or overridden by the `now` option. t.getNow = function() { var now = options.now; if (typeof now === 'function') { now = now(); } return t.moment(now); }; // Calculates the week number for a moment according to the calendar's // `weekNumberCalculation` setting. t.calculateWeekNumber = function(mom) { var calc = options.weekNumberCalculation; if (typeof calc === 'function') { return calc(mom); } else if (calc === 'local') { return mom.week(); } else if (calc.toUpperCase() === 'ISO') { return mom.isoWeek(); } }; // Get an event's normalized end date. If not present, calculate it from the defaults. t.getEventEnd = function(event) { if (event.end) { return event.end.clone(); } else { return t.getDefaultEventEnd(event.allDay, event.start); } }; // Given an event's allDay status and start date, return swhat its fallback end date should be. t.getDefaultEventEnd = function(allDay, start) { // TODO: rename to computeDefaultEventEnd var end = start.clone(); if (allDay) { end.stripTime().add(t.defaultAllDayEventDuration); } else { end.add(t.defaultTimedEventDuration); } if (t.getIsAmbigTimezone()) { end.stripZone(); // we don't know what the tzo should be } return end; }; // Date-formatting Utilities // ----------------------------------------------------------------------------------- // Like the vanilla formatRange, but with calendar-specific settings applied. t.formatRange = function(m1, m2, formatStr) { // a function that returns a formatStr // TODO: in future, precompute this if (typeof formatStr === 'function') { formatStr = formatStr.call(t, options, localeData); } return formatRange(m1, m2, formatStr, null, options.isRTL); }; // Like the vanilla formatDate, but with calendar-specific settings applied. t.formatDate = function(mom, formatStr) { // a function that returns a formatStr // TODO: in future, precompute this if (typeof formatStr === 'function') { formatStr = formatStr.call(t, options, localeData); } return formatDate(mom, formatStr); }; // Imports // ----------------------------------------------------------------------------------- EventManager.call(t, options); ResourceManager.call(t, options); var isFetchNeeded = t.isFetchNeeded; var fetchEvents = t.fetchEvents; // Locals // ----------------------------------------------------------------------------------- var _element = element[0]; var header; var headerElement; var content; var tm; // for making theme classes var currentView; var suggestedViewHeight; var windowResizeProxy; // wraps the windowResize function var ignoreWindowResize = 0; var date; var events = []; // Main Rendering // ----------------------------------------------------------------------------------- if (options.defaultDate != null) { date = t.moment(options.defaultDate); } else { date = t.getNow(); } function render(inc) { if (!content) { initialRender(); } else if (elementVisible()) { // mainly for the public API calcSize(); renderView(inc); } } function initialRender() { tm = options.theme ? 'ui' : 'fc'; element.addClass('fc'); if (options.isRTL) { element.addClass('fc-rtl'); } else { element.addClass('fc-ltr'); } if (options.theme) { element.addClass('ui-widget'); } else { element.addClass('fc-unthemed'); } content = $("
").prependTo(element); header = new Header(t, options); headerElement = header.render(); if (headerElement) { element.prepend(headerElement); } changeView(options.defaultView); if (options.handleWindowResize) { windowResizeProxy = debounce(windowResize, options.windowResizeDelay); // prevents rapid calls $(window).resize(windowResizeProxy); } } function destroy() { if (currentView) { currentView.destroy(); } header.destroy(); content.remove(); element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget'); $(window).unbind('resize', windowResizeProxy); } function elementVisible() { return element.is(':visible'); } // View Rendering // ----------------------------------------------------------------------------------- function changeView(viewName) { renderView(0, viewName); } // Renders a view because of a date change, view-type change, or for the first time function renderView(delta, viewName) { ignoreWindowResize++; // if viewName is changing, destroy the old view if (currentView && viewName && currentView.name !== viewName) { header.deactivateButton(currentView.name); freezeContentHeight(); // prevent a scroll jump when view element is removed if (currentView.start) { // rendered before? currentView.destroy(); } currentView.el.remove(); currentView = null; } // if viewName changed, or the view was never created, create a fresh view if (!currentView && viewName) { currentView = new fcViews[viewName](t); currentView.el = $("").appendTo(content); header.activateButton(viewName); } if (currentView) { // let the view determine what the delta means if (delta) { date = currentView.incrementDate(date, delta); } // render or rerender the view if ( !currentView.start || // never rendered before delta || // explicit date window change !date.isWithin(currentView.intervalStart, currentView.intervalEnd) // implicit date window change ) { if (elementVisible()) { freezeContentHeight(); if (currentView.start) { // rendered before? currentView.destroy(); } currentView.render(date); unfreezeContentHeight(); // need to do this after View::render, so dates are calculated updateTitle(); updateTodayButton(); getAndRenderEvents(); } } } unfreezeContentHeight(); // undo any lone freezeContentHeight calls ignoreWindowResize--; } // Resizing // ----------------------------------------------------------------------------------- t.getSuggestedViewHeight = function() { if (suggestedViewHeight === undefined) { calcSize(); } return suggestedViewHeight; }; t.isHeightAuto = function() { return options.contentHeight === 'auto' || options.height === 'auto'; }; function updateSize(shouldRecalc) { if (elementVisible()) { if (shouldRecalc) { _calcSize(); } ignoreWindowResize++; currentView.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto() ignoreWindowResize--; return true; // signal success } } function calcSize() { if (elementVisible()) { _calcSize(); } } function _calcSize() { // assumes elementVisible if (typeof options.contentHeight === 'number') { // exists and not 'auto' suggestedViewHeight = options.contentHeight; } else if (typeof options.height === 'number') { // exists and not 'auto' suggestedViewHeight = options.height - (headerElement ? headerElement.outerHeight(true) : 0); } else { suggestedViewHeight = Math.round(content.width() / Math.max(options.aspectRatio, .5)); } } function windowResize(ev) { if ( !ignoreWindowResize && ev.target === window && // so we don't process jqui "resize" events that have bubbled up currentView.start // view has already been rendered ) { if (updateSize(true)) { currentView.trigger('windowResize', _element); } } } /* Event Fetching/Rendering -----------------------------------------------------------------------------*/ // TODO: going forward, most of this stuff should be directly handled by the view function refetchEvents() { // can be called as an API method destroyEvents(); // so that events are cleared before user starts waiting for AJAX fetchAndRenderEvents(); } function renderEvents() { // destroys old events if previously rendered if (elementVisible()) { freezeContentHeight(); currentView.destroyEvents(); // no performance cost if never rendered currentView.renderEvents(events); unfreezeContentHeight(); } } function destroyEvents() { freezeContentHeight(); currentView.destroyEvents(); unfreezeContentHeight(); } function getAndRenderEvents() { if (!options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) { fetchAndRenderEvents(); } else { renderEvents(); } } function fetchAndRenderEvents() { fetchEvents(currentView.start, currentView.end); // ... will call reportEvents // ... which will call renderEvents } // called when event data arrives function reportEvents(_events) { events = _events; renderEvents(); } // called when a single event's data has been changed function reportEventChange() { renderEvents(); } /* Header Updating -----------------------------------------------------------------------------*/ function 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) { start = t.moment(start); if (end) { end = t.moment(end); } else if (start.hasTime()) { end = start.clone().add(t.defaultTimedEventDuration); } else { end = start.clone().add(t.defaultAllDayEventDuration); } currentView.select(start, 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(-1, 'years'); renderView(); } function nextYear() { date.add(1, 'years'); renderView(); } function today() { date = t.getNow(); renderView(); } function gotoDate(dateInput) { date = t.moment(dateInput); renderView(); } function incrementDate(delta) { date.add(moment.duration(delta)); renderView(); } // Forces navigation to a view for the given date. // `viewName` can be a specific view name or a generic one like "week" or "day". function zoomTo(newDate, viewName) { var viewStr; var match; if (!viewName || fcViews[viewName] === undefined) { // a general view name, or "auto" viewName = viewName || 'day'; viewStr = header.getViewsWithButtons().join(' '); // space-separated string of all the views in the header // try to match a general view name, like "week", against a specific one, like "agendaWeek" match = viewStr.match(new RegExp('\\w+' + capitaliseFirstLetter(viewName))); // fall back to the day view being used in the header if (!match) { match = viewStr.match(/\w+Day/); } viewName = match ? match[0] : 'agendaDay'; // fall back to agendaDay } date = newDate; changeView(viewName); } 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(true); // true = allow recalculation of height } } function trigger(name, thisObj) { if (options[name]) { return options[name].apply( thisObj || _element, Array.prototype.slice.call(arguments, 2) ); } } }