github.com/apremalal/vamps-core@v1.0.1-0.20161221121535-d430b56ec174/server/webapps/app/base/plugins/fullcalendar/fullcalendar.js (about) 1 /*! 2 * FullCalendar v2.1.1 3 * Docs & License: http://arshaw.com/fullcalendar/ 4 * (c) 2013 Adam Shaw 5 */ 6 7 (function(factory) { 8 if (typeof define === 'function' && define.amd) { 9 define([ 'jquery', 'moment' ], factory); 10 } 11 else { 12 factory(jQuery, moment); 13 } 14 })(function($, moment) { 15 16 ;; 17 18 var defaults = { 19 20 lang: 'en', 21 22 defaultTimedEventDuration: '02:00:00', 23 defaultAllDayEventDuration: { days: 1 }, 24 forceEventDuration: false, 25 nextDayThreshold: '09:00:00', // 9am 26 27 // display 28 defaultView: 'month', 29 aspectRatio: 1.35, 30 header: { 31 left: 'title', 32 center: '', 33 right: 'today prev,next' 34 }, 35 weekends: true, 36 weekNumbers: false, 37 38 weekNumberTitle: 'W', 39 weekNumberCalculation: 'local', 40 41 //editable: false, 42 43 // event ajax 44 lazyFetching: true, 45 startParam: 'start', 46 endParam: 'end', 47 timezoneParam: 'timezone', 48 49 timezone: false, 50 51 //allDayDefault: undefined, 52 53 // time formats 54 titleFormat: { 55 month: 'MMMM YYYY', // like "September 1986". each language will override this 56 week: 'll', // like "Sep 4 1986" 57 day: 'LL' // like "September 4 1986" 58 }, 59 columnFormat: { 60 month: 'ddd', // like "Sat" 61 week: generateWeekColumnFormat, 62 day: 'dddd' // like "Saturday" 63 }, 64 timeFormat: { // for event elements 65 'default': generateShortTimeFormat 66 }, 67 68 displayEventEnd: { 69 month: false, 70 basicWeek: false, 71 'default': true 72 }, 73 74 // locale 75 isRTL: false, 76 defaultButtonText: { 77 prev: "prev", 78 next: "next", 79 prevYear: "prev year", 80 nextYear: "next year", 81 today: 'today', 82 month: 'month', 83 week: 'week', 84 day: 'day' 85 }, 86 87 buttonIcons: { 88 prev: 'left-single-arrow', 89 next: 'right-single-arrow', 90 prevYear: 'left-double-arrow', 91 nextYear: 'right-double-arrow' 92 }, 93 94 // jquery-ui theming 95 theme: false, 96 themeButtonIcons: { 97 prev: 'circle-triangle-w', 98 next: 'circle-triangle-e', 99 prevYear: 'seek-prev', 100 nextYear: 'seek-next' 101 }, 102 103 dragOpacity: .75, 104 dragRevertDuration: 500, 105 dragScroll: true, 106 107 //selectable: false, 108 unselectAuto: true, 109 110 dropAccept: '*', 111 112 eventLimit: false, 113 eventLimitText: 'more', 114 eventLimitClick: 'popover', 115 dayPopoverFormat: 'LL', 116 117 handleWindowResize: true, 118 windowResizeDelay: 200 // milliseconds before a rerender happens 119 120 }; 121 122 123 function generateShortTimeFormat(options, langData) { 124 return langData.longDateFormat('LT') 125 .replace(':mm', '(:mm)') 126 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs 127 .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand 128 } 129 130 131 function generateWeekColumnFormat(options, langData) { 132 var format = langData.longDateFormat('L'); // for the format like "MM/DD/YYYY" 133 format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, ''); // strip the year off the edge, as well as other misc non-whitespace chars 134 if (options.isRTL) { 135 format += ' ddd'; // for RTL, add day-of-week to end 136 } 137 else { 138 format = 'ddd ' + format; // for LTR, add day-of-week to beginning 139 } 140 return format; 141 } 142 143 144 var langOptionHash = { 145 en: { 146 columnFormat: { 147 week: 'ddd M/D' // override for english. different from the generated default, which is MM/DD 148 }, 149 dayPopoverFormat: 'dddd, MMMM D' 150 } 151 }; 152 153 154 // right-to-left defaults 155 var rtlDefaults = { 156 header: { 157 left: 'next,prev today', 158 center: '', 159 right: 'title' 160 }, 161 buttonIcons: { 162 prev: 'right-single-arrow', 163 next: 'left-single-arrow', 164 prevYear: 'right-double-arrow', 165 nextYear: 'left-double-arrow' 166 }, 167 themeButtonIcons: { 168 prev: 'circle-triangle-e', 169 next: 'circle-triangle-w', 170 nextYear: 'seek-prev', 171 prevYear: 'seek-next' 172 } 173 }; 174 175 ;; 176 177 var fc = $.fullCalendar = { version: "2.1.1" }; 178 var fcViews = fc.views = {}; 179 180 181 $.fn.fullCalendar = function(options) { 182 var args = Array.prototype.slice.call(arguments, 1); // for a possible method call 183 var res = this; // what this function will return (this jQuery object by default) 184 185 this.each(function(i, _element) { // loop each DOM element involved 186 var element = $(_element); 187 var calendar = element.data('fullCalendar'); // get the existing calendar object (if any) 188 var singleRes; // the returned value of this single method call 189 190 // a method call 191 if (typeof options === 'string') { 192 if (calendar && $.isFunction(calendar[options])) { 193 singleRes = calendar[options].apply(calendar, args); 194 if (!i) { 195 res = singleRes; // record the first method call result 196 } 197 if (options === 'destroy') { // for the destroy method, must remove Calendar object data 198 element.removeData('fullCalendar'); 199 } 200 } 201 } 202 // a new calendar initialization 203 else if (!calendar) { // don't initialize twice 204 calendar = new Calendar(element, options); 205 element.data('fullCalendar', calendar); 206 calendar.render(); 207 } 208 }); 209 210 return res; 211 }; 212 213 214 // function for adding/overriding defaults 215 function setDefaults(d) { 216 mergeOptions(defaults, d); 217 } 218 219 220 // Recursively combines option hash-objects. 221 // Better than `$.extend(true, ...)` because arrays are not traversed/copied. 222 // 223 // called like: 224 // mergeOptions(target, obj1, obj2, ...) 225 // 226 function mergeOptions(target) { 227 228 function mergeIntoTarget(name, value) { 229 if ($.isPlainObject(value) && $.isPlainObject(target[name]) && !isForcedAtomicOption(name)) { 230 // merge into a new object to avoid destruction 231 target[name] = mergeOptions({}, target[name], value); // combine. `value` object takes precedence 232 } 233 else if (value !== undefined) { // only use values that are set and not undefined 234 target[name] = value; 235 } 236 } 237 238 for (var i=1; i<arguments.length; i++) { 239 $.each(arguments[i], mergeIntoTarget); 240 } 241 242 return target; 243 } 244 245 246 // overcome sucky view-option-hash and option-merging behavior messing with options it shouldn't 247 function isForcedAtomicOption(name) { 248 // Any option that ends in "Time" or "Duration" is probably a Duration, 249 // and these will commonly be specified as plain objects, which we don't want to mess up. 250 return /(Time|Duration)$/.test(name); 251 } 252 // FIX: find a different solution for view-option-hashes and have a whitelist 253 // for options that can be recursively merged. 254 255 ;; 256 257 //var langOptionHash = {}; // initialized in defaults.js 258 fc.langs = langOptionHash; // expose 259 260 261 // Initialize jQuery UI Datepicker translations while using some of the translations 262 // for our own purposes. Will set this as the default language for datepicker. 263 // Called from a translation file. 264 fc.datepickerLang = function(langCode, datepickerLangCode, options) { 265 var langOptions = langOptionHash[langCode]; 266 267 // initialize FullCalendar's lang hash for this language 268 if (!langOptions) { 269 langOptions = langOptionHash[langCode] = {}; 270 } 271 272 // merge certain Datepicker options into FullCalendar's options 273 mergeOptions(langOptions, { 274 isRTL: options.isRTL, 275 weekNumberTitle: options.weekHeader, 276 titleFormat: { 277 month: options.showMonthAfterYear ? 278 'YYYY[' + options.yearSuffix + '] MMMM' : 279 'MMMM YYYY[' + options.yearSuffix + ']' 280 }, 281 defaultButtonText: { 282 // the translations sometimes wrongly contain HTML entities 283 prev: stripHtmlEntities(options.prevText), 284 next: stripHtmlEntities(options.nextText), 285 today: stripHtmlEntities(options.currentText) 286 } 287 }); 288 289 // is jQuery UI Datepicker is on the page? 290 if ($.datepicker) { 291 292 // Register the language data. 293 // FullCalendar and MomentJS use language codes like "pt-br" but Datepicker 294 // does it like "pt-BR" or if it doesn't have the language, maybe just "pt". 295 // Make an alias so the language can be referenced either way. 296 $.datepicker.regional[datepickerLangCode] = 297 $.datepicker.regional[langCode] = // alias 298 options; 299 300 // Alias 'en' to the default language data. Do this every time. 301 $.datepicker.regional.en = $.datepicker.regional['']; 302 303 // Set as Datepicker's global defaults. 304 $.datepicker.setDefaults(options); 305 } 306 }; 307 308 309 // Sets FullCalendar-specific translations. Also sets the language as the global default. 310 // Called from a translation file. 311 fc.lang = function(langCode, options) { 312 var langOptions; 313 314 if (options) { 315 langOptions = langOptionHash[langCode]; 316 317 // initialize the hash for this language 318 if (!langOptions) { 319 langOptions = langOptionHash[langCode] = {}; 320 } 321 322 mergeOptions(langOptions, options || {}); 323 } 324 325 // set it as the default language for FullCalendar 326 defaults.lang = langCode; 327 }; 328 ;; 329 330 331 function Calendar(element, instanceOptions) { 332 var t = this; 333 334 335 336 // Build options object 337 // ----------------------------------------------------------------------------------- 338 // Precedence (lowest to highest): defaults, rtlDefaults, langOptions, instanceOptions 339 340 instanceOptions = instanceOptions || {}; 341 342 var options = mergeOptions({}, defaults, instanceOptions); 343 var langOptions; 344 345 // determine language options 346 if (options.lang in langOptionHash) { 347 langOptions = langOptionHash[options.lang]; 348 } 349 else { 350 langOptions = langOptionHash[defaults.lang]; 351 } 352 353 if (langOptions) { // if language options exist, rebuild... 354 options = mergeOptions({}, defaults, langOptions, instanceOptions); 355 } 356 357 if (options.isRTL) { // is isRTL, rebuild... 358 options = mergeOptions({}, defaults, rtlDefaults, langOptions || {}, instanceOptions); 359 } 360 361 362 363 // Exports 364 // ----------------------------------------------------------------------------------- 365 366 t.options = options; 367 t.render = render; 368 t.destroy = destroy; 369 t.refetchEvents = refetchEvents; 370 t.reportEvents = reportEvents; 371 t.reportEventChange = reportEventChange; 372 t.rerenderEvents = renderEvents; // `renderEvents` serves as a rerender. an API method 373 t.changeView = changeView; 374 t.select = select; 375 t.unselect = unselect; 376 t.prev = prev; 377 t.next = next; 378 t.prevYear = prevYear; 379 t.nextYear = nextYear; 380 t.today = today; 381 t.gotoDate = gotoDate; 382 t.incrementDate = incrementDate; 383 t.zoomTo = zoomTo; 384 t.getDate = getDate; 385 t.getCalendar = getCalendar; 386 t.getView = getView; 387 t.option = option; 388 t.trigger = trigger; 389 390 391 392 // Language-data Internals 393 // ----------------------------------------------------------------------------------- 394 // Apply overrides to the current language's data 395 396 397 // Returns moment's internal locale data. If doesn't exist, returns English. 398 // Works with moment-pre-2.8 399 function getLocaleData(langCode) { 400 var f = moment.localeData || moment.langData; 401 return f.call(moment, langCode) || 402 f.call(moment, 'en'); // the newer localData could return null, so fall back to en 403 } 404 405 406 var localeData = createObject(getLocaleData(options.lang)); // make a cheap copy 407 408 if (options.monthNames) { 409 localeData._months = options.monthNames; 410 } 411 if (options.monthNamesShort) { 412 localeData._monthsShort = options.monthNamesShort; 413 } 414 if (options.dayNames) { 415 localeData._weekdays = options.dayNames; 416 } 417 if (options.dayNamesShort) { 418 localeData._weekdaysShort = options.dayNamesShort; 419 } 420 if (options.firstDay != null) { 421 var _week = createObject(localeData._week); // _week: { dow: # } 422 _week.dow = options.firstDay; 423 localeData._week = _week; 424 } 425 426 427 428 // Calendar-specific Date Utilities 429 // ----------------------------------------------------------------------------------- 430 431 432 t.defaultAllDayEventDuration = moment.duration(options.defaultAllDayEventDuration); 433 t.defaultTimedEventDuration = moment.duration(options.defaultTimedEventDuration); 434 435 436 // Builds a moment using the settings of the current calendar: timezone and language. 437 // Accepts anything the vanilla moment() constructor accepts. 438 t.moment = function() { 439 var mom; 440 441 if (options.timezone === 'local') { 442 mom = fc.moment.apply(null, arguments); 443 444 // Force the moment to be local, because fc.moment doesn't guarantee it. 445 if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone 446 mom.local(); 447 } 448 } 449 else if (options.timezone === 'UTC') { 450 mom = fc.moment.utc.apply(null, arguments); // process as UTC 451 } 452 else { 453 mom = fc.moment.parseZone.apply(null, arguments); // let the input decide the zone 454 } 455 456 if ('_locale' in mom) { // moment 2.8 and above 457 mom._locale = localeData; 458 } 459 else { // pre-moment-2.8 460 mom._lang = localeData; 461 } 462 463 return mom; 464 }; 465 466 467 // Returns a boolean about whether or not the calendar knows how to calculate 468 // the timezone offset of arbitrary dates in the current timezone. 469 t.getIsAmbigTimezone = function() { 470 return options.timezone !== 'local' && options.timezone !== 'UTC'; 471 }; 472 473 474 // Returns a copy of the given date in the current timezone of it is ambiguously zoned. 475 // This will also give the date an unambiguous time. 476 t.rezoneDate = function(date) { 477 return t.moment(date.toArray()); 478 }; 479 480 481 // Returns a moment for the current date, as defined by the client's computer, 482 // or overridden by the `now` option. 483 t.getNow = function() { 484 var now = options.now; 485 if (typeof now === 'function') { 486 now = now(); 487 } 488 return t.moment(now); 489 }; 490 491 492 // Calculates the week number for a moment according to the calendar's 493 // `weekNumberCalculation` setting. 494 t.calculateWeekNumber = function(mom) { 495 var calc = options.weekNumberCalculation; 496 497 if (typeof calc === 'function') { 498 return calc(mom); 499 } 500 else if (calc === 'local') { 501 return mom.week(); 502 } 503 else if (calc.toUpperCase() === 'ISO') { 504 return mom.isoWeek(); 505 } 506 }; 507 508 509 // Get an event's normalized end date. If not present, calculate it from the defaults. 510 t.getEventEnd = function(event) { 511 if (event.end) { 512 return event.end.clone(); 513 } 514 else { 515 return t.getDefaultEventEnd(event.allDay, event.start); 516 } 517 }; 518 519 520 // Given an event's allDay status and start date, return swhat its fallback end date should be. 521 t.getDefaultEventEnd = function(allDay, start) { // TODO: rename to computeDefaultEventEnd 522 var end = start.clone(); 523 524 if (allDay) { 525 end.stripTime().add(t.defaultAllDayEventDuration); 526 } 527 else { 528 end.add(t.defaultTimedEventDuration); 529 } 530 531 if (t.getIsAmbigTimezone()) { 532 end.stripZone(); // we don't know what the tzo should be 533 } 534 535 return end; 536 }; 537 538 539 540 // Date-formatting Utilities 541 // ----------------------------------------------------------------------------------- 542 543 544 // Like the vanilla formatRange, but with calendar-specific settings applied. 545 t.formatRange = function(m1, m2, formatStr) { 546 547 // a function that returns a formatStr // TODO: in future, precompute this 548 if (typeof formatStr === 'function') { 549 formatStr = formatStr.call(t, options, localeData); 550 } 551 552 return formatRange(m1, m2, formatStr, null, options.isRTL); 553 }; 554 555 556 // Like the vanilla formatDate, but with calendar-specific settings applied. 557 t.formatDate = function(mom, formatStr) { 558 559 // a function that returns a formatStr // TODO: in future, precompute this 560 if (typeof formatStr === 'function') { 561 formatStr = formatStr.call(t, options, localeData); 562 } 563 564 return formatDate(mom, formatStr); 565 }; 566 567 568 569 // Imports 570 // ----------------------------------------------------------------------------------- 571 572 573 EventManager.call(t, options); 574 var isFetchNeeded = t.isFetchNeeded; 575 var fetchEvents = t.fetchEvents; 576 577 578 579 // Locals 580 // ----------------------------------------------------------------------------------- 581 582 583 var _element = element[0]; 584 var header; 585 var headerElement; 586 var content; 587 var tm; // for making theme classes 588 var currentView; 589 var suggestedViewHeight; 590 var windowResizeProxy; // wraps the windowResize function 591 var ignoreWindowResize = 0; 592 var date; 593 var events = []; 594 595 596 597 // Main Rendering 598 // ----------------------------------------------------------------------------------- 599 600 601 if (options.defaultDate != null) { 602 date = t.moment(options.defaultDate); 603 } 604 else { 605 date = t.getNow(); 606 } 607 608 609 function render(inc) { 610 if (!content) { 611 initialRender(); 612 } 613 else if (elementVisible()) { 614 // mainly for the public API 615 calcSize(); 616 renderView(inc); 617 } 618 } 619 620 621 function initialRender() { 622 tm = options.theme ? 'ui' : 'fc'; 623 element.addClass('fc'); 624 625 if (options.isRTL) { 626 element.addClass('fc-rtl'); 627 } 628 else { 629 element.addClass('fc-ltr'); 630 } 631 632 if (options.theme) { 633 element.addClass('ui-widget'); 634 } 635 else { 636 element.addClass('fc-unthemed'); 637 } 638 639 content = $("<div class='fc-view-container'/>").prependTo(element); 640 641 header = new Header(t, options); 642 headerElement = header.render(); 643 if (headerElement) { 644 element.prepend(headerElement); 645 } 646 647 changeView(options.defaultView); 648 649 if (options.handleWindowResize) { 650 windowResizeProxy = debounce(windowResize, options.windowResizeDelay); // prevents rapid calls 651 $(window).resize(windowResizeProxy); 652 } 653 } 654 655 656 function destroy() { 657 658 if (currentView) { 659 currentView.destroy(); 660 } 661 662 header.destroy(); 663 content.remove(); 664 element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget'); 665 666 $(window).unbind('resize', windowResizeProxy); 667 } 668 669 670 function elementVisible() { 671 return element.is(':visible'); 672 } 673 674 675 676 // View Rendering 677 // ----------------------------------------------------------------------------------- 678 679 680 function changeView(viewName) { 681 renderView(0, viewName); 682 } 683 684 685 // Renders a view because of a date change, view-type change, or for the first time 686 function renderView(delta, viewName) { 687 ignoreWindowResize++; 688 689 // if viewName is changing, destroy the old view 690 if (currentView && viewName && currentView.name !== viewName) { 691 header.deactivateButton(currentView.name); 692 freezeContentHeight(); // prevent a scroll jump when view element is removed 693 if (currentView.start) { // rendered before? 694 currentView.destroy(); 695 } 696 currentView.el.remove(); 697 currentView = null; 698 } 699 700 // if viewName changed, or the view was never created, create a fresh view 701 if (!currentView && viewName) { 702 currentView = new fcViews[viewName](t); 703 currentView.el = $("<div class='fc-view fc-" + viewName + "-view' />").appendTo(content); 704 header.activateButton(viewName); 705 } 706 707 if (currentView) { 708 709 // let the view determine what the delta means 710 if (delta) { 711 date = currentView.incrementDate(date, delta); 712 } 713 714 // render or rerender the view 715 if ( 716 !currentView.start || // never rendered before 717 delta || // explicit date window change 718 !date.isWithin(currentView.intervalStart, currentView.intervalEnd) // implicit date window change 719 ) { 720 if (elementVisible()) { 721 722 freezeContentHeight(); 723 if (currentView.start) { // rendered before? 724 currentView.destroy(); 725 } 726 currentView.render(date); 727 unfreezeContentHeight(); 728 729 // need to do this after View::render, so dates are calculated 730 updateTitle(); 731 updateTodayButton(); 732 733 getAndRenderEvents(); 734 } 735 } 736 } 737 738 unfreezeContentHeight(); // undo any lone freezeContentHeight calls 739 ignoreWindowResize--; 740 } 741 742 743 744 // Resizing 745 // ----------------------------------------------------------------------------------- 746 747 748 t.getSuggestedViewHeight = function() { 749 if (suggestedViewHeight === undefined) { 750 calcSize(); 751 } 752 return suggestedViewHeight; 753 }; 754 755 756 t.isHeightAuto = function() { 757 return options.contentHeight === 'auto' || options.height === 'auto'; 758 }; 759 760 761 function updateSize(shouldRecalc) { 762 if (elementVisible()) { 763 764 if (shouldRecalc) { 765 _calcSize(); 766 } 767 768 ignoreWindowResize++; 769 currentView.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto() 770 ignoreWindowResize--; 771 772 return true; // signal success 773 } 774 } 775 776 777 function calcSize() { 778 if (elementVisible()) { 779 _calcSize(); 780 } 781 } 782 783 784 function _calcSize() { // assumes elementVisible 785 if (typeof options.contentHeight === 'number') { // exists and not 'auto' 786 suggestedViewHeight = options.contentHeight; 787 } 788 else if (typeof options.height === 'number') { // exists and not 'auto' 789 suggestedViewHeight = options.height - (headerElement ? headerElement.outerHeight(true) : 0); 790 } 791 else { 792 suggestedViewHeight = Math.round(content.width() / Math.max(options.aspectRatio, .5)); 793 } 794 } 795 796 797 function windowResize(ev) { 798 if ( 799 !ignoreWindowResize && 800 ev.target === window && // so we don't process jqui "resize" events that have bubbled up 801 currentView.start // view has already been rendered 802 ) { 803 if (updateSize(true)) { 804 currentView.trigger('windowResize', _element); 805 } 806 } 807 } 808 809 810 811 /* Event Fetching/Rendering 812 -----------------------------------------------------------------------------*/ 813 // TODO: going forward, most of this stuff should be directly handled by the view 814 815 816 function refetchEvents() { // can be called as an API method 817 destroyEvents(); // so that events are cleared before user starts waiting for AJAX 818 fetchAndRenderEvents(); 819 } 820 821 822 function renderEvents() { // destroys old events if previously rendered 823 if (elementVisible()) { 824 freezeContentHeight(); 825 currentView.destroyEvents(); // no performance cost if never rendered 826 currentView.renderEvents(events); 827 unfreezeContentHeight(); 828 } 829 } 830 831 832 function destroyEvents() { 833 freezeContentHeight(); 834 currentView.destroyEvents(); 835 unfreezeContentHeight(); 836 } 837 838 839 function getAndRenderEvents() { 840 if (!options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) { 841 fetchAndRenderEvents(); 842 } 843 else { 844 renderEvents(); 845 } 846 } 847 848 849 function fetchAndRenderEvents() { 850 fetchEvents(currentView.start, currentView.end); 851 // ... will call reportEvents 852 // ... which will call renderEvents 853 } 854 855 856 // called when event data arrives 857 function reportEvents(_events) { 858 events = _events; 859 renderEvents(); 860 } 861 862 863 // called when a single event's data has been changed 864 function reportEventChange() { 865 renderEvents(); 866 } 867 868 869 870 /* Header Updating 871 -----------------------------------------------------------------------------*/ 872 873 874 function updateTitle() { 875 header.updateTitle(currentView.title); 876 } 877 878 879 function updateTodayButton() { 880 var now = t.getNow(); 881 if (now.isWithin(currentView.intervalStart, currentView.intervalEnd)) { 882 header.disableButton('today'); 883 } 884 else { 885 header.enableButton('today'); 886 } 887 } 888 889 890 891 /* Selection 892 -----------------------------------------------------------------------------*/ 893 894 895 function select(start, end) { 896 897 start = t.moment(start); 898 if (end) { 899 end = t.moment(end); 900 } 901 else if (start.hasTime()) { 902 end = start.clone().add(t.defaultTimedEventDuration); 903 } 904 else { 905 end = start.clone().add(t.defaultAllDayEventDuration); 906 } 907 908 currentView.select(start, end); 909 } 910 911 912 function unselect() { // safe to be called before renderView 913 if (currentView) { 914 currentView.unselect(); 915 } 916 } 917 918 919 920 /* Date 921 -----------------------------------------------------------------------------*/ 922 923 924 function prev() { 925 renderView(-1); 926 } 927 928 929 function next() { 930 renderView(1); 931 } 932 933 934 function prevYear() { 935 date.add(-1, 'years'); 936 renderView(); 937 } 938 939 940 function nextYear() { 941 date.add(1, 'years'); 942 renderView(); 943 } 944 945 946 function today() { 947 date = t.getNow(); 948 renderView(); 949 } 950 951 952 function gotoDate(dateInput) { 953 date = t.moment(dateInput); 954 renderView(); 955 } 956 957 958 function incrementDate(delta) { 959 date.add(moment.duration(delta)); 960 renderView(); 961 } 962 963 964 // Forces navigation to a view for the given date. 965 // `viewName` can be a specific view name or a generic one like "week" or "day". 966 function zoomTo(newDate, viewName) { 967 var viewStr; 968 var match; 969 970 if (!viewName || fcViews[viewName] === undefined) { // a general view name, or "auto" 971 viewName = viewName || 'day'; 972 viewStr = header.getViewsWithButtons().join(' '); // space-separated string of all the views in the header 973 974 // try to match a general view name, like "week", against a specific one, like "agendaWeek" 975 match = viewStr.match(new RegExp('\\w+' + capitaliseFirstLetter(viewName))); 976 977 // fall back to the day view being used in the header 978 if (!match) { 979 match = viewStr.match(/\w+Day/); 980 } 981 982 viewName = match ? match[0] : 'agendaDay'; // fall back to agendaDay 983 } 984 985 date = newDate; 986 changeView(viewName); 987 } 988 989 990 function getDate() { 991 return date.clone(); 992 } 993 994 995 996 /* Height "Freezing" 997 -----------------------------------------------------------------------------*/ 998 999 1000 function freezeContentHeight() { 1001 content.css({ 1002 width: '100%', 1003 height: content.height(), 1004 overflow: 'hidden' 1005 }); 1006 } 1007 1008 1009 function unfreezeContentHeight() { 1010 content.css({ 1011 width: '', 1012 height: '', 1013 overflow: '' 1014 }); 1015 } 1016 1017 1018 1019 /* Misc 1020 -----------------------------------------------------------------------------*/ 1021 1022 1023 function getCalendar() { 1024 return t; 1025 } 1026 1027 1028 function getView() { 1029 return currentView; 1030 } 1031 1032 1033 function option(name, value) { 1034 if (value === undefined) { 1035 return options[name]; 1036 } 1037 if (name == 'height' || name == 'contentHeight' || name == 'aspectRatio') { 1038 options[name] = value; 1039 updateSize(true); // true = allow recalculation of height 1040 } 1041 } 1042 1043 1044 function trigger(name, thisObj) { 1045 if (options[name]) { 1046 return options[name].apply( 1047 thisObj || _element, 1048 Array.prototype.slice.call(arguments, 2) 1049 ); 1050 } 1051 } 1052 1053 } 1054 1055 ;; 1056 1057 /* Top toolbar area with buttons and title 1058 ----------------------------------------------------------------------------------------------------------------------*/ 1059 // TODO: rename all header-related things to "toolbar" 1060 1061 function Header(calendar, options) { 1062 var t = this; 1063 1064 // exports 1065 t.render = render; 1066 t.destroy = destroy; 1067 t.updateTitle = updateTitle; 1068 t.activateButton = activateButton; 1069 t.deactivateButton = deactivateButton; 1070 t.disableButton = disableButton; 1071 t.enableButton = enableButton; 1072 t.getViewsWithButtons = getViewsWithButtons; 1073 1074 // locals 1075 var el = $(); 1076 var viewsWithButtons = []; 1077 var tm; 1078 1079 1080 function render() { 1081 var sections = options.header; 1082 1083 tm = options.theme ? 'ui' : 'fc'; 1084 1085 if (sections) { 1086 el = $("<div class='fc-toolbar'/>") 1087 .append(renderSection('left')) 1088 .append(renderSection('right')) 1089 .append(renderSection('center')) 1090 .append('<div class="fc-clear"/>'); 1091 1092 return el; 1093 } 1094 } 1095 1096 1097 function destroy() { 1098 el.remove(); 1099 } 1100 1101 1102 function renderSection(position) { 1103 var sectionEl = $('<div class="fc-' + position + '"/>'); 1104 var buttonStr = options.header[position]; 1105 1106 if (buttonStr) { 1107 $.each(buttonStr.split(' '), function(i) { 1108 var groupChildren = $(); 1109 var isOnlyButtons = true; 1110 var groupEl; 1111 1112 $.each(this.split(','), function(j, buttonName) { 1113 var buttonClick; 1114 var themeIcon; 1115 var normalIcon; 1116 var defaultText; 1117 var customText; 1118 var innerHtml; 1119 var classes; 1120 var button; 1121 1122 if (buttonName == 'title') { 1123 groupChildren = groupChildren.add($('<h2> </h2>')); // we always want it to take up height 1124 isOnlyButtons = false; 1125 } 1126 else { 1127 if (calendar[buttonName]) { // a calendar method 1128 buttonClick = function() { 1129 calendar[buttonName](); 1130 }; 1131 } 1132 else if (fcViews[buttonName]) { // a view name 1133 buttonClick = function() { 1134 calendar.changeView(buttonName); 1135 }; 1136 viewsWithButtons.push(buttonName); 1137 } 1138 if (buttonClick) { 1139 1140 // smartProperty allows different text per view button (ex: "Agenda Week" vs "Basic Week") 1141 themeIcon = smartProperty(options.themeButtonIcons, buttonName); 1142 normalIcon = smartProperty(options.buttonIcons, buttonName); 1143 defaultText = smartProperty(options.defaultButtonText, buttonName); 1144 customText = smartProperty(options.buttonText, buttonName); 1145 1146 if (customText) { 1147 innerHtml = htmlEscape(customText); 1148 } 1149 else if (themeIcon && options.theme) { 1150 innerHtml = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>"; 1151 } 1152 else if (normalIcon && !options.theme) { 1153 innerHtml = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>"; 1154 } 1155 else { 1156 innerHtml = htmlEscape(defaultText || buttonName); 1157 } 1158 1159 classes = [ 1160 'fc-' + buttonName + '-button', 1161 tm + '-button', 1162 tm + '-state-default' 1163 ]; 1164 1165 button = $( // type="button" so that it doesn't submit a form 1166 '<button type="button" class="' + classes.join(' ') + '">' + 1167 innerHtml + 1168 '</button>' 1169 ) 1170 .click(function() { 1171 // don't process clicks for disabled buttons 1172 if (!button.hasClass(tm + '-state-disabled')) { 1173 1174 buttonClick(); 1175 1176 // after the click action, if the button becomes the "active" tab, or disabled, 1177 // it should never have a hover class, so remove it now. 1178 if ( 1179 button.hasClass(tm + '-state-active') || 1180 button.hasClass(tm + '-state-disabled') 1181 ) { 1182 button.removeClass(tm + '-state-hover'); 1183 } 1184 } 1185 }) 1186 .mousedown(function() { 1187 // the *down* effect (mouse pressed in). 1188 // only on buttons that are not the "active" tab, or disabled 1189 button 1190 .not('.' + tm + '-state-active') 1191 .not('.' + tm + '-state-disabled') 1192 .addClass(tm + '-state-down'); 1193 }) 1194 .mouseup(function() { 1195 // undo the *down* effect 1196 button.removeClass(tm + '-state-down'); 1197 }) 1198 .hover( 1199 function() { 1200 // the *hover* effect. 1201 // only on buttons that are not the "active" tab, or disabled 1202 button 1203 .not('.' + tm + '-state-active') 1204 .not('.' + tm + '-state-disabled') 1205 .addClass(tm + '-state-hover'); 1206 }, 1207 function() { 1208 // undo the *hover* effect 1209 button 1210 .removeClass(tm + '-state-hover') 1211 .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup 1212 } 1213 ); 1214 1215 groupChildren = groupChildren.add(button); 1216 } 1217 } 1218 }); 1219 1220 if (isOnlyButtons) { 1221 groupChildren 1222 .first().addClass(tm + '-corner-left').end() 1223 .last().addClass(tm + '-corner-right').end(); 1224 } 1225 1226 if (groupChildren.length > 1) { 1227 groupEl = $('<div/>'); 1228 if (isOnlyButtons) { 1229 groupEl.addClass('fc-button-group'); 1230 } 1231 groupEl.append(groupChildren); 1232 sectionEl.append(groupEl); 1233 } 1234 else { 1235 sectionEl.append(groupChildren); // 1 or 0 children 1236 } 1237 }); 1238 } 1239 1240 return sectionEl; 1241 } 1242 1243 1244 function updateTitle(text) { 1245 el.find('h2').text(text); 1246 } 1247 1248 1249 function activateButton(buttonName) { 1250 el.find('.fc-' + buttonName + '-button') 1251 .addClass(tm + '-state-active'); 1252 } 1253 1254 1255 function deactivateButton(buttonName) { 1256 el.find('.fc-' + buttonName + '-button') 1257 .removeClass(tm + '-state-active'); 1258 } 1259 1260 1261 function disableButton(buttonName) { 1262 el.find('.fc-' + buttonName + '-button') 1263 .attr('disabled', 'disabled') 1264 .addClass(tm + '-state-disabled'); 1265 } 1266 1267 1268 function enableButton(buttonName) { 1269 el.find('.fc-' + buttonName + '-button') 1270 .removeAttr('disabled') 1271 .removeClass(tm + '-state-disabled'); 1272 } 1273 1274 1275 function getViewsWithButtons() { 1276 return viewsWithButtons; 1277 } 1278 1279 } 1280 1281 ;; 1282 1283 fc.sourceNormalizers = []; 1284 fc.sourceFetchers = []; 1285 1286 var ajaxDefaults = { 1287 dataType: 'json', 1288 cache: false 1289 }; 1290 1291 var eventGUID = 1; 1292 1293 1294 function EventManager(options) { // assumed to be a calendar 1295 var t = this; 1296 1297 1298 // exports 1299 t.isFetchNeeded = isFetchNeeded; 1300 t.fetchEvents = fetchEvents; 1301 t.addEventSource = addEventSource; 1302 t.removeEventSource = removeEventSource; 1303 t.updateEvent = updateEvent; 1304 t.renderEvent = renderEvent; 1305 t.removeEvents = removeEvents; 1306 t.clientEvents = clientEvents; 1307 t.mutateEvent = mutateEvent; 1308 1309 1310 // imports 1311 var trigger = t.trigger; 1312 var getView = t.getView; 1313 var reportEvents = t.reportEvents; 1314 var getEventEnd = t.getEventEnd; 1315 1316 1317 // locals 1318 var stickySource = { events: [] }; 1319 var sources = [ stickySource ]; 1320 var rangeStart, rangeEnd; 1321 var currentFetchID = 0; 1322 var pendingSourceCnt = 0; 1323 var loadingLevel = 0; 1324 var cache = []; 1325 1326 1327 $.each( 1328 (options.events ? [ options.events ] : []).concat(options.eventSources || []), 1329 function(i, sourceInput) { 1330 var source = buildEventSource(sourceInput); 1331 if (source) { 1332 sources.push(source); 1333 } 1334 } 1335 ); 1336 1337 1338 1339 /* Fetching 1340 -----------------------------------------------------------------------------*/ 1341 1342 1343 function isFetchNeeded(start, end) { 1344 return !rangeStart || // nothing has been fetched yet? 1345 // or, a part of the new range is outside of the old range? (after normalizing) 1346 start.clone().stripZone() < rangeStart.clone().stripZone() || 1347 end.clone().stripZone() > rangeEnd.clone().stripZone(); 1348 } 1349 1350 1351 function fetchEvents(start, end) { 1352 rangeStart = start; 1353 rangeEnd = end; 1354 cache = []; 1355 var fetchID = ++currentFetchID; 1356 var len = sources.length; 1357 pendingSourceCnt = len; 1358 for (var i=0; i<len; i++) { 1359 fetchEventSource(sources[i], fetchID); 1360 } 1361 } 1362 1363 1364 function fetchEventSource(source, fetchID) { 1365 _fetchEventSource(source, function(events) { 1366 var isArraySource = $.isArray(source.events); 1367 var i; 1368 var event; 1369 1370 if (fetchID == currentFetchID) { 1371 1372 if (events) { 1373 for (i=0; i<events.length; i++) { 1374 event = events[i]; 1375 1376 // event array sources have already been convert to Event Objects 1377 if (!isArraySource) { 1378 event = buildEvent(event, source); 1379 } 1380 1381 if (event) { 1382 cache.push(event); 1383 } 1384 } 1385 } 1386 1387 pendingSourceCnt--; 1388 if (!pendingSourceCnt) { 1389 reportEvents(cache); 1390 } 1391 } 1392 }); 1393 } 1394 1395 1396 function _fetchEventSource(source, callback) { 1397 var i; 1398 var fetchers = fc.sourceFetchers; 1399 var res; 1400 1401 for (i=0; i<fetchers.length; i++) { 1402 res = fetchers[i].call( 1403 t, // this, the Calendar object 1404 source, 1405 rangeStart.clone(), 1406 rangeEnd.clone(), 1407 options.timezone, 1408 callback 1409 ); 1410 1411 if (res === true) { 1412 // the fetcher is in charge. made its own async request 1413 return; 1414 } 1415 else if (typeof res == 'object') { 1416 // the fetcher returned a new source. process it 1417 _fetchEventSource(res, callback); 1418 return; 1419 } 1420 } 1421 1422 var events = source.events; 1423 if (events) { 1424 if ($.isFunction(events)) { 1425 pushLoading(); 1426 events.call( 1427 t, // this, the Calendar object 1428 rangeStart.clone(), 1429 rangeEnd.clone(), 1430 options.timezone, 1431 function(events) { 1432 callback(events); 1433 popLoading(); 1434 } 1435 ); 1436 } 1437 else if ($.isArray(events)) { 1438 callback(events); 1439 } 1440 else { 1441 callback(); 1442 } 1443 }else{ 1444 var url = source.url; 1445 if (url) { 1446 var success = source.success; 1447 var error = source.error; 1448 var complete = source.complete; 1449 1450 // retrieve any outbound GET/POST $.ajax data from the options 1451 var customData; 1452 if ($.isFunction(source.data)) { 1453 // supplied as a function that returns a key/value object 1454 customData = source.data(); 1455 } 1456 else { 1457 // supplied as a straight key/value object 1458 customData = source.data; 1459 } 1460 1461 // use a copy of the custom data so we can modify the parameters 1462 // and not affect the passed-in object. 1463 var data = $.extend({}, customData || {}); 1464 1465 var startParam = firstDefined(source.startParam, options.startParam); 1466 var endParam = firstDefined(source.endParam, options.endParam); 1467 var timezoneParam = firstDefined(source.timezoneParam, options.timezoneParam); 1468 1469 if (startParam) { 1470 data[startParam] = rangeStart.format(); 1471 } 1472 if (endParam) { 1473 data[endParam] = rangeEnd.format(); 1474 } 1475 if (options.timezone && options.timezone != 'local') { 1476 data[timezoneParam] = options.timezone; 1477 } 1478 1479 pushLoading(); 1480 $.ajax($.extend({}, ajaxDefaults, source, { 1481 data: data, 1482 success: function(events) { 1483 events = events || []; 1484 var res = applyAll(success, this, arguments); 1485 if ($.isArray(res)) { 1486 events = res; 1487 } 1488 callback(events); 1489 }, 1490 error: function() { 1491 applyAll(error, this, arguments); 1492 callback(); 1493 }, 1494 complete: function() { 1495 applyAll(complete, this, arguments); 1496 popLoading(); 1497 } 1498 })); 1499 }else{ 1500 callback(); 1501 } 1502 } 1503 } 1504 1505 1506 1507 /* Sources 1508 -----------------------------------------------------------------------------*/ 1509 1510 1511 function addEventSource(sourceInput) { 1512 var source = buildEventSource(sourceInput); 1513 if (source) { 1514 sources.push(source); 1515 pendingSourceCnt++; 1516 fetchEventSource(source, currentFetchID); // will eventually call reportEvents 1517 } 1518 } 1519 1520 1521 function buildEventSource(sourceInput) { // will return undefined if invalid source 1522 var normalizers = fc.sourceNormalizers; 1523 var source; 1524 var i; 1525 1526 if ($.isFunction(sourceInput) || $.isArray(sourceInput)) { 1527 source = { events: sourceInput }; 1528 } 1529 else if (typeof sourceInput === 'string') { 1530 source = { url: sourceInput }; 1531 } 1532 else if (typeof sourceInput === 'object') { 1533 source = $.extend({}, sourceInput); // shallow copy 1534 } 1535 1536 if (source) { 1537 1538 // TODO: repeat code, same code for event classNames 1539 if (source.className) { 1540 if (typeof source.className === 'string') { 1541 source.className = source.className.split(/\s+/); 1542 } 1543 // otherwise, assumed to be an array 1544 } 1545 else { 1546 source.className = []; 1547 } 1548 1549 // for array sources, we convert to standard Event Objects up front 1550 if ($.isArray(source.events)) { 1551 source.origArray = source.events; // for removeEventSource 1552 source.events = $.map(source.events, function(eventInput) { 1553 return buildEvent(eventInput, source); 1554 }); 1555 } 1556 1557 for (i=0; i<normalizers.length; i++) { 1558 normalizers[i].call(t, source); 1559 } 1560 1561 return source; 1562 } 1563 } 1564 1565 1566 function removeEventSource(source) { 1567 sources = $.grep(sources, function(src) { 1568 return !isSourcesEqual(src, source); 1569 }); 1570 // remove all client events from that source 1571 cache = $.grep(cache, function(e) { 1572 return !isSourcesEqual(e.source, source); 1573 }); 1574 reportEvents(cache); 1575 } 1576 1577 1578 function isSourcesEqual(source1, source2) { 1579 return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2); 1580 } 1581 1582 1583 function getSourcePrimitive(source) { 1584 return ( 1585 (typeof source === 'object') ? // a normalized event source? 1586 (source.origArray || source.url || source.events) : // get the primitive 1587 null 1588 ) || 1589 source; // the given argument *is* the primitive 1590 } 1591 1592 1593 1594 /* Manipulation 1595 -----------------------------------------------------------------------------*/ 1596 1597 1598 function updateEvent(event) { 1599 1600 event.start = t.moment(event.start); 1601 if (event.end) { 1602 event.end = t.moment(event.end); 1603 } 1604 1605 mutateEvent(event); 1606 propagateMiscProperties(event); 1607 reportEvents(cache); // reports event modifications (so we can redraw) 1608 } 1609 1610 1611 var miscCopyableProps = [ 1612 'title', 1613 'url', 1614 'allDay', 1615 'className', 1616 'editable', 1617 'color', 1618 'backgroundColor', 1619 'borderColor', 1620 'textColor' 1621 ]; 1622 1623 function propagateMiscProperties(event) { 1624 var i; 1625 var cachedEvent; 1626 var j; 1627 var prop; 1628 1629 for (i=0; i<cache.length; i++) { 1630 cachedEvent = cache[i]; 1631 if (cachedEvent._id == event._id && cachedEvent !== event) { 1632 for (j=0; j<miscCopyableProps.length; j++) { 1633 prop = miscCopyableProps[j]; 1634 if (event[prop] !== undefined) { 1635 cachedEvent[prop] = event[prop]; 1636 } 1637 } 1638 } 1639 } 1640 } 1641 1642 1643 1644 function renderEvent(eventData, stick) { 1645 var event = buildEvent(eventData); 1646 if (event) { 1647 if (!event.source) { 1648 if (stick) { 1649 stickySource.events.push(event); 1650 event.source = stickySource; 1651 } 1652 cache.push(event); 1653 } 1654 reportEvents(cache); 1655 } 1656 } 1657 1658 1659 function removeEvents(filter) { 1660 var eventID; 1661 var i; 1662 1663 if (filter == null) { // null or undefined. remove all events 1664 filter = function() { return true; }; // will always match 1665 } 1666 else if (!$.isFunction(filter)) { // an event ID 1667 eventID = filter + ''; 1668 filter = function(event) { 1669 return event._id == eventID; 1670 }; 1671 } 1672 1673 // Purge event(s) from our local cache 1674 cache = $.grep(cache, filter, true); // inverse=true 1675 1676 // Remove events from array sources. 1677 // This works because they have been converted to official Event Objects up front. 1678 // (and as a result, event._id has been calculated). 1679 for (i=0; i<sources.length; i++) { 1680 if ($.isArray(sources[i].events)) { 1681 sources[i].events = $.grep(sources[i].events, filter, true); 1682 } 1683 } 1684 1685 reportEvents(cache); 1686 } 1687 1688 1689 function clientEvents(filter) { 1690 if ($.isFunction(filter)) { 1691 return $.grep(cache, filter); 1692 } 1693 else if (filter != null) { // not null, not undefined. an event ID 1694 filter += ''; 1695 return $.grep(cache, function(e) { 1696 return e._id == filter; 1697 }); 1698 } 1699 return cache; // else, return all 1700 } 1701 1702 1703 1704 /* Loading State 1705 -----------------------------------------------------------------------------*/ 1706 1707 1708 function pushLoading() { 1709 if (!(loadingLevel++)) { 1710 trigger('loading', null, true, getView()); 1711 } 1712 } 1713 1714 1715 function popLoading() { 1716 if (!(--loadingLevel)) { 1717 trigger('loading', null, false, getView()); 1718 } 1719 } 1720 1721 1722 1723 /* Event Normalization 1724 -----------------------------------------------------------------------------*/ 1725 1726 function buildEvent(data, source) { // source may be undefined! 1727 var out = {}; 1728 var start; 1729 var end; 1730 var allDay; 1731 var allDayDefault; 1732 1733 if (options.eventDataTransform) { 1734 data = options.eventDataTransform(data); 1735 } 1736 if (source && source.eventDataTransform) { 1737 data = source.eventDataTransform(data); 1738 } 1739 1740 start = t.moment(data.start || data.date); // "date" is an alias for "start" 1741 if (!start.isValid()) { 1742 return; 1743 } 1744 1745 end = null; 1746 if (data.end) { 1747 end = t.moment(data.end); 1748 if (!end.isValid()) { 1749 return; 1750 } 1751 } 1752 1753 allDay = data.allDay; 1754 if (allDay === undefined) { 1755 allDayDefault = firstDefined( 1756 source ? source.allDayDefault : undefined, 1757 options.allDayDefault 1758 ); 1759 if (allDayDefault !== undefined) { 1760 // use the default 1761 allDay = allDayDefault; 1762 } 1763 else { 1764 // all dates need to have ambig time for the event to be considered allDay 1765 allDay = !start.hasTime() && (!end || !end.hasTime()); 1766 } 1767 } 1768 1769 // normalize the date based on allDay 1770 if (allDay) { 1771 // neither date should have a time 1772 if (start.hasTime()) { 1773 start.stripTime(); 1774 } 1775 if (end && end.hasTime()) { 1776 end.stripTime(); 1777 } 1778 } 1779 else { 1780 // force a time/zone up the dates 1781 if (!start.hasTime()) { 1782 start = t.rezoneDate(start); 1783 } 1784 if (end && !end.hasTime()) { 1785 end = t.rezoneDate(end); 1786 } 1787 } 1788 1789 // Copy all properties over to the resulting object. 1790 // The special-case properties will be copied over afterwards. 1791 $.extend(out, data); 1792 1793 if (source) { 1794 out.source = source; 1795 } 1796 1797 out._id = data._id || (data.id === undefined ? '_fc' + eventGUID++ : data.id + ''); 1798 1799 if (data.className) { 1800 if (typeof data.className == 'string') { 1801 out.className = data.className.split(/\s+/); 1802 } 1803 else { // assumed to be an array 1804 out.className = data.className; 1805 } 1806 } 1807 else { 1808 out.className = []; 1809 } 1810 1811 out.allDay = allDay; 1812 out.start = start; 1813 out.end = end; 1814 1815 if (options.forceEventDuration && !out.end) { 1816 out.end = getEventEnd(out); 1817 } 1818 1819 backupEventDates(out); 1820 1821 return out; 1822 } 1823 1824 1825 1826 /* Event Modification Math 1827 -----------------------------------------------------------------------------------------*/ 1828 1829 1830 // Modify the date(s) of an event and make this change propagate to all other events with 1831 // the same ID (related repeating events). 1832 // 1833 // If `newStart`/`newEnd` are not specified, the "new" dates are assumed to be `event.start` and `event.end`. 1834 // The "old" dates to be compare against are always `event._start` and `event._end` (set by EventManager). 1835 // 1836 // Returns an object with delta information and a function to undo all operations. 1837 // 1838 function mutateEvent(event, newStart, newEnd) { 1839 var oldAllDay = event._allDay; 1840 var oldStart = event._start; 1841 var oldEnd = event._end; 1842 var clearEnd = false; 1843 var newAllDay; 1844 var dateDelta; 1845 var durationDelta; 1846 var undoFunc; 1847 1848 // if no new dates were passed in, compare against the event's existing dates 1849 if (!newStart && !newEnd) { 1850 newStart = event.start; 1851 newEnd = event.end; 1852 } 1853 1854 // NOTE: throughout this function, the initial values of `newStart` and `newEnd` are 1855 // preserved. These values may be undefined. 1856 1857 // detect new allDay 1858 if (event.allDay != oldAllDay) { // if value has changed, use it 1859 newAllDay = event.allDay; 1860 } 1861 else { // otherwise, see if any of the new dates are allDay 1862 newAllDay = !(newStart || newEnd).hasTime(); 1863 } 1864 1865 // normalize the new dates based on allDay 1866 if (newAllDay) { 1867 if (newStart) { 1868 newStart = newStart.clone().stripTime(); 1869 } 1870 if (newEnd) { 1871 newEnd = newEnd.clone().stripTime(); 1872 } 1873 } 1874 1875 // compute dateDelta 1876 if (newStart) { 1877 if (newAllDay) { 1878 dateDelta = dayishDiff(newStart, oldStart.clone().stripTime()); // treat oldStart as allDay 1879 } 1880 else { 1881 dateDelta = dayishDiff(newStart, oldStart); 1882 } 1883 } 1884 1885 if (newAllDay != oldAllDay) { 1886 // if allDay has changed, always throw away the end 1887 clearEnd = true; 1888 } 1889 else if (newEnd) { 1890 durationDelta = dayishDiff( 1891 // new duration 1892 newEnd || t.getDefaultEventEnd(newAllDay, newStart || oldStart), 1893 newStart || oldStart 1894 ).subtract(dayishDiff( 1895 // subtract old duration 1896 oldEnd || t.getDefaultEventEnd(oldAllDay, oldStart), 1897 oldStart 1898 )); 1899 } 1900 1901 undoFunc = mutateEvents( 1902 clientEvents(event._id), // get events with this ID 1903 clearEnd, 1904 newAllDay, 1905 dateDelta, 1906 durationDelta 1907 ); 1908 1909 return { 1910 dateDelta: dateDelta, 1911 durationDelta: durationDelta, 1912 undo: undoFunc 1913 }; 1914 } 1915 1916 1917 // Modifies an array of events in the following ways (operations are in order): 1918 // - clear the event's `end` 1919 // - convert the event to allDay 1920 // - add `dateDelta` to the start and end 1921 // - add `durationDelta` to the event's duration 1922 // 1923 // Returns a function that can be called to undo all the operations. 1924 // 1925 function mutateEvents(events, clearEnd, forceAllDay, dateDelta, durationDelta) { 1926 var isAmbigTimezone = t.getIsAmbigTimezone(); 1927 var undoFunctions = []; 1928 1929 $.each(events, function(i, event) { 1930 var oldAllDay = event._allDay; 1931 var oldStart = event._start; 1932 var oldEnd = event._end; 1933 var newAllDay = forceAllDay != null ? forceAllDay : oldAllDay; 1934 var newStart = oldStart.clone(); 1935 var newEnd = (!clearEnd && oldEnd) ? oldEnd.clone() : null; 1936 1937 // NOTE: this function is responsible for transforming `newStart` and `newEnd`, 1938 // which were initialized to the OLD values first. `newEnd` may be null. 1939 1940 // normlize newStart/newEnd to be consistent with newAllDay 1941 if (newAllDay) { 1942 newStart.stripTime(); 1943 if (newEnd) { 1944 newEnd.stripTime(); 1945 } 1946 } 1947 else { 1948 if (!newStart.hasTime()) { 1949 newStart = t.rezoneDate(newStart); 1950 } 1951 if (newEnd && !newEnd.hasTime()) { 1952 newEnd = t.rezoneDate(newEnd); 1953 } 1954 } 1955 1956 // ensure we have an end date if necessary 1957 if (!newEnd && (options.forceEventDuration || +durationDelta)) { 1958 newEnd = t.getDefaultEventEnd(newAllDay, newStart); 1959 } 1960 1961 // translate the dates 1962 newStart.add(dateDelta); 1963 if (newEnd) { 1964 newEnd.add(dateDelta).add(durationDelta); 1965 } 1966 1967 // if the dates have changed, and we know it is impossible to recompute the 1968 // timezone offsets, strip the zone. 1969 if (isAmbigTimezone) { 1970 if (+dateDelta || +durationDelta) { 1971 newStart.stripZone(); 1972 if (newEnd) { 1973 newEnd.stripZone(); 1974 } 1975 } 1976 } 1977 1978 event.allDay = newAllDay; 1979 event.start = newStart; 1980 event.end = newEnd; 1981 backupEventDates(event); 1982 1983 undoFunctions.push(function() { 1984 event.allDay = oldAllDay; 1985 event.start = oldStart; 1986 event.end = oldEnd; 1987 backupEventDates(event); 1988 }); 1989 }); 1990 1991 return function() { 1992 for (var i=0; i<undoFunctions.length; i++) { 1993 undoFunctions[i](); 1994 } 1995 }; 1996 } 1997 1998 } 1999 2000 2001 // updates the "backup" properties, which are preserved in order to compute diffs later on. 2002 function backupEventDates(event) { 2003 event._allDay = event.allDay; 2004 event._start = event.start.clone(); 2005 event._end = event.end ? event.end.clone() : null; 2006 } 2007 2008 ;; 2009 2010 /* FullCalendar-specific DOM Utilities 2011 ----------------------------------------------------------------------------------------------------------------------*/ 2012 2013 2014 // Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left 2015 // and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that. 2016 function compensateScroll(rowEls, scrollbarWidths) { 2017 if (scrollbarWidths.left) { 2018 rowEls.css({ 2019 'border-left-width': 1, 2020 'margin-left': scrollbarWidths.left - 1 2021 }); 2022 } 2023 if (scrollbarWidths.right) { 2024 rowEls.css({ 2025 'border-right-width': 1, 2026 'margin-right': scrollbarWidths.right - 1 2027 }); 2028 } 2029 } 2030 2031 2032 // Undoes compensateScroll and restores all borders/margins 2033 function uncompensateScroll(rowEls) { 2034 rowEls.css({ 2035 'margin-left': '', 2036 'margin-right': '', 2037 'border-left-width': '', 2038 'border-right-width': '' 2039 }); 2040 } 2041 2042 2043 // Given a total available height to fill, have `els` (essentially child rows) expand to accomodate. 2044 // By default, all elements that are shorter than the recommended height are expanded uniformly, not considering 2045 // any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and 2046 // reduces the available height. 2047 function distributeHeight(els, availableHeight, shouldRedistribute) { 2048 2049 // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions, 2050 // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars. 2051 2052 var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element 2053 var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE* 2054 var flexEls = []; // elements that are allowed to expand. array of DOM nodes 2055 var flexOffsets = []; // amount of vertical space it takes up 2056 var flexHeights = []; // actual css height 2057 var usedHeight = 0; 2058 2059 undistributeHeight(els); // give all elements their natural height 2060 2061 // find elements that are below the recommended height (expandable). 2062 // important to query for heights in a single first pass (to avoid reflow oscillation). 2063 els.each(function(i, el) { 2064 var minOffset = i === els.length - 1 ? minOffset2 : minOffset1; 2065 var naturalOffset = $(el).outerHeight(true); 2066 2067 if (naturalOffset < minOffset) { 2068 flexEls.push(el); 2069 flexOffsets.push(naturalOffset); 2070 flexHeights.push($(el).height()); 2071 } 2072 else { 2073 // this element stretches past recommended height (non-expandable). mark the space as occupied. 2074 usedHeight += naturalOffset; 2075 } 2076 }); 2077 2078 // readjust the recommended height to only consider the height available to non-maxed-out rows. 2079 if (shouldRedistribute) { 2080 availableHeight -= usedHeight; 2081 minOffset1 = Math.floor(availableHeight / flexEls.length); 2082 minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE* 2083 } 2084 2085 // assign heights to all expandable elements 2086 $(flexEls).each(function(i, el) { 2087 var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1; 2088 var naturalOffset = flexOffsets[i]; 2089 var naturalHeight = flexHeights[i]; 2090 var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding 2091 2092 if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things 2093 $(el).height(newHeight); 2094 } 2095 }); 2096 } 2097 2098 2099 // Undoes distrubuteHeight, restoring all els to their natural height 2100 function undistributeHeight(els) { 2101 els.height(''); 2102 } 2103 2104 2105 // Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the 2106 // cells to be that width. 2107 // PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline 2108 function matchCellWidths(els) { 2109 var maxInnerWidth = 0; 2110 2111 els.find('> *').each(function(i, innerEl) { 2112 var innerWidth = $(innerEl).outerWidth(); 2113 if (innerWidth > maxInnerWidth) { 2114 maxInnerWidth = innerWidth; 2115 } 2116 }); 2117 2118 maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance 2119 2120 els.width(maxInnerWidth); 2121 2122 return maxInnerWidth; 2123 } 2124 2125 2126 // Turns a container element into a scroller if its contents is taller than the allotted height. 2127 // Returns true if the element is now a scroller, false otherwise. 2128 // NOTE: this method is best because it takes weird zooming dimensions into account 2129 function setPotentialScroller(containerEl, height) { 2130 containerEl.height(height).addClass('fc-scroller'); 2131 2132 // are scrollbars needed? 2133 if (containerEl[0].scrollHeight - 1 > containerEl[0].clientHeight) { // !!! -1 because IE is often off-by-one :( 2134 return true; 2135 } 2136 2137 unsetScroller(containerEl); // undo 2138 return false; 2139 } 2140 2141 2142 // Takes an element that might have been a scroller, and turns it back into a normal element. 2143 function unsetScroller(containerEl) { 2144 containerEl.height('').removeClass('fc-scroller'); 2145 } 2146 2147 2148 /* General DOM Utilities 2149 ----------------------------------------------------------------------------------------------------------------------*/ 2150 2151 2152 // borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51 2153 function getScrollParent(el) { 2154 var position = el.css('position'), 2155 scrollParent = el.parents().filter(function() { 2156 var parent = $(this); 2157 return (/(auto|scroll)/).test( 2158 parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x') 2159 ); 2160 }).eq(0); 2161 2162 return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent; 2163 } 2164 2165 2166 // Given a container element, return an object with the pixel values of the left/right scrollbars. 2167 // Left scrollbars might occur on RTL browsers (IE maybe?) but I have not tested. 2168 // PREREQUISITE: container element must have a single child with display:block 2169 function getScrollbarWidths(container) { 2170 var containerLeft = container.offset().left; 2171 var containerRight = containerLeft + container.width(); 2172 var inner = container.children(); 2173 var innerLeft = inner.offset().left; 2174 var innerRight = innerLeft + inner.outerWidth(); 2175 2176 return { 2177 left: innerLeft - containerLeft, 2178 right: containerRight - innerRight 2179 }; 2180 } 2181 2182 2183 // Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac) 2184 function isPrimaryMouseButton(ev) { 2185 return ev.which == 1 && !ev.ctrlKey; 2186 } 2187 2188 2189 /* FullCalendar-specific Misc Utilities 2190 ----------------------------------------------------------------------------------------------------------------------*/ 2191 2192 2193 // Creates a basic segment with the intersection of the two ranges. Returns undefined if no intersection. 2194 // Expects all dates to be normalized to the same timezone beforehand. 2195 function intersectionToSeg(subjectStart, subjectEnd, intervalStart, intervalEnd) { 2196 var segStart, segEnd; 2197 var isStart, isEnd; 2198 2199 if (subjectEnd > intervalStart && subjectStart < intervalEnd) { // in bounds at all? 2200 2201 if (subjectStart >= intervalStart) { 2202 segStart = subjectStart.clone(); 2203 isStart = true; 2204 } 2205 else { 2206 segStart = intervalStart.clone(); 2207 isStart = false; 2208 } 2209 2210 if (subjectEnd <= intervalEnd) { 2211 segEnd = subjectEnd.clone(); 2212 isEnd = true; 2213 } 2214 else { 2215 segEnd = intervalEnd.clone(); 2216 isEnd = false; 2217 } 2218 2219 return { 2220 start: segStart, 2221 end: segEnd, 2222 isStart: isStart, 2223 isEnd: isEnd 2224 }; 2225 } 2226 } 2227 2228 2229 function smartProperty(obj, name) { // get a camel-cased/namespaced property of an object 2230 obj = obj || {}; 2231 if (obj[name] !== undefined) { 2232 return obj[name]; 2233 } 2234 var parts = name.split(/(?=[A-Z])/), 2235 i = parts.length - 1, res; 2236 for (; i>=0; i--) { 2237 res = obj[parts[i].toLowerCase()]; 2238 if (res !== undefined) { 2239 return res; 2240 } 2241 } 2242 return obj['default']; 2243 } 2244 2245 2246 /* Date Utilities 2247 ----------------------------------------------------------------------------------------------------------------------*/ 2248 2249 var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ]; 2250 2251 2252 // Diffs the two moments into a Duration where full-days are recorded first, then the remaining time. 2253 // Moments will have their timezones normalized. 2254 function dayishDiff(a, b) { 2255 return moment.duration({ 2256 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'), 2257 ms: a.time() - b.time() 2258 }); 2259 } 2260 2261 2262 function isNativeDate(input) { 2263 return Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date; 2264 } 2265 2266 2267 function dateCompare(a, b) { // works with Moments and native Dates 2268 return a - b; 2269 } 2270 2271 2272 /* General Utilities 2273 ----------------------------------------------------------------------------------------------------------------------*/ 2274 2275 fc.applyAll = applyAll; // export 2276 2277 2278 // Create an object that has the given prototype. Just like Object.create 2279 function createObject(proto) { 2280 var f = function() {}; 2281 f.prototype = proto; 2282 return new f(); 2283 } 2284 2285 2286 // Copies specifically-owned (non-protoype) properties of `b` onto `a`. 2287 // FYI, $.extend would copy *all* properties of `b` onto `a`. 2288 function extend(a, b) { 2289 for (var i in b) { 2290 if (b.hasOwnProperty(i)) { 2291 a[i] = b[i]; 2292 } 2293 } 2294 } 2295 2296 2297 function applyAll(functions, thisObj, args) { 2298 if ($.isFunction(functions)) { 2299 functions = [ functions ]; 2300 } 2301 if (functions) { 2302 var i; 2303 var ret; 2304 for (i=0; i<functions.length; i++) { 2305 ret = functions[i].apply(thisObj, args) || ret; 2306 } 2307 return ret; 2308 } 2309 } 2310 2311 2312 function firstDefined() { 2313 for (var i=0; i<arguments.length; i++) { 2314 if (arguments[i] !== undefined) { 2315 return arguments[i]; 2316 } 2317 } 2318 } 2319 2320 2321 function htmlEscape(s) { 2322 return (s + '').replace(/&/g, '&') 2323 .replace(/</g, '<') 2324 .replace(/>/g, '>') 2325 .replace(/'/g, ''') 2326 .replace(/"/g, '"') 2327 .replace(/\n/g, '<br />'); 2328 } 2329 2330 2331 function stripHtmlEntities(text) { 2332 return text.replace(/&.*?;/g, ''); 2333 } 2334 2335 2336 function capitaliseFirstLetter(str) { 2337 return str.charAt(0).toUpperCase() + str.slice(1); 2338 } 2339 2340 2341 // Returns a function, that, as long as it continues to be invoked, will not 2342 // be triggered. The function will be called after it stops being called for 2343 // N milliseconds. 2344 // https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714 2345 function debounce(func, wait) { 2346 var timeoutId; 2347 var args; 2348 var context; 2349 var timestamp; // of most recent call 2350 var later = function() { 2351 var last = +new Date() - timestamp; 2352 if (last < wait && last > 0) { 2353 timeoutId = setTimeout(later, wait - last); 2354 } 2355 else { 2356 timeoutId = null; 2357 func.apply(context, args); 2358 if (!timeoutId) { 2359 context = args = null; 2360 } 2361 } 2362 }; 2363 2364 return function() { 2365 context = this; 2366 args = arguments; 2367 timestamp = +new Date(); 2368 if (!timeoutId) { 2369 timeoutId = setTimeout(later, wait); 2370 } 2371 }; 2372 } 2373 2374 ;; 2375 2376 var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/; 2377 var ambigTimeOrZoneRegex = 2378 /^\s*\d{4}-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?)?$/; 2379 2380 2381 // Creating 2382 // ------------------------------------------------------------------------------------------------- 2383 2384 // Creates a new moment, similar to the vanilla moment(...) constructor, but with 2385 // extra features (ambiguous time, enhanced formatting). When gived an existing moment, 2386 // it will function as a clone (and retain the zone of the moment). Anything else will 2387 // result in a moment in the local zone. 2388 fc.moment = function() { 2389 return makeMoment(arguments); 2390 }; 2391 2392 // Sames as fc.moment, but forces the resulting moment to be in the UTC timezone. 2393 fc.moment.utc = function() { 2394 var mom = makeMoment(arguments, true); 2395 2396 // Force it into UTC because makeMoment doesn't guarantee it. 2397 if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone 2398 mom.utc(); 2399 } 2400 2401 return mom; 2402 }; 2403 2404 // Same as fc.moment, but when given an ISO8601 string, the timezone offset is preserved. 2405 // ISO8601 strings with no timezone offset will become ambiguously zoned. 2406 fc.moment.parseZone = function() { 2407 return makeMoment(arguments, true, true); 2408 }; 2409 2410 // Builds an FCMoment from args. When given an existing moment, it clones. When given a native 2411 // Date, or called with no arguments (the current time), the resulting moment will be local. 2412 // Anything else needs to be "parsed" (a string or an array), and will be affected by: 2413 // parseAsUTC - if there is no zone information, should we parse the input in UTC? 2414 // parseZone - if there is zone information, should we force the zone of the moment? 2415 function makeMoment(args, parseAsUTC, parseZone) { 2416 var input = args[0]; 2417 var isSingleString = args.length == 1 && typeof input === 'string'; 2418 var isAmbigTime; 2419 var isAmbigZone; 2420 var ambigMatch; 2421 var output; // an object with fields for the new FCMoment object 2422 2423 if (moment.isMoment(input)) { 2424 output = moment.apply(null, args); // clone it 2425 2426 // the ambig properties have not been preserved in the clone, so reassign them 2427 if (input._ambigTime) { 2428 output._ambigTime = true; 2429 } 2430 if (input._ambigZone) { 2431 output._ambigZone = true; 2432 } 2433 } 2434 else if (isNativeDate(input) || input === undefined) { 2435 output = moment.apply(null, args); // will be local 2436 } 2437 else { // "parsing" is required 2438 isAmbigTime = false; 2439 isAmbigZone = false; 2440 2441 if (isSingleString) { 2442 if (ambigDateOfMonthRegex.test(input)) { 2443 // accept strings like '2014-05', but convert to the first of the month 2444 input += '-01'; 2445 args = [ input ]; // for when we pass it on to moment's constructor 2446 isAmbigTime = true; 2447 isAmbigZone = true; 2448 } 2449 else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) { 2450 isAmbigTime = !ambigMatch[5]; // no time part? 2451 isAmbigZone = true; 2452 } 2453 } 2454 else if ($.isArray(input)) { 2455 // arrays have no timezone information, so assume ambiguous zone 2456 isAmbigZone = true; 2457 } 2458 // otherwise, probably a string with a format 2459 2460 if (parseAsUTC) { 2461 output = moment.utc.apply(moment, args); 2462 } 2463 else { 2464 output = moment.apply(null, args); 2465 } 2466 2467 if (isAmbigTime) { 2468 output._ambigTime = true; 2469 output._ambigZone = true; // ambiguous time always means ambiguous zone 2470 } 2471 else if (parseZone) { // let's record the inputted zone somehow 2472 if (isAmbigZone) { 2473 output._ambigZone = true; 2474 } 2475 else if (isSingleString) { 2476 output.zone(input); // if not a valid zone, will assign UTC 2477 } 2478 } 2479 } 2480 2481 return new FCMoment(output); 2482 } 2483 2484 // Our subclass of Moment. 2485 // Accepts an object with the internal Moment properties that should be copied over to 2486 // `this` object (most likely another Moment object). The values in this data must not 2487 // be referenced by anything else (two moments sharing a Date object for example). 2488 function FCMoment(internalData) { 2489 extend(this, internalData); 2490 } 2491 2492 // Chain the prototype to Moment's 2493 FCMoment.prototype = createObject(moment.fn); 2494 2495 // We need this because Moment's implementation won't create an FCMoment, 2496 // nor will it copy over the ambig flags. 2497 FCMoment.prototype.clone = function() { 2498 return makeMoment([ this ]); 2499 }; 2500 2501 2502 // Time-of-day 2503 // ------------------------------------------------------------------------------------------------- 2504 2505 // GETTER 2506 // Returns a Duration with the hours/minutes/seconds/ms values of the moment. 2507 // If the moment has an ambiguous time, a duration of 00:00 will be returned. 2508 // 2509 // SETTER 2510 // You can supply a Duration, a Moment, or a Duration-like argument. 2511 // When setting the time, and the moment has an ambiguous time, it then becomes unambiguous. 2512 FCMoment.prototype.time = function(time) { 2513 if (time == null) { // getter 2514 return moment.duration({ 2515 hours: this.hours(), 2516 minutes: this.minutes(), 2517 seconds: this.seconds(), 2518 milliseconds: this.milliseconds() 2519 }); 2520 } 2521 else { // setter 2522 2523 delete this._ambigTime; // mark that the moment now has a time 2524 2525 if (!moment.isDuration(time) && !moment.isMoment(time)) { 2526 time = moment.duration(time); 2527 } 2528 2529 // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day). 2530 // Only for Duration times, not Moment times. 2531 var dayHours = 0; 2532 if (moment.isDuration(time)) { 2533 dayHours = Math.floor(time.asDays()) * 24; 2534 } 2535 2536 // We need to set the individual fields. 2537 // Can't use startOf('day') then add duration. In case of DST at start of day. 2538 return this.hours(dayHours + time.hours()) 2539 .minutes(time.minutes()) 2540 .seconds(time.seconds()) 2541 .milliseconds(time.milliseconds()); 2542 } 2543 }; 2544 2545 // Converts the moment to UTC, stripping out its time-of-day and timezone offset, 2546 // but preserving its YMD. A moment with a stripped time will display no time 2547 // nor timezone offset when .format() is called. 2548 FCMoment.prototype.stripTime = function() { 2549 var a = this.toArray(); // year,month,date,hours,minutes,seconds as an array 2550 2551 // set the internal UTC flag 2552 moment.fn.utc.call(this); // call the original method, because we don't want to affect _ambigZone 2553 2554 this.year(a[0]) // TODO: find a way to do this in one shot 2555 .month(a[1]) 2556 .date(a[2]) 2557 .hours(0) 2558 .minutes(0) 2559 .seconds(0) 2560 .milliseconds(0); 2561 2562 // Mark the time as ambiguous. This needs to happen after the .utc() call, which calls .zone(), which 2563 // clears all ambig flags. Same concept with the .year/month/date calls in the case of moment-timezone. 2564 this._ambigTime = true; 2565 this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset 2566 2567 return this; // for chaining 2568 }; 2569 2570 // Returns if the moment has a non-ambiguous time (boolean) 2571 FCMoment.prototype.hasTime = function() { 2572 return !this._ambigTime; 2573 }; 2574 2575 2576 // Timezone 2577 // ------------------------------------------------------------------------------------------------- 2578 2579 // Converts the moment to UTC, stripping out its timezone offset, but preserving its 2580 // YMD and time-of-day. A moment with a stripped timezone offset will display no 2581 // timezone offset when .format() is called. 2582 FCMoment.prototype.stripZone = function() { 2583 var a = this.toArray(); // year,month,date,hours,minutes,seconds as an array 2584 var wasAmbigTime = this._ambigTime; 2585 2586 moment.fn.utc.call(this); // set the internal UTC flag 2587 2588 this.year(a[0]) // TODO: find a way to do this in one shot 2589 .month(a[1]) 2590 .date(a[2]) 2591 .hours(a[3]) 2592 .minutes(a[4]) 2593 .seconds(a[5]) 2594 .milliseconds(a[6]); 2595 2596 if (wasAmbigTime) { 2597 // the above call to .utc()/.zone() unfortunately clears the ambig flags, so reassign 2598 this._ambigTime = true; 2599 } 2600 2601 // Mark the zone as ambiguous. This needs to happen after the .utc() call, which calls .zone(), which 2602 // clears all ambig flags. Same concept with the .year/month/date calls in the case of moment-timezone. 2603 this._ambigZone = true; 2604 2605 return this; // for chaining 2606 }; 2607 2608 // Returns of the moment has a non-ambiguous timezone offset (boolean) 2609 FCMoment.prototype.hasZone = function() { 2610 return !this._ambigZone; 2611 }; 2612 2613 // this method implicitly marks a zone 2614 FCMoment.prototype.zone = function(tzo) { 2615 2616 if (tzo != null) { 2617 // FYI, the delete statements need to be before the .zone() call or else chaos ensues 2618 // for reasons I don't understand. 2619 delete this._ambigTime; 2620 delete this._ambigZone; 2621 } 2622 2623 return moment.fn.zone.apply(this, arguments); 2624 }; 2625 2626 // this method implicitly marks a zone 2627 FCMoment.prototype.local = function() { 2628 var a = this.toArray(); // year,month,date,hours,minutes,seconds as an array 2629 var wasAmbigZone = this._ambigZone; 2630 2631 // will happen anyway via .local()/.zone(), but don't want to rely on internal implementation 2632 delete this._ambigTime; 2633 delete this._ambigZone; 2634 2635 moment.fn.local.apply(this, arguments); 2636 2637 if (wasAmbigZone) { 2638 // If the moment was ambiguously zoned, the date fields were stored as UTC. 2639 // We want to preserve these, but in local time. 2640 this.year(a[0]) // TODO: find a way to do this in one shot 2641 .month(a[1]) 2642 .date(a[2]) 2643 .hours(a[3]) 2644 .minutes(a[4]) 2645 .seconds(a[5]) 2646 .milliseconds(a[6]); 2647 } 2648 2649 return this; // for chaining 2650 }; 2651 2652 // this method implicitly marks a zone 2653 FCMoment.prototype.utc = function() { 2654 2655 // will happen anyway via .local()/.zone(), but don't want to rely on internal implementation 2656 delete this._ambigTime; 2657 delete this._ambigZone; 2658 2659 return moment.fn.utc.apply(this, arguments); 2660 }; 2661 2662 2663 // Formatting 2664 // ------------------------------------------------------------------------------------------------- 2665 2666 FCMoment.prototype.format = function() { 2667 if (arguments[0]) { 2668 return formatDate(this, arguments[0]); // our extended formatting 2669 } 2670 if (this._ambigTime) { 2671 return momentFormat(this, 'YYYY-MM-DD'); 2672 } 2673 if (this._ambigZone) { 2674 return momentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); 2675 } 2676 return momentFormat(this); // default moment original formatting 2677 }; 2678 2679 FCMoment.prototype.toISOString = function() { 2680 if (this._ambigTime) { 2681 return momentFormat(this, 'YYYY-MM-DD'); 2682 } 2683 if (this._ambigZone) { 2684 return momentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); 2685 } 2686 return moment.fn.toISOString.apply(this, arguments); 2687 }; 2688 2689 2690 // Querying 2691 // ------------------------------------------------------------------------------------------------- 2692 2693 // Is the moment within the specified range? `end` is exclusive. 2694 FCMoment.prototype.isWithin = function(start, end) { 2695 var a = commonlyAmbiguate([ this, start, end ]); 2696 return a[0] >= a[1] && a[0] < a[2]; 2697 }; 2698 2699 // When isSame is called with units, timezone ambiguity is normalized before the comparison happens. 2700 // If no units are specified, the two moments must be identically the same, with matching ambig flags. 2701 FCMoment.prototype.isSame = function(input, units) { 2702 var a; 2703 2704 if (units) { 2705 a = commonlyAmbiguate([ this, input ], true); // normalize timezones but don't erase times 2706 return moment.fn.isSame.call(a[0], a[1], units); 2707 } 2708 else { 2709 input = fc.moment.parseZone(input); // normalize input 2710 return moment.fn.isSame.call(this, input) && 2711 Boolean(this._ambigTime) === Boolean(input._ambigTime) && 2712 Boolean(this._ambigZone) === Boolean(input._ambigZone); 2713 } 2714 }; 2715 2716 // Make these query methods work with ambiguous moments 2717 $.each([ 2718 'isBefore', 2719 'isAfter' 2720 ], function(i, methodName) { 2721 FCMoment.prototype[methodName] = function(input, units) { 2722 var a = commonlyAmbiguate([ this, input ]); 2723 return moment.fn[methodName].call(a[0], a[1], units); 2724 }; 2725 }); 2726 2727 2728 // Misc Internals 2729 // ------------------------------------------------------------------------------------------------- 2730 2731 // given an array of moment-like inputs, return a parallel array w/ moments similarly ambiguated. 2732 // for example, of one moment has ambig time, but not others, all moments will have their time stripped. 2733 // set `preserveTime` to `true` to keep times, but only normalize zone ambiguity. 2734 function commonlyAmbiguate(inputs, preserveTime) { 2735 var outputs = []; 2736 var anyAmbigTime = false; 2737 var anyAmbigZone = false; 2738 var i; 2739 2740 for (i=0; i<inputs.length; i++) { 2741 outputs.push(fc.moment.parseZone(inputs[i])); 2742 anyAmbigTime = anyAmbigTime || outputs[i]._ambigTime; 2743 anyAmbigZone = anyAmbigZone || outputs[i]._ambigZone; 2744 } 2745 2746 for (i=0; i<outputs.length; i++) { 2747 if (anyAmbigTime && !preserveTime) { 2748 outputs[i].stripTime(); 2749 } 2750 else if (anyAmbigZone) { 2751 outputs[i].stripZone(); 2752 } 2753 } 2754 2755 return outputs; 2756 } 2757 2758 ;; 2759 2760 // Single Date Formatting 2761 // ------------------------------------------------------------------------------------------------- 2762 2763 2764 // call this if you want Moment's original format method to be used 2765 function momentFormat(mom, formatStr) { 2766 return moment.fn.format.call(mom, formatStr); 2767 } 2768 2769 2770 // Formats `date` with a Moment formatting string, but allow our non-zero areas and 2771 // additional token. 2772 function formatDate(date, formatStr) { 2773 return formatDateWithChunks(date, getFormatStringChunks(formatStr)); 2774 } 2775 2776 2777 function formatDateWithChunks(date, chunks) { 2778 var s = ''; 2779 var i; 2780 2781 for (i=0; i<chunks.length; i++) { 2782 s += formatDateWithChunk(date, chunks[i]); 2783 } 2784 2785 return s; 2786 } 2787 2788 2789 // addition formatting tokens we want recognized 2790 var tokenOverrides = { 2791 t: function(date) { // "a" or "p" 2792 return momentFormat(date, 'a').charAt(0); 2793 }, 2794 T: function(date) { // "A" or "P" 2795 return momentFormat(date, 'A').charAt(0); 2796 } 2797 }; 2798 2799 2800 function formatDateWithChunk(date, chunk) { 2801 var token; 2802 var maybeStr; 2803 2804 if (typeof chunk === 'string') { // a literal string 2805 return chunk; 2806 } 2807 else if ((token = chunk.token)) { // a token, like "YYYY" 2808 if (tokenOverrides[token]) { 2809 return tokenOverrides[token](date); // use our custom token 2810 } 2811 return momentFormat(date, token); 2812 } 2813 else if (chunk.maybe) { // a grouping of other chunks that must be non-zero 2814 maybeStr = formatDateWithChunks(date, chunk.maybe); 2815 if (maybeStr.match(/[1-9]/)) { 2816 return maybeStr; 2817 } 2818 } 2819 2820 return ''; 2821 } 2822 2823 2824 // Date Range Formatting 2825 // ------------------------------------------------------------------------------------------------- 2826 // TODO: make it work with timezone offset 2827 2828 // Using a formatting string meant for a single date, generate a range string, like 2829 // "Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ. 2830 // If the dates are the same as far as the format string is concerned, just return a single 2831 // rendering of one date, without any separator. 2832 function formatRange(date1, date2, formatStr, separator, isRTL) { 2833 var localeData; 2834 2835 date1 = fc.moment.parseZone(date1); 2836 date2 = fc.moment.parseZone(date2); 2837 2838 localeData = (date1.localeData || date1.lang).call(date1); // works with moment-pre-2.8 2839 2840 // Expand localized format strings, like "LL" -> "MMMM D YYYY" 2841 formatStr = localeData.longDateFormat(formatStr) || formatStr; 2842 // BTW, this is not important for `formatDate` because it is impossible to put custom tokens 2843 // or non-zero areas in Moment's localized format strings. 2844 2845 separator = separator || ' - '; 2846 2847 return formatRangeWithChunks( 2848 date1, 2849 date2, 2850 getFormatStringChunks(formatStr), 2851 separator, 2852 isRTL 2853 ); 2854 } 2855 fc.formatRange = formatRange; // expose 2856 2857 2858 function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { 2859 var chunkStr; // the rendering of the chunk 2860 var leftI; 2861 var leftStr = ''; 2862 var rightI; 2863 var rightStr = ''; 2864 var middleI; 2865 var middleStr1 = ''; 2866 var middleStr2 = ''; 2867 var middleStr = ''; 2868 2869 // Start at the leftmost side of the formatting string and continue until you hit a token 2870 // that is not the same between dates. 2871 for (leftI=0; leftI<chunks.length; leftI++) { 2872 chunkStr = formatSimilarChunk(date1, date2, chunks[leftI]); 2873 if (chunkStr === false) { 2874 break; 2875 } 2876 leftStr += chunkStr; 2877 } 2878 2879 // Similarly, start at the rightmost side of the formatting string and move left 2880 for (rightI=chunks.length-1; rightI>leftI; rightI--) { 2881 chunkStr = formatSimilarChunk(date1, date2, chunks[rightI]); 2882 if (chunkStr === false) { 2883 break; 2884 } 2885 rightStr = chunkStr + rightStr; 2886 } 2887 2888 // The area in the middle is different for both of the dates. 2889 // Collect them distinctly so we can jam them together later. 2890 for (middleI=leftI; middleI<=rightI; middleI++) { 2891 middleStr1 += formatDateWithChunk(date1, chunks[middleI]); 2892 middleStr2 += formatDateWithChunk(date2, chunks[middleI]); 2893 } 2894 2895 if (middleStr1 || middleStr2) { 2896 if (isRTL) { 2897 middleStr = middleStr2 + separator + middleStr1; 2898 } 2899 else { 2900 middleStr = middleStr1 + separator + middleStr2; 2901 } 2902 } 2903 2904 return leftStr + middleStr + rightStr; 2905 } 2906 2907 2908 var similarUnitMap = { 2909 Y: 'year', 2910 M: 'month', 2911 D: 'day', // day of month 2912 d: 'day', // day of week 2913 // prevents a separator between anything time-related... 2914 A: 'second', // AM/PM 2915 a: 'second', // am/pm 2916 T: 'second', // A/P 2917 t: 'second', // a/p 2918 H: 'second', // hour (24) 2919 h: 'second', // hour (12) 2920 m: 'second', // minute 2921 s: 'second' // second 2922 }; 2923 // TODO: week maybe? 2924 2925 2926 // Given a formatting chunk, and given that both dates are similar in the regard the 2927 // formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`. 2928 function formatSimilarChunk(date1, date2, chunk) { 2929 var token; 2930 var unit; 2931 2932 if (typeof chunk === 'string') { // a literal string 2933 return chunk; 2934 } 2935 else if ((token = chunk.token)) { 2936 unit = similarUnitMap[token.charAt(0)]; 2937 // are the dates the same for this unit of measurement? 2938 if (unit && date1.isSame(date2, unit)) { 2939 return momentFormat(date1, token); // would be the same if we used `date2` 2940 // BTW, don't support custom tokens 2941 } 2942 } 2943 2944 return false; // the chunk is NOT the same for the two dates 2945 // BTW, don't support splitting on non-zero areas 2946 } 2947 2948 2949 // Chunking Utils 2950 // ------------------------------------------------------------------------------------------------- 2951 2952 2953 var formatStringChunkCache = {}; 2954 2955 2956 function getFormatStringChunks(formatStr) { 2957 if (formatStr in formatStringChunkCache) { 2958 return formatStringChunkCache[formatStr]; 2959 } 2960 return (formatStringChunkCache[formatStr] = chunkFormatString(formatStr)); 2961 } 2962 2963 2964 // Break the formatting string into an array of chunks 2965 function chunkFormatString(formatStr) { 2966 var chunks = []; 2967 var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LT|(\w)\4*o?)|([^\w\[\(]+)/g; // TODO: more descrimination 2968 var match; 2969 2970 while ((match = chunker.exec(formatStr))) { 2971 if (match[1]) { // a literal string inside [ ... ] 2972 chunks.push(match[1]); 2973 } 2974 else if (match[2]) { // non-zero formatting inside ( ... ) 2975 chunks.push({ maybe: chunkFormatString(match[2]) }); 2976 } 2977 else if (match[3]) { // a formatting token 2978 chunks.push({ token: match[3] }); 2979 } 2980 else if (match[5]) { // an unenclosed literal string 2981 chunks.push(match[5]); 2982 } 2983 } 2984 2985 return chunks; 2986 } 2987 2988 ;; 2989 2990 /* A rectangular panel that is absolutely positioned over other content 2991 ------------------------------------------------------------------------------------------------------------------------ 2992 Options: 2993 - className (string) 2994 - content (HTML string or jQuery element set) 2995 - parentEl 2996 - top 2997 - left 2998 - right (the x coord of where the right edge should be. not a "CSS" right) 2999 - autoHide (boolean) 3000 - show (callback) 3001 - hide (callback) 3002 */ 3003 3004 function Popover(options) { 3005 this.options = options || {}; 3006 } 3007 3008 3009 Popover.prototype = { 3010 3011 isHidden: true, 3012 options: null, 3013 el: null, // the container element for the popover. generated by this object 3014 documentMousedownProxy: null, // document mousedown handler bound to `this` 3015 margin: 10, // the space required between the popover and the edges of the scroll container 3016 3017 3018 // Shows the popover on the specified position. Renders it if not already 3019 show: function() { 3020 if (this.isHidden) { 3021 if (!this.el) { 3022 this.render(); 3023 } 3024 this.el.show(); 3025 this.position(); 3026 this.isHidden = false; 3027 this.trigger('show'); 3028 } 3029 }, 3030 3031 3032 // Hides the popover, through CSS, but does not remove it from the DOM 3033 hide: function() { 3034 if (!this.isHidden) { 3035 this.el.hide(); 3036 this.isHidden = true; 3037 this.trigger('hide'); 3038 } 3039 }, 3040 3041 3042 // Creates `this.el` and renders content inside of it 3043 render: function() { 3044 var _this = this; 3045 var options = this.options; 3046 3047 this.el = $('<div class="fc-popover"/>') 3048 .addClass(options.className || '') 3049 .css({ 3050 // position initially to the top left to avoid creating scrollbars 3051 top: 0, 3052 left: 0 3053 }) 3054 .append(options.content) 3055 .appendTo(options.parentEl); 3056 3057 // when a click happens on anything inside with a 'fc-close' className, hide the popover 3058 this.el.on('click', '.fc-close', function() { 3059 _this.hide(); 3060 }); 3061 3062 if (options.autoHide) { 3063 $(document).on('mousedown', this.documentMousedownProxy = $.proxy(this, 'documentMousedown')); 3064 } 3065 }, 3066 3067 3068 // Triggered when the user clicks *anywhere* in the document, for the autoHide feature 3069 documentMousedown: function(ev) { 3070 // only hide the popover if the click happened outside the popover 3071 if (this.el && !$(ev.target).closest(this.el).length) { 3072 this.hide(); 3073 } 3074 }, 3075 3076 3077 // Hides and unregisters any handlers 3078 destroy: function() { 3079 this.hide(); 3080 3081 if (this.el) { 3082 this.el.remove(); 3083 this.el = null; 3084 } 3085 3086 $(document).off('mousedown', this.documentMousedownProxy); 3087 }, 3088 3089 3090 // Positions the popover optimally, using the top/left/right options 3091 position: function() { 3092 var options = this.options; 3093 var origin = this.el.offsetParent().offset(); 3094 var width = this.el.outerWidth(); 3095 var height = this.el.outerHeight(); 3096 var windowEl = $(window); 3097 var viewportEl = getScrollParent(this.el); 3098 var viewportTop; 3099 var viewportLeft; 3100 var viewportOffset; 3101 var top; // the "position" (not "offset") values for the popover 3102 var left; // 3103 3104 // compute top and left 3105 top = options.top || 0; 3106 if (options.left !== undefined) { 3107 left = options.left; 3108 } 3109 else if (options.right !== undefined) { 3110 left = options.right - width; // derive the left value from the right value 3111 } 3112 else { 3113 left = 0; 3114 } 3115 3116 if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result 3117 viewportEl = windowEl; 3118 viewportTop = 0; // the window is always at the top left 3119 viewportLeft = 0; // (and .offset() won't work if called here) 3120 } 3121 else { 3122 viewportOffset = viewportEl.offset(); 3123 viewportTop = viewportOffset.top; 3124 viewportLeft = viewportOffset.left; 3125 } 3126 3127 // if the window is scrolled, it causes the visible area to be further down 3128 viewportTop += windowEl.scrollTop(); 3129 viewportLeft += windowEl.scrollLeft(); 3130 3131 // constrain to the view port. if constrained by two edges, give precedence to top/left 3132 if (options.viewportConstrain !== false) { 3133 top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin); 3134 top = Math.max(top, viewportTop + this.margin); 3135 left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin); 3136 left = Math.max(left, viewportLeft + this.margin); 3137 } 3138 3139 this.el.css({ 3140 top: top - origin.top, 3141 left: left - origin.left 3142 }); 3143 }, 3144 3145 3146 // Triggers a callback. Calls a function in the option hash of the same name. 3147 // Arguments beyond the first `name` are forwarded on. 3148 // TODO: better code reuse for this. Repeat code 3149 trigger: function(name) { 3150 if (this.options[name]) { 3151 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); 3152 } 3153 } 3154 3155 }; 3156 3157 ;; 3158 3159 /* A "coordinate map" converts pixel coordinates into an associated cell, which has an associated date 3160 ------------------------------------------------------------------------------------------------------------------------ 3161 Common interface: 3162 3163 CoordMap.prototype = { 3164 build: function() {}, 3165 getCell: function(x, y) {} 3166 }; 3167 3168 */ 3169 3170 /* Coordinate map for a grid component 3171 ----------------------------------------------------------------------------------------------------------------------*/ 3172 3173 function GridCoordMap(grid) { 3174 this.grid = grid; 3175 } 3176 3177 3178 GridCoordMap.prototype = { 3179 3180 grid: null, // reference to the Grid 3181 rows: null, // the top-to-bottom y coordinates. including the bottom of the last item 3182 cols: null, // the left-to-right x coordinates. including the right of the last item 3183 3184 containerEl: null, // container element that all coordinates are constrained to. optionally assigned 3185 minX: null, 3186 maxX: null, // exclusive 3187 minY: null, 3188 maxY: null, // exclusive 3189 3190 3191 // Queries the grid for the coordinates of all the cells 3192 build: function() { 3193 this.grid.buildCoords( 3194 this.rows = [], 3195 this.cols = [] 3196 ); 3197 this.computeBounds(); 3198 }, 3199 3200 3201 // Given a coordinate of the document, gets the associated cell. If no cell is underneath, returns null 3202 getCell: function(x, y) { 3203 var cell = null; 3204 var rows = this.rows; 3205 var cols = this.cols; 3206 var r = -1; 3207 var c = -1; 3208 var i; 3209 3210 if (this.inBounds(x, y)) { 3211 3212 for (i = 0; i < rows.length; i++) { 3213 if (y >= rows[i][0] && y < rows[i][1]) { 3214 r = i; 3215 break; 3216 } 3217 } 3218 3219 for (i = 0; i < cols.length; i++) { 3220 if (x >= cols[i][0] && x < cols[i][1]) { 3221 c = i; 3222 break; 3223 } 3224 } 3225 3226 if (r >= 0 && c >= 0) { 3227 cell = { row: r, col: c }; 3228 cell.grid = this.grid; 3229 cell.date = this.grid.getCellDate(cell); 3230 } 3231 } 3232 3233 return cell; 3234 }, 3235 3236 3237 // If there is a containerEl, compute the bounds into min/max values 3238 computeBounds: function() { 3239 var containerOffset; 3240 3241 if (this.containerEl) { 3242 containerOffset = this.containerEl.offset(); 3243 this.minX = containerOffset.left; 3244 this.maxX = containerOffset.left + this.containerEl.outerWidth(); 3245 this.minY = containerOffset.top; 3246 this.maxY = containerOffset.top + this.containerEl.outerHeight(); 3247 } 3248 }, 3249 3250 3251 // Determines if the given coordinates are in bounds. If no `containerEl`, always true 3252 inBounds: function(x, y) { 3253 if (this.containerEl) { 3254 return x >= this.minX && x < this.maxX && y >= this.minY && y < this.maxY; 3255 } 3256 return true; 3257 } 3258 3259 }; 3260 3261 3262 /* Coordinate map that is a combination of multiple other coordinate maps 3263 ----------------------------------------------------------------------------------------------------------------------*/ 3264 3265 function ComboCoordMap(coordMaps) { 3266 this.coordMaps = coordMaps; 3267 } 3268 3269 3270 ComboCoordMap.prototype = { 3271 3272 coordMaps: null, // an array of CoordMaps 3273 3274 3275 // Builds all coordMaps 3276 build: function() { 3277 var coordMaps = this.coordMaps; 3278 var i; 3279 3280 for (i = 0; i < coordMaps.length; i++) { 3281 coordMaps[i].build(); 3282 } 3283 }, 3284 3285 3286 // Queries all coordMaps for the cell underneath the given coordinates, returning the first result 3287 getCell: function(x, y) { 3288 var coordMaps = this.coordMaps; 3289 var cell = null; 3290 var i; 3291 3292 for (i = 0; i < coordMaps.length && !cell; i++) { 3293 cell = coordMaps[i].getCell(x, y); 3294 } 3295 3296 return cell; 3297 } 3298 3299 }; 3300 3301 ;; 3302 3303 /* Tracks mouse movements over a CoordMap and raises events about which cell the mouse is over. 3304 ----------------------------------------------------------------------------------------------------------------------*/ 3305 // TODO: implement scrolling 3306 3307 function DragListener(coordMap, options) { 3308 this.coordMap = coordMap; 3309 this.options = options || {}; 3310 } 3311 3312 3313 DragListener.prototype = { 3314 3315 coordMap: null, 3316 options: null, 3317 3318 isListening: false, 3319 isDragging: false, 3320 3321 // the cell/date the mouse was over when listening started 3322 origCell: null, 3323 origDate: null, 3324 3325 // the cell/date the mouse is over 3326 cell: null, 3327 date: null, 3328 3329 // coordinates of the initial mousedown 3330 mouseX0: null, 3331 mouseY0: null, 3332 3333 // handler attached to the document, bound to the DragListener's `this` 3334 mousemoveProxy: null, 3335 mouseupProxy: null, 3336 3337 scrollEl: null, 3338 scrollBounds: null, // { top, bottom, left, right } 3339 scrollTopVel: null, // pixels per second 3340 scrollLeftVel: null, // pixels per second 3341 scrollIntervalId: null, // ID of setTimeout for scrolling animation loop 3342 scrollHandlerProxy: null, // this-scoped function for handling when scrollEl is scrolled 3343 3344 scrollSensitivity: 30, // pixels from edge for scrolling to start 3345 scrollSpeed: 200, // pixels per second, at maximum speed 3346 scrollIntervalMs: 50, // millisecond wait between scroll increment 3347 3348 3349 // Call this when the user does a mousedown. Will probably lead to startListening 3350 mousedown: function(ev) { 3351 if (isPrimaryMouseButton(ev)) { 3352 3353 ev.preventDefault(); // prevents native selection in most browsers 3354 3355 this.startListening(ev); 3356 3357 // start the drag immediately if there is no minimum distance for a drag start 3358 if (!this.options.distance) { 3359 this.startDrag(ev); 3360 } 3361 } 3362 }, 3363 3364 3365 // Call this to start tracking mouse movements 3366 startListening: function(ev) { 3367 var scrollParent; 3368 var cell; 3369 3370 if (!this.isListening) { 3371 3372 // grab scroll container and attach handler 3373 if (ev && this.options.scroll) { 3374 scrollParent = getScrollParent($(ev.target)); 3375 if (!scrollParent.is(window) && !scrollParent.is(document)) { 3376 this.scrollEl = scrollParent; 3377 3378 // scope to `this`, and use `debounce` to make sure rapid calls don't happen 3379 this.scrollHandlerProxy = debounce($.proxy(this, 'scrollHandler'), 100); 3380 this.scrollEl.on('scroll', this.scrollHandlerProxy); 3381 } 3382 } 3383 3384 this.computeCoords(); // relies on `scrollEl` 3385 3386 // get info on the initial cell, date, and coordinates 3387 if (ev) { 3388 cell = this.getCell(ev); 3389 this.origCell = cell; 3390 this.origDate = cell ? cell.date : null; 3391 3392 this.mouseX0 = ev.pageX; 3393 this.mouseY0 = ev.pageY; 3394 } 3395 3396 $(document) 3397 .on('mousemove', this.mousemoveProxy = $.proxy(this, 'mousemove')) 3398 .on('mouseup', this.mouseupProxy = $.proxy(this, 'mouseup')) 3399 .on('selectstart', this.preventDefault); // prevents native selection in IE<=8 3400 3401 this.isListening = true; 3402 this.trigger('listenStart', ev); 3403 } 3404 }, 3405 3406 3407 // Recomputes the drag-critical positions of elements 3408 computeCoords: function() { 3409 this.coordMap.build(); 3410 this.computeScrollBounds(); 3411 }, 3412 3413 3414 // Called when the user moves the mouse 3415 mousemove: function(ev) { 3416 var minDistance; 3417 var distanceSq; // current distance from mouseX0/mouseY0, squared 3418 3419 if (!this.isDragging) { // if not already dragging... 3420 // then start the drag if the minimum distance criteria is met 3421 minDistance = this.options.distance || 1; 3422 distanceSq = Math.pow(ev.pageX - this.mouseX0, 2) + Math.pow(ev.pageY - this.mouseY0, 2); 3423 if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem 3424 this.startDrag(ev); 3425 } 3426 } 3427 3428 if (this.isDragging) { 3429 this.drag(ev); // report a drag, even if this mousemove initiated the drag 3430 } 3431 }, 3432 3433 3434 // Call this to initiate a legitimate drag. 3435 // This function is called internally from this class, but can also be called explicitly from outside 3436 startDrag: function(ev) { 3437 var cell; 3438 3439 if (!this.isListening) { // startDrag must have manually initiated 3440 this.startListening(); 3441 } 3442 3443 if (!this.isDragging) { 3444 this.isDragging = true; 3445 this.trigger('dragStart', ev); 3446 3447 // report the initial cell the mouse is over 3448 cell = this.getCell(ev); 3449 if (cell) { 3450 this.cellOver(cell, true); 3451 } 3452 } 3453 }, 3454 3455 3456 // Called while the mouse is being moved and when we know a legitimate drag is taking place 3457 drag: function(ev) { 3458 var cell; 3459 3460 if (this.isDragging) { 3461 cell = this.getCell(ev); 3462 3463 if (!isCellsEqual(cell, this.cell)) { // a different cell than before? 3464 if (this.cell) { 3465 this.cellOut(); 3466 } 3467 if (cell) { 3468 this.cellOver(cell); 3469 } 3470 } 3471 3472 this.dragScroll(ev); // will possibly cause scrolling 3473 } 3474 }, 3475 3476 3477 // Called when a the mouse has just moved over a new cell 3478 cellOver: function(cell) { 3479 this.cell = cell; 3480 this.date = cell.date; 3481 this.trigger('cellOver', cell, cell.date); 3482 }, 3483 3484 3485 // Called when the mouse has just moved out of a cell 3486 cellOut: function() { 3487 if (this.cell) { 3488 this.trigger('cellOut', this.cell); 3489 this.cell = null; 3490 this.date = null; 3491 } 3492 }, 3493 3494 3495 // Called when the user does a mouseup 3496 mouseup: function(ev) { 3497 this.stopDrag(ev); 3498 this.stopListening(ev); 3499 }, 3500 3501 3502 // Called when the drag is over. Will not cause listening to stop however. 3503 // A concluding 'cellOut' event will NOT be triggered. 3504 stopDrag: function(ev) { 3505 if (this.isDragging) { 3506 this.stopScrolling(); 3507 this.trigger('dragStop', ev); 3508 this.isDragging = false; 3509 } 3510 }, 3511 3512 3513 // Call this to stop listening to the user's mouse events 3514 stopListening: function(ev) { 3515 if (this.isListening) { 3516 3517 // remove the scroll handler if there is a scrollEl 3518 if (this.scrollEl) { 3519 this.scrollEl.off('scroll', this.scrollHandlerProxy); 3520 this.scrollHandlerProxy = null; 3521 } 3522 3523 $(document) 3524 .off('mousemove', this.mousemoveProxy) 3525 .off('mouseup', this.mouseupProxy) 3526 .off('selectstart', this.preventDefault); 3527 3528 this.mousemoveProxy = null; 3529 this.mouseupProxy = null; 3530 3531 this.isListening = false; 3532 this.trigger('listenStop', ev); 3533 3534 this.origCell = this.cell = null; 3535 this.origDate = this.date = null; 3536 } 3537 }, 3538 3539 3540 // Gets the cell underneath the coordinates for the given mouse event 3541 getCell: function(ev) { 3542 return this.coordMap.getCell(ev.pageX, ev.pageY); 3543 }, 3544 3545 3546 // Triggers a callback. Calls a function in the option hash of the same name. 3547 // Arguments beyond the first `name` are forwarded on. 3548 trigger: function(name) { 3549 if (this.options[name]) { 3550 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); 3551 } 3552 }, 3553 3554 3555 // Stops a given mouse event from doing it's native browser action. In our case, text selection. 3556 preventDefault: function(ev) { 3557 ev.preventDefault(); 3558 }, 3559 3560 3561 /* Scrolling 3562 ------------------------------------------------------------------------------------------------------------------*/ 3563 3564 3565 // Computes and stores the bounding rectangle of scrollEl 3566 computeScrollBounds: function() { 3567 var el = this.scrollEl; 3568 var offset; 3569 3570 if (el) { 3571 offset = el.offset(); 3572 this.scrollBounds = { 3573 top: offset.top, 3574 left: offset.left, 3575 bottom: offset.top + el.outerHeight(), 3576 right: offset.left + el.outerWidth() 3577 }; 3578 } 3579 }, 3580 3581 3582 // Called when the dragging is in progress and scrolling should be updated 3583 dragScroll: function(ev) { 3584 var sensitivity = this.scrollSensitivity; 3585 var bounds = this.scrollBounds; 3586 var topCloseness, bottomCloseness; 3587 var leftCloseness, rightCloseness; 3588 var topVel = 0; 3589 var leftVel = 0; 3590 3591 if (bounds) { // only scroll if scrollEl exists 3592 3593 // compute closeness to edges. valid range is from 0.0 - 1.0 3594 topCloseness = (sensitivity - (ev.pageY - bounds.top)) / sensitivity; 3595 bottomCloseness = (sensitivity - (bounds.bottom - ev.pageY)) / sensitivity; 3596 leftCloseness = (sensitivity - (ev.pageX - bounds.left)) / sensitivity; 3597 rightCloseness = (sensitivity - (bounds.right - ev.pageX)) / sensitivity; 3598 3599 // translate vertical closeness into velocity. 3600 // mouse must be completely in bounds for velocity to happen. 3601 if (topCloseness >= 0 && topCloseness <= 1) { 3602 topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up 3603 } 3604 else if (bottomCloseness >= 0 && bottomCloseness <= 1) { 3605 topVel = bottomCloseness * this.scrollSpeed; 3606 } 3607 3608 // translate horizontal closeness into velocity 3609 if (leftCloseness >= 0 && leftCloseness <= 1) { 3610 leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left 3611 } 3612 else if (rightCloseness >= 0 && rightCloseness <= 1) { 3613 leftVel = rightCloseness * this.scrollSpeed; 3614 } 3615 } 3616 3617 this.setScrollVel(topVel, leftVel); 3618 }, 3619 3620 3621 // Sets the speed-of-scrolling for the scrollEl 3622 setScrollVel: function(topVel, leftVel) { 3623 3624 this.scrollTopVel = topVel; 3625 this.scrollLeftVel = leftVel; 3626 3627 this.constrainScrollVel(); // massages into realistic values 3628 3629 // if there is non-zero velocity, and an animation loop hasn't already started, then START 3630 if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) { 3631 this.scrollIntervalId = setInterval( 3632 $.proxy(this, 'scrollIntervalFunc'), // scope to `this` 3633 this.scrollIntervalMs 3634 ); 3635 } 3636 }, 3637 3638 3639 // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way 3640 constrainScrollVel: function() { 3641 var el = this.scrollEl; 3642 3643 if (this.scrollTopVel < 0) { // scrolling up? 3644 if (el.scrollTop() <= 0) { // already scrolled all the way up? 3645 this.scrollTopVel = 0; 3646 } 3647 } 3648 else if (this.scrollTopVel > 0) { // scrolling down? 3649 if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down? 3650 this.scrollTopVel = 0; 3651 } 3652 } 3653 3654 if (this.scrollLeftVel < 0) { // scrolling left? 3655 if (el.scrollLeft() <= 0) { // already scrolled all the left? 3656 this.scrollLeftVel = 0; 3657 } 3658 } 3659 else if (this.scrollLeftVel > 0) { // scrolling right? 3660 if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right? 3661 this.scrollLeftVel = 0; 3662 } 3663 } 3664 }, 3665 3666 3667 // This function gets called during every iteration of the scrolling animation loop 3668 scrollIntervalFunc: function() { 3669 var el = this.scrollEl; 3670 var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by 3671 3672 // change the value of scrollEl's scroll 3673 if (this.scrollTopVel) { 3674 el.scrollTop(el.scrollTop() + this.scrollTopVel * frac); 3675 } 3676 if (this.scrollLeftVel) { 3677 el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac); 3678 } 3679 3680 this.constrainScrollVel(); // since the scroll values changed, recompute the velocities 3681 3682 // if scrolled all the way, which causes the vels to be zero, stop the animation loop 3683 if (!this.scrollTopVel && !this.scrollLeftVel) { 3684 this.stopScrolling(); 3685 } 3686 }, 3687 3688 3689 // Kills any existing scrolling animation loop 3690 stopScrolling: function() { 3691 if (this.scrollIntervalId) { 3692 clearInterval(this.scrollIntervalId); 3693 this.scrollIntervalId = null; 3694 3695 // when all done with scrolling, recompute positions since they probably changed 3696 this.computeCoords(); 3697 } 3698 }, 3699 3700 3701 // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce) 3702 scrollHandler: function() { 3703 // recompute all coordinates, but *only* if this is *not* part of our scrolling animation 3704 if (!this.scrollIntervalId) { 3705 this.computeCoords(); 3706 } 3707 } 3708 3709 }; 3710 3711 3712 // Returns `true` if the cells are identically equal. `false` otherwise. 3713 // They must have the same row, col, and be from the same grid. 3714 // Two null values will be considered equal, as two "out of the grid" states are the same. 3715 function isCellsEqual(cell1, cell2) { 3716 3717 if (!cell1 && !cell2) { 3718 return true; 3719 } 3720 3721 if (cell1 && cell2) { 3722 return cell1.grid === cell2.grid && 3723 cell1.row === cell2.row && 3724 cell1.col === cell2.col; 3725 } 3726 3727 return false; 3728 } 3729 3730 ;; 3731 3732 /* Creates a clone of an element and lets it track the mouse as it moves 3733 ----------------------------------------------------------------------------------------------------------------------*/ 3734 3735 function MouseFollower(sourceEl, options) { 3736 this.options = options = options || {}; 3737 this.sourceEl = sourceEl; 3738 this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent 3739 } 3740 3741 3742 MouseFollower.prototype = { 3743 3744 options: null, 3745 3746 sourceEl: null, // the element that will be cloned and made to look like it is dragging 3747 el: null, // the clone of `sourceEl` that will track the mouse 3748 parentEl: null, // the element that `el` (the clone) will be attached to 3749 3750 // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl 3751 top0: null, 3752 left0: null, 3753 3754 // the initial position of the mouse 3755 mouseY0: null, 3756 mouseX0: null, 3757 3758 // the number of pixels the mouse has moved from its initial position 3759 topDelta: null, 3760 leftDelta: null, 3761 3762 mousemoveProxy: null, // document mousemove handler, bound to the MouseFollower's `this` 3763 3764 isFollowing: false, 3765 isHidden: false, 3766 isAnimating: false, // doing the revert animation? 3767 3768 3769 // Causes the element to start following the mouse 3770 start: function(ev) { 3771 if (!this.isFollowing) { 3772 this.isFollowing = true; 3773 3774 this.mouseY0 = ev.pageY; 3775 this.mouseX0 = ev.pageX; 3776 this.topDelta = 0; 3777 this.leftDelta = 0; 3778 3779 if (!this.isHidden) { 3780 this.updatePosition(); 3781 } 3782 3783 $(document).on('mousemove', this.mousemoveProxy = $.proxy(this, 'mousemove')); 3784 } 3785 }, 3786 3787 3788 // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position. 3789 // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately. 3790 stop: function(shouldRevert, callback) { 3791 var _this = this; 3792 var revertDuration = this.options.revertDuration; 3793 3794 function complete() { 3795 this.isAnimating = false; 3796 _this.destroyEl(); 3797 3798 this.top0 = this.left0 = null; // reset state for future updatePosition calls 3799 3800 if (callback) { 3801 callback(); 3802 } 3803 } 3804 3805 if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time 3806 this.isFollowing = false; 3807 3808 $(document).off('mousemove', this.mousemoveProxy); 3809 3810 if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation? 3811 this.isAnimating = true; 3812 this.el.animate({ 3813 top: this.top0, 3814 left: this.left0 3815 }, { 3816 duration: revertDuration, 3817 complete: complete 3818 }); 3819 } 3820 else { 3821 complete(); 3822 } 3823 } 3824 }, 3825 3826 3827 // Gets the tracking element. Create it if necessary 3828 getEl: function() { 3829 var el = this.el; 3830 3831 if (!el) { 3832 this.sourceEl.width(); // hack to force IE8 to compute correct bounding box 3833 el = this.el = this.sourceEl.clone() 3834 .css({ 3835 position: 'absolute', 3836 visibility: '', // in case original element was hidden (commonly through hideEvents()) 3837 display: this.isHidden ? 'none' : '', // for when initially hidden 3838 margin: 0, 3839 right: 'auto', // erase and set width instead 3840 bottom: 'auto', // erase and set height instead 3841 width: this.sourceEl.width(), // explicit height in case there was a 'right' value 3842 height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value 3843 opacity: this.options.opacity || '', 3844 zIndex: this.options.zIndex 3845 }) 3846 .appendTo(this.parentEl); 3847 } 3848 3849 return el; 3850 }, 3851 3852 3853 // Removes the tracking element if it has already been created 3854 destroyEl: function() { 3855 if (this.el) { 3856 this.el.remove(); 3857 this.el = null; 3858 } 3859 }, 3860 3861 3862 // Update the CSS position of the tracking element 3863 updatePosition: function() { 3864 var sourceOffset; 3865 var origin; 3866 3867 this.getEl(); // ensure this.el 3868 3869 // make sure origin info was computed 3870 if (this.top0 === null) { 3871 this.sourceEl.width(); // hack to force IE8 to compute correct bounding box 3872 sourceOffset = this.sourceEl.offset(); 3873 origin = this.el.offsetParent().offset(); 3874 this.top0 = sourceOffset.top - origin.top; 3875 this.left0 = sourceOffset.left - origin.left; 3876 } 3877 3878 this.el.css({ 3879 top: this.top0 + this.topDelta, 3880 left: this.left0 + this.leftDelta 3881 }); 3882 }, 3883 3884 3885 // Gets called when the user moves the mouse 3886 mousemove: function(ev) { 3887 this.topDelta = ev.pageY - this.mouseY0; 3888 this.leftDelta = ev.pageX - this.mouseX0; 3889 3890 if (!this.isHidden) { 3891 this.updatePosition(); 3892 } 3893 }, 3894 3895 3896 // Temporarily makes the tracking element invisible. Can be called before following starts 3897 hide: function() { 3898 if (!this.isHidden) { 3899 this.isHidden = true; 3900 if (this.el) { 3901 this.el.hide(); 3902 } 3903 } 3904 }, 3905 3906 3907 // Show the tracking element after it has been temporarily hidden 3908 show: function() { 3909 if (this.isHidden) { 3910 this.isHidden = false; 3911 this.updatePosition(); 3912 this.getEl().show(); 3913 } 3914 } 3915 3916 }; 3917 3918 ;; 3919 3920 /* A utility class for rendering <tr> rows. 3921 ----------------------------------------------------------------------------------------------------------------------*/ 3922 // It leverages methods of the subclass and the View to determine custom rendering behavior for each row "type" 3923 // (such as highlight rows, day rows, helper rows, etc). 3924 3925 function RowRenderer(view) { 3926 this.view = view; 3927 } 3928 3929 3930 RowRenderer.prototype = { 3931 3932 view: null, // a View object 3933 cellHtml: '<td/>', // plain default HTML used for a cell when no other is available 3934 3935 3936 // Renders the HTML for a row, leveraging custom cell-HTML-renderers based on the `rowType`. 3937 // Also applies the "intro" and "outro" cells, which are specified by the subclass and views. 3938 // `row` is an optional row number. 3939 rowHtml: function(rowType, row) { 3940 var view = this.view; 3941 var renderCell = this.getHtmlRenderer('cell', rowType); 3942 var cellHtml = ''; 3943 var col; 3944 var date; 3945 3946 row = row || 0; 3947 3948 for (col = 0; col < view.colCnt; col++) { 3949 date = view.cellToDate(row, col); 3950 cellHtml += renderCell(row, col, date); 3951 } 3952 3953 cellHtml = this.bookendCells(cellHtml, rowType, row); // apply intro and outro 3954 3955 return '<tr>' + cellHtml + '</tr>'; 3956 }, 3957 3958 3959 // Applies the "intro" and "outro" HTML to the given cells. 3960 // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro. 3961 // `cells` can be an HTML string of <td>'s or a jQuery <tr> element 3962 // `row` is an optional row number. 3963 bookendCells: function(cells, rowType, row) { 3964 var view = this.view; 3965 var intro = this.getHtmlRenderer('intro', rowType)(row || 0); 3966 var outro = this.getHtmlRenderer('outro', rowType)(row || 0); 3967 var isRTL = view.opt('isRTL'); 3968 var prependHtml = isRTL ? outro : intro; 3969 var appendHtml = isRTL ? intro : outro; 3970 3971 if (typeof cells === 'string') { 3972 return prependHtml + cells + appendHtml; 3973 } 3974 else { // a jQuery <tr> element 3975 return cells.prepend(prependHtml).append(appendHtml); 3976 } 3977 }, 3978 3979 3980 // Returns an HTML-rendering function given a specific `rendererName` (like cell, intro, or outro) and a specific 3981 // `rowType` (like day, eventSkeleton, helperSkeleton), which is optional. 3982 // If a renderer for the specific rowType doesn't exist, it will fall back to a generic renderer. 3983 // We will query the View object first for any custom rendering functions, then the methods of the subclass. 3984 getHtmlRenderer: function(rendererName, rowType) { 3985 var view = this.view; 3986 var generalName; // like "cellHtml" 3987 var specificName; // like "dayCellHtml". based on rowType 3988 var provider; // either the View or the RowRenderer subclass, whichever provided the method 3989 var renderer; 3990 3991 generalName = rendererName + 'Html'; 3992 if (rowType) { 3993 specificName = rowType + capitaliseFirstLetter(rendererName) + 'Html'; 3994 } 3995 3996 if (specificName && (renderer = view[specificName])) { 3997 provider = view; 3998 } 3999 else if (specificName && (renderer = this[specificName])) { 4000 provider = this; 4001 } 4002 else if ((renderer = view[generalName])) { 4003 provider = view; 4004 } 4005 else if ((renderer = this[generalName])) { 4006 provider = this; 4007 } 4008 4009 if (typeof renderer === 'function') { 4010 return function(row) { 4011 return renderer.apply(provider, arguments) || ''; // use correct `this` and always return a string 4012 }; 4013 } 4014 4015 // the rendered can be a plain string as well. if not specified, always an empty string. 4016 return function() { 4017 return renderer || ''; 4018 }; 4019 } 4020 4021 }; 4022 4023 ;; 4024 4025 /* An abstract class comprised of a "grid" of cells that each represent a specific datetime 4026 ----------------------------------------------------------------------------------------------------------------------*/ 4027 4028 function Grid(view) { 4029 RowRenderer.call(this, view); // call the super-constructor 4030 this.coordMap = new GridCoordMap(this); 4031 } 4032 4033 4034 Grid.prototype = createObject(RowRenderer.prototype); // declare the super-class 4035 $.extend(Grid.prototype, { 4036 4037 el: null, // the containing element 4038 coordMap: null, // a GridCoordMap that converts pixel values to datetimes 4039 cellDuration: null, // a cell's duration. subclasses must assign this ASAP 4040 4041 4042 // Renders the grid into the `el` element. 4043 // Subclasses should override and call this super-method when done. 4044 render: function() { 4045 this.bindHandlers(); 4046 }, 4047 4048 4049 // Called when the grid's resources need to be cleaned up 4050 destroy: function() { 4051 // subclasses can implement 4052 }, 4053 4054 4055 /* Coordinates & Cells 4056 ------------------------------------------------------------------------------------------------------------------*/ 4057 4058 4059 // Populates the given empty arrays with the y and x coordinates of the cells 4060 buildCoords: function(rows, cols) { 4061 // subclasses must implement 4062 }, 4063 4064 4065 // Given a cell object, returns the date for that cell 4066 getCellDate: function(cell) { 4067 // subclasses must implement 4068 }, 4069 4070 4071 // Given a cell object, returns the element that represents the cell's whole-day 4072 getCellDayEl: function(cell) { 4073 // subclasses must implement 4074 }, 4075 4076 4077 // Converts a range with an inclusive `start` and an exclusive `end` into an array of segment objects 4078 rangeToSegs: function(start, end) { 4079 // subclasses must implement 4080 }, 4081 4082 4083 /* Handlers 4084 ------------------------------------------------------------------------------------------------------------------*/ 4085 4086 4087 // Attach handlers to `this.el`, using bubbling to listen to all ancestors. 4088 // We don't need to undo any of this in a "destroy" method, because the view will simply remove `this.el` from the 4089 // DOM and jQuery will be smart enough to garbage collect the handlers. 4090 bindHandlers: function() { 4091 var _this = this; 4092 4093 this.el.on('mousedown', function(ev) { 4094 if ( 4095 !$(ev.target).is('.fc-event-container *, .fc-more') && // not an an event element, or "more.." link 4096 !$(ev.target).closest('.fc-popover').length // not on a popover (like the "more.." events one) 4097 ) { 4098 _this.dayMousedown(ev); 4099 } 4100 }); 4101 4102 this.bindSegHandlers(); // attach event-element-related handlers. in Grid.events.js 4103 }, 4104 4105 4106 // Process a mousedown on an element that represents a day. For day clicking and selecting. 4107 dayMousedown: function(ev) { 4108 var _this = this; 4109 var view = this.view; 4110 var isSelectable = view.opt('selectable'); 4111 var dates = null; // the inclusive dates of the selection. will be null if no selection 4112 var start; // the inclusive start of the selection 4113 var end; // the *exclusive* end of the selection 4114 var dayEl; 4115 4116 // this listener tracks a mousedown on a day element, and a subsequent drag. 4117 // if the drag ends on the same day, it is a 'dayClick'. 4118 // if 'selectable' is enabled, this listener also detects selections. 4119 var dragListener = new DragListener(this.coordMap, { 4120 //distance: 5, // needs more work if we want dayClick to fire correctly 4121 scroll: view.opt('dragScroll'), 4122 dragStart: function() { 4123 view.unselect(); // since we could be rendering a new selection, we want to clear any old one 4124 }, 4125 cellOver: function(cell, date) { 4126 if (dragListener.origDate) { // click needs to have started on a cell 4127 4128 dayEl = _this.getCellDayEl(cell); 4129 4130 dates = [ date, dragListener.origDate ].sort(dateCompare); 4131 start = dates[0]; 4132 end = dates[1].clone().add(_this.cellDuration); 4133 4134 if (isSelectable) { 4135 _this.renderSelection(start, end); 4136 } 4137 } 4138 }, 4139 cellOut: function(cell, date) { 4140 dates = null; 4141 _this.destroySelection(); 4142 }, 4143 listenStop: function(ev) { 4144 if (dates) { // started and ended on a cell? 4145 if (dates[0].isSame(dates[1])) { 4146 view.trigger('dayClick', dayEl[0], start, ev); 4147 } 4148 if (isSelectable) { 4149 // the selection will already have been rendered. just report it 4150 view.reportSelection(start, end, ev); 4151 } 4152 } 4153 } 4154 }); 4155 4156 dragListener.mousedown(ev); // start listening, which will eventually initiate a dragStart 4157 }, 4158 4159 4160 /* Event Dragging 4161 ------------------------------------------------------------------------------------------------------------------*/ 4162 4163 4164 // Renders a visual indication of a event being dragged over the given date(s). 4165 // `end` can be null, as well as `seg`. See View's documentation on renderDrag for more info. 4166 // A returned value of `true` signals that a mock "helper" event has been rendered. 4167 renderDrag: function(start, end, seg) { 4168 // subclasses must implement 4169 }, 4170 4171 4172 // Unrenders a visual indication of an event being dragged 4173 destroyDrag: function() { 4174 // subclasses must implement 4175 }, 4176 4177 4178 /* Event Resizing 4179 ------------------------------------------------------------------------------------------------------------------*/ 4180 4181 4182 // Renders a visual indication of an event being resized. 4183 // `start` and `end` are the updated dates of the event. `seg` is the original segment object involved in the drag. 4184 renderResize: function(start, end, seg) { 4185 // subclasses must implement 4186 }, 4187 4188 4189 // Unrenders a visual indication of an event being resized. 4190 destroyResize: function() { 4191 // subclasses must implement 4192 }, 4193 4194 4195 /* Event Helper 4196 ------------------------------------------------------------------------------------------------------------------*/ 4197 4198 4199 // Renders a mock event over the given date(s). 4200 // `end` can be null, in which case the mock event that is rendered will have a null end time. 4201 // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging. 4202 renderRangeHelper: function(start, end, sourceSeg) { 4203 var view = this.view; 4204 var fakeEvent; 4205 4206 // compute the end time if forced to do so (this is what EventManager does) 4207 if (!end && view.opt('forceEventDuration')) { 4208 end = view.calendar.getDefaultEventEnd(!start.hasTime(), start); 4209 } 4210 4211 fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible 4212 fakeEvent.start = start; 4213 fakeEvent.end = end; 4214 fakeEvent.allDay = !(start.hasTime() || (end && end.hasTime())); // freshly compute allDay 4215 4216 // this extra className will be useful for differentiating real events from mock events in CSS 4217 fakeEvent.className = (fakeEvent.className || []).concat('fc-helper'); 4218 4219 // if something external is being dragged in, don't render a resizer 4220 if (!sourceSeg) { 4221 fakeEvent.editable = false; 4222 } 4223 4224 this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering 4225 }, 4226 4227 4228 // Renders a mock event 4229 renderHelper: function(event, sourceSeg) { 4230 // subclasses must implement 4231 }, 4232 4233 4234 // Unrenders a mock event 4235 destroyHelper: function() { 4236 // subclasses must implement 4237 }, 4238 4239 4240 /* Selection 4241 ------------------------------------------------------------------------------------------------------------------*/ 4242 4243 4244 // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses. 4245 renderSelection: function(start, end) { 4246 this.renderHighlight(start, end); 4247 }, 4248 4249 4250 // Unrenders any visual indications of a selection. Will unrender a highlight by default. 4251 destroySelection: function() { 4252 this.destroyHighlight(); 4253 }, 4254 4255 4256 /* Highlight 4257 ------------------------------------------------------------------------------------------------------------------*/ 4258 4259 4260 // Puts visual emphasis on a certain date range 4261 renderHighlight: function(start, end) { 4262 // subclasses should implement 4263 }, 4264 4265 4266 // Removes visual emphasis on a date range 4267 destroyHighlight: function() { 4268 // subclasses should implement 4269 }, 4270 4271 4272 4273 /* Generic rendering utilities for subclasses 4274 ------------------------------------------------------------------------------------------------------------------*/ 4275 4276 4277 // Renders a day-of-week header row 4278 headHtml: function() { 4279 return '' + 4280 '<div class="fc-row ' + this.view.widgetHeaderClass + '">' + 4281 '<table>' + 4282 '<thead>' + 4283 this.rowHtml('head') + // leverages RowRenderer 4284 '</thead>' + 4285 '</table>' + 4286 '</div>'; 4287 }, 4288 4289 4290 // Used by the `headHtml` method, via RowRenderer, for rendering the HTML of a day-of-week header cell 4291 headCellHtml: function(row, col, date) { 4292 var view = this.view; 4293 var calendar = view.calendar; 4294 var colFormat = view.opt('columnFormat'); 4295 4296 return '' + 4297 '<th class="fc-day-header ' + view.widgetHeaderClass + ' fc-' + dayIDs[date.day()] + '">' + 4298 htmlEscape(calendar.formatDate(date, colFormat)) + 4299 '</th>'; 4300 }, 4301 4302 4303 // Renders the HTML for a single-day background cell 4304 bgCellHtml: function(row, col, date) { 4305 var view = this.view; 4306 var classes = this.getDayClasses(date); 4307 4308 classes.unshift('fc-day', view.widgetContentClass); 4309 4310 return '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '"></td>'; 4311 }, 4312 4313 4314 // Computes HTML classNames for a single-day cell 4315 getDayClasses: function(date) { 4316 var view = this.view; 4317 var today = view.calendar.getNow().stripTime(); 4318 var classes = [ 'fc-' + dayIDs[date.day()] ]; 4319 4320 if ( 4321 view.name === 'month' && 4322 date.month() != view.intervalStart.month() 4323 ) { 4324 classes.push('fc-other-month'); 4325 } 4326 4327 if (date.isSame(today, 'day')) { 4328 classes.push( 4329 'fc-today', 4330 view.highlightStateClass 4331 ); 4332 } 4333 else if (date < today) { 4334 classes.push('fc-past'); 4335 } 4336 else { 4337 classes.push('fc-future'); 4338 } 4339 4340 return classes; 4341 } 4342 4343 }); 4344 4345 ;; 4346 4347 /* Event-rendering and event-interaction methods for the abstract Grid class 4348 ----------------------------------------------------------------------------------------------------------------------*/ 4349 4350 $.extend(Grid.prototype, { 4351 4352 mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing 4353 isDraggingSeg: false, // is a segment being dragged? boolean 4354 isResizingSeg: false, // is a segment being resized? boolean 4355 4356 4357 // Renders the given events onto the grid 4358 renderEvents: function(events) { 4359 // subclasses must implement 4360 }, 4361 4362 4363 // Retrieves all rendered segment objects in this grid 4364 getSegs: function() { 4365 // subclasses must implement 4366 }, 4367 4368 4369 // Unrenders all events. Subclasses should implement, calling this super-method first. 4370 destroyEvents: function() { 4371 this.triggerSegMouseout(); // trigger an eventMouseout if user's mouse is over an event 4372 }, 4373 4374 4375 // Renders a `el` property for each seg, and only returns segments that successfully rendered 4376 renderSegs: function(segs, disableResizing) { 4377 var view = this.view; 4378 var html = ''; 4379 var renderedSegs = []; 4380 var i; 4381 4382 // build a large concatenation of event segment HTML 4383 for (i = 0; i < segs.length; i++) { 4384 html += this.renderSegHtml(segs[i], disableResizing); 4385 } 4386 4387 // Grab individual elements from the combined HTML string. Use each as the default rendering. 4388 // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false. 4389 $(html).each(function(i, node) { 4390 var seg = segs[i]; 4391 var el = view.resolveEventEl(seg.event, $(node)); 4392 if (el) { 4393 el.data('fc-seg', seg); // used by handlers 4394 seg.el = el; 4395 renderedSegs.push(seg); 4396 } 4397 }); 4398 4399 return renderedSegs; 4400 }, 4401 4402 4403 // Generates the HTML for the default rendering of a segment 4404 renderSegHtml: function(seg, disableResizing) { 4405 // subclasses must implement 4406 }, 4407 4408 4409 // Converts an array of event objects into an array of segment objects 4410 eventsToSegs: function(events, intervalStart, intervalEnd) { 4411 var _this = this; 4412 4413 return $.map(events, function(event) { 4414 return _this.eventToSegs(event, intervalStart, intervalEnd); // $.map flattens all returned arrays together 4415 }); 4416 }, 4417 4418 4419 // Slices a single event into an array of event segments. 4420 // When `intervalStart` and `intervalEnd` are specified, intersect the events with that interval. 4421 // Otherwise, let the subclass decide how it wants to slice the segments over the grid. 4422 eventToSegs: function(event, intervalStart, intervalEnd) { 4423 var eventStart = event.start.clone().stripZone(); // normalize 4424 var eventEnd = this.view.calendar.getEventEnd(event).stripZone(); // compute (if necessary) and normalize 4425 var segs; 4426 var i, seg; 4427 4428 if (intervalStart && intervalEnd) { 4429 seg = intersectionToSeg(eventStart, eventEnd, intervalStart, intervalEnd); 4430 segs = seg ? [ seg ] : []; 4431 } 4432 else { 4433 segs = this.rangeToSegs(eventStart, eventEnd); // defined by the subclass 4434 } 4435 4436 // assign extra event-related properties to the segment objects 4437 for (i = 0; i < segs.length; i++) { 4438 seg = segs[i]; 4439 seg.event = event; 4440 seg.eventStartMS = +eventStart; 4441 seg.eventDurationMS = eventEnd - eventStart; 4442 } 4443 4444 return segs; 4445 }, 4446 4447 4448 /* Handlers 4449 ------------------------------------------------------------------------------------------------------------------*/ 4450 4451 4452 // Attaches event-element-related handlers to the container element and leverage bubbling 4453 bindSegHandlers: function() { 4454 var _this = this; 4455 var view = this.view; 4456 4457 $.each( 4458 { 4459 mouseenter: function(seg, ev) { 4460 _this.triggerSegMouseover(seg, ev); 4461 }, 4462 mouseleave: function(seg, ev) { 4463 _this.triggerSegMouseout(seg, ev); 4464 }, 4465 click: function(seg, ev) { 4466 return view.trigger('eventClick', this, seg.event, ev); // can return `false` to cancel 4467 }, 4468 mousedown: function(seg, ev) { 4469 if ($(ev.target).is('.fc-resizer') && view.isEventResizable(seg.event)) { 4470 _this.segResizeMousedown(seg, ev); 4471 } 4472 else if (view.isEventDraggable(seg.event)) { 4473 _this.segDragMousedown(seg, ev); 4474 } 4475 } 4476 }, 4477 function(name, func) { 4478 // attach the handler to the container element and only listen for real event elements via bubbling 4479 _this.el.on(name, '.fc-event-container > *', function(ev) { 4480 var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents 4481 4482 // only call the handlers if there is not a drag/resize in progress 4483 if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) { 4484 return func.call(this, seg, ev); // `this` will be the event element 4485 } 4486 }); 4487 } 4488 ); 4489 }, 4490 4491 4492 // Updates internal state and triggers handlers for when an event element is moused over 4493 triggerSegMouseover: function(seg, ev) { 4494 if (!this.mousedOverSeg) { 4495 this.mousedOverSeg = seg; 4496 this.view.trigger('eventMouseover', seg.el[0], seg.event, ev); 4497 } 4498 }, 4499 4500 4501 // Updates internal state and triggers handlers for when an event element is moused out. 4502 // Can be given no arguments, in which case it will mouseout the segment that was previously moused over. 4503 triggerSegMouseout: function(seg, ev) { 4504 ev = ev || {}; // if given no args, make a mock mouse event 4505 4506 if (this.mousedOverSeg) { 4507 seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment 4508 this.mousedOverSeg = null; 4509 this.view.trigger('eventMouseout', seg.el[0], seg.event, ev); 4510 } 4511 }, 4512 4513 4514 /* Dragging 4515 ------------------------------------------------------------------------------------------------------------------*/ 4516 4517 4518 // Called when the user does a mousedown on an event, which might lead to dragging. 4519 // Generic enough to work with any type of Grid. 4520 segDragMousedown: function(seg, ev) { 4521 var _this = this; 4522 var view = this.view; 4523 var el = seg.el; 4524 var event = seg.event; 4525 var newStart, newEnd; 4526 4527 // A clone of the original element that will move with the mouse 4528 var mouseFollower = new MouseFollower(seg.el, { 4529 parentEl: view.el, 4530 opacity: view.opt('dragOpacity'), 4531 revertDuration: view.opt('dragRevertDuration'), 4532 zIndex: 2 // one above the .fc-view 4533 }); 4534 4535 // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents 4536 // of the view. 4537 var dragListener = new DragListener(view.coordMap, { 4538 distance: 5, 4539 scroll: view.opt('dragScroll'), 4540 listenStart: function(ev) { 4541 mouseFollower.hide(); // don't show until we know this is a real drag 4542 mouseFollower.start(ev); 4543 }, 4544 dragStart: function(ev) { 4545 _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported 4546 _this.isDraggingSeg = true; 4547 view.hideEvent(event); // hide all event segments. our mouseFollower will take over 4548 view.trigger('eventDragStart', el[0], event, ev, {}); // last argument is jqui dummy 4549 }, 4550 cellOver: function(cell, date) { 4551 var origDate = seg.cellDate || dragListener.origDate; 4552 var res = _this.computeDraggedEventDates(seg, origDate, date); 4553 newStart = res.start; 4554 newEnd = res.end; 4555 4556 if (view.renderDrag(newStart, newEnd, seg)) { // have the view render a visual indication 4557 mouseFollower.hide(); // if the view is already using a mock event "helper", hide our own 4558 } 4559 else { 4560 mouseFollower.show(); 4561 } 4562 }, 4563 cellOut: function() { // called before mouse moves to a different cell OR moved out of all cells 4564 newStart = null; 4565 view.destroyDrag(); // unrender whatever was done in view.renderDrag 4566 mouseFollower.show(); // show in case we are moving out of all cells 4567 }, 4568 dragStop: function(ev) { 4569 var hasChanged = newStart && !newStart.isSame(event.start); 4570 4571 // do revert animation if hasn't changed. calls a callback when finished (whether animation or not) 4572 mouseFollower.stop(!hasChanged, function() { 4573 _this.isDraggingSeg = false; 4574 view.destroyDrag(); 4575 view.showEvent(event); 4576 view.trigger('eventDragStop', el[0], event, ev, {}); // last argument is jqui dummy 4577 4578 if (hasChanged) { 4579 view.eventDrop(el[0], event, newStart, ev); // will rerender all events... 4580 } 4581 }); 4582 }, 4583 listenStop: function() { 4584 mouseFollower.stop(); // put in listenStop in case there was a mousedown but the drag never started 4585 } 4586 }); 4587 4588 dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart 4589 }, 4590 4591 4592 // Given a segment, the dates where a drag began and ended, calculates the Event Object's new start and end dates 4593 computeDraggedEventDates: function(seg, dragStartDate, dropDate) { 4594 var view = this.view; 4595 var event = seg.event; 4596 var start = event.start; 4597 var end = view.calendar.getEventEnd(event); 4598 var delta; 4599 var newStart; 4600 var newEnd; 4601 4602 if (dropDate.hasTime() === dragStartDate.hasTime()) { 4603 delta = dayishDiff(dropDate, dragStartDate); 4604 newStart = start.clone().add(delta); 4605 if (event.end === null) { // do we need to compute an end? 4606 newEnd = null; 4607 } 4608 else { 4609 newEnd = end.clone().add(delta); 4610 } 4611 } 4612 else { 4613 // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared 4614 newStart = dropDate; 4615 newEnd = null; // end should be cleared 4616 } 4617 4618 return { start: newStart, end: newEnd }; 4619 }, 4620 4621 4622 /* Resizing 4623 ------------------------------------------------------------------------------------------------------------------*/ 4624 4625 4626 // Called when the user does a mousedown on an event's resizer, which might lead to resizing. 4627 // Generic enough to work with any type of Grid. 4628 segResizeMousedown: function(seg, ev) { 4629 var _this = this; 4630 var view = this.view; 4631 var el = seg.el; 4632 var event = seg.event; 4633 var start = event.start; 4634 var end = view.calendar.getEventEnd(event); 4635 var newEnd = null; 4636 var dragListener; 4637 4638 function destroy() { // resets the rendering 4639 _this.destroyResize(); 4640 view.showEvent(event); 4641 } 4642 4643 // Tracks mouse movement over the *grid's* coordinate map 4644 dragListener = new DragListener(this.coordMap, { 4645 distance: 5, 4646 scroll: view.opt('dragScroll'), 4647 dragStart: function(ev) { 4648 _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported 4649 _this.isResizingSeg = true; 4650 view.trigger('eventResizeStart', el[0], event, ev, {}); // last argument is jqui dummy 4651 }, 4652 cellOver: function(cell, date) { 4653 // compute the new end. don't allow it to go before the event's start 4654 if (date.isBefore(start)) { // allows comparing ambig to non-ambig 4655 date = start; 4656 } 4657 newEnd = date.clone().add(_this.cellDuration); // make it an exclusive end 4658 4659 if (newEnd.isSame(end)) { 4660 newEnd = null; 4661 destroy(); 4662 } 4663 else { 4664 _this.renderResize(start, newEnd, seg); 4665 view.hideEvent(event); 4666 } 4667 }, 4668 cellOut: function() { // called before mouse moves to a different cell OR moved out of all cells 4669 newEnd = null; 4670 destroy(); 4671 }, 4672 dragStop: function(ev) { 4673 _this.isResizingSeg = false; 4674 destroy(); 4675 view.trigger('eventResizeStop', el[0], event, ev, {}); // last argument is jqui dummy 4676 4677 if (newEnd) { 4678 view.eventResize(el[0], event, newEnd, ev); // will rerender all events... 4679 } 4680 } 4681 }); 4682 4683 dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart 4684 }, 4685 4686 4687 /* Rendering Utils 4688 ------------------------------------------------------------------------------------------------------------------*/ 4689 4690 4691 // Generic utility for generating the HTML classNames for an event segment's element 4692 getSegClasses: function(seg, isDraggable, isResizable) { 4693 var event = seg.event; 4694 var classes = [ 4695 'fc-event', 4696 seg.isStart ? 'fc-start' : 'fc-not-start', 4697 seg.isEnd ? 'fc-end' : 'fc-not-end' 4698 ].concat( 4699 event.className, 4700 event.source ? event.source.className : [] 4701 ); 4702 4703 if (isDraggable) { 4704 classes.push('fc-draggable'); 4705 } 4706 if (isResizable) { 4707 classes.push('fc-resizable'); 4708 } 4709 4710 return classes; 4711 }, 4712 4713 4714 // Utility for generating a CSS string with all the event skin-related properties 4715 getEventSkinCss: function(event) { 4716 var view = this.view; 4717 var source = event.source || {}; 4718 var eventColor = event.color; 4719 var sourceColor = source.color; 4720 var optionColor = view.opt('eventColor'); 4721 var backgroundColor = 4722 event.backgroundColor || 4723 eventColor || 4724 source.backgroundColor || 4725 sourceColor || 4726 view.opt('eventBackgroundColor') || 4727 optionColor; 4728 var borderColor = 4729 event.borderColor || 4730 eventColor || 4731 source.borderColor || 4732 sourceColor || 4733 view.opt('eventBorderColor') || 4734 optionColor; 4735 var textColor = 4736 event.textColor || 4737 source.textColor || 4738 view.opt('eventTextColor'); 4739 var statements = []; 4740 if (backgroundColor) { 4741 statements.push('background-color:' + backgroundColor); 4742 } 4743 if (borderColor) { 4744 statements.push('border-color:' + borderColor); 4745 } 4746 if (textColor) { 4747 statements.push('color:' + textColor); 4748 } 4749 return statements.join(';'); 4750 } 4751 4752 }); 4753 4754 4755 /* Event Segment Utilities 4756 ----------------------------------------------------------------------------------------------------------------------*/ 4757 4758 4759 // A cmp function for determining which segments should take visual priority 4760 function compareSegs(seg1, seg2) { 4761 return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first 4762 seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first 4763 seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1) 4764 (seg1.event.title || '').localeCompare(seg2.event.title); // tie? alphabetically by title 4765 } 4766 4767 4768 ;; 4769 4770 /* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week. 4771 ----------------------------------------------------------------------------------------------------------------------*/ 4772 4773 function DayGrid(view) { 4774 Grid.call(this, view); // call the super-constructor 4775 } 4776 4777 4778 DayGrid.prototype = createObject(Grid.prototype); // declare the super-class 4779 $.extend(DayGrid.prototype, { 4780 4781 numbersVisible: false, // should render a row for day/week numbers? manually set by the view 4782 cellDuration: moment.duration({ days: 1 }), // required for Grid.event.js. Each cell is always a single day 4783 bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid 4784 4785 rowEls: null, // set of fake row elements 4786 dayEls: null, // set of whole-day elements comprising the row's background 4787 helperEls: null, // set of cell skeleton elements for rendering the mock event "helper" 4788 highlightEls: null, // set of cell skeleton elements for rendering the highlight 4789 4790 4791 // Renders the rows and columns into the component's `this.el`, which should already be assigned. 4792 // isRigid determins whether the individual rows should ignore the contents and be a constant height. 4793 // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient. 4794 render: function(isRigid) { 4795 var view = this.view; 4796 var html = ''; 4797 var row; 4798 4799 for (row = 0; row < view.rowCnt; row++) { 4800 html += this.dayRowHtml(row, isRigid); 4801 } 4802 this.el.html(html); 4803 4804 this.rowEls = this.el.find('.fc-row'); 4805 this.dayEls = this.el.find('.fc-day'); 4806 4807 // run all the day cells through the dayRender callback 4808 this.dayEls.each(function(i, node) { 4809 var date = view.cellToDate(Math.floor(i / view.colCnt), i % view.colCnt); 4810 view.trigger('dayRender', null, date, $(node)); 4811 }); 4812 4813 Grid.prototype.render.call(this); // call the super-method 4814 }, 4815 4816 4817 destroy: function() { 4818 this.destroySegPopover(); 4819 }, 4820 4821 4822 // Generates the HTML for a single row. `row` is the row number. 4823 dayRowHtml: function(row, isRigid) { 4824 var view = this.view; 4825 var classes = [ 'fc-row', 'fc-week', view.widgetContentClass ]; 4826 4827 if (isRigid) { 4828 classes.push('fc-rigid'); 4829 } 4830 4831 return '' + 4832 '<div class="' + classes.join(' ') + '">' + 4833 '<div class="fc-bg">' + 4834 '<table>' + 4835 this.rowHtml('day', row) + // leverages RowRenderer. calls dayCellHtml() 4836 '</table>' + 4837 '</div>' + 4838 '<div class="fc-content-skeleton">' + 4839 '<table>' + 4840 (this.numbersVisible ? 4841 '<thead>' + 4842 this.rowHtml('number', row) + // leverages RowRenderer. View will define render method 4843 '</thead>' : 4844 '' 4845 ) + 4846 '</table>' + 4847 '</div>' + 4848 '</div>'; 4849 }, 4850 4851 4852 // Renders the HTML for a whole-day cell. Will eventually end up in the day-row's background. 4853 // We go through a 'day' row type instead of just doing a 'bg' row type so that the View can do custom rendering 4854 // specifically for whole-day rows, whereas a 'bg' might also be used for other purposes (TimeGrid bg for example). 4855 dayCellHtml: function(row, col, date) { 4856 return this.bgCellHtml(row, col, date); 4857 }, 4858 4859 4860 /* Coordinates & Cells 4861 ------------------------------------------------------------------------------------------------------------------*/ 4862 4863 4864 // Populates the empty `rows` and `cols` arrays with coordinates of the cells. For CoordGrid. 4865 buildCoords: function(rows, cols) { 4866 var colCnt = this.view.colCnt; 4867 var e, n, p; 4868 4869 this.dayEls.slice(0, colCnt).each(function(i, _e) { // iterate the first row of day elements 4870 e = $(_e); 4871 n = e.offset().left; 4872 if (i) { 4873 p[1] = n; 4874 } 4875 p = [ n ]; 4876 cols[i] = p; 4877 }); 4878 p[1] = n + e.outerWidth(); 4879 4880 this.rowEls.each(function(i, _e) { 4881 e = $(_e); 4882 n = e.offset().top; 4883 if (i) { 4884 p[1] = n; 4885 } 4886 p = [ n ]; 4887 rows[i] = p; 4888 }); 4889 p[1] = n + e.outerHeight() + this.bottomCoordPadding; // hack to extend hit area of last row 4890 }, 4891 4892 4893 // Converts a cell to a date 4894 getCellDate: function(cell) { 4895 return this.view.cellToDate(cell); // leverages the View's cell system 4896 }, 4897 4898 4899 // Gets the whole-day element associated with the cell 4900 getCellDayEl: function(cell) { 4901 return this.dayEls.eq(cell.row * this.view.colCnt + cell.col); 4902 }, 4903 4904 4905 // Converts a range with an inclusive `start` and an exclusive `end` into an array of segment objects 4906 rangeToSegs: function(start, end) { 4907 return this.view.rangeToSegments(start, end); // leverages the View's cell system 4908 }, 4909 4910 4911 /* Event Drag Visualization 4912 ------------------------------------------------------------------------------------------------------------------*/ 4913 4914 4915 // Renders a visual indication of an event hovering over the given date(s). 4916 // `end` can be null, as well as `seg`. See View's documentation on renderDrag for more info. 4917 // A returned value of `true` signals that a mock "helper" event has been rendered. 4918 renderDrag: function(start, end, seg) { 4919 var opacity; 4920 4921 // always render a highlight underneath 4922 this.renderHighlight( 4923 start, 4924 end || this.view.calendar.getDefaultEventEnd(true, start) 4925 ); 4926 4927 // if a segment from the same calendar but another component is being dragged, render a helper event 4928 if (seg && !seg.el.closest(this.el).length) { 4929 4930 this.renderRangeHelper(start, end, seg); 4931 4932 opacity = this.view.opt('dragOpacity'); 4933 if (opacity !== undefined) { 4934 this.helperEls.css('opacity', opacity); 4935 } 4936 4937 return true; // a helper has been rendered 4938 } 4939 }, 4940 4941 4942 // Unrenders any visual indication of a hovering event 4943 destroyDrag: function() { 4944 this.destroyHighlight(); 4945 this.destroyHelper(); 4946 }, 4947 4948 4949 /* Event Resize Visualization 4950 ------------------------------------------------------------------------------------------------------------------*/ 4951 4952 4953 // Renders a visual indication of an event being resized 4954 renderResize: function(start, end, seg) { 4955 this.renderHighlight(start, end); 4956 this.renderRangeHelper(start, end, seg); 4957 }, 4958 4959 4960 // Unrenders a visual indication of an event being resized 4961 destroyResize: function() { 4962 this.destroyHighlight(); 4963 this.destroyHelper(); 4964 }, 4965 4966 4967 /* Event Helper 4968 ------------------------------------------------------------------------------------------------------------------*/ 4969 4970 4971 // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null. 4972 renderHelper: function(event, sourceSeg) { 4973 var helperNodes = []; 4974 var rowStructs = this.renderEventRows([ event ]); 4975 4976 // inject each new event skeleton into each associated row 4977 this.rowEls.each(function(row, rowNode) { 4978 var rowEl = $(rowNode); // the .fc-row 4979 var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned 4980 var skeletonTop; 4981 4982 // If there is an original segment, match the top position. Otherwise, put it at the row's top level 4983 if (sourceSeg && sourceSeg.row === row) { 4984 skeletonTop = sourceSeg.el.position().top; 4985 } 4986 else { 4987 skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top; 4988 } 4989 4990 skeletonEl.css('top', skeletonTop) 4991 .find('table') 4992 .append(rowStructs[row].tbodyEl); 4993 4994 rowEl.append(skeletonEl); 4995 helperNodes.push(skeletonEl[0]); 4996 }); 4997 4998 this.helperEls = $(helperNodes); // array -> jQuery set 4999 }, 5000 5001 5002 // Unrenders any visual indication of a mock helper event 5003 destroyHelper: function() { 5004 if (this.helperEls) { 5005 this.helperEls.remove(); 5006 this.helperEls = null; 5007 } 5008 }, 5009 5010 5011 /* Highlighting 5012 ------------------------------------------------------------------------------------------------------------------*/ 5013 5014 5015 // Renders an emphasis on the given date range. `start` is an inclusive, `end` is exclusive. 5016 renderHighlight: function(start, end) { 5017 var segs = this.rangeToSegs(start, end); 5018 var highlightNodes = []; 5019 var i, seg; 5020 var el; 5021 5022 // build an event skeleton for each row that needs it 5023 for (i = 0; i < segs.length; i++) { 5024 seg = segs[i]; 5025 el = $( 5026 this.highlightSkeletonHtml(seg.leftCol, seg.rightCol + 1) // make end exclusive 5027 ); 5028 el.appendTo(this.rowEls[seg.row]); 5029 highlightNodes.push(el[0]); 5030 } 5031 5032 this.highlightEls = $(highlightNodes); // array -> jQuery set 5033 }, 5034 5035 5036 // Unrenders any visual emphasis on a date range 5037 destroyHighlight: function() { 5038 if (this.highlightEls) { 5039 this.highlightEls.remove(); 5040 this.highlightEls = null; 5041 } 5042 }, 5043 5044 5045 // Generates the HTML used to build a single-row "highlight skeleton", a table that frames highlight cells 5046 highlightSkeletonHtml: function(startCol, endCol) { 5047 var colCnt = this.view.colCnt; 5048 var cellHtml = ''; 5049 5050 if (startCol > 0) { 5051 cellHtml += '<td colspan="' + startCol + '"/>'; 5052 } 5053 if (endCol > startCol) { 5054 cellHtml += '<td colspan="' + (endCol - startCol) + '" class="fc-highlight" />'; 5055 } 5056 if (colCnt > endCol) { 5057 cellHtml += '<td colspan="' + (colCnt - endCol) + '"/>'; 5058 } 5059 5060 cellHtml = this.bookendCells(cellHtml, 'highlight'); 5061 5062 return '' + 5063 '<div class="fc-highlight-skeleton">' + 5064 '<table>' + 5065 '<tr>' + 5066 cellHtml + 5067 '</tr>' + 5068 '</table>' + 5069 '</div>'; 5070 } 5071 5072 }); 5073 5074 ;; 5075 5076 /* Event-rendering methods for the DayGrid class 5077 ----------------------------------------------------------------------------------------------------------------------*/ 5078 5079 $.extend(DayGrid.prototype, { 5080 5081 segs: null, 5082 rowStructs: null, // an array of objects, each holding information about a row's event-rendering 5083 5084 5085 // Render the given events onto the Grid and return the rendered segments 5086 renderEvents: function(events) { 5087 var rowStructs = this.rowStructs = this.renderEventRows(events); 5088 var segs = []; 5089 5090 // append to each row's content skeleton 5091 this.rowEls.each(function(i, rowNode) { 5092 $(rowNode).find('.fc-content-skeleton > table').append( 5093 rowStructs[i].tbodyEl 5094 ); 5095 segs.push.apply(segs, rowStructs[i].segs); 5096 }); 5097 5098 this.segs = segs; 5099 }, 5100 5101 5102 // Retrieves all segment objects that have been rendered 5103 getSegs: function() { 5104 return (this.segs || []).concat( 5105 this.popoverSegs || [] // segs rendered in the "more" events popover 5106 ); 5107 }, 5108 5109 5110 // Removes all rendered event elements 5111 destroyEvents: function() { 5112 var rowStructs; 5113 var rowStruct; 5114 5115 Grid.prototype.destroyEvents.call(this); // call the super-method 5116 5117 rowStructs = this.rowStructs || []; 5118 while ((rowStruct = rowStructs.pop())) { 5119 rowStruct.tbodyEl.remove(); 5120 } 5121 5122 this.segs = null; 5123 this.destroySegPopover(); // removes the "more.." events popover 5124 }, 5125 5126 5127 // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton. 5128 // Returns an array of rowStruct objects (see the bottom of `renderEventRow`). 5129 renderEventRows: function(events) { 5130 var segs = this.eventsToSegs(events); 5131 var rowStructs = []; 5132 var segRows; 5133 var row; 5134 5135 segs = this.renderSegs(segs); // returns a new array with only visible segments 5136 segRows = this.groupSegRows(segs); // group into nested arrays 5137 5138 // iterate each row of segment groupings 5139 for (row = 0; row < segRows.length; row++) { 5140 rowStructs.push( 5141 this.renderEventRow(row, segRows[row]) 5142 ); 5143 } 5144 5145 return rowStructs; 5146 }, 5147 5148 5149 // Builds the HTML to be used for the default element for an individual segment 5150 renderSegHtml: function(seg, disableResizing) { 5151 var view = this.view; 5152 var isRTL = view.opt('isRTL'); 5153 var event = seg.event; 5154 var isDraggable = view.isEventDraggable(event); 5155 var isResizable = !disableResizing && event.allDay && seg.isEnd && view.isEventResizable(event); 5156 var classes = this.getSegClasses(seg, isDraggable, isResizable); 5157 var skinCss = this.getEventSkinCss(event); 5158 var timeHtml = ''; 5159 var titleHtml; 5160 5161 classes.unshift('fc-day-grid-event'); 5162 5163 // Only display a timed events time if it is the starting segment 5164 if (!event.allDay && seg.isStart) { 5165 timeHtml = '<span class="fc-time">' + htmlEscape(view.getEventTimeText(event)) + '</span>'; 5166 } 5167 5168 titleHtml = 5169 '<span class="fc-title">' + 5170 (htmlEscape(event.title || '') || ' ') + // we always want one line of height 5171 '</span>'; 5172 5173 return '<a class="' + classes.join(' ') + '"' + 5174 (event.url ? 5175 ' href="' + htmlEscape(event.url) + '"' : 5176 '' 5177 ) + 5178 (skinCss ? 5179 ' style="' + skinCss + '"' : 5180 '' 5181 ) + 5182 '>' + 5183 '<div class="fc-content">' + 5184 (isRTL ? 5185 titleHtml + ' ' + timeHtml : // put a natural space in between 5186 timeHtml + ' ' + titleHtml // 5187 ) + 5188 '</div>' + 5189 (isResizable ? 5190 '<div class="fc-resizer"/>' : 5191 '' 5192 ) + 5193 '</a>'; 5194 }, 5195 5196 5197 // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains 5198 // the segments. Returns object with a bunch of internal data about how the render was calculated. 5199 renderEventRow: function(row, rowSegs) { 5200 var view = this.view; 5201 var colCnt = view.colCnt; 5202 var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels 5203 var levelCnt = Math.max(1, segLevels.length); // ensure at least one level 5204 var tbody = $('<tbody/>'); 5205 var segMatrix = []; // lookup for which segments are rendered into which level+col cells 5206 var cellMatrix = []; // lookup for all <td> elements of the level+col matrix 5207 var loneCellMatrix = []; // lookup for <td> elements that only take up a single column 5208 var i, levelSegs; 5209 var col; 5210 var tr; 5211 var j, seg; 5212 var td; 5213 5214 // populates empty cells from the current column (`col`) to `endCol` 5215 function emptyCellsUntil(endCol) { 5216 while (col < endCol) { 5217 // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell 5218 td = (loneCellMatrix[i - 1] || [])[col]; 5219 if (td) { 5220 td.attr( 5221 'rowspan', 5222 parseInt(td.attr('rowspan') || 1, 10) + 1 5223 ); 5224 } 5225 else { 5226 td = $('<td/>'); 5227 tr.append(td); 5228 } 5229 cellMatrix[i][col] = td; 5230 loneCellMatrix[i][col] = td; 5231 col++; 5232 } 5233 } 5234 5235 for (i = 0; i < levelCnt; i++) { // iterate through all levels 5236 levelSegs = segLevels[i]; 5237 col = 0; 5238 tr = $('<tr/>'); 5239 5240 segMatrix.push([]); 5241 cellMatrix.push([]); 5242 loneCellMatrix.push([]); 5243 5244 // levelCnt might be 1 even though there are no actual levels. protect against this. 5245 // this single empty row is useful for styling. 5246 if (levelSegs) { 5247 for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level 5248 seg = levelSegs[j]; 5249 5250 emptyCellsUntil(seg.leftCol); 5251 5252 // create a container that occupies or more columns. append the event element. 5253 td = $('<td class="fc-event-container"/>').append(seg.el); 5254 if (seg.leftCol != seg.rightCol) { 5255 td.attr('colspan', seg.rightCol - seg.leftCol + 1); 5256 } 5257 else { // a single-column segment 5258 loneCellMatrix[i][col] = td; 5259 } 5260 5261 while (col <= seg.rightCol) { 5262 cellMatrix[i][col] = td; 5263 segMatrix[i][col] = seg; 5264 col++; 5265 } 5266 5267 tr.append(td); 5268 } 5269 } 5270 5271 emptyCellsUntil(colCnt); // finish off the row 5272 this.bookendCells(tr, 'eventSkeleton'); 5273 tbody.append(tr); 5274 } 5275 5276 return { // a "rowStruct" 5277 row: row, // the row number 5278 tbodyEl: tbody, 5279 cellMatrix: cellMatrix, 5280 segMatrix: segMatrix, 5281 segLevels: segLevels, 5282 segs: rowSegs 5283 }; 5284 }, 5285 5286 5287 // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels. 5288 buildSegLevels: function(segs) { 5289 var levels = []; 5290 var i, seg; 5291 var j; 5292 5293 // Give preference to elements with certain criteria, so they have 5294 // a chance to be closer to the top. 5295 segs.sort(compareSegs); 5296 5297 for (i = 0; i < segs.length; i++) { 5298 seg = segs[i]; 5299 5300 // loop through levels, starting with the topmost, until the segment doesn't collide with other segments 5301 for (j = 0; j < levels.length; j++) { 5302 if (!isDaySegCollision(seg, levels[j])) { 5303 break; 5304 } 5305 } 5306 // `j` now holds the desired subrow index 5307 seg.level = j; 5308 5309 // create new level array if needed and append segment 5310 (levels[j] || (levels[j] = [])).push(seg); 5311 } 5312 5313 // order segments left-to-right. very important if calendar is RTL 5314 for (j = 0; j < levels.length; j++) { 5315 levels[j].sort(compareDaySegCols); 5316 } 5317 5318 return levels; 5319 }, 5320 5321 5322 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row 5323 groupSegRows: function(segs) { 5324 var view = this.view; 5325 var segRows = []; 5326 var i; 5327 5328 for (i = 0; i < view.rowCnt; i++) { 5329 segRows.push([]); 5330 } 5331 5332 for (i = 0; i < segs.length; i++) { 5333 segRows[segs[i].row].push(segs[i]); 5334 } 5335 5336 return segRows; 5337 } 5338 5339 }); 5340 5341 5342 // Computes whether two segments' columns collide. They are assumed to be in the same row. 5343 function isDaySegCollision(seg, otherSegs) { 5344 var i, otherSeg; 5345 5346 for (i = 0; i < otherSegs.length; i++) { 5347 otherSeg = otherSegs[i]; 5348 5349 if ( 5350 otherSeg.leftCol <= seg.rightCol && 5351 otherSeg.rightCol >= seg.leftCol 5352 ) { 5353 return true; 5354 } 5355 } 5356 5357 return false; 5358 } 5359 5360 5361 // A cmp function for determining the leftmost event 5362 function compareDaySegCols(a, b) { 5363 return a.leftCol - b.leftCol; 5364 } 5365 5366 ;; 5367 5368 /* Methods relate to limiting the number events for a given day on a DayGrid 5369 ----------------------------------------------------------------------------------------------------------------------*/ 5370 5371 $.extend(DayGrid.prototype, { 5372 5373 5374 segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible 5375 popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible 5376 5377 5378 destroySegPopover: function() { 5379 if (this.segPopover) { 5380 this.segPopover.hide(); // will trigger destruction of `segPopover` and `popoverSegs` 5381 } 5382 }, 5383 5384 5385 // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid. 5386 // `levelLimit` can be false (don't limit), a number, or true (should be computed). 5387 limitRows: function(levelLimit) { 5388 var rowStructs = this.rowStructs || []; 5389 var row; // row # 5390 var rowLevelLimit; 5391 5392 for (row = 0; row < rowStructs.length; row++) { 5393 this.unlimitRow(row); 5394 5395 if (!levelLimit) { 5396 rowLevelLimit = false; 5397 } 5398 else if (typeof levelLimit === 'number') { 5399 rowLevelLimit = levelLimit; 5400 } 5401 else { 5402 rowLevelLimit = this.computeRowLevelLimit(row); 5403 } 5404 5405 if (rowLevelLimit !== false) { 5406 this.limitRow(row, rowLevelLimit); 5407 } 5408 } 5409 }, 5410 5411 5412 // Computes the number of levels a row will accomodate without going outside its bounds. 5413 // Assumes the row is "rigid" (maintains a constant height regardless of what is inside). 5414 // `row` is the row number. 5415 computeRowLevelLimit: function(row) { 5416 var rowEl = this.rowEls.eq(row); // the containing "fake" row div 5417 var rowHeight = rowEl.height(); // TODO: cache somehow? 5418 var trEls = this.rowStructs[row].tbodyEl.children(); 5419 var i, trEl; 5420 5421 // Reveal one level <tr> at a time and stop when we find one out of bounds 5422 for (i = 0; i < trEls.length; i++) { 5423 trEl = trEls.eq(i).removeClass('fc-limited'); // get and reveal 5424 if (trEl.position().top + trEl.outerHeight() > rowHeight) { 5425 return i; 5426 } 5427 } 5428 5429 return false; // should not limit at all 5430 }, 5431 5432 5433 // Limits the given grid row to the maximum number of levels and injects "more" links if necessary. 5434 // `row` is the row number. 5435 // `levelLimit` is a number for the maximum (inclusive) number of levels allowed. 5436 limitRow: function(row, levelLimit) { 5437 var _this = this; 5438 var view = this.view; 5439 var rowStruct = this.rowStructs[row]; 5440 var moreNodes = []; // array of "more" <a> links and <td> DOM nodes 5441 var col = 0; // col # 5442 var cell; 5443 var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right 5444 var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row 5445 var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes 5446 var i, seg; 5447 var segsBelow; // array of segment objects below `seg` in the current `col` 5448 var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies 5449 var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column) 5450 var td, rowspan; 5451 var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell 5452 var j; 5453 var moreTd, moreWrap, moreLink; 5454 5455 // Iterates through empty level cells and places "more" links inside if need be 5456 function emptyCellsUntil(endCol) { // goes from current `col` to `endCol` 5457 while (col < endCol) { 5458 cell = { row: row, col: col }; 5459 segsBelow = _this.getCellSegs(cell, levelLimit); 5460 if (segsBelow.length) { 5461 td = cellMatrix[levelLimit - 1][col]; 5462 moreLink = _this.renderMoreLink(cell, segsBelow); 5463 moreWrap = $('<div/>').append(moreLink); 5464 td.append(moreWrap); 5465 moreNodes.push(moreWrap[0]); 5466 } 5467 col++; 5468 } 5469 } 5470 5471 if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit? 5472 levelSegs = rowStruct.segLevels[levelLimit - 1]; 5473 cellMatrix = rowStruct.cellMatrix; 5474 5475 limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit 5476 .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array 5477 5478 // iterate though segments in the last allowable level 5479 for (i = 0; i < levelSegs.length; i++) { 5480 seg = levelSegs[i]; 5481 emptyCellsUntil(seg.leftCol); // process empty cells before the segment 5482 5483 // determine *all* segments below `seg` that occupy the same columns 5484 colSegsBelow = []; 5485 totalSegsBelow = 0; 5486 while (col <= seg.rightCol) { 5487 cell = { row: row, col: col }; 5488 segsBelow = this.getCellSegs(cell, levelLimit); 5489 colSegsBelow.push(segsBelow); 5490 totalSegsBelow += segsBelow.length; 5491 col++; 5492 } 5493 5494 if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links? 5495 td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell 5496 rowspan = td.attr('rowspan') || 1; 5497 segMoreNodes = []; 5498 5499 // make a replacement <td> for each column the segment occupies. will be one for each colspan 5500 for (j = 0; j < colSegsBelow.length; j++) { 5501 moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan); 5502 segsBelow = colSegsBelow[j]; 5503 cell = { row: row, col: seg.leftCol + j }; 5504 moreLink = this.renderMoreLink(cell, [ seg ].concat(segsBelow)); // count seg as hidden too 5505 moreWrap = $('<div/>').append(moreLink); 5506 moreTd.append(moreWrap); 5507 segMoreNodes.push(moreTd[0]); 5508 moreNodes.push(moreTd[0]); 5509 } 5510 5511 td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements 5512 limitedNodes.push(td[0]); 5513 } 5514 } 5515 5516 emptyCellsUntil(view.colCnt); // finish off the level 5517 rowStruct.moreEls = $(moreNodes); // for easy undoing later 5518 rowStruct.limitedEls = $(limitedNodes); // for easy undoing later 5519 } 5520 }, 5521 5522 5523 // Reveals all levels and removes all "more"-related elements for a grid's row. 5524 // `row` is a row number. 5525 unlimitRow: function(row) { 5526 var rowStruct = this.rowStructs[row]; 5527 5528 if (rowStruct.moreEls) { 5529 rowStruct.moreEls.remove(); 5530 rowStruct.moreEls = null; 5531 } 5532 5533 if (rowStruct.limitedEls) { 5534 rowStruct.limitedEls.removeClass('fc-limited'); 5535 rowStruct.limitedEls = null; 5536 } 5537 }, 5538 5539 5540 // Renders an <a> element that represents hidden event element for a cell. 5541 // Responsible for attaching click handler as well. 5542 renderMoreLink: function(cell, hiddenSegs) { 5543 var _this = this; 5544 var view = this.view; 5545 5546 return $('<a class="fc-more"/>') 5547 .text( 5548 this.getMoreLinkText(hiddenSegs.length) 5549 ) 5550 .on('click', function(ev) { 5551 var clickOption = view.opt('eventLimitClick'); 5552 var date = view.cellToDate(cell); 5553 var moreEl = $(this); 5554 var dayEl = _this.getCellDayEl(cell); 5555 var allSegs = _this.getCellSegs(cell); 5556 5557 // rescope the segments to be within the cell's date 5558 var reslicedAllSegs = _this.resliceDaySegs(allSegs, date); 5559 var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date); 5560 5561 if (typeof clickOption === 'function') { 5562 // the returned value can be an atomic option 5563 clickOption = view.trigger('eventLimitClick', null, { 5564 date: date, 5565 dayEl: dayEl, 5566 moreEl: moreEl, 5567 segs: reslicedAllSegs, 5568 hiddenSegs: reslicedHiddenSegs 5569 }, ev); 5570 } 5571 5572 if (clickOption === 'popover') { 5573 _this.showSegPopover(date, cell, moreEl, reslicedAllSegs); 5574 } 5575 else if (typeof clickOption === 'string') { // a view name 5576 view.calendar.zoomTo(date, clickOption); 5577 } 5578 }); 5579 }, 5580 5581 5582 // Reveals the popover that displays all events within a cell 5583 showSegPopover: function(date, cell, moreLink, segs) { 5584 var _this = this; 5585 var view = this.view; 5586 var moreWrap = moreLink.parent(); // the <div> wrapper around the <a> 5587 var topEl; // the element we want to match the top coordinate of 5588 var options; 5589 5590 if (view.rowCnt == 1) { 5591 topEl = this.view.el; // will cause the popover to cover any sort of header 5592 } 5593 else { 5594 topEl = this.rowEls.eq(cell.row); // will align with top of row 5595 } 5596 5597 options = { 5598 className: 'fc-more-popover', 5599 content: this.renderSegPopoverContent(date, segs), 5600 parentEl: this.el, 5601 top: topEl.offset().top, 5602 autoHide: true, // when the user clicks elsewhere, hide the popover 5603 viewportConstrain: view.opt('popoverViewportConstrain'), 5604 hide: function() { 5605 // destroy everything when the popover is hidden 5606 _this.segPopover.destroy(); 5607 _this.segPopover = null; 5608 _this.popoverSegs = null; 5609 } 5610 }; 5611 5612 // Determine horizontal coordinate. 5613 // We use the moreWrap instead of the <td> to avoid border confusion. 5614 if (view.opt('isRTL')) { 5615 options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border 5616 } 5617 else { 5618 options.left = moreWrap.offset().left - 1; // -1 to be over cell border 5619 } 5620 5621 this.segPopover = new Popover(options); 5622 this.segPopover.show(); 5623 }, 5624 5625 5626 // Builds the inner DOM contents of the segment popover 5627 renderSegPopoverContent: function(date, segs) { 5628 var view = this.view; 5629 var isTheme = view.opt('theme'); 5630 var title = date.format(view.opt('dayPopoverFormat')); 5631 var content = $( 5632 '<div class="fc-header ' + view.widgetHeaderClass + '">' + 5633 '<span class="fc-close ' + 5634 (isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') + 5635 '"></span>' + 5636 '<span class="fc-title">' + 5637 htmlEscape(title) + 5638 '</span>' + 5639 '<div class="fc-clear"/>' + 5640 '</div>' + 5641 '<div class="fc-body ' + view.widgetContentClass + '">' + 5642 '<div class="fc-event-container"></div>' + 5643 '</div>' 5644 ); 5645 var segContainer = content.find('.fc-event-container'); 5646 var i; 5647 5648 // render each seg's `el` and only return the visible segs 5649 segs = this.renderSegs(segs, true); // disableResizing=true 5650 this.popoverSegs = segs; 5651 5652 for (i = 0; i < segs.length; i++) { 5653 5654 // because segments in the popover are not part of a grid coordinate system, provide a hint to any 5655 // grids that want to do drag-n-drop about which cell it came from 5656 segs[i].cellDate = date; 5657 5658 segContainer.append(segs[i].el); 5659 } 5660 5661 return content; 5662 }, 5663 5664 5665 // Given the events within an array of segment objects, reslice them to be in a single day 5666 resliceDaySegs: function(segs, dayDate) { 5667 var events = $.map(segs, function(seg) { 5668 return seg.event; 5669 }); 5670 var dayStart = dayDate.clone().stripTime(); 5671 var dayEnd = dayStart.clone().add(1, 'days'); 5672 5673 return this.eventsToSegs(events, dayStart, dayEnd); 5674 }, 5675 5676 5677 // Generates the text that should be inside a "more" link, given the number of events it represents 5678 getMoreLinkText: function(num) { 5679 var view = this.view; 5680 var opt = view.opt('eventLimitText'); 5681 5682 if (typeof opt === 'function') { 5683 return opt(num); 5684 } 5685 else { 5686 return '+' + num + ' ' + opt; 5687 } 5688 }, 5689 5690 5691 // Returns segments within a given cell. 5692 // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs. 5693 getCellSegs: function(cell, startLevel) { 5694 var segMatrix = this.rowStructs[cell.row].segMatrix; 5695 var level = startLevel || 0; 5696 var segs = []; 5697 var seg; 5698 5699 while (level < segMatrix.length) { 5700 seg = segMatrix[level][cell.col]; 5701 if (seg) { 5702 segs.push(seg); 5703 } 5704 level++; 5705 } 5706 5707 return segs; 5708 } 5709 5710 }); 5711 5712 ;; 5713 5714 /* A component that renders one or more columns of vertical time slots 5715 ----------------------------------------------------------------------------------------------------------------------*/ 5716 5717 function TimeGrid(view) { 5718 Grid.call(this, view); // call the super-constructor 5719 } 5720 5721 5722 TimeGrid.prototype = createObject(Grid.prototype); // define the super-class 5723 $.extend(TimeGrid.prototype, { 5724 5725 slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines 5726 snapDuration: null, // granularity of time for dragging and selecting 5727 5728 minTime: null, // Duration object that denotes the first visible time of any given day 5729 maxTime: null, // Duration object that denotes the exclusive visible end time of any given day 5730 5731 dayEls: null, // cells elements in the day-row background 5732 slatEls: null, // elements running horizontally across all columns 5733 5734 slatTops: null, // an array of top positions, relative to the container. last item holds bottom of last slot 5735 5736 highlightEl: null, // cell skeleton element for rendering the highlight 5737 helperEl: null, // cell skeleton element for rendering the mock event "helper" 5738 5739 5740 // Renders the time grid into `this.el`, which should already be assigned. 5741 // Relies on the view's colCnt. In the future, this component should probably be self-sufficient. 5742 render: function() { 5743 this.processOptions(); 5744 5745 this.el.html(this.renderHtml()); 5746 5747 this.dayEls = this.el.find('.fc-day'); 5748 this.slatEls = this.el.find('.fc-slats tr'); 5749 5750 this.computeSlatTops(); 5751 5752 Grid.prototype.render.call(this); // call the super-method 5753 }, 5754 5755 5756 // Renders the basic HTML skeleton for the grid 5757 renderHtml: function() { 5758 return '' + 5759 '<div class="fc-bg">' + 5760 '<table>' + 5761 this.rowHtml('slotBg') + // leverages RowRenderer, which will call slotBgCellHtml 5762 '</table>' + 5763 '</div>' + 5764 '<div class="fc-slats">' + 5765 '<table>' + 5766 this.slatRowHtml() + 5767 '</table>' + 5768 '</div>'; 5769 }, 5770 5771 5772 // Renders the HTML for a vertical background cell behind the slots. 5773 // This method is distinct from 'bg' because we wanted a new `rowType` so the View could customize the rendering. 5774 slotBgCellHtml: function(row, col, date) { 5775 return this.bgCellHtml(row, col, date); 5776 }, 5777 5778 5779 // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL. 5780 slatRowHtml: function() { 5781 var view = this.view; 5782 var calendar = view.calendar; 5783 var isRTL = view.opt('isRTL'); 5784 var html = ''; 5785 var slotNormal = this.slotDuration.asMinutes() % 15 === 0; 5786 var slotTime = moment.duration(+this.minTime); // wish there was .clone() for durations 5787 var slotDate; // will be on the view's first day, but we only care about its time 5788 var minutes; 5789 var axisHtml; 5790 5791 // Calculate the time for each slot 5792 while (slotTime < this.maxTime) { 5793 slotDate = view.start.clone().time(slotTime); // will be in UTC but that's good. to avoid DST issues 5794 minutes = slotDate.minutes(); 5795 5796 axisHtml = 5797 '<td class="fc-axis fc-time ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' + 5798 ((!slotNormal || !minutes) ? // if irregular slot duration, or on the hour, then display the time 5799 '<span>' + // for matchCellWidths 5800 htmlEscape(calendar.formatDate(slotDate, view.opt('axisFormat'))) + 5801 '</span>' : 5802 '' 5803 ) + 5804 '</td>'; 5805 5806 html += 5807 '<tr ' + (!minutes ? '' : 'class="fc-minor"') + '>' + 5808 (!isRTL ? axisHtml : '') + 5809 '<td class="' + view.widgetContentClass + '"/>' + 5810 (isRTL ? axisHtml : '') + 5811 "</tr>"; 5812 5813 slotTime.add(this.slotDuration); 5814 } 5815 5816 return html; 5817 }, 5818 5819 5820 // Parses various options into properties of this object 5821 processOptions: function() { 5822 var view = this.view; 5823 var slotDuration = view.opt('slotDuration'); 5824 var snapDuration = view.opt('snapDuration'); 5825 5826 slotDuration = moment.duration(slotDuration); 5827 snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration; 5828 5829 this.slotDuration = slotDuration; 5830 this.snapDuration = snapDuration; 5831 this.cellDuration = snapDuration; // important to assign this for Grid.events.js 5832 5833 this.minTime = moment.duration(view.opt('minTime')); 5834 this.maxTime = moment.duration(view.opt('maxTime')); 5835 }, 5836 5837 5838 // Slices up a date range into a segment for each column 5839 rangeToSegs: function(rangeStart, rangeEnd) { 5840 var view = this.view; 5841 var segs = []; 5842 var seg; 5843 var col; 5844 var cellDate; 5845 var colStart, colEnd; 5846 5847 // normalize 5848 rangeStart = rangeStart.clone().stripZone(); 5849 rangeEnd = rangeEnd.clone().stripZone(); 5850 5851 for (col = 0; col < view.colCnt; col++) { 5852 cellDate = view.cellToDate(0, col); // use the View's cell system for this 5853 colStart = cellDate.clone().time(this.minTime); 5854 colEnd = cellDate.clone().time(this.maxTime); 5855 seg = intersectionToSeg(rangeStart, rangeEnd, colStart, colEnd); 5856 if (seg) { 5857 seg.col = col; 5858 segs.push(seg); 5859 } 5860 } 5861 5862 return segs; 5863 }, 5864 5865 5866 /* Coordinates 5867 ------------------------------------------------------------------------------------------------------------------*/ 5868 5869 5870 // Called when there is a window resize/zoom and we need to recalculate coordinates for the grid 5871 resize: function() { 5872 this.computeSlatTops(); 5873 this.updateSegVerticals(); 5874 }, 5875 5876 5877 // Populates the given empty `rows` and `cols` arrays with offset positions of the "snap" cells. 5878 // "Snap" cells are different the slots because they might have finer granularity. 5879 buildCoords: function(rows, cols) { 5880 var colCnt = this.view.colCnt; 5881 var originTop = this.el.offset().top; 5882 var snapTime = moment.duration(+this.minTime); 5883 var p = null; 5884 var e, n; 5885 5886 this.dayEls.slice(0, colCnt).each(function(i, _e) { 5887 e = $(_e); 5888 n = e.offset().left; 5889 if (p) { 5890 p[1] = n; 5891 } 5892 p = [ n ]; 5893 cols[i] = p; 5894 }); 5895 p[1] = n + e.outerWidth(); 5896 5897 p = null; 5898 while (snapTime < this.maxTime) { 5899 n = originTop + this.computeTimeTop(snapTime); 5900 if (p) { 5901 p[1] = n; 5902 } 5903 p = [ n ]; 5904 rows.push(p); 5905 snapTime.add(this.snapDuration); 5906 } 5907 p[1] = originTop + this.computeTimeTop(snapTime); // the position of the exclusive end 5908 }, 5909 5910 5911 // Gets the datetime for the given slot cell 5912 getCellDate: function(cell) { 5913 var view = this.view; 5914 var calendar = view.calendar; 5915 5916 return calendar.rezoneDate( // since we are adding a time, it needs to be in the calendar's timezone 5917 view.cellToDate(0, cell.col) // View's coord system only accounts for start-of-day for column 5918 .time(this.minTime + this.snapDuration * cell.row) 5919 ); 5920 }, 5921 5922 5923 // Gets the element that represents the whole-day the cell resides on 5924 getCellDayEl: function(cell) { 5925 return this.dayEls.eq(cell.col); 5926 }, 5927 5928 5929 // Computes the top coordinate, relative to the bounds of the grid, of the given date. 5930 // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight. 5931 computeDateTop: function(date, startOfDayDate) { 5932 return this.computeTimeTop( 5933 moment.duration( 5934 date.clone().stripZone() - startOfDayDate.clone().stripTime() 5935 ) 5936 ); 5937 }, 5938 5939 5940 // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration). 5941 computeTimeTop: function(time) { 5942 var slatCoverage = (time - this.minTime) / this.slotDuration; // floating-point value of # of slots covered 5943 var slatIndex; 5944 var slatRemainder; 5945 var slatTop; 5946 var slatBottom; 5947 5948 // constrain. because minTime/maxTime might be customized 5949 slatCoverage = Math.max(0, slatCoverage); 5950 slatCoverage = Math.min(this.slatEls.length, slatCoverage); 5951 5952 slatIndex = Math.floor(slatCoverage); // an integer index of the furthest whole slot 5953 slatRemainder = slatCoverage - slatIndex; 5954 slatTop = this.slatTops[slatIndex]; // the top position of the furthest whole slot 5955 5956 if (slatRemainder) { // time spans part-way into the slot 5957 slatBottom = this.slatTops[slatIndex + 1]; 5958 return slatTop + (slatBottom - slatTop) * slatRemainder; // part-way between slots 5959 } 5960 else { 5961 return slatTop; 5962 } 5963 }, 5964 5965 5966 // Queries each `slatEl` for its position relative to the grid's container and stores it in `slatTops`. 5967 // Includes the the bottom of the last slat as the last item in the array. 5968 computeSlatTops: function() { 5969 var tops = []; 5970 var top; 5971 5972 this.slatEls.each(function(i, node) { 5973 top = $(node).position().top; 5974 tops.push(top); 5975 }); 5976 5977 tops.push(top + this.slatEls.last().outerHeight()); // bottom of the last slat 5978 5979 this.slatTops = tops; 5980 }, 5981 5982 5983 /* Event Drag Visualization 5984 ------------------------------------------------------------------------------------------------------------------*/ 5985 5986 5987 // Renders a visual indication of an event being dragged over the specified date(s). 5988 // `end` and `seg` can be null. See View's documentation on renderDrag for more info. 5989 renderDrag: function(start, end, seg) { 5990 var opacity; 5991 5992 if (seg) { // if there is event information for this drag, render a helper event 5993 this.renderRangeHelper(start, end, seg); 5994 5995 opacity = this.view.opt('dragOpacity'); 5996 if (opacity !== undefined) { 5997 this.helperEl.css('opacity', opacity); 5998 } 5999 6000 return true; // signal that a helper has been rendered 6001 } 6002 else { 6003 // otherwise, just render a highlight 6004 this.renderHighlight( 6005 start, 6006 end || this.view.calendar.getDefaultEventEnd(false, start) 6007 ); 6008 } 6009 }, 6010 6011 6012 // Unrenders any visual indication of an event being dragged 6013 destroyDrag: function() { 6014 this.destroyHelper(); 6015 this.destroyHighlight(); 6016 }, 6017 6018 6019 /* Event Resize Visualization 6020 ------------------------------------------------------------------------------------------------------------------*/ 6021 6022 6023 // Renders a visual indication of an event being resized 6024 renderResize: function(start, end, seg) { 6025 this.renderRangeHelper(start, end, seg); 6026 }, 6027 6028 6029 // Unrenders any visual indication of an event being resized 6030 destroyResize: function() { 6031 this.destroyHelper(); 6032 }, 6033 6034 6035 /* Event Helper 6036 ------------------------------------------------------------------------------------------------------------------*/ 6037 6038 6039 // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag) 6040 renderHelper: function(event, sourceSeg) { 6041 var res = this.renderEventTable([ event ]); 6042 var tableEl = res.tableEl; 6043 var segs = res.segs; 6044 var i, seg; 6045 var sourceEl; 6046 6047 // Try to make the segment that is in the same row as sourceSeg look the same 6048 for (i = 0; i < segs.length; i++) { 6049 seg = segs[i]; 6050 if (sourceSeg && sourceSeg.col === seg.col) { 6051 sourceEl = sourceSeg.el; 6052 seg.el.css({ 6053 left: sourceEl.css('left'), 6054 right: sourceEl.css('right'), 6055 'margin-left': sourceEl.css('margin-left'), 6056 'margin-right': sourceEl.css('margin-right') 6057 }); 6058 } 6059 } 6060 6061 this.helperEl = $('<div class="fc-helper-skeleton"/>') 6062 .append(tableEl) 6063 .appendTo(this.el); 6064 }, 6065 6066 6067 // Unrenders any mock helper event 6068 destroyHelper: function() { 6069 if (this.helperEl) { 6070 this.helperEl.remove(); 6071 this.helperEl = null; 6072 } 6073 }, 6074 6075 6076 /* Selection 6077 ------------------------------------------------------------------------------------------------------------------*/ 6078 6079 6080 // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight. 6081 renderSelection: function(start, end) { 6082 if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered 6083 this.renderRangeHelper(start, end); 6084 } 6085 else { 6086 this.renderHighlight(start, end); 6087 } 6088 }, 6089 6090 6091 // Unrenders any visual indication of a selection 6092 destroySelection: function() { 6093 this.destroyHelper(); 6094 this.destroyHighlight(); 6095 }, 6096 6097 6098 /* Highlight 6099 ------------------------------------------------------------------------------------------------------------------*/ 6100 6101 6102 // Renders an emphasis on the given date range. `start` is inclusive. `end` is exclusive. 6103 renderHighlight: function(start, end) { 6104 this.highlightEl = $( 6105 this.highlightSkeletonHtml(start, end) 6106 ).appendTo(this.el); 6107 }, 6108 6109 6110 // Unrenders the emphasis on a date range 6111 destroyHighlight: function() { 6112 if (this.highlightEl) { 6113 this.highlightEl.remove(); 6114 this.highlightEl = null; 6115 } 6116 }, 6117 6118 6119 // Generates HTML for a table element with containers in each column, responsible for absolutely positioning the 6120 // highlight elements to cover the highlighted slots. 6121 highlightSkeletonHtml: function(start, end) { 6122 var view = this.view; 6123 var segs = this.rangeToSegs(start, end); 6124 var cellHtml = ''; 6125 var col = 0; 6126 var i, seg; 6127 var dayDate; 6128 var top, bottom; 6129 6130 for (i = 0; i < segs.length; i++) { // loop through the segments. one per column 6131 seg = segs[i]; 6132 6133 // need empty cells beforehand? 6134 if (col < seg.col) { 6135 cellHtml += '<td colspan="' + (seg.col - col) + '"/>'; 6136 col = seg.col; 6137 } 6138 6139 // compute vertical position 6140 dayDate = view.cellToDate(0, col); 6141 top = this.computeDateTop(seg.start, dayDate); 6142 bottom = this.computeDateTop(seg.end, dayDate); // the y position of the bottom edge 6143 6144 // generate the cell HTML. bottom becomes negative because it needs to be a CSS value relative to the 6145 // bottom edge of the zero-height container. 6146 cellHtml += 6147 '<td>' + 6148 '<div class="fc-highlight-container">' + 6149 '<div class="fc-highlight" style="top:' + top + 'px;bottom:-' + bottom + 'px"/>' + 6150 '</div>' + 6151 '</td>'; 6152 6153 col++; 6154 } 6155 6156 // need empty cells after the last segment? 6157 if (col < view.colCnt) { 6158 cellHtml += '<td colspan="' + (view.colCnt - col) + '"/>'; 6159 } 6160 6161 cellHtml = this.bookendCells(cellHtml, 'highlight'); 6162 6163 return '' + 6164 '<div class="fc-highlight-skeleton">' + 6165 '<table>' + 6166 '<tr>' + 6167 cellHtml + 6168 '</tr>' + 6169 '</table>' + 6170 '</div>'; 6171 } 6172 6173 }); 6174 6175 ;; 6176 6177 /* Event-rendering methods for the TimeGrid class 6178 ----------------------------------------------------------------------------------------------------------------------*/ 6179 6180 $.extend(TimeGrid.prototype, { 6181 6182 segs: null, // segment objects rendered in the component. null of events haven't been rendered yet 6183 eventSkeletonEl: null, // has cells with event-containers, which contain absolutely positioned event elements 6184 6185 6186 // Renders the events onto the grid and returns an array of segments that have been rendered 6187 renderEvents: function(events) { 6188 var res = this.renderEventTable(events); 6189 6190 this.eventSkeletonEl = $('<div class="fc-content-skeleton"/>').append(res.tableEl); 6191 this.el.append(this.eventSkeletonEl); 6192 6193 this.segs = res.segs; 6194 }, 6195 6196 6197 // Retrieves rendered segment objects 6198 getSegs: function() { 6199 return this.segs || []; 6200 }, 6201 6202 6203 // Removes all event segment elements from the view 6204 destroyEvents: function() { 6205 Grid.prototype.destroyEvents.call(this); // call the super-method 6206 6207 if (this.eventSkeletonEl) { 6208 this.eventSkeletonEl.remove(); 6209 this.eventSkeletonEl = null; 6210 } 6211 6212 this.segs = null; 6213 }, 6214 6215 6216 // Renders and returns the <table> portion of the event-skeleton. 6217 // Returns an object with properties 'tbodyEl' and 'segs'. 6218 renderEventTable: function(events) { 6219 var tableEl = $('<table><tr/></table>'); 6220 var trEl = tableEl.find('tr'); 6221 var segs = this.eventsToSegs(events); 6222 var segCols; 6223 var i, seg; 6224 var col, colSegs; 6225 var containerEl; 6226 6227 segs = this.renderSegs(segs); // returns only the visible segs 6228 segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg 6229 6230 this.computeSegVerticals(segs); // compute and assign top/bottom 6231 6232 for (col = 0; col < segCols.length; col++) { // iterate each column grouping 6233 colSegs = segCols[col]; 6234 placeSlotSegs(colSegs); // compute horizontal coordinates, z-index's, and reorder the array 6235 6236 containerEl = $('<div class="fc-event-container"/>'); 6237 6238 // assign positioning CSS and insert into container 6239 for (i = 0; i < colSegs.length; i++) { 6240 seg = colSegs[i]; 6241 seg.el.css(this.generateSegPositionCss(seg)); 6242 6243 // if the height is short, add a className for alternate styling 6244 if (seg.bottom - seg.top < 30) { 6245 seg.el.addClass('fc-short'); 6246 } 6247 6248 containerEl.append(seg.el); 6249 } 6250 6251 trEl.append($('<td/>').append(containerEl)); 6252 } 6253 6254 this.bookendCells(trEl, 'eventSkeleton'); 6255 6256 return { 6257 tableEl: tableEl, 6258 segs: segs 6259 }; 6260 }, 6261 6262 6263 // Refreshes the CSS top/bottom coordinates for each segment element. Probably after a window resize/zoom. 6264 updateSegVerticals: function() { 6265 var segs = this.segs; 6266 var i; 6267 6268 if (segs) { 6269 this.computeSegVerticals(segs); 6270 6271 for (i = 0; i < segs.length; i++) { 6272 segs[i].el.css( 6273 this.generateSegVerticalCss(segs[i]) 6274 ); 6275 } 6276 } 6277 }, 6278 6279 6280 // For each segment in an array, computes and assigns its top and bottom properties 6281 computeSegVerticals: function(segs) { 6282 var i, seg; 6283 6284 for (i = 0; i < segs.length; i++) { 6285 seg = segs[i]; 6286 seg.top = this.computeDateTop(seg.start, seg.start); 6287 seg.bottom = this.computeDateTop(seg.end, seg.start); 6288 } 6289 }, 6290 6291 6292 // Renders the HTML for a single event segment's default rendering 6293 renderSegHtml: function(seg, disableResizing) { 6294 var view = this.view; 6295 var event = seg.event; 6296 var isDraggable = view.isEventDraggable(event); 6297 var isResizable = !disableResizing && seg.isEnd && view.isEventResizable(event); 6298 var classes = this.getSegClasses(seg, isDraggable, isResizable); 6299 var skinCss = this.getEventSkinCss(event); 6300 var timeText; 6301 var fullTimeText; // more verbose time text. for the print stylesheet 6302 var startTimeText; // just the start time text 6303 6304 classes.unshift('fc-time-grid-event'); 6305 6306 if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day... 6307 // Don't display time text on segments that run entirely through a day. 6308 // That would appear as midnight-midnight and would look dumb. 6309 // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am) 6310 if (seg.isStart || seg.isEnd) { 6311 timeText = view.getEventTimeText(seg.start, seg.end); 6312 fullTimeText = view.getEventTimeText(seg.start, seg.end, 'LT'); 6313 startTimeText = view.getEventTimeText(seg.start, null); 6314 } 6315 } else { 6316 // Display the normal time text for the *event's* times 6317 timeText = view.getEventTimeText(event); 6318 fullTimeText = view.getEventTimeText(event, 'LT'); 6319 startTimeText = view.getEventTimeText(event.start, null); 6320 } 6321 6322 return '<a class="' + classes.join(' ') + '"' + 6323 (event.url ? 6324 ' href="' + htmlEscape(event.url) + '"' : 6325 '' 6326 ) + 6327 (skinCss ? 6328 ' style="' + skinCss + '"' : 6329 '' 6330 ) + 6331 '>' + 6332 '<div class="fc-content">' + 6333 (timeText ? 6334 '<div class="fc-time"' + 6335 ' data-start="' + htmlEscape(startTimeText) + '"' + 6336 ' data-full="' + htmlEscape(fullTimeText) + '"' + 6337 '>' + 6338 '<span>' + htmlEscape(timeText) + '</span>' + 6339 '</div>' : 6340 '' 6341 ) + 6342 (event.title ? 6343 '<div class="fc-title">' + 6344 htmlEscape(event.title) + 6345 '</div>' : 6346 '' 6347 ) + 6348 '</div>' + 6349 '<div class="fc-bg"/>' + 6350 (isResizable ? 6351 '<div class="fc-resizer"/>' : 6352 '' 6353 ) + 6354 '</a>'; 6355 }, 6356 6357 6358 // Generates an object with CSS properties/values that should be applied to an event segment element. 6359 // Contains important positioning-related properties that should be applied to any event element, customized or not. 6360 generateSegPositionCss: function(seg) { 6361 var view = this.view; 6362 var isRTL = view.opt('isRTL'); 6363 var shouldOverlap = view.opt('slotEventOverlap'); 6364 var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point 6365 var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point 6366 var props = this.generateSegVerticalCss(seg); // get top/bottom first 6367 var left; // amount of space from left edge, a fraction of the total width 6368 var right; // amount of space from right edge, a fraction of the total width 6369 6370 if (shouldOverlap) { 6371 // double the width, but don't go beyond the maximum forward coordinate (1.0) 6372 forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2); 6373 } 6374 6375 if (isRTL) { 6376 left = 1 - forwardCoord; 6377 right = backwardCoord; 6378 } 6379 else { 6380 left = backwardCoord; 6381 right = 1 - forwardCoord; 6382 } 6383 6384 props.zIndex = seg.level + 1; // convert from 0-base to 1-based 6385 props.left = left * 100 + '%'; 6386 props.right = right * 100 + '%'; 6387 6388 if (shouldOverlap && seg.forwardPressure) { 6389 // add padding to the edge so that forward stacked events don't cover the resizer's icon 6390 props[isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width 6391 } 6392 6393 return props; 6394 }, 6395 6396 6397 // Generates an object with CSS properties for the top/bottom coordinates of a segment element 6398 generateSegVerticalCss: function(seg) { 6399 return { 6400 top: seg.top, 6401 bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container 6402 }; 6403 }, 6404 6405 6406 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col 6407 groupSegCols: function(segs) { 6408 var view = this.view; 6409 var segCols = []; 6410 var i; 6411 6412 for (i = 0; i < view.colCnt; i++) { 6413 segCols.push([]); 6414 } 6415 6416 for (i = 0; i < segs.length; i++) { 6417 segCols[segs[i].col].push(segs[i]); 6418 } 6419 6420 return segCols; 6421 } 6422 6423 }); 6424 6425 6426 // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each. 6427 // Also reorders the given array by date! 6428 function placeSlotSegs(segs) { 6429 var levels; 6430 var level0; 6431 var i; 6432 6433 segs.sort(compareSegs); // order by date 6434 levels = buildSlotSegLevels(segs); 6435 computeForwardSlotSegs(levels); 6436 6437 if ((level0 = levels[0])) { 6438 6439 for (i = 0; i < level0.length; i++) { 6440 computeSlotSegPressures(level0[i]); 6441 } 6442 6443 for (i = 0; i < level0.length; i++) { 6444 computeSlotSegCoords(level0[i], 0, 0); 6445 } 6446 } 6447 } 6448 6449 6450 // Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is 6451 // left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date. 6452 function buildSlotSegLevels(segs) { 6453 var levels = []; 6454 var i, seg; 6455 var j; 6456 6457 for (i=0; i<segs.length; i++) { 6458 seg = segs[i]; 6459 6460 // go through all the levels and stop on the first level where there are no collisions 6461 for (j=0; j<levels.length; j++) { 6462 if (!computeSlotSegCollisions(seg, levels[j]).length) { 6463 break; 6464 } 6465 } 6466 6467 seg.level = j; 6468 6469 (levels[j] || (levels[j] = [])).push(seg); 6470 } 6471 6472 return levels; 6473 } 6474 6475 6476 // For every segment, figure out the other segments that are in subsequent 6477 // levels that also occupy the same vertical space. Accumulate in seg.forwardSegs 6478 function computeForwardSlotSegs(levels) { 6479 var i, level; 6480 var j, seg; 6481 var k; 6482 6483 for (i=0; i<levels.length; i++) { 6484 level = levels[i]; 6485 6486 for (j=0; j<level.length; j++) { 6487 seg = level[j]; 6488 6489 seg.forwardSegs = []; 6490 for (k=i+1; k<levels.length; k++) { 6491 computeSlotSegCollisions(seg, levels[k], seg.forwardSegs); 6492 } 6493 } 6494 } 6495 } 6496 6497 6498 // Figure out which path forward (via seg.forwardSegs) results in the longest path until 6499 // the furthest edge is reached. The number of segments in this path will be seg.forwardPressure 6500 function computeSlotSegPressures(seg) { 6501 var forwardSegs = seg.forwardSegs; 6502 var forwardPressure = 0; 6503 var i, forwardSeg; 6504 6505 if (seg.forwardPressure === undefined) { // not already computed 6506 6507 for (i=0; i<forwardSegs.length; i++) { 6508 forwardSeg = forwardSegs[i]; 6509 6510 // figure out the child's maximum forward path 6511 computeSlotSegPressures(forwardSeg); 6512 6513 // either use the existing maximum, or use the child's forward pressure 6514 // plus one (for the forwardSeg itself) 6515 forwardPressure = Math.max( 6516 forwardPressure, 6517 1 + forwardSeg.forwardPressure 6518 ); 6519 } 6520 6521 seg.forwardPressure = forwardPressure; 6522 } 6523 } 6524 6525 6526 // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range 6527 // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and 6528 // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left. 6529 // 6530 // The segment might be part of a "series", which means consecutive segments with the same pressure 6531 // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of 6532 // segments behind this one in the current series, and `seriesBackwardCoord` is the starting 6533 // coordinate of the first segment in the series. 6534 function computeSlotSegCoords(seg, seriesBackwardPressure, seriesBackwardCoord) { 6535 var forwardSegs = seg.forwardSegs; 6536 var i; 6537 6538 if (seg.forwardCoord === undefined) { // not already computed 6539 6540 if (!forwardSegs.length) { 6541 6542 // if there are no forward segments, this segment should butt up against the edge 6543 seg.forwardCoord = 1; 6544 } 6545 else { 6546 6547 // sort highest pressure first 6548 forwardSegs.sort(compareForwardSlotSegs); 6549 6550 // this segment's forwardCoord will be calculated from the backwardCoord of the 6551 // highest-pressure forward segment. 6552 computeSlotSegCoords(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord); 6553 seg.forwardCoord = forwardSegs[0].backwardCoord; 6554 } 6555 6556 // calculate the backwardCoord from the forwardCoord. consider the series 6557 seg.backwardCoord = seg.forwardCoord - 6558 (seg.forwardCoord - seriesBackwardCoord) / // available width for series 6559 (seriesBackwardPressure + 1); // # of segments in the series 6560 6561 // use this segment's coordinates to computed the coordinates of the less-pressurized 6562 // forward segments 6563 for (i=0; i<forwardSegs.length; i++) { 6564 computeSlotSegCoords(forwardSegs[i], 0, seg.forwardCoord); 6565 } 6566 } 6567 } 6568 6569 6570 // Find all the segments in `otherSegs` that vertically collide with `seg`. 6571 // Append into an optionally-supplied `results` array and return. 6572 function computeSlotSegCollisions(seg, otherSegs, results) { 6573 results = results || []; 6574 6575 for (var i=0; i<otherSegs.length; i++) { 6576 if (isSlotSegCollision(seg, otherSegs[i])) { 6577 results.push(otherSegs[i]); 6578 } 6579 } 6580 6581 return results; 6582 } 6583 6584 6585 // Do these segments occupy the same vertical space? 6586 function isSlotSegCollision(seg1, seg2) { 6587 return seg1.bottom > seg2.top && seg1.top < seg2.bottom; 6588 } 6589 6590 6591 // A cmp function for determining which forward segment to rely on more when computing coordinates. 6592 function compareForwardSlotSegs(seg1, seg2) { 6593 // put higher-pressure first 6594 return seg2.forwardPressure - seg1.forwardPressure || 6595 // put segments that are closer to initial edge first (and favor ones with no coords yet) 6596 (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) || 6597 // do normal sorting... 6598 compareSegs(seg1, seg2); 6599 } 6600 6601 ;; 6602 6603 /* An abstract class from which other views inherit from 6604 ----------------------------------------------------------------------------------------------------------------------*/ 6605 // Newer methods should be written as prototype methods, not in the monster `View` function at the bottom. 6606 6607 View.prototype = { 6608 6609 calendar: null, // owner Calendar object 6610 coordMap: null, // a CoordMap object for converting pixel regions to dates 6611 el: null, // the view's containing element. set by Calendar 6612 6613 // important Moments 6614 start: null, // the date of the very first cell 6615 end: null, // the date after the very last cell 6616 intervalStart: null, // the start of the interval of time the view represents (1st of month for month view) 6617 intervalEnd: null, // the exclusive end of the interval of time the view represents 6618 6619 // used for cell-to-date and date-to-cell calculations 6620 rowCnt: null, // # of weeks 6621 colCnt: null, // # of days displayed in a week 6622 6623 isSelected: false, // boolean whether cells are user-selected or not 6624 6625 // subclasses can optionally use a scroll container 6626 scrollerEl: null, // the element that will most likely scroll when content is too tall 6627 scrollTop: null, // cached vertical scroll value 6628 6629 // classNames styled by jqui themes 6630 widgetHeaderClass: null, 6631 widgetContentClass: null, 6632 highlightStateClass: null, 6633 6634 // document handlers, bound to `this` object 6635 documentMousedownProxy: null, 6636 documentDragStartProxy: null, 6637 6638 6639 // Serves as a "constructor" to suppliment the monster `View` constructor below 6640 init: function() { 6641 var tm = this.opt('theme') ? 'ui' : 'fc'; 6642 6643 this.widgetHeaderClass = tm + '-widget-header'; 6644 this.widgetContentClass = tm + '-widget-content'; 6645 this.highlightStateClass = tm + '-state-highlight'; 6646 6647 // save references to `this`-bound handlers 6648 this.documentMousedownProxy = $.proxy(this, 'documentMousedown'); 6649 this.documentDragStartProxy = $.proxy(this, 'documentDragStart'); 6650 }, 6651 6652 6653 // Renders the view inside an already-defined `this.el`. 6654 // Subclasses should override this and then call the super method afterwards. 6655 render: function() { 6656 this.updateSize(); 6657 this.trigger('viewRender', this, this, this.el); 6658 6659 // attach handlers to document. do it here to allow for destroy/rerender 6660 $(document) 6661 .on('mousedown', this.documentMousedownProxy) 6662 .on('dragstart', this.documentDragStartProxy); // jqui drag 6663 }, 6664 6665 6666 // Clears all view rendering, event elements, and unregisters handlers 6667 destroy: function() { 6668 this.unselect(); 6669 this.trigger('viewDestroy', this, this, this.el); 6670 this.destroyEvents(); 6671 this.el.empty(); // removes inner contents but leaves the element intact 6672 6673 $(document) 6674 .off('mousedown', this.documentMousedownProxy) 6675 .off('dragstart', this.documentDragStartProxy); 6676 }, 6677 6678 6679 // Used to determine what happens when the users clicks next/prev. Given -1 for prev, 1 for next. 6680 // Should apply the delta to `date` (a Moment) and return it. 6681 incrementDate: function(date, delta) { 6682 // subclasses should implement 6683 }, 6684 6685 6686 /* Dimensions 6687 ------------------------------------------------------------------------------------------------------------------*/ 6688 6689 6690 // Refreshes anything dependant upon sizing of the container element of the grid 6691 updateSize: function(isResize) { 6692 if (isResize) { 6693 this.recordScroll(); 6694 } 6695 this.updateHeight(); 6696 this.updateWidth(); 6697 }, 6698 6699 6700 // Refreshes the horizontal dimensions of the calendar 6701 updateWidth: function() { 6702 // subclasses should implement 6703 }, 6704 6705 6706 // Refreshes the vertical dimensions of the calendar 6707 updateHeight: function() { 6708 var calendar = this.calendar; // we poll the calendar for height information 6709 6710 this.setHeight( 6711 calendar.getSuggestedViewHeight(), 6712 calendar.isHeightAuto() 6713 ); 6714 }, 6715 6716 6717 // Updates the vertical dimensions of the calendar to the specified height. 6718 // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height. 6719 setHeight: function(height, isAuto) { 6720 // subclasses should implement 6721 }, 6722 6723 6724 // Given the total height of the view, return the number of pixels that should be used for the scroller. 6725 // Utility for subclasses. 6726 computeScrollerHeight: function(totalHeight) { 6727 var both = this.el.add(this.scrollerEl); 6728 var otherHeight; // cumulative height of everything that is not the scrollerEl in the view (header+borders) 6729 6730 // fuckin IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked 6731 both.css({ 6732 position: 'relative', // cause a reflow, which will force fresh dimension recalculation 6733 left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll 6734 }); 6735 otherHeight = this.el.outerHeight() - this.scrollerEl.height(); // grab the dimensions 6736 both.css({ position: '', left: '' }); // undo hack 6737 6738 return totalHeight - otherHeight; 6739 }, 6740 6741 6742 // Called for remembering the current scroll value of the scroller. 6743 // Should be called before there is a destructive operation (like removing DOM elements) that might inadvertently 6744 // change the scroll of the container. 6745 recordScroll: function() { 6746 if (this.scrollerEl) { 6747 this.scrollTop = this.scrollerEl.scrollTop(); 6748 } 6749 }, 6750 6751 6752 // Set the scroll value of the scroller to the previously recorded value. 6753 // Should be called after we know the view's dimensions have been restored following some type of destructive 6754 // operation (like temporarily removing DOM elements). 6755 restoreScroll: function() { 6756 if (this.scrollTop !== null) { 6757 this.scrollerEl.scrollTop(this.scrollTop); 6758 } 6759 }, 6760 6761 6762 /* Events 6763 ------------------------------------------------------------------------------------------------------------------*/ 6764 6765 6766 // Renders the events onto the view. 6767 // Should be overriden by subclasses. Subclasses should call the super-method afterwards. 6768 renderEvents: function(events) { 6769 this.segEach(function(seg) { 6770 this.trigger('eventAfterRender', seg.event, seg.event, seg.el); 6771 }); 6772 this.trigger('eventAfterAllRender'); 6773 }, 6774 6775 6776 // Removes event elements from the view. 6777 // Should be overridden by subclasses. Should call this super-method FIRST, then subclass DOM destruction. 6778 destroyEvents: function() { 6779 this.segEach(function(seg) { 6780 this.trigger('eventDestroy', seg.event, seg.event, seg.el); 6781 }); 6782 }, 6783 6784 6785 // Given an event and the default element used for rendering, returns the element that should actually be used. 6786 // Basically runs events and elements through the eventRender hook. 6787 resolveEventEl: function(event, el) { 6788 var custom = this.trigger('eventRender', event, event, el); 6789 6790 if (custom === false) { // means don't render at all 6791 el = null; 6792 } 6793 else if (custom && custom !== true) { 6794 el = $(custom); 6795 } 6796 6797 return el; 6798 }, 6799 6800 6801 // Hides all rendered event segments linked to the given event 6802 showEvent: function(event) { 6803 this.segEach(function(seg) { 6804 seg.el.css('visibility', ''); 6805 }, event); 6806 }, 6807 6808 6809 // Shows all rendered event segments linked to the given event 6810 hideEvent: function(event) { 6811 this.segEach(function(seg) { 6812 seg.el.css('visibility', 'hidden'); 6813 }, event); 6814 }, 6815 6816 6817 // Iterates through event segments. Goes through all by default. 6818 // If the optional `event` argument is specified, only iterates through segments linked to that event. 6819 // The `this` value of the callback function will be the view. 6820 segEach: function(func, event) { 6821 var segs = this.getSegs(); 6822 var i; 6823 6824 for (i = 0; i < segs.length; i++) { 6825 if (!event || segs[i].event._id === event._id) { 6826 func.call(this, segs[i]); 6827 } 6828 } 6829 }, 6830 6831 6832 // Retrieves all the rendered segment objects for the view 6833 getSegs: function() { 6834 // subclasses must implement 6835 }, 6836 6837 6838 /* Event Drag Visualization 6839 ------------------------------------------------------------------------------------------------------------------*/ 6840 6841 6842 // Renders a visual indication of an event hovering over the specified date. 6843 // `end` is a Moment and might be null. 6844 // `seg` might be null. if specified, it is the segment object of the event being dragged. 6845 // otherwise, an external event from outside the calendar is being dragged. 6846 renderDrag: function(start, end, seg) { 6847 // subclasses should implement 6848 }, 6849 6850 6851 // Unrenders a visual indication of event hovering 6852 destroyDrag: function() { 6853 // subclasses should implement 6854 }, 6855 6856 6857 // Handler for accepting externally dragged events being dropped in the view. 6858 // Gets called when jqui's 'dragstart' is fired. 6859 documentDragStart: function(ev, ui) { 6860 var _this = this; 6861 var dropDate = null; 6862 var dragListener; 6863 6864 if (this.opt('droppable')) { // only listen if this setting is on 6865 6866 // listener that tracks mouse movement over date-associated pixel regions 6867 dragListener = new DragListener(this.coordMap, { 6868 cellOver: function(cell, date) { 6869 dropDate = date; 6870 _this.renderDrag(date); 6871 }, 6872 cellOut: function() { 6873 dropDate = null; 6874 _this.destroyDrag(); 6875 } 6876 }); 6877 6878 // gets called, only once, when jqui drag is finished 6879 $(document).one('dragstop', function(ev, ui) { 6880 _this.destroyDrag(); 6881 if (dropDate) { 6882 _this.trigger('drop', ev.target, dropDate, ev, ui); 6883 } 6884 }); 6885 6886 dragListener.startDrag(ev); // start listening immediately 6887 } 6888 }, 6889 6890 6891 /* Selection 6892 ------------------------------------------------------------------------------------------------------------------*/ 6893 6894 6895 // Selects a date range on the view. `start` and `end` are both Moments. 6896 // `ev` is the native mouse event that begin the interaction. 6897 select: function(start, end, ev) { 6898 this.unselect(ev); 6899 this.renderSelection(start, end); 6900 this.reportSelection(start, end, ev); 6901 }, 6902 6903 6904 // Renders a visual indication of the selection 6905 renderSelection: function(start, end) { 6906 // subclasses should implement 6907 }, 6908 6909 6910 // Called when a new selection is made. Updates internal state and triggers handlers. 6911 reportSelection: function(start, end, ev) { 6912 this.isSelected = true; 6913 this.trigger('select', null, start, end, ev); 6914 }, 6915 6916 6917 // Undoes a selection. updates in the internal state and triggers handlers. 6918 // `ev` is the native mouse event that began the interaction. 6919 unselect: function(ev) { 6920 if (this.isSelected) { 6921 this.isSelected = false; 6922 this.destroySelection(); 6923 this.trigger('unselect', null, ev); 6924 } 6925 }, 6926 6927 6928 // Unrenders a visual indication of selection 6929 destroySelection: function() { 6930 // subclasses should implement 6931 }, 6932 6933 6934 // Handler for unselecting when the user clicks something and the 'unselectAuto' setting is on 6935 documentMousedown: function(ev) { 6936 var ignore; 6937 6938 // is there a selection, and has the user made a proper left click? 6939 if (this.isSelected && this.opt('unselectAuto') && isPrimaryMouseButton(ev)) { 6940 6941 // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element 6942 ignore = this.opt('unselectCancel'); 6943 if (!ignore || !$(ev.target).closest(ignore).length) { 6944 this.unselect(ev); 6945 } 6946 } 6947 } 6948 6949 }; 6950 6951 6952 // We are mixing JavaScript OOP design patterns here by putting methods and member variables in the closed scope of the 6953 // constructor. Going forward, methods should be part of the prototype. 6954 function View(calendar) { 6955 var t = this; 6956 6957 // exports 6958 t.calendar = calendar; 6959 t.opt = opt; 6960 t.trigger = trigger; 6961 t.isEventDraggable = isEventDraggable; 6962 t.isEventResizable = isEventResizable; 6963 t.eventDrop = eventDrop; 6964 t.eventResize = eventResize; 6965 6966 // imports 6967 var reportEventChange = calendar.reportEventChange; 6968 6969 // locals 6970 var options = calendar.options; 6971 var nextDayThreshold = moment.duration(options.nextDayThreshold); 6972 6973 6974 t.init(); // the "constructor" that concerns the prototype methods 6975 6976 6977 function opt(name) { 6978 var v = options[name]; 6979 if ($.isPlainObject(v) && !isForcedAtomicOption(name)) { 6980 return smartProperty(v, t.name); 6981 } 6982 return v; 6983 } 6984 6985 6986 function trigger(name, thisObj) { 6987 return calendar.trigger.apply( 6988 calendar, 6989 [name, thisObj || t].concat(Array.prototype.slice.call(arguments, 2), [t]) 6990 ); 6991 } 6992 6993 6994 6995 /* Event Editable Boolean Calculations 6996 ------------------------------------------------------------------------------*/ 6997 6998 6999 function isEventDraggable(event) { 7000 var source = event.source || {}; 7001 7002 return firstDefined( 7003 event.startEditable, 7004 source.startEditable, 7005 opt('eventStartEditable'), 7006 event.editable, 7007 source.editable, 7008 opt('editable') 7009 ); 7010 } 7011 7012 7013 function isEventResizable(event) { 7014 var source = event.source || {}; 7015 7016 return firstDefined( 7017 event.durationEditable, 7018 source.durationEditable, 7019 opt('eventDurationEditable'), 7020 event.editable, 7021 source.editable, 7022 opt('editable') 7023 ); 7024 } 7025 7026 7027 7028 /* Event Elements 7029 ------------------------------------------------------------------------------*/ 7030 7031 7032 // Compute the text that should be displayed on an event's element. 7033 // Based off the settings of the view. Possible signatures: 7034 // .getEventTimeText(event, formatStr) 7035 // .getEventTimeText(startMoment, endMoment, formatStr) 7036 // .getEventTimeText(startMoment, null, formatStr) 7037 // `timeFormat` is used but the `formatStr` argument can be used to override. 7038 t.getEventTimeText = function(event, formatStr) { 7039 var start; 7040 var end; 7041 7042 if (typeof event === 'object' && typeof formatStr === 'object') { 7043 // first two arguments are actually moments (or null). shift arguments. 7044 start = event; 7045 end = formatStr; 7046 formatStr = arguments[2]; 7047 } 7048 else { 7049 // otherwise, an event object was the first argument 7050 start = event.start; 7051 end = event.end; 7052 } 7053 7054 formatStr = formatStr || opt('timeFormat'); 7055 7056 if (end && opt('displayEventEnd')) { 7057 return calendar.formatRange(start, end, formatStr); 7058 } 7059 else { 7060 return calendar.formatDate(start, formatStr); 7061 } 7062 }; 7063 7064 7065 7066 /* Event Modification Reporting 7067 ---------------------------------------------------------------------------------*/ 7068 7069 7070 function eventDrop(el, event, newStart, ev) { 7071 var mutateResult = calendar.mutateEvent(event, newStart, null); 7072 7073 trigger( 7074 'eventDrop', 7075 el, 7076 event, 7077 mutateResult.dateDelta, 7078 function() { 7079 mutateResult.undo(); 7080 reportEventChange(); 7081 }, 7082 ev, 7083 {} // jqui dummy 7084 ); 7085 7086 reportEventChange(); 7087 } 7088 7089 7090 function eventResize(el, event, newEnd, ev) { 7091 var mutateResult = calendar.mutateEvent(event, null, newEnd); 7092 7093 trigger( 7094 'eventResize', 7095 el, 7096 event, 7097 mutateResult.durationDelta, 7098 function() { 7099 mutateResult.undo(); 7100 reportEventChange(); 7101 }, 7102 ev, 7103 {} // jqui dummy 7104 ); 7105 7106 reportEventChange(); 7107 } 7108 7109 7110 // ==================================================================================================== 7111 // Utilities for day "cells" 7112 // ==================================================================================================== 7113 // The "basic" views are completely made up of day cells. 7114 // The "agenda" views have day cells at the top "all day" slot. 7115 // This was the obvious common place to put these utilities, but they should be abstracted out into 7116 // a more meaningful class (like DayEventRenderer). 7117 // ==================================================================================================== 7118 7119 7120 // For determining how a given "cell" translates into a "date": 7121 // 7122 // 1. Convert the "cell" (row and column) into a "cell offset" (the # of the cell, cronologically from the first). 7123 // Keep in mind that column indices are inverted with isRTL. This is taken into account. 7124 // 7125 // 2. Convert the "cell offset" to a "day offset" (the # of days since the first visible day in the view). 7126 // 7127 // 3. Convert the "day offset" into a "date" (a Moment). 7128 // 7129 // The reverse transformation happens when transforming a date into a cell. 7130 7131 7132 // exports 7133 t.isHiddenDay = isHiddenDay; 7134 t.skipHiddenDays = skipHiddenDays; 7135 t.getCellsPerWeek = getCellsPerWeek; 7136 t.dateToCell = dateToCell; 7137 t.dateToDayOffset = dateToDayOffset; 7138 t.dayOffsetToCellOffset = dayOffsetToCellOffset; 7139 t.cellOffsetToCell = cellOffsetToCell; 7140 t.cellToDate = cellToDate; 7141 t.cellToCellOffset = cellToCellOffset; 7142 t.cellOffsetToDayOffset = cellOffsetToDayOffset; 7143 t.dayOffsetToDate = dayOffsetToDate; 7144 t.rangeToSegments = rangeToSegments; 7145 t.isMultiDayEvent = isMultiDayEvent; 7146 7147 7148 // internals 7149 var hiddenDays = opt('hiddenDays') || []; // array of day-of-week indices that are hidden 7150 var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool) 7151 var cellsPerWeek; 7152 var dayToCellMap = []; // hash from dayIndex -> cellIndex, for one week 7153 var cellToDayMap = []; // hash from cellIndex -> dayIndex, for one week 7154 var isRTL = opt('isRTL'); 7155 7156 7157 // initialize important internal variables 7158 (function() { 7159 7160 if (opt('weekends') === false) { 7161 hiddenDays.push(0, 6); // 0=sunday, 6=saturday 7162 } 7163 7164 // Loop through a hypothetical week and determine which 7165 // days-of-week are hidden. Record in both hashes (one is the reverse of the other). 7166 for (var dayIndex=0, cellIndex=0; dayIndex<7; dayIndex++) { 7167 dayToCellMap[dayIndex] = cellIndex; 7168 isHiddenDayHash[dayIndex] = $.inArray(dayIndex, hiddenDays) != -1; 7169 if (!isHiddenDayHash[dayIndex]) { 7170 cellToDayMap[cellIndex] = dayIndex; 7171 cellIndex++; 7172 } 7173 } 7174 7175 cellsPerWeek = cellIndex; 7176 if (!cellsPerWeek) { 7177 throw 'invalid hiddenDays'; // all days were hidden? bad. 7178 } 7179 7180 })(); 7181 7182 7183 // Is the current day hidden? 7184 // `day` is a day-of-week index (0-6), or a Moment 7185 function isHiddenDay(day) { 7186 if (moment.isMoment(day)) { 7187 day = day.day(); 7188 } 7189 return isHiddenDayHash[day]; 7190 } 7191 7192 7193 function getCellsPerWeek() { 7194 return cellsPerWeek; 7195 } 7196 7197 7198 // Incrementing the current day until it is no longer a hidden day, returning a copy. 7199 // If the initial value of `date` is not a hidden day, don't do anything. 7200 // Pass `isExclusive` as `true` if you are dealing with an end date. 7201 // `inc` defaults to `1` (increment one day forward each time) 7202 function skipHiddenDays(date, inc, isExclusive) { 7203 var out = date.clone(); 7204 inc = inc || 1; 7205 while ( 7206 isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7] 7207 ) { 7208 out.add(inc, 'days'); 7209 } 7210 return out; 7211 } 7212 7213 7214 // 7215 // TRANSFORMATIONS: cell -> cell offset -> day offset -> date 7216 // 7217 7218 // cell -> date (combines all transformations) 7219 // Possible arguments: 7220 // - row, col 7221 // - { row:#, col: # } 7222 function cellToDate() { 7223 var cellOffset = cellToCellOffset.apply(null, arguments); 7224 var dayOffset = cellOffsetToDayOffset(cellOffset); 7225 var date = dayOffsetToDate(dayOffset); 7226 return date; 7227 } 7228 7229 // cell -> cell offset 7230 // Possible arguments: 7231 // - row, col 7232 // - { row:#, col:# } 7233 function cellToCellOffset(row, col) { 7234 var colCnt = t.colCnt; 7235 7236 // rtl variables. wish we could pre-populate these. but where? 7237 var dis = isRTL ? -1 : 1; 7238 var dit = isRTL ? colCnt - 1 : 0; 7239 7240 if (typeof row == 'object') { 7241 col = row.col; 7242 row = row.row; 7243 } 7244 var cellOffset = row * colCnt + (col * dis + dit); // column, adjusted for RTL (dis & dit) 7245 7246 return cellOffset; 7247 } 7248 7249 // cell offset -> day offset 7250 function cellOffsetToDayOffset(cellOffset) { 7251 var day0 = t.start.day(); // first date's day of week 7252 cellOffset += dayToCellMap[day0]; // normlize cellOffset to beginning-of-week 7253 return Math.floor(cellOffset / cellsPerWeek) * 7 + // # of days from full weeks 7254 cellToDayMap[ // # of days from partial last week 7255 (cellOffset % cellsPerWeek + cellsPerWeek) % cellsPerWeek // crazy math to handle negative cellOffsets 7256 ] - 7257 day0; // adjustment for beginning-of-week normalization 7258 } 7259 7260 // day offset -> date 7261 function dayOffsetToDate(dayOffset) { 7262 return t.start.clone().add(dayOffset, 'days'); 7263 } 7264 7265 7266 // 7267 // TRANSFORMATIONS: date -> day offset -> cell offset -> cell 7268 // 7269 7270 // date -> cell (combines all transformations) 7271 function dateToCell(date) { 7272 var dayOffset = dateToDayOffset(date); 7273 var cellOffset = dayOffsetToCellOffset(dayOffset); 7274 var cell = cellOffsetToCell(cellOffset); 7275 return cell; 7276 } 7277 7278 // date -> day offset 7279 function dateToDayOffset(date) { 7280 return date.clone().stripTime().diff(t.start, 'days'); 7281 } 7282 7283 // day offset -> cell offset 7284 function dayOffsetToCellOffset(dayOffset) { 7285 var day0 = t.start.day(); // first date's day of week 7286 dayOffset += day0; // normalize dayOffset to beginning-of-week 7287 return Math.floor(dayOffset / 7) * cellsPerWeek + // # of cells from full weeks 7288 dayToCellMap[ // # of cells from partial last week 7289 (dayOffset % 7 + 7) % 7 // crazy math to handle negative dayOffsets 7290 ] - 7291 dayToCellMap[day0]; // adjustment for beginning-of-week normalization 7292 } 7293 7294 // cell offset -> cell (object with row & col keys) 7295 function cellOffsetToCell(cellOffset) { 7296 var colCnt = t.colCnt; 7297 7298 // rtl variables. wish we could pre-populate these. but where? 7299 var dis = isRTL ? -1 : 1; 7300 var dit = isRTL ? colCnt - 1 : 0; 7301 7302 var row = Math.floor(cellOffset / colCnt); 7303 var col = ((cellOffset % colCnt + colCnt) % colCnt) * dis + dit; // column, adjusted for RTL (dis & dit) 7304 return { 7305 row: row, 7306 col: col 7307 }; 7308 } 7309 7310 7311 // 7312 // Converts a date range into an array of segment objects. 7313 // "Segments" are horizontal stretches of time, sliced up by row. 7314 // A segment object has the following properties: 7315 // - row 7316 // - cols 7317 // - isStart 7318 // - isEnd 7319 // 7320 function rangeToSegments(start, end) { 7321 7322 var rowCnt = t.rowCnt; 7323 var colCnt = t.colCnt; 7324 var segments = []; // array of segments to return 7325 7326 // day offset for given date range 7327 var dayRange = computeDayRange(start, end); // convert to a whole-day range 7328 var rangeDayOffsetStart = dateToDayOffset(dayRange.start); 7329 var rangeDayOffsetEnd = dateToDayOffset(dayRange.end); // an exclusive value 7330 7331 // first and last cell offset for the given date range 7332 // "last" implies inclusivity 7333 var rangeCellOffsetFirst = dayOffsetToCellOffset(rangeDayOffsetStart); 7334 var rangeCellOffsetLast = dayOffsetToCellOffset(rangeDayOffsetEnd) - 1; 7335 7336 // loop through all the rows in the view 7337 for (var row=0; row<rowCnt; row++) { 7338 7339 // first and last cell offset for the row 7340 var rowCellOffsetFirst = row * colCnt; 7341 var rowCellOffsetLast = rowCellOffsetFirst + colCnt - 1; 7342 7343 // get the segment's cell offsets by constraining the range's cell offsets to the bounds of the row 7344 var segmentCellOffsetFirst = Math.max(rangeCellOffsetFirst, rowCellOffsetFirst); 7345 var segmentCellOffsetLast = Math.min(rangeCellOffsetLast, rowCellOffsetLast); 7346 7347 // make sure segment's offsets are valid and in view 7348 if (segmentCellOffsetFirst <= segmentCellOffsetLast) { 7349 7350 // translate to cells 7351 var segmentCellFirst = cellOffsetToCell(segmentCellOffsetFirst); 7352 var segmentCellLast = cellOffsetToCell(segmentCellOffsetLast); 7353 7354 // view might be RTL, so order by leftmost column 7355 var cols = [ segmentCellFirst.col, segmentCellLast.col ].sort(); 7356 7357 // Determine if segment's first/last cell is the beginning/end of the date range. 7358 // We need to compare "day offset" because "cell offsets" are often ambiguous and 7359 // can translate to multiple days, and an edge case reveals itself when we the 7360 // range's first cell is hidden (we don't want isStart to be true). 7361 var isStart = cellOffsetToDayOffset(segmentCellOffsetFirst) == rangeDayOffsetStart; 7362 var isEnd = cellOffsetToDayOffset(segmentCellOffsetLast) + 1 == rangeDayOffsetEnd; 7363 // +1 for comparing exclusively 7364 7365 segments.push({ 7366 row: row, 7367 leftCol: cols[0], 7368 rightCol: cols[1], 7369 isStart: isStart, 7370 isEnd: isEnd 7371 }); 7372 } 7373 } 7374 7375 return segments; 7376 } 7377 7378 7379 // Returns the date range of the full days the given range visually appears to occupy. 7380 // Returns object with properties `start` (moment) and `end` (moment, exclusive end). 7381 function computeDayRange(start, end) { 7382 var startDay = start.clone().stripTime(); // the beginning of the day the range starts 7383 var endDay; 7384 var endTimeMS; 7385 7386 if (end) { 7387 endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends 7388 endTimeMS = +end.time(); // # of milliseconds into `endDay` 7389 7390 // If the end time is actually inclusively part of the next day and is equal to or 7391 // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`. 7392 // Otherwise, leaving it as inclusive will cause it to exclude `endDay`. 7393 if (endTimeMS && endTimeMS >= nextDayThreshold) { 7394 endDay.add(1, 'days'); 7395 } 7396 } 7397 7398 // If no end was specified, or if it is within `startDay` but not past nextDayThreshold, 7399 // assign the default duration of one day. 7400 if (!end || endDay <= startDay) { 7401 endDay = startDay.clone().add(1, 'days'); 7402 } 7403 7404 return { start: startDay, end: endDay }; 7405 } 7406 7407 7408 // Does the given event visually appear to occupy more than one day? 7409 function isMultiDayEvent(event) { 7410 var range = computeDayRange(event.start, event.end); 7411 7412 return range.end.diff(range.start, 'days') > 1; 7413 } 7414 7415 } 7416 7417 ;; 7418 7419 /* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells. 7420 ----------------------------------------------------------------------------------------------------------------------*/ 7421 // It is a manager for a DayGrid subcomponent, which does most of the heavy lifting. 7422 // It is responsible for managing width/height. 7423 7424 function BasicView(calendar) { 7425 View.call(this, calendar); // call the super-constructor 7426 this.dayGrid = new DayGrid(this); 7427 this.coordMap = this.dayGrid.coordMap; // the view's date-to-cell mapping is identical to the subcomponent's 7428 } 7429 7430 7431 BasicView.prototype = createObject(View.prototype); // define the super-class 7432 $.extend(BasicView.prototype, { 7433 7434 dayGrid: null, // the main subcomponent that does most of the heavy lifting 7435 7436 dayNumbersVisible: false, // display day numbers on each day cell? 7437 weekNumbersVisible: false, // display week numbers along the side? 7438 7439 weekNumberWidth: null, // width of all the week-number cells running down the side 7440 7441 headRowEl: null, // the fake row element of the day-of-week header 7442 7443 7444 // Renders the view into `this.el`, which should already be assigned. 7445 // rowCnt, colCnt, and dayNumbersVisible have been calculated by a subclass and passed here. 7446 render: function(rowCnt, colCnt, dayNumbersVisible) { 7447 7448 // needed for cell-to-date and date-to-cell calculations in View 7449 this.rowCnt = rowCnt; 7450 this.colCnt = colCnt; 7451 7452 this.dayNumbersVisible = dayNumbersVisible; 7453 this.weekNumbersVisible = this.opt('weekNumbers'); 7454 this.dayGrid.numbersVisible = this.dayNumbersVisible || this.weekNumbersVisible; 7455 7456 this.el.addClass('fc-basic-view').html(this.renderHtml()); 7457 7458 this.headRowEl = this.el.find('thead .fc-row'); 7459 7460 this.scrollerEl = this.el.find('.fc-day-grid-container'); 7461 this.dayGrid.coordMap.containerEl = this.scrollerEl; // constrain clicks/etc to the dimensions of the scroller 7462 7463 this.dayGrid.el = this.el.find('.fc-day-grid'); 7464 this.dayGrid.render(this.hasRigidRows()); 7465 7466 View.prototype.render.call(this); // call the super-method 7467 }, 7468 7469 7470 // Make subcomponents ready for cleanup 7471 destroy: function() { 7472 this.dayGrid.destroy(); 7473 View.prototype.destroy.call(this); // call the super-method 7474 }, 7475 7476 7477 // Builds the HTML skeleton for the view. 7478 // The day-grid component will render inside of a container defined by this HTML. 7479 renderHtml: function() { 7480 return '' + 7481 '<table>' + 7482 '<thead>' + 7483 '<tr>' + 7484 '<td class="' + this.widgetHeaderClass + '">' + 7485 this.dayGrid.headHtml() + // render the day-of-week headers 7486 '</td>' + 7487 '</tr>' + 7488 '</thead>' + 7489 '<tbody>' + 7490 '<tr>' + 7491 '<td class="' + this.widgetContentClass + '">' + 7492 '<div class="fc-day-grid-container">' + 7493 '<div class="fc-day-grid"/>' + 7494 '</div>' + 7495 '</td>' + 7496 '</tr>' + 7497 '</tbody>' + 7498 '</table>'; 7499 }, 7500 7501 7502 // Generates the HTML that will go before the day-of week header cells. 7503 // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL. 7504 headIntroHtml: function() { 7505 if (this.weekNumbersVisible) { 7506 return '' + 7507 '<th class="fc-week-number ' + this.widgetHeaderClass + '" ' + this.weekNumberStyleAttr() + '>' + 7508 '<span>' + // needed for matchCellWidths 7509 htmlEscape(this.opt('weekNumberTitle')) + 7510 '</span>' + 7511 '</th>'; 7512 } 7513 }, 7514 7515 7516 // Generates the HTML that will go before content-skeleton cells that display the day/week numbers. 7517 // Queried by the DayGrid subcomponent. Ordering depends on isRTL. 7518 numberIntroHtml: function(row) { 7519 if (this.weekNumbersVisible) { 7520 return '' + 7521 '<td class="fc-week-number" ' + this.weekNumberStyleAttr() + '>' + 7522 '<span>' + // needed for matchCellWidths 7523 this.calendar.calculateWeekNumber(this.cellToDate(row, 0)) + 7524 '</span>' + 7525 '</td>'; 7526 } 7527 }, 7528 7529 7530 // Generates the HTML that goes before the day bg cells for each day-row. 7531 // Queried by the DayGrid subcomponent. Ordering depends on isRTL. 7532 dayIntroHtml: function() { 7533 if (this.weekNumbersVisible) { 7534 return '<td class="fc-week-number ' + this.widgetContentClass + '" ' + 7535 this.weekNumberStyleAttr() + '></td>'; 7536 } 7537 }, 7538 7539 7540 // Generates the HTML that goes before every other type of row generated by DayGrid. Ordering depends on isRTL. 7541 // Affects helper-skeleton and highlight-skeleton rows. 7542 introHtml: function() { 7543 if (this.weekNumbersVisible) { 7544 return '<td class="fc-week-number" ' + this.weekNumberStyleAttr() + '></td>'; 7545 } 7546 }, 7547 7548 7549 // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton. 7550 // The number row will only exist if either day numbers or week numbers are turned on. 7551 numberCellHtml: function(row, col, date) { 7552 var classes; 7553 7554 if (!this.dayNumbersVisible) { // if there are week numbers but not day numbers 7555 return '<td/>'; // will create an empty space above events :( 7556 } 7557 7558 classes = this.dayGrid.getDayClasses(date); 7559 classes.unshift('fc-day-number'); 7560 7561 return '' + 7562 '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '">' + 7563 date.date() + 7564 '</td>'; 7565 }, 7566 7567 7568 // Generates an HTML attribute string for setting the width of the week number column, if it is known 7569 weekNumberStyleAttr: function() { 7570 if (this.weekNumberWidth !== null) { 7571 return 'style="width:' + this.weekNumberWidth + 'px"'; 7572 } 7573 return ''; 7574 }, 7575 7576 7577 // Determines whether each row should have a constant height 7578 hasRigidRows: function() { 7579 var eventLimit = this.opt('eventLimit'); 7580 return eventLimit && typeof eventLimit !== 'number'; 7581 }, 7582 7583 7584 /* Dimensions 7585 ------------------------------------------------------------------------------------------------------------------*/ 7586 7587 7588 // Refreshes the horizontal dimensions of the view 7589 updateWidth: function() { 7590 if (this.weekNumbersVisible) { 7591 // Make sure all week number cells running down the side have the same width. 7592 // Record the width for cells created later. 7593 this.weekNumberWidth = matchCellWidths( 7594 this.el.find('.fc-week-number') 7595 ); 7596 } 7597 }, 7598 7599 7600 // Adjusts the vertical dimensions of the view to the specified values 7601 setHeight: function(totalHeight, isAuto) { 7602 var eventLimit = this.opt('eventLimit'); 7603 var scrollerHeight; 7604 7605 // reset all heights to be natural 7606 unsetScroller(this.scrollerEl); 7607 uncompensateScroll(this.headRowEl); 7608 7609 this.dayGrid.destroySegPopover(); // kill the "more" popover if displayed 7610 7611 // is the event limit a constant level number? 7612 if (eventLimit && typeof eventLimit === 'number') { 7613 this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after 7614 } 7615 7616 scrollerHeight = this.computeScrollerHeight(totalHeight); 7617 this.setGridHeight(scrollerHeight, isAuto); 7618 7619 // is the event limit dynamically calculated? 7620 if (eventLimit && typeof eventLimit !== 'number') { 7621 this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set 7622 } 7623 7624 if (!isAuto && setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars? 7625 7626 compensateScroll(this.headRowEl, getScrollbarWidths(this.scrollerEl)); 7627 7628 // doing the scrollbar compensation might have created text overflow which created more height. redo 7629 scrollerHeight = this.computeScrollerHeight(totalHeight); 7630 this.scrollerEl.height(scrollerHeight); 7631 7632 this.restoreScroll(); 7633 } 7634 }, 7635 7636 7637 // Sets the height of just the DayGrid component in this view 7638 setGridHeight: function(height, isAuto) { 7639 if (isAuto) { 7640 undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding 7641 } 7642 else { 7643 distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows 7644 } 7645 }, 7646 7647 7648 /* Events 7649 ------------------------------------------------------------------------------------------------------------------*/ 7650 7651 7652 // Renders the given events onto the view and populates the segments array 7653 renderEvents: function(events) { 7654 this.dayGrid.renderEvents(events); 7655 7656 this.updateHeight(); // must compensate for events that overflow the row 7657 7658 View.prototype.renderEvents.call(this, events); // call the super-method 7659 }, 7660 7661 7662 // Retrieves all segment objects that are rendered in the view 7663 getSegs: function() { 7664 return this.dayGrid.getSegs(); 7665 }, 7666 7667 7668 // Unrenders all event elements and clears internal segment data 7669 destroyEvents: function() { 7670 View.prototype.destroyEvents.call(this); // do this before dayGrid's segs have been cleared 7671 7672 this.recordScroll(); // removing events will reduce height and mess with the scroll, so record beforehand 7673 this.dayGrid.destroyEvents(); 7674 7675 // we DON'T need to call updateHeight() because: 7676 // A) a renderEvents() call always happens after this, which will eventually call updateHeight() 7677 // B) in IE8, this causes a flash whenever events are rerendered 7678 }, 7679 7680 7681 /* Event Dragging 7682 ------------------------------------------------------------------------------------------------------------------*/ 7683 7684 7685 // Renders a visual indication of an event being dragged over the view. 7686 // A returned value of `true` signals that a mock "helper" event has been rendered. 7687 renderDrag: function(start, end, seg) { 7688 return this.dayGrid.renderDrag(start, end, seg); 7689 }, 7690 7691 7692 // Unrenders the visual indication of an event being dragged over the view 7693 destroyDrag: function() { 7694 this.dayGrid.destroyDrag(); 7695 }, 7696 7697 7698 /* Selection 7699 ------------------------------------------------------------------------------------------------------------------*/ 7700 7701 7702 // Renders a visual indication of a selection 7703 renderSelection: function(start, end) { 7704 this.dayGrid.renderSelection(start, end); 7705 }, 7706 7707 7708 // Unrenders a visual indications of a selection 7709 destroySelection: function() { 7710 this.dayGrid.destroySelection(); 7711 } 7712 7713 }); 7714 7715 ;; 7716 7717 /* A month view with day cells running in rows (one-per-week) and columns 7718 ----------------------------------------------------------------------------------------------------------------------*/ 7719 7720 setDefaults({ 7721 fixedWeekCount: true 7722 }); 7723 7724 fcViews.month = MonthView; // register the view 7725 7726 function MonthView(calendar) { 7727 BasicView.call(this, calendar); // call the super-constructor 7728 } 7729 7730 7731 MonthView.prototype = createObject(BasicView.prototype); // define the super-class 7732 $.extend(MonthView.prototype, { 7733 7734 name: 'month', 7735 7736 7737 incrementDate: function(date, delta) { 7738 return date.clone().stripTime().add(delta, 'months').startOf('month'); 7739 }, 7740 7741 7742 render: function(date) { 7743 var rowCnt; 7744 7745 this.intervalStart = date.clone().stripTime().startOf('month'); 7746 this.intervalEnd = this.intervalStart.clone().add(1, 'months'); 7747 7748 this.start = this.intervalStart.clone(); 7749 this.start = this.skipHiddenDays(this.start); // move past the first week if no visible days 7750 this.start.startOf('week'); 7751 this.start = this.skipHiddenDays(this.start); // move past the first invisible days of the week 7752 7753 this.end = this.intervalEnd.clone(); 7754 this.end = this.skipHiddenDays(this.end, -1, true); // move in from the last week if no visible days 7755 this.end.add((7 - this.end.weekday()) % 7, 'days'); // move to end of week if not already 7756 this.end = this.skipHiddenDays(this.end, -1, true); // move in from the last invisible days of the week 7757 7758 rowCnt = Math.ceil( // need to ceil in case there are hidden days 7759 this.end.diff(this.start, 'weeks', true) // returnfloat=true 7760 ); 7761 if (this.isFixedWeeks()) { 7762 this.end.add(6 - rowCnt, 'weeks'); 7763 rowCnt = 6; 7764 } 7765 7766 this.title = this.calendar.formatDate(this.intervalStart, this.opt('titleFormat')); 7767 7768 BasicView.prototype.render.call(this, rowCnt, this.getCellsPerWeek(), true); // call the super-method 7769 }, 7770 7771 7772 // Overrides the default BasicView behavior to have special multi-week auto-height logic 7773 setGridHeight: function(height, isAuto) { 7774 7775 isAuto = isAuto || this.opt('weekMode') === 'variable'; // LEGACY: weekMode is deprecated 7776 7777 // if auto, make the height of each row the height that it would be if there were 6 weeks 7778 if (isAuto) { 7779 height *= this.rowCnt / 6; 7780 } 7781 7782 distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows 7783 }, 7784 7785 7786 isFixedWeeks: function() { 7787 var weekMode = this.opt('weekMode'); // LEGACY: weekMode is deprecated 7788 if (weekMode) { 7789 return weekMode === 'fixed'; // if any other type of weekMode, assume NOT fixed 7790 } 7791 7792 return this.opt('fixedWeekCount'); 7793 } 7794 7795 }); 7796 7797 ;; 7798 7799 /* A week view with simple day cells running horizontally 7800 ----------------------------------------------------------------------------------------------------------------------*/ 7801 // TODO: a WeekView mixin for calculating dates and titles 7802 7803 fcViews.basicWeek = BasicWeekView; // register this view 7804 7805 function BasicWeekView(calendar) { 7806 BasicView.call(this, calendar); // call the super-constructor 7807 } 7808 7809 7810 BasicWeekView.prototype = createObject(BasicView.prototype); // define the super-class 7811 $.extend(BasicWeekView.prototype, { 7812 7813 name: 'basicWeek', 7814 7815 7816 incrementDate: function(date, delta) { 7817 return date.clone().stripTime().add(delta, 'weeks').startOf('week'); 7818 }, 7819 7820 7821 render: function(date) { 7822 7823 this.intervalStart = date.clone().stripTime().startOf('week'); 7824 this.intervalEnd = this.intervalStart.clone().add(1, 'weeks'); 7825 7826 this.start = this.skipHiddenDays(this.intervalStart); 7827 this.end = this.skipHiddenDays(this.intervalEnd, -1, true); 7828 7829 this.title = this.calendar.formatRange( 7830 this.start, 7831 this.end.clone().subtract(1), // make inclusive by subtracting 1 ms 7832 this.opt('titleFormat'), 7833 ' \u2014 ' // emphasized dash 7834 ); 7835 7836 BasicView.prototype.render.call(this, 1, this.getCellsPerWeek(), false); // call the super-method 7837 } 7838 7839 }); 7840 ;; 7841 7842 /* A view with a single simple day cell 7843 ----------------------------------------------------------------------------------------------------------------------*/ 7844 7845 fcViews.basicDay = BasicDayView; // register this view 7846 7847 function BasicDayView(calendar) { 7848 BasicView.call(this, calendar); // call the super-constructor 7849 } 7850 7851 7852 BasicDayView.prototype = createObject(BasicView.prototype); // define the super-class 7853 $.extend(BasicDayView.prototype, { 7854 7855 name: 'basicDay', 7856 7857 7858 incrementDate: function(date, delta) { 7859 var out = date.clone().stripTime().add(delta, 'days'); 7860 out = this.skipHiddenDays(out, delta < 0 ? -1 : 1); 7861 return out; 7862 }, 7863 7864 7865 render: function(date) { 7866 7867 this.start = this.intervalStart = date.clone().stripTime(); 7868 this.end = this.intervalEnd = this.start.clone().add(1, 'days'); 7869 7870 this.title = this.calendar.formatDate(this.start, this.opt('titleFormat')); 7871 7872 BasicView.prototype.render.call(this, 1, 1, false); // call the super-method 7873 } 7874 7875 }); 7876 ;; 7877 7878 /* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically. 7879 ----------------------------------------------------------------------------------------------------------------------*/ 7880 // Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on). 7881 // Responsible for managing width/height. 7882 7883 setDefaults({ 7884 allDaySlot: true, 7885 allDayText: 'all-day', 7886 7887 scrollTime: '06:00:00', 7888 7889 slotDuration: '00:30:00', 7890 7891 axisFormat: generateAgendaAxisFormat, 7892 timeFormat: { 7893 agenda: generateAgendaTimeFormat 7894 }, 7895 7896 minTime: '00:00:00', 7897 maxTime: '24:00:00', 7898 slotEventOverlap: true 7899 }); 7900 7901 var AGENDA_ALL_DAY_EVENT_LIMIT = 5; 7902 7903 7904 function generateAgendaAxisFormat(options, langData) { 7905 return langData.longDateFormat('LT') 7906 .replace(':mm', '(:mm)') 7907 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs 7908 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand 7909 } 7910 7911 7912 function generateAgendaTimeFormat(options, langData) { 7913 return langData.longDateFormat('LT') 7914 .replace(/\s*a$/i, ''); // remove trailing AM/PM 7915 } 7916 7917 7918 function AgendaView(calendar) { 7919 View.call(this, calendar); // call the super-constructor 7920 7921 this.timeGrid = new TimeGrid(this); 7922 7923 if (this.opt('allDaySlot')) { // should we display the "all-day" area? 7924 this.dayGrid = new DayGrid(this); // the all-day subcomponent of this view 7925 7926 // the coordinate grid will be a combination of both subcomponents' grids 7927 this.coordMap = new ComboCoordMap([ 7928 this.dayGrid.coordMap, 7929 this.timeGrid.coordMap 7930 ]); 7931 } 7932 else { 7933 this.coordMap = this.timeGrid.coordMap; 7934 } 7935 } 7936 7937 7938 AgendaView.prototype = createObject(View.prototype); // define the super-class 7939 $.extend(AgendaView.prototype, { 7940 7941 timeGrid: null, // the main time-grid subcomponent of this view 7942 dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null 7943 7944 axisWidth: null, // the width of the time axis running down the side 7945 7946 noScrollRowEls: null, // set of fake row elements that must compensate when scrollerEl has scrollbars 7947 7948 // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath 7949 bottomRuleEl: null, 7950 bottomRuleHeight: null, 7951 7952 7953 /* Rendering 7954 ------------------------------------------------------------------------------------------------------------------*/ 7955 7956 7957 // Renders the view into `this.el`, which has already been assigned. 7958 // `colCnt` has been calculated by a subclass and passed here. 7959 render: function(colCnt) { 7960 7961 // needed for cell-to-date and date-to-cell calculations in View 7962 this.rowCnt = 1; 7963 this.colCnt = colCnt; 7964 7965 this.el.addClass('fc-agenda-view').html(this.renderHtml()); 7966 7967 // the element that wraps the time-grid that will probably scroll 7968 this.scrollerEl = this.el.find('.fc-time-grid-container'); 7969 this.timeGrid.coordMap.containerEl = this.scrollerEl; // don't accept clicks/etc outside of this 7970 7971 this.timeGrid.el = this.el.find('.fc-time-grid'); 7972 this.timeGrid.render(); 7973 7974 // the <hr> that sometimes displays under the time-grid 7975 this.bottomRuleEl = $('<hr class="' + this.widgetHeaderClass + '"/>') 7976 .appendTo(this.timeGrid.el); // inject it into the time-grid 7977 7978 if (this.dayGrid) { 7979 this.dayGrid.el = this.el.find('.fc-day-grid'); 7980 this.dayGrid.render(); 7981 7982 // have the day-grid extend it's coordinate area over the <hr> dividing the two grids 7983 this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight(); 7984 } 7985 7986 this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller 7987 7988 View.prototype.render.call(this); // call the super-method 7989 7990 this.resetScroll(); // do this after sizes have been set 7991 }, 7992 7993 7994 // Make subcomponents ready for cleanup 7995 destroy: function() { 7996 this.timeGrid.destroy(); 7997 if (this.dayGrid) { 7998 this.dayGrid.destroy(); 7999 } 8000 View.prototype.destroy.call(this); // call the super-method 8001 }, 8002 8003 8004 // Builds the HTML skeleton for the view. 8005 // The day-grid and time-grid components will render inside containers defined by this HTML. 8006 renderHtml: function() { 8007 return '' + 8008 '<table>' + 8009 '<thead>' + 8010 '<tr>' + 8011 '<td class="' + this.widgetHeaderClass + '">' + 8012 this.timeGrid.headHtml() + // render the day-of-week headers 8013 '</td>' + 8014 '</tr>' + 8015 '</thead>' + 8016 '<tbody>' + 8017 '<tr>' + 8018 '<td class="' + this.widgetContentClass + '">' + 8019 (this.dayGrid ? 8020 '<div class="fc-day-grid"/>' + 8021 '<hr class="' + this.widgetHeaderClass + '"/>' : 8022 '' 8023 ) + 8024 '<div class="fc-time-grid-container">' + 8025 '<div class="fc-time-grid"/>' + 8026 '</div>' + 8027 '</td>' + 8028 '</tr>' + 8029 '</tbody>' + 8030 '</table>'; 8031 }, 8032 8033 8034 // Generates the HTML that will go before the day-of week header cells. 8035 // Queried by the TimeGrid subcomponent when generating rows. Ordering depends on isRTL. 8036 headIntroHtml: function() { 8037 var date; 8038 var weekNumber; 8039 var weekTitle; 8040 var weekText; 8041 8042 if (this.opt('weekNumbers')) { 8043 date = this.cellToDate(0, 0); 8044 weekNumber = this.calendar.calculateWeekNumber(date); 8045 weekTitle = this.opt('weekNumberTitle'); 8046 8047 if (this.opt('isRTL')) { 8048 weekText = weekNumber + weekTitle; 8049 } 8050 else { 8051 weekText = weekTitle + weekNumber; 8052 } 8053 8054 return '' + 8055 '<th class="fc-axis fc-week-number ' + this.widgetHeaderClass + '" ' + this.axisStyleAttr() + '>' + 8056 '<span>' + // needed for matchCellWidths 8057 htmlEscape(weekText) + 8058 '</span>' + 8059 '</th>'; 8060 } 8061 else { 8062 return '<th class="fc-axis ' + this.widgetHeaderClass + '" ' + this.axisStyleAttr() + '></th>'; 8063 } 8064 }, 8065 8066 8067 // Generates the HTML that goes before the all-day cells. 8068 // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL. 8069 dayIntroHtml: function() { 8070 return '' + 8071 '<td class="fc-axis ' + this.widgetContentClass + '" ' + this.axisStyleAttr() + '>' + 8072 '<span>' + // needed for matchCellWidths 8073 (this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'))) + 8074 '</span>' + 8075 '</td>'; 8076 }, 8077 8078 8079 // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column. 8080 slotBgIntroHtml: function() { 8081 return '<td class="fc-axis ' + this.widgetContentClass + '" ' + this.axisStyleAttr() + '></td>'; 8082 }, 8083 8084 8085 // Generates the HTML that goes before all other types of cells. 8086 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. 8087 // Queried by the TimeGrid and DayGrid subcomponents when generating rows. Ordering depends on isRTL. 8088 introHtml: function() { 8089 return '<td class="fc-axis" ' + this.axisStyleAttr() + '></td>'; 8090 }, 8091 8092 8093 // Generates an HTML attribute string for setting the width of the axis, if it is known 8094 axisStyleAttr: function() { 8095 if (this.axisWidth !== null) { 8096 return 'style="width:' + this.axisWidth + 'px"'; 8097 } 8098 return ''; 8099 }, 8100 8101 8102 /* Dimensions 8103 ------------------------------------------------------------------------------------------------------------------*/ 8104 8105 updateSize: function(isResize) { 8106 if (isResize) { 8107 this.timeGrid.resize(); 8108 } 8109 View.prototype.updateSize.call(this, isResize); 8110 }, 8111 8112 8113 // Refreshes the horizontal dimensions of the view 8114 updateWidth: function() { 8115 // make all axis cells line up, and record the width so newly created axis cells will have it 8116 this.axisWidth = matchCellWidths(this.el.find('.fc-axis')); 8117 }, 8118 8119 8120 // Adjusts the vertical dimensions of the view to the specified values 8121 setHeight: function(totalHeight, isAuto) { 8122 var eventLimit; 8123 var scrollerHeight; 8124 8125 if (this.bottomRuleHeight === null) { 8126 // calculate the height of the rule the very first time 8127 this.bottomRuleHeight = this.bottomRuleEl.outerHeight(); 8128 } 8129 this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary 8130 8131 // reset all dimensions back to the original state 8132 this.scrollerEl.css('overflow', ''); 8133 unsetScroller(this.scrollerEl); 8134 uncompensateScroll(this.noScrollRowEls); 8135 8136 // limit number of events in the all-day area 8137 if (this.dayGrid) { 8138 this.dayGrid.destroySegPopover(); // kill the "more" popover if displayed 8139 8140 eventLimit = this.opt('eventLimit'); 8141 if (eventLimit && typeof eventLimit !== 'number') { 8142 eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number 8143 } 8144 if (eventLimit) { 8145 this.dayGrid.limitRows(eventLimit); 8146 } 8147 } 8148 8149 if (!isAuto) { // should we force dimensions of the scroll container, or let the contents be natural height? 8150 8151 scrollerHeight = this.computeScrollerHeight(totalHeight); 8152 if (setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars? 8153 8154 // make the all-day and header rows lines up 8155 compensateScroll(this.noScrollRowEls, getScrollbarWidths(this.scrollerEl)); 8156 8157 // the scrollbar compensation might have changed text flow, which might affect height, so recalculate 8158 // and reapply the desired height to the scroller. 8159 scrollerHeight = this.computeScrollerHeight(totalHeight); 8160 this.scrollerEl.height(scrollerHeight); 8161 8162 this.restoreScroll(); 8163 } 8164 else { // no scrollbars 8165 // still, force a height and display the bottom rule (marks the end of day) 8166 this.scrollerEl.height(scrollerHeight).css('overflow', 'hidden'); // in case <hr> goes outside 8167 this.bottomRuleEl.show(); 8168 } 8169 } 8170 }, 8171 8172 8173 // Sets the scroll value of the scroller to the intial pre-configured state prior to allowing the user to change it. 8174 resetScroll: function() { 8175 var _this = this; 8176 var scrollTime = moment.duration(this.opt('scrollTime')); 8177 var top = this.timeGrid.computeTimeTop(scrollTime); 8178 8179 // zoom can give weird floating-point values. rather scroll a little bit further 8180 top = Math.ceil(top); 8181 8182 if (top) { 8183 top++; // to overcome top border that slots beyond the first have. looks better 8184 } 8185 8186 function scroll() { 8187 _this.scrollerEl.scrollTop(top); 8188 } 8189 8190 scroll(); 8191 setTimeout(scroll, 0); // overrides any previous scroll state made by the browser 8192 }, 8193 8194 8195 /* Events 8196 ------------------------------------------------------------------------------------------------------------------*/ 8197 8198 8199 // Renders events onto the view and populates the View's segment array 8200 renderEvents: function(events) { 8201 var dayEvents = []; 8202 var timedEvents = []; 8203 var daySegs = []; 8204 var timedSegs; 8205 var i; 8206 8207 // separate the events into all-day and timed 8208 for (i = 0; i < events.length; i++) { 8209 if (events[i].allDay) { 8210 dayEvents.push(events[i]); 8211 } 8212 else { 8213 timedEvents.push(events[i]); 8214 } 8215 } 8216 8217 // render the events in the subcomponents 8218 timedSegs = this.timeGrid.renderEvents(timedEvents); 8219 if (this.dayGrid) { 8220 daySegs = this.dayGrid.renderEvents(dayEvents); 8221 } 8222 8223 // the all-day area is flexible and might have a lot of events, so shift the height 8224 this.updateHeight(); 8225 8226 View.prototype.renderEvents.call(this, events); // call the super-method 8227 }, 8228 8229 8230 // Retrieves all segment objects that are rendered in the view 8231 getSegs: function() { 8232 return this.timeGrid.getSegs().concat( 8233 this.dayGrid ? this.dayGrid.getSegs() : [] 8234 ); 8235 }, 8236 8237 8238 // Unrenders all event elements and clears internal segment data 8239 destroyEvents: function() { 8240 View.prototype.destroyEvents.call(this); // do this before the grids' segs have been cleared 8241 8242 // if destroyEvents is being called as part of an event rerender, renderEvents will be called shortly 8243 // after, so remember what the scroll value was so we can restore it. 8244 this.recordScroll(); 8245 8246 // destroy the events in the subcomponents 8247 this.timeGrid.destroyEvents(); 8248 if (this.dayGrid) { 8249 this.dayGrid.destroyEvents(); 8250 } 8251 8252 // we DON'T need to call updateHeight() because: 8253 // A) a renderEvents() call always happens after this, which will eventually call updateHeight() 8254 // B) in IE8, this causes a flash whenever events are rerendered 8255 }, 8256 8257 8258 /* Event Dragging 8259 ------------------------------------------------------------------------------------------------------------------*/ 8260 8261 8262 // Renders a visual indication of an event being dragged over the view. 8263 // A returned value of `true` signals that a mock "helper" event has been rendered. 8264 renderDrag: function(start, end, seg) { 8265 if (start.hasTime()) { 8266 return this.timeGrid.renderDrag(start, end, seg); 8267 } 8268 else if (this.dayGrid) { 8269 return this.dayGrid.renderDrag(start, end, seg); 8270 } 8271 }, 8272 8273 8274 // Unrenders a visual indications of an event being dragged over the view 8275 destroyDrag: function() { 8276 this.timeGrid.destroyDrag(); 8277 if (this.dayGrid) { 8278 this.dayGrid.destroyDrag(); 8279 } 8280 }, 8281 8282 8283 /* Selection 8284 ------------------------------------------------------------------------------------------------------------------*/ 8285 8286 8287 // Renders a visual indication of a selection 8288 renderSelection: function(start, end) { 8289 if (start.hasTime() || end.hasTime()) { 8290 this.timeGrid.renderSelection(start, end); 8291 } 8292 else if (this.dayGrid) { 8293 this.dayGrid.renderSelection(start, end); 8294 } 8295 }, 8296 8297 8298 // Unrenders a visual indications of a selection 8299 destroySelection: function() { 8300 this.timeGrid.destroySelection(); 8301 if (this.dayGrid) { 8302 this.dayGrid.destroySelection(); 8303 } 8304 } 8305 8306 }); 8307 8308 ;; 8309 8310 /* A week view with an all-day cell area at the top, and a time grid below 8311 ----------------------------------------------------------------------------------------------------------------------*/ 8312 // TODO: a WeekView mixin for calculating dates and titles 8313 8314 fcViews.agendaWeek = AgendaWeekView; // register the view 8315 8316 function AgendaWeekView(calendar) { 8317 AgendaView.call(this, calendar); // call the super-constructor 8318 } 8319 8320 8321 AgendaWeekView.prototype = createObject(AgendaView.prototype); // define the super-class 8322 $.extend(AgendaWeekView.prototype, { 8323 8324 name: 'agendaWeek', 8325 8326 8327 incrementDate: function(date, delta) { 8328 return date.clone().stripTime().add(delta, 'weeks').startOf('week'); 8329 }, 8330 8331 8332 render: function(date) { 8333 8334 this.intervalStart = date.clone().stripTime().startOf('week'); 8335 this.intervalEnd = this.intervalStart.clone().add(1, 'weeks'); 8336 8337 this.start = this.skipHiddenDays(this.intervalStart); 8338 this.end = this.skipHiddenDays(this.intervalEnd, -1, true); 8339 8340 this.title = this.calendar.formatRange( 8341 this.start, 8342 this.end.clone().subtract(1), // make inclusive by subtracting 1 ms 8343 this.opt('titleFormat'), 8344 ' \u2014 ' // emphasized dash 8345 ); 8346 8347 AgendaView.prototype.render.call(this, this.getCellsPerWeek()); // call the super-method 8348 } 8349 8350 }); 8351 8352 ;; 8353 8354 /* A day view with an all-day cell area at the top, and a time grid below 8355 ----------------------------------------------------------------------------------------------------------------------*/ 8356 8357 fcViews.agendaDay = AgendaDayView; // register the view 8358 8359 function AgendaDayView(calendar) { 8360 AgendaView.call(this, calendar); // call the super-constructor 8361 } 8362 8363 8364 AgendaDayView.prototype = createObject(AgendaView.prototype); // define the super-class 8365 $.extend(AgendaDayView.prototype, { 8366 8367 name: 'agendaDay', 8368 8369 8370 incrementDate: function(date, delta) { 8371 var out = date.clone().stripTime().add(delta, 'days'); 8372 out = this.skipHiddenDays(out, delta < 0 ? -1 : 1); 8373 return out; 8374 }, 8375 8376 8377 render: function(date) { 8378 8379 this.start = this.intervalStart = date.clone().stripTime(); 8380 this.end = this.intervalEnd = this.start.clone().add(1, 'days'); 8381 8382 this.title = this.calendar.formatDate(this.start, this.opt('titleFormat')); 8383 8384 AgendaView.prototype.render.call(this, 1); // call the super-method 8385 } 8386 8387 }); 8388 8389 ;; 8390 8391 });