1/*! 2 * FullCalendar v3.2.0 3 * Docs & License: https://fullcalendar.io/ 4 * (c) 2017 Adam Shaw 5 */ 6 7(function(factory) { 8 if (typeof define === 'function' && define.amd) { 9 define([ 'jquery', 'moment' ], factory); 10 } 11 else if (typeof exports === 'object') { // Node/CommonJS 12 module.exports = factory(require('jquery'), require('moment')); 13 } 14 else { 15 factory(jQuery, moment); 16 } 17})(function($, moment) { 18 19;; 20 21var FC = $.fullCalendar = { 22 version: "3.2.0", 23 // When introducing internal API incompatibilities (where fullcalendar plugins would break), 24 // the minor version of the calendar should be upped (ex: 2.7.2 -> 2.8.0) 25 // and the below integer should be incremented. 26 internalApiVersion: 8 27}; 28var fcViews = FC.views = {}; 29 30 31$.fn.fullCalendar = function(options) { 32 var args = Array.prototype.slice.call(arguments, 1); // for a possible method call 33 var res = this; // what this function will return (this jQuery object by default) 34 35 this.each(function(i, _element) { // loop each DOM element involved 36 var element = $(_element); 37 var calendar = element.data('fullCalendar'); // get the existing calendar object (if any) 38 var singleRes; // the returned value of this single method call 39 40 // a method call 41 if (typeof options === 'string') { 42 if (calendar && $.isFunction(calendar[options])) { 43 singleRes = calendar[options].apply(calendar, args); 44 if (!i) { 45 res = singleRes; // record the first method call result 46 } 47 if (options === 'destroy') { // for the destroy method, must remove Calendar object data 48 element.removeData('fullCalendar'); 49 } 50 } 51 } 52 // a new calendar initialization 53 else if (!calendar) { // don't initialize twice 54 calendar = new Calendar(element, options); 55 element.data('fullCalendar', calendar); 56 calendar.render(); 57 } 58 }); 59 60 return res; 61}; 62 63 64var complexOptions = [ // names of options that are objects whose properties should be combined 65 'header', 66 'footer', 67 'buttonText', 68 'buttonIcons', 69 'themeButtonIcons' 70]; 71 72 73// Merges an array of option objects into a single object 74function mergeOptions(optionObjs) { 75 return mergeProps(optionObjs, complexOptions); 76} 77 78;; 79 80// exports 81FC.intersectRanges = intersectRanges; 82FC.applyAll = applyAll; 83FC.debounce = debounce; 84FC.isInt = isInt; 85FC.htmlEscape = htmlEscape; 86FC.cssToStr = cssToStr; 87FC.proxy = proxy; 88FC.capitaliseFirstLetter = capitaliseFirstLetter; 89 90 91/* FullCalendar-specific DOM Utilities 92----------------------------------------------------------------------------------------------------------------------*/ 93 94 95// Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left 96// and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that. 97function compensateScroll(rowEls, scrollbarWidths) { 98 if (scrollbarWidths.left) { 99 rowEls.css({ 100 'border-left-width': 1, 101 'margin-left': scrollbarWidths.left - 1 102 }); 103 } 104 if (scrollbarWidths.right) { 105 rowEls.css({ 106 'border-right-width': 1, 107 'margin-right': scrollbarWidths.right - 1 108 }); 109 } 110} 111 112 113// Undoes compensateScroll and restores all borders/margins 114function uncompensateScroll(rowEls) { 115 rowEls.css({ 116 'margin-left': '', 117 'margin-right': '', 118 'border-left-width': '', 119 'border-right-width': '' 120 }); 121} 122 123 124// Make the mouse cursor express that an event is not allowed in the current area 125function disableCursor() { 126 $('body').addClass('fc-not-allowed'); 127} 128 129 130// Returns the mouse cursor to its original look 131function enableCursor() { 132 $('body').removeClass('fc-not-allowed'); 133} 134 135 136// Given a total available height to fill, have `els` (essentially child rows) expand to accomodate. 137// By default, all elements that are shorter than the recommended height are expanded uniformly, not considering 138// any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and 139// reduces the available height. 140function distributeHeight(els, availableHeight, shouldRedistribute) { 141 142 // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions, 143 // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars. 144 145 var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element 146 var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE* 147 var flexEls = []; // elements that are allowed to expand. array of DOM nodes 148 var flexOffsets = []; // amount of vertical space it takes up 149 var flexHeights = []; // actual css height 150 var usedHeight = 0; 151 152 undistributeHeight(els); // give all elements their natural height 153 154 // find elements that are below the recommended height (expandable). 155 // important to query for heights in a single first pass (to avoid reflow oscillation). 156 els.each(function(i, el) { 157 var minOffset = i === els.length - 1 ? minOffset2 : minOffset1; 158 var naturalOffset = $(el).outerHeight(true); 159 160 if (naturalOffset < minOffset) { 161 flexEls.push(el); 162 flexOffsets.push(naturalOffset); 163 flexHeights.push($(el).height()); 164 } 165 else { 166 // this element stretches past recommended height (non-expandable). mark the space as occupied. 167 usedHeight += naturalOffset; 168 } 169 }); 170 171 // readjust the recommended height to only consider the height available to non-maxed-out rows. 172 if (shouldRedistribute) { 173 availableHeight -= usedHeight; 174 minOffset1 = Math.floor(availableHeight / flexEls.length); 175 minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE* 176 } 177 178 // assign heights to all expandable elements 179 $(flexEls).each(function(i, el) { 180 var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1; 181 var naturalOffset = flexOffsets[i]; 182 var naturalHeight = flexHeights[i]; 183 var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding 184 185 if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things 186 $(el).height(newHeight); 187 } 188 }); 189} 190 191 192// Undoes distrubuteHeight, restoring all els to their natural height 193function undistributeHeight(els) { 194 els.height(''); 195} 196 197 198// Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the 199// cells to be that width. 200// PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline 201function matchCellWidths(els) { 202 var maxInnerWidth = 0; 203 204 els.find('> *').each(function(i, innerEl) { 205 var innerWidth = $(innerEl).outerWidth(); 206 if (innerWidth > maxInnerWidth) { 207 maxInnerWidth = innerWidth; 208 } 209 }); 210 211 maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance 212 213 els.width(maxInnerWidth); 214
215 return maxInnerWidth; 216} 217 218 219// Given one element that resides inside another, 220// Subtracts the height of the inner element from the outer element. 221function subtractInnerElHeight(outerEl, innerEl) { 222 var both = outerEl.add(innerEl); 223 var diff; 224 225 // effin' IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked 226 both.css({ 227 position: 'relative', // cause a reflow, which will force fresh dimension recalculation 228 left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll 229 }); 230 diff = outerEl.outerHeight() - innerEl.outerHeight(); // grab the dimensions 231 both.css({ position: '', left: '' }); // undo hack 232 233 return diff; 234} 235 236 237/* Element Geom Utilities 238----------------------------------------------------------------------------------------------------------------------*/ 239 240FC.getOuterRect = getOuterRect; 241FC.getClientRect = getClientRect; 242FC.getContentRect = getContentRect; 243FC.getScrollbarWidths = getScrollbarWidths; 244 245 246// borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51 247function getScrollParent(el) { 248 var position = el.css('position'), 249 scrollParent = el.parents().filter(function() { 250 var parent = $(this); 251 return (/(auto|scroll)/).test( 252 parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x') 253 ); 254 }).eq(0); 255 256 return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent; 257} 258 259 260// Queries the outer bounding area of a jQuery element. 261// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). 262// Origin is optional. 263function getOuterRect(el, origin) { 264 var offset = el.offset(); 265 var left = offset.left - (origin ? origin.left : 0); 266 var top = offset.top - (origin ? origin.top : 0); 267 268 return { 269 left: left, 270 right: left + el.outerWidth(), 271 top: top, 272 bottom: top + el.outerHeight() 273 }; 274} 275 276 277// Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding. 278// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). 279// Origin is optional. 280// NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. 281function getClientRect(el, origin) { 282 var offset = el.offset(); 283 var scrollbarWidths = getScrollbarWidths(el); 284 var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left - (origin ? origin.left : 0); 285 var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top - (origin ? origin.top : 0); 286 287 return { 288 left: left, 289 right: left + el[0].clientWidth, // clientWidth includes padding but NOT scrollbars 290 top: top, 291 bottom: top + el[0].clientHeight // clientHeight includes padding but NOT scrollbars 292 }; 293} 294 295 296// Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars. 297// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). 298// Origin is optional. 299function getContentRect(el, origin) { 300 var offset = el.offset(); // just outside of border, margin not included 301 var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left') - 302 (origin ? origin.left : 0); 303 var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top') - 304 (origin ? origin.top : 0); 305 306 return { 307 left: left, 308 right: left + el.width(), 309 top: top, 310 bottom: top + el.height() 311 }; 312} 313 314 315// Returns the computed left/right/top/bottom scrollbar widths for the given jQuery element. 316// NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. 317function getScrollbarWidths(el) { 318 var leftRightWidth = el.innerWidth() - el[0].clientWidth; // the paddings cancel out, leaving the scrollbars
319 var bottomWidth = el.innerHeight() - el[0].clientHeight; // " 320 var widths; 321 322 leftRightWidth = sanitizeScrollbarWidth(leftRightWidth); 323 bottomWidth = sanitizeScrollbarWidth(bottomWidth); 324 325 widths = { left: 0, right: 0, top: 0, bottom: bottomWidth }; 326 327 if (getIsLeftRtlScrollbars() && el.css('direction') == 'rtl') { // is the scrollbar on the left side? 328 widths.left = leftRightWidth; 329 } 330 else { 331 widths.right = leftRightWidth; 332 } 333 334 return widths; 335} 336 337 338// The scrollbar width computations in getScrollbarWidths are sometimes flawed when it comes to 339// retina displays, rounding, and IE11. Massage them into a usable value. 340function sanitizeScrollbarWidth(width) { 341 width = Math.max(0, width); // no negatives 342 width = Math.round(width); 343 return width; 344} 345 346 347// Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side 348 349var _isLeftRtlScrollbars = null; 350 351function getIsLeftRtlScrollbars() { // responsible for caching the computation 352 if (_isLeftRtlScrollbars === null) { 353 _isLeftRtlScrollbars = computeIsLeftRtlScrollbars(); 354 } 355 return _isLeftRtlScrollbars; 356} 357 358function computeIsLeftRtlScrollbars() { // creates an offscreen test element, then removes it 359 var el = $('<div><div/></div>') 360 .css({ 361 position: 'absolute', 362 top: -1000, 363 left: 0, 364 border: 0, 365 padding: 0, 366 overflow: 'scroll', 367 direction: 'rtl' 368 }) 369 .appendTo('body'); 370 var innerEl = el.children(); 371 var res = innerEl.offset().left > el.offset().left; // is the inner div shifted to accommodate a left scrollbar? 372 el.remove(); 373 return res; 374} 375 376 377// Retrieves a jQuery element's computed CSS value as a floating-point number. 378// If the queried value is non-numeric (ex: IE can return "medium" for border width), will just return zero. 379function getCssFloat(el, prop) { 380 return parseFloat(el.css(prop)) || 0; 381} 382 383 384/* Mouse / Touch Utilities 385----------------------------------------------------------------------------------------------------------------------*/ 386 387FC.preventDefault = preventDefault; 388 389 390// Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac) 391function isPrimaryMouseButton(ev) { 392 return ev.which == 1 && !ev.ctrlKey; 393} 394 395 396function getEvX(ev) { 397 var touches = ev.originalEvent.touches; 398 399 // on mobile FF, pageX for touch events is present, but incorrect, 400 // so, look at touch coordinates first. 401 if (touches && touches.length) { 402 return touches[0].pageX; 403 } 404 405 return ev.pageX; 406} 407 408 409function getEvY(ev) { 410 var touches = ev.originalEvent.touches; 411 412 // on mobile FF, pageX for touch events is present, but incorrect, 413 // so, look at touch coordinates first. 414 if (touches && touches.length) { 415 return touches[0].pageY; 416 } 417 418 return ev.pageY; 419} 420 421 422function getEvIsTouch(ev) { 423 return /^touch/.test(ev.type); 424} 425 426 427function preventSelection(el) { 428 el.addClass('fc-unselectable') 429 .on('selectstart', preventDefault); 430} 431 432 433function allowSelection(el) { 434 el.removeClass('fc-unselectable') 435 .off('selectstart', preventDefault); 436} 437 438 439// Stops a mouse/touch event from doing it's native browser action 440function preventDefault(ev) { 441 ev.preventDefault(); 442} 443 444 445/* General Geometry Utils 446----------------------------------------------------------------------------------------------------------------------*/ 447 448FC.intersectRects = intersectRects; 449 450// Returns a new rectangle that is the intersection of the two rectangles. If they don't intersect, returns false 451function intersectRects(rect1, rect2) { 452 var res = { 453 left: Math.max(rect1.left, rect2.left), 454 right: Math.min(rect1.right, rect2.right), 455 top: Math.max(rect1.top, rect2.top), 456 bottom: Math.min(rect1.bottom, rect2.bottom) 457 }; 458 459 if (res.left < res.right && res.top < res.bottom) { 460 return res; 461 } 462 return false; 463} 464 465 466// Returns a new point that will have been moved to reside within the given rectangle 467function constrainPoint(point, rect) { 468 return { 469 left: Math.min(Math.max(point.left, rect.left), rect.right), 470 top: Math.min(Math.max(point.top, rect.top), rect.bottom) 471 }; 472} 473 474 475// Returns a point that is the center of the given rectangle 476function getRectCenter(rect) { 477 return { 478 left: (rect.left + rect.right) / 2, 479 top: (rect.top + rect.bottom) / 2 480 }; 481} 482 483 484// Subtracts point2's coordinates from point1's coordinates, returning a delta 485function diffPoints(point1, point2) { 486 return { 487 left: point1.left - point2.left, 488 top: point1.top - point2.top 489 }; 490} 491 492 493/* Object Ordering by Field 494----------------------------------------------------------------------------------------------------------------------*/ 495 496FC.parseFieldSpecs = parseFieldSpecs; 497FC.compareByFieldSpecs = compareByFieldSpecs; 498FC.compareByFieldSpec = compareByFieldSpec; 499FC.flexibleCompare = flexibleCompare; 500 501 502function parseFieldSpecs(input) { 503 var specs = []; 504 var tokens = []; 505 var i, token; 506 507 if (typeof input === 'string') { 508 tokens = input.split(/\s*,\s*/); 509 } 510 else if (typeof input === 'function') { 511 tokens = [ input ]; 512 } 513 else if ($.isArray(input)) { 514 tokens = input; 515 } 516 517 for (i = 0; i < tokens.length; i++) { 518 token = tokens[i]; 519 520 if (typeof token === 'string') { 521 specs.push( 522 token.charAt(0) == '-' ? 523 { field: token.substring(1), order: -1 } : 524 { field: token, order: 1 } 525 ); 526 } 527 else if (typeof token === 'function') { 528 specs.push({ func: token }); 529 } 530 } 531 532 return specs; 533} 534 535 536function compareByFieldSpecs(obj1, obj2, fieldSpecs) { 537 var i; 538 var cmp; 539 540 for (i = 0; i < fieldSpecs.length; i++) { 541 cmp = compareByFieldSpec(obj1, obj2, fieldSpecs[i]); 542 if (cmp) { 543 return cmp; 544 } 545 } 546 547 return 0; 548} 549 550 551function compareByFieldSpec(obj1, obj2, fieldSpec) { 552 if (fieldSpec.func) { 553 return fieldSpec.func(obj1, obj2); 554 } 555 return flexibleCompare(obj1[fieldSpec.field], obj2[fieldSpec.field]) * 556 (fieldSpec.order || 1); 557} 558 559 560function flexibleCompare(a, b) { 561 if (!a && !b) { 562 return 0; 563 } 564 if (b == null) { 565 return -1; 566 } 567 if (a == null) { 568 return 1; 569 } 570 if ($.type(a) === 'string' || $.type(b) === 'string') { 571 return String(a).localeCompare(String(b)); 572 } 573 return a - b; 574} 575 576 577/* FullCalendar-specific Misc Utilities 578----------------------------------------------------------------------------------------------------------------------*/ 579 580 581// Computes the intersection of the two ranges. Will return fresh date clones in a range. 582// Returns undefined if no intersection. 583// Expects all dates to be normalized to the same timezone beforehand. 584// TODO: move to date section? 585function intersectRanges(subjectRange, constraintRange) { 586 var subjectStart = subjectRange.start; 587 var subjectEnd = subjectRange.end; 588 var constraintStart = constraintRange.start; 589 var constraintEnd = constraintRange.end; 590 var segStart, segEnd; 591 var isStart, isEnd; 592 593 if (subjectEnd > constraintStart && subjectStart < constraintEnd) { // in bounds at all? 594 595 if (subjectStart >= constraintStart) { 596 segStart = subjectStart.clone(); 597 isStart = true; 598 } 599 else { 600 segStart = constraintStart.clone(); 601 isStart = false; 602 } 603 604 if (subjectEnd <= constraintEnd) { 605 segEnd = subjectEnd.clone(); 606 isEnd = true; 607 } 608 else { 609 segEnd = constraintEnd.clone(); 610 isEnd = false;
611 } 612 613 return { 614 start: segStart, 615 end: segEnd, 616 isStart: isStart, 617 isEnd: isEnd 618 }; 619 } 620} 621 622 623/* Date Utilities 624----------------------------------------------------------------------------------------------------------------------*/ 625 626FC.computeIntervalUnit = computeIntervalUnit; 627FC.divideRangeByDuration = divideRangeByDuration; 628FC.divideDurationByDuration = divideDurationByDuration; 629FC.multiplyDuration = multiplyDuration; 630FC.durationHasTime = durationHasTime; 631 632var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ]; 633var intervalUnits = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ]; 634 635 636// Diffs the two moments into a Duration where full-days are recorded first, then the remaining time. 637// Moments will have their timezones normalized. 638function diffDayTime(a, b) { 639 return moment.duration({ 640 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'), 641 ms: a.time() - b.time() // time-of-day from day start. disregards timezone 642 }); 643} 644 645 646// Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations. 647function diffDay(a, b) { 648 return moment.duration({ 649 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days') 650 }); 651} 652 653 654// Diffs two moments, producing a duration, made of a whole-unit-increment of the given unit. Uses rounding. 655function diffByUnit(a, b, unit) { 656 return moment.duration( 657 Math.round(a.diff(b, unit, true)), // returnFloat=true 658 unit 659 ); 660} 661 662 663// Computes the unit name of the largest whole-unit period of time. 664// For example, 48 hours will be "days" whereas 49 hours will be "hours". 665// Accepts start/end, a range object, or an original duration object. 666function computeIntervalUnit(start, end) { 667 var i, unit; 668 var val; 669 670 for (i = 0; i < intervalUnits.length; i++) { 671 unit = intervalUnits[i]; 672 val = computeRangeAs(unit, start, end); 673 674 if (val >= 1 && isInt(val)) { 675 break; 676 } 677 } 678 679 return unit; // will be "milliseconds" if nothing else matches 680} 681 682 683// Computes the number of units (like "hours") in the given range. 684// Range can be a {start,end} object, separate start/end args, or a Duration. 685// Results are based on Moment's .as() and .diff() methods, so results can depend on internal handling 686// of month-diffing logic (which tends to vary from version to version). 687function computeRangeAs(unit, start, end) { 688 689 if (end != null) { // given start, end 690 return end.diff(start, unit, true); 691 } 692 else if (moment.isDuration(start)) { // given duration 693 return start.as(unit); 694 } 695 else { // given { start, end } range object 696 return start.end.diff(start.start, unit, true); 697 } 698} 699 700 701// Intelligently divides a range (specified by a start/end params) by a duration 702function divideRangeByDuration(start, end, dur) { 703 var months; 704 705 if (durationHasTime(dur)) { 706 return (end - start) / dur; 707 } 708 months = dur.asMonths(); 709 if (Math.abs(months) >= 1 && isInt(months)) { 710 return end.diff(start, 'months', true) / months; 711 } 712 return end.diff(start, 'days', true) / dur.asDays(); 713} 714 715 716// Intelligently divides one duration by another 717function divideDurationByDuration(dur1, dur2) { 718 var months1, months2; 719 720 if (durationHasTime(dur1) || durationHasTime(dur2)) { 721 return dur1 / dur2; 722 } 723 months1 = dur1.asMonths(); 724 months2 = dur2.asMonths(); 725 if ( 726 Math.abs(months1) >= 1 && isInt(months1) && 727 Math.abs(months2) >= 1 && isInt(months2) 728 ) { 729 return months1 / months2; 730 } 731 return dur1.asDays() / dur2.asDays(); 732} 733 734 735// Intelligently multiplies a duration by a number 736function multiplyDuration(dur, n) { 737 var months; 738 739 if (durationHasTime(dur)) { 740 return moment.duration(dur * n); 741 } 742 months = dur.asMonths(); 743 if (Math.abs(months) >= 1 && isInt(months)) { 744 return moment.duration({ months: months * n }); 745 } 746 return moment.duration({ days: dur.asDays() * n }); 747} 748 749 750// Returns a boolean about whether the given duration has any time parts (hours/minutes/seconds/ms) 751function durationHasTime(dur) { 752 return Boolean(dur.hours() || dur.minutes() || dur.seconds() || dur.milliseconds()); 753} 754 755 756function isNativeDate(input) { 757 return Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date; 758} 759 760 761// Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00"
762function isTimeString(str) { 763 return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str); 764} 765 766 767/* Logging and Debug 768----------------------------------------------------------------------------------------------------------------------*/ 769 770FC.log = function() { 771 var console = window.console; 772 773 if (console && console.log) { 774 return console.log.apply(console, arguments); 775 } 776}; 777 778FC.warn = function() { 779 var console = window.console; 780 781 if (console && console.warn) { 782 return console.warn.apply(console, arguments); 783 } 784 else { 785 return FC.log.apply(FC, arguments); 786 } 787}; 788 789 790/* General Utilities 791----------------------------------------------------------------------------------------------------------------------*/ 792 793var hasOwnPropMethod = {}.hasOwnProperty; 794 795 796// Merges an array of objects into a single object. 797// The second argument allows for an array of property names who's object values will be merged together. 798function mergeProps(propObjs, complexProps) { 799 var dest = {}; 800 var i, name; 801 var complexObjs; 802 var j, val; 803 var props; 804 805 if (complexProps) { 806 for (i = 0; i < complexProps.length; i++) { 807 name = complexProps[i]; 808 complexObjs = []; 809 810 // collect the trailing object values, stopping when a non-object is discovered 811 for (j = propObjs.length - 1; j >= 0; j--) { 812 val = propObjs[j][name]; 813 814 if (typeof val === 'object') { 815 complexObjs.unshift(val); 816 } 817 else if (val !== undefined) { 818 dest[name] = val; // if there were no objects, this value will be used 819 break; 820 } 821 } 822 823 // if the trailing values were objects, use the merged value 824 if (complexObjs.length) { 825 dest[name] = mergeProps(complexObjs); 826 } 827 } 828 } 829 830 // copy values into the destination, going from last to first 831 for (i = propObjs.length - 1; i >= 0; i--) { 832 props = propObjs[i]; 833 834 for (name in props) { 835 if (!(name in dest)) { // if already assigned by previous props or complex props, don't reassign 836 dest[name] = props[name]; 837 } 838 } 839 } 840 841 return dest; 842} 843 844 845// Create an object that has the given prototype. Just like Object.create 846function createObject(proto) { 847 var f = function() {}; 848 f.prototype = proto; 849 return new f(); 850} 851FC.createObject = createObject; 852 853 854function copyOwnProps(src, dest) { 855 for (var name in src) { 856 if (hasOwnProp(src, name)) { 857 dest[name] = src[name]; 858 } 859 } 860} 861 862 863function hasOwnProp(obj, name) { 864 return hasOwnPropMethod.call(obj, name); 865} 866 867 868// Is the given value a non-object non-function value? 869function isAtomic(val) { 870 return /undefined|null|boolean|number|string/.test($.type(val)); 871} 872 873 874function applyAll(functions, thisObj, args) { 875 if ($.isFunction(functions)) { 876 functions = [ functions ]; 877 } 878 if (functions) { 879 var i; 880 var ret; 881 for (i=0; i<functions.length; i++) { 882 ret = functions[i].apply(thisObj, args) || ret; 883 } 884 return ret; 885 } 886} 887 888 889function firstDefined() { 890 for (var i=0; i<arguments.length; i++) { 891 if (arguments[i] !== undefined) { 892 return arguments[i]; 893 } 894 } 895} 896 897 898function htmlEscape(s) { 899 return (s + '').replace(/&/g, '&') 900 .replace(/</g, '<') 901 .replace(/>/g, '>') 902 .replace(/'/g, ''') 903 .replace(/"/g, '"') 904 .replace(/\n/g, '<br />'); 905} 906 907 908function stripHtmlEntities(text) { 909 return text.replace(/&.*?;/g, ''); 910} 911 912 913// Given a hash of CSS properties, returns a string of CSS. 914// Uses property names as-is (no camel-case conversion). Will not make statements for null/undefined values. 915function cssToStr(cssProps) { 916 var statements = []; 917 918 $.each(cssProps, function(name, val) { 919 if (val != null) { 920 statements.push(name + ':' + val); 921 } 922 }); 923 924 return statements.join(';'); 925} 926 927 928// Given an object hash of HTML attribute names to values, 929// generates a string that can be injected between < > in HTML 930function attrsToStr(attrs) { 931 var parts = []; 932 933 $.each(attrs, function(name, val) { 934 if (val != null) { 935 parts.push(name + '="' + htmlEscape(val) + '"'); 936 } 937 }); 938 939 return parts.join(' '); 940} 941 942 943function capitaliseFirstLetter(str) { 944 return str.charAt(0).toUpperCase() + str.slice(1); 945} 946 947 948function compareNumbers(a, b) { // for .sort() 949 return a - b; 950} 951 952 953function isInt(n) { 954 return n % 1 === 0; 955} 956 957
958// Returns a method bound to the given object context. 959// Just like one of the jQuery.proxy signatures, but without the undesired behavior of treating the same method with 960// different contexts as identical when binding/unbinding events. 961function proxy(obj, methodName) { 962 var method = obj[methodName]; 963 964 return function() { 965 return method.apply(obj, arguments); 966 }; 967} 968 969 970// Returns a function, that, as long as it continues to be invoked, will not 971// be triggered. The function will be called after it stops being called for 972// N milliseconds. If `immediate` is passed, trigger the function on the 973// leading edge, instead of the trailing. 974// https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714 975function debounce(func, wait, immediate) { 976 var timeout, args, context, timestamp, result; 977 978 var later = function() { 979 var last = +new Date() - timestamp; 980 if (last < wait) { 981 timeout = setTimeout(later, wait - last); 982 } 983 else { 984 timeout = null; 985 if (!immediate) { 986 result = func.apply(context, args); 987 context = args = null; 988 } 989 } 990 }; 991 992 return function() { 993 context = this; 994 args = arguments; 995 timestamp = +new Date(); 996 var callNow = immediate && !timeout; 997 if (!timeout) { 998 timeout = setTimeout(later, wait); 999 } 1000 if (callNow) { 1001 result = func.apply(context, args); 1002 context = args = null; 1003 } 1004 return result; 1005 }; 1006} 1007 1008;; 1009 1010/* 1011GENERAL NOTE on moments throughout the *entire rest* of the codebase: 1012All moments are assumed to be ambiguously-zoned unless otherwise noted, 1013with the NOTABLE EXCEOPTION of start/end dates that live on *Event Objects*. 1014Ambiguously-TIMED moments are assumed to be ambiguously-zoned by nature. 1015*/ 1016 1017var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/; 1018var ambigTimeOrZoneRegex = 1019 /^\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+)?)?)?)?)?$/; 1020var newMomentProto = moment.fn; // where we will attach our new methods 1021var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods 1022 1023// tell momentjs to transfer these properties upon clone 1024var momentProperties = moment.momentProperties; 1025momentProperties.push('_fullCalendar'); 1026momentProperties.push('_ambigTime'); 1027momentProperties.push('_ambigZone'); 1028 1029 1030// Creating 1031// ------------------------------------------------------------------------------------------------- 1032 1033// Creates a new moment, similar to the vanilla moment(...) constructor, but with 1034// extra features (ambiguous time, enhanced formatting). When given an existing moment, 1035// it will function as a clone (and retain the zone of the moment). Anything else will 1036// result in a moment in the local zone. 1037FC.moment = function() { 1038 return makeMoment(arguments); 1039}; 1040 1041// Sames as FC.moment, but forces the resulting moment to be in the UTC timezone. 1042FC.moment.utc = function() { 1043 var mom = makeMoment(arguments, true); 1044 1045 // Force it into UTC because makeMoment doesn't guarantee it 1046 // (if given a pre-existing moment for example) 1047 if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone 1048 mom.utc(); 1049 } 1050 1051 return mom; 1052}; 1053 1054// Same as FC.moment, but when given an ISO8601 string, the timezone offset is preserved. 1055// ISO8601 strings with no timezone offset will become ambiguously zoned. 1056FC.moment.parseZone = function() { 1057 return makeMoment(arguments, true, true); 1058}; 1059 1060// Builds an enhanced moment from args. When given an existing moment, it clones. When given a 1061// native Date, or called with no arguments (the current time), the resulting moment will be local. 1062// Anything else needs to be "parsed" (a string or an array), and will be affected by: 1063// parseAsUTC - if there is no zone information, should we parse the input in UTC? 1064// parseZone - if there is zone information, should we force the zone of the moment? 1065function makeMoment(args, parseAsUTC, parseZone) { 1066 var input = args[0]; 1067 var isSingleString = args.length == 1 && typeof input === 'string'; 1068 var isAmbigTime; 1069 var isAmbigZone; 1070 var ambigMatch; 1071 var mom; 1072 1073 if (moment.isMoment(input) || isNativeDate(input) || input === undefined) { 1074 mom = moment.apply(null, args); 1075 } 1076 else { // "parsing" is required 1077 isAmbigTime = false;
1078 isAmbigZone = false; 1079 1080 if (isSingleString) { 1081 if (ambigDateOfMonthRegex.test(input)) { 1082 // accept strings like '2014-05', but convert to the first of the month 1083 input += '-01'; 1084 args = [ input ]; // for when we pass it on to moment's constructor 1085 isAmbigTime = true; 1086 isAmbigZone = true; 1087 } 1088 else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) { 1089 isAmbigTime = !ambigMatch[5]; // no time part? 1090 isAmbigZone = true; 1091 } 1092 } 1093 else if ($.isArray(input)) { 1094 // arrays have no timezone information, so assume ambiguous zone 1095 isAmbigZone = true; 1096 } 1097 // otherwise, probably a string with a format 1098 1099 if (parseAsUTC || isAmbigTime) { 1100 mom = moment.utc.apply(moment, args); 1101 } 1102 else { 1103 mom = moment.apply(null, args); 1104 } 1105 1106 if (isAmbigTime) { 1107 mom._ambigTime = true; 1108 mom._ambigZone = true; // ambiguous time always means ambiguous zone 1109 } 1110 else if (parseZone) { // let's record the inputted zone somehow 1111 if (isAmbigZone) { 1112 mom._ambigZone = true; 1113 } 1114 else if (isSingleString) { 1115 mom.utcOffset(input); // if not a valid zone, will assign UTC 1116 } 1117 } 1118 } 1119 1120 mom._fullCalendar = true; // flag for extended functionality 1121 1122 return mom; 1123} 1124 1125 1126// Week Number 1127// ------------------------------------------------------------------------------------------------- 1128 1129 1130// Returns the week number, considering the locale's custom week number calcuation 1131// `weeks` is an alias for `week` 1132newMomentProto.week = newMomentProto.weeks = function(input) { 1133 var weekCalc = this._locale._fullCalendar_weekCalc; 1134 1135 if (input == null && typeof weekCalc === 'function') { // custom function only works for getter 1136 return weekCalc(this); 1137 } 1138 else if (weekCalc === 'ISO') { 1139 return oldMomentProto.isoWeek.apply(this, arguments); // ISO getter/setter 1140 } 1141 1142 return oldMomentProto.week.apply(this, arguments); // local getter/setter 1143}; 1144 1145 1146// Time-of-day 1147// ------------------------------------------------------------------------------------------------- 1148 1149// GETTER 1150// Returns a Duration with the hours/minutes/seconds/ms values of the moment. 1151// If the moment has an ambiguous time, a duration of 00:00 will be returned. 1152// 1153// SETTER 1154// You can supply a Duration, a Moment, or a Duration-like argument. 1155// When setting the time, and the moment has an ambiguous time, it then becomes unambiguous. 1156newMomentProto.time = function(time) { 1157 1158 // Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar. 1159 // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins. 1160 if (!this._fullCalendar) { 1161 return oldMomentProto.time.apply(this, arguments); 1162 } 1163 1164 if (time == null) { // getter 1165 return moment.duration({ 1166 hours: this.hours(), 1167 minutes: this.minutes(), 1168 seconds: this.seconds(), 1169 milliseconds: this.milliseconds() 1170 }); 1171 } 1172 else { // setter 1173 1174 this._ambigTime = false; // mark that the moment now has a time 1175 1176 if (!moment.isDuration(time) && !moment.isMoment(time)) { 1177 time = moment.duration(time); 1178 } 1179 1180 // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day). 1181 // Only for Duration times, not Moment times. 1182 var dayHours = 0; 1183 if (moment.isDuration(time)) { 1184 dayHours = Math.floor(time.asDays()) * 24; 1185 } 1186 1187 // We need to set the individual fields. 1188 // Can't use startOf('day') then add duration. In case of DST at start of day. 1189 return this.hours(dayHours + time.hours()) 1190 .minutes(time.minutes()) 1191 .seconds(time.seconds()) 1192 .milliseconds(time.milliseconds()); 1193 } 1194}; 1195 1196// Converts the moment to UTC, stripping out its time-of-day and timezone offset, 1197// but preserving its YMD. A moment with a stripped time will display no time 1198// nor timezone offset when .format() is called. 1199newMomentProto.stripTime = function() { 1200 1201 if (!this._ambigTime) { 1202 1203 this.utc(true); // keepLocalTime=true (for keeping *date* value) 1204 1205 // set time to zero 1206 this.set({ 1207 hours: 0, 1208 minutes: 0, 1209 seconds: 0, 1210 ms: 0 1211 }); 1212 1213 // Mark the time as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), 1214 // which clears all ambig flags. 1215 this._ambigTime = true; 1216 this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset 1217 } 1218 1219 return this; // for chaining 1220}; 1221
1222// Returns if the moment has a non-ambiguous time (boolean) 1223newMomentProto.hasTime = function() { 1224 return !this._ambigTime; 1225}; 1226 1227 1228// Timezone 1229// ------------------------------------------------------------------------------------------------- 1230 1231// Converts the moment to UTC, stripping out its timezone offset, but preserving its 1232// YMD and time-of-day. A moment with a stripped timezone offset will display no 1233// timezone offset when .format() is called. 1234newMomentProto.stripZone = function() { 1235 var wasAmbigTime; 1236 1237 if (!this._ambigZone) { 1238 1239 wasAmbigTime = this._ambigTime; 1240 1241 this.utc(true); // keepLocalTime=true (for keeping date and time values) 1242 1243 // the above call to .utc()/.utcOffset() unfortunately might clear the ambig flags, so restore 1244 this._ambigTime = wasAmbigTime || false; 1245 1246 // Mark the zone as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), 1247 // which clears the ambig flags. 1248 this._ambigZone = true; 1249 } 1250 1251 return this; // for chaining 1252}; 1253 1254// Returns of the moment has a non-ambiguous timezone offset (boolean) 1255newMomentProto.hasZone = function() { 1256 return !this._ambigZone; 1257}; 1258 1259 1260// implicitly marks a zone 1261newMomentProto.local = function(keepLocalTime) { 1262 1263 // for when converting from ambiguously-zoned to local, 1264 // keep the time values when converting from UTC -> local 1265 oldMomentProto.local.call(this, this._ambigZone || keepLocalTime); 1266 1267 // ensure non-ambiguous 1268 // this probably already happened via local() -> utcOffset(), but don't rely on Moment's internals 1269 this._ambigTime = false; 1270 this._ambigZone = false; 1271 1272 return this; // for chaining 1273}; 1274 1275 1276// implicitly marks a zone 1277newMomentProto.utc = function(keepLocalTime) { 1278 1279 oldMomentProto.utc.call(this, keepLocalTime); 1280 1281 // ensure non-ambiguous 1282 // this probably already happened via utc() -> utcOffset(), but don't rely on Moment's internals 1283 this._ambigTime = false; 1284 this._ambigZone = false; 1285 1286 return this; 1287}; 1288 1289 1290// implicitly marks a zone (will probably get called upon .utc() and .local()) 1291newMomentProto.utcOffset = function(tzo) { 1292 1293 if (tzo != null) { // setter 1294 // these assignments needs to happen before the original zone method is called. 1295 // I forget why, something to do with a browser crash. 1296 this._ambigTime = false; 1297 this._ambigZone = false; 1298 } 1299 1300 return oldMomentProto.utcOffset.apply(this, arguments); 1301}; 1302 1303 1304// Formatting 1305// ------------------------------------------------------------------------------------------------- 1306 1307newMomentProto.format = function() { 1308 if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided? 1309 return formatDate(this, arguments[0]); // our extended formatting 1310 } 1311 if (this._ambigTime) { 1312 return oldMomentFormat(this, 'YYYY-MM-DD'); 1313 } 1314 if (this._ambigZone) { 1315 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); 1316 } 1317 return oldMomentProto.format.apply(this, arguments); 1318}; 1319 1320newMomentProto.toISOString = function() { 1321 if (this._ambigTime) { 1322 return oldMomentFormat(this, 'YYYY-MM-DD'); 1323 } 1324 if (this._ambigZone) { 1325 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); 1326 } 1327 return oldMomentProto.toISOString.apply(this, arguments); 1328}; 1329 1330;; 1331(function() { 1332 1333// exports 1334FC.formatDate = formatDate; 1335FC.formatRange = formatRange; 1336FC.oldMomentFormat = oldMomentFormat; 1337FC.queryMostGranularFormatUnit = queryMostGranularFormatUnit; 1338 1339 1340// Config 1341// --------------------------------------------------------------------------------------------------------------------- 1342 1343/* 1344Inserted between chunks in the fake ("intermediate") formatting string. 1345Important that it passes as whitespace (\s) because moment often identifies non-standalone months 1346via a regexp with an \s. 1347*/ 1348var PART_SEPARATOR = '\u000b'; // vertical tab 1349 1350/* 1351Inserted as the first character of a literal-text chunk to indicate that the literal text is not actually literal text, 1352but rather, a "special" token that has custom rendering (see specialTokens map). 1353*/ 1354var SPECIAL_TOKEN_MARKER = '\u001f'; // information separator 1 1355 1356/* 1357Inserted at the beginning and end of a span of text that must have non-zero numeric characters. 1358Handling of these markers is done in a post-processing step at the very end of text rendering. 1359*/ 1360var MAYBE_MARKER = '\u001e'; // information separator 2 1361var MAYBE_REGEXP = new RegExp(MAYBE_MARKER + '([^' + MAYBE_MARKER + ']*)' + MAYBE_MARKER, 'g'); // must be global 1362 1363/* 1364Addition formatting tokens we want recognized 1365*/ 1366var specialTokens = { 1367 t: function(date) { // "a" or "p" 1368 return oldMomentFormat(date, 'a').charAt(0); 1369 }, 1370 T: function(date) { // "A" or "P" 1371 return oldMomentFormat(date, 'A').charAt(0); 1372 } 1373}; 1374 1375/* 1376The first characters of formatting tokens for units that are 1 day or larger. 1377`value` is for ranking relative size (lower means bigger).
1378`unit` is a normalized unit, used for comparing moments. 1379*/ 1380var largeTokenMap = { 1381 Y: { value: 1, unit: 'year' }, 1382 M: { value: 2, unit: 'month' }, 1383 W: { value: 3, unit: 'week' }, // ISO week 1384 w: { value: 3, unit: 'week' }, // local week 1385 D: { value: 4, unit: 'day' }, // day of month 1386 d: { value: 4, unit: 'day' } // day of week 1387}; 1388 1389 1390// Single Date Formatting 1391// --------------------------------------------------------------------------------------------------------------------- 1392 1393/* 1394Formats `date` with a Moment formatting string, but allow our non-zero areas and special token 1395*/ 1396function formatDate(date, formatStr) { 1397 return renderFakeFormatString( 1398 getParsedFormatString(formatStr).fakeFormatString, 1399 date 1400 ); 1401} 1402 1403/* 1404Call this if you want Moment's original format method to be used 1405*/ 1406function oldMomentFormat(mom, formatStr) { 1407 return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js 1408} 1409 1410 1411// Date Range Formatting 1412// ------------------------------------------------------------------------------------------------- 1413// TODO: make it work with timezone offset 1414 1415/* 1416Using a formatting string meant for a single date, generate a range string, like 1417"Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ. 1418If the dates are the same as far as the format string is concerned, just return a single 1419rendering of one date, without any separator. 1420*/ 1421function formatRange(date1, date2, formatStr, separator, isRTL) { 1422 var localeData; 1423 1424 date1 = FC.moment.parseZone(date1); 1425 date2 = FC.moment.parseZone(date2); 1426 1427 localeData = date1.localeData(); 1428 1429 // Expand localized format strings, like "LL" -> "MMMM D YYYY". 1430 // BTW, this is not important for `formatDate` because it is impossible to put custom tokens 1431 // or non-zero areas in Moment's localized format strings. 1432 formatStr = localeData.longDateFormat(formatStr) || formatStr; 1433 1434 return renderParsedFormat( 1435 getParsedFormatString(formatStr), 1436 date1, 1437 date2, 1438 separator || ' - ', 1439 isRTL 1440 ); 1441} 1442 1443/* 1444Renders a range with an already-parsed format string. 1445*/ 1446function renderParsedFormat(parsedFormat, date1, date2, separator, isRTL) { 1447 var sameUnits = parsedFormat.sameUnits; 1448 var unzonedDate1 = date1.clone().stripZone(); // for same-unit comparisons 1449 var unzonedDate2 = date2.clone().stripZone(); // " 1450 1451 var renderedParts1 = renderFakeFormatStringParts(parsedFormat.fakeFormatString, date1); 1452 var renderedParts2 = renderFakeFormatStringParts(parsedFormat.fakeFormatString, date2); 1453 1454 var leftI; 1455 var leftStr = ''; 1456 var rightI; 1457 var rightStr = ''; 1458 var middleI; 1459 var middleStr1 = ''; 1460 var middleStr2 = ''; 1461 var middleStr = ''; 1462 1463 // Start at the leftmost side of the formatting string and continue until you hit a token 1464 // that is not the same between dates. 1465 for ( 1466 leftI = 0; 1467 leftI < sameUnits.length && (!sameUnits[leftI] || unzonedDate1.isSame(unzonedDate2, sameUnits[leftI])); 1468 leftI++ 1469 ) { 1470 leftStr += renderedParts1[leftI]; 1471 } 1472 1473 // Similarly, start at the rightmost side of the formatting string and move left 1474 for ( 1475 rightI = sameUnits.length - 1; 1476 rightI > leftI && (!sameUnits[rightI] || unzonedDate1.isSame(unzonedDate2, sameUnits[rightI])); 1477 rightI-- 1478 ) { 1479 // If current chunk is on the boundary of unique date-content, and is a special-case 1480 // date-formatting postfix character, then don't consume it. Consider it unique date-content. 1481 // TODO: make configurable 1482 if (rightI - 1 === leftI && renderedParts1[rightI] === '.') { 1483 break; 1484 } 1485 1486 rightStr = renderedParts1[rightI] + rightStr; 1487 } 1488 1489 // The area in the middle is different for both of the dates. 1490 // Collect them distinctly so we can jam them together later. 1491 for (middleI = leftI; middleI <= rightI; middleI++) { 1492 middleStr1 += renderedParts1[middleI]; 1493 middleStr2 += renderedParts2[middleI]; 1494 } 1495 1496 if (middleStr1 || middleStr2) { 1497 if (isRTL) { 1498 middleStr = middleStr2 + separator + middleStr1; 1499 } 1500 else { 1501 middleStr = middleStr1 + separator + middleStr2; 1502 } 1503 } 1504 1505 return processMaybeMarkers( 1506 leftStr + middleStr + rightStr 1507 ); 1508} 1509 1510 1511// Format String Parsing 1512// --------------------------------------------------------------------------------------------------------------------- 1513 1514var parsedFormatStrCache = {}; 1515 1516/*
1517Returns a parsed format string, leveraging a cache. 1518*/ 1519function getParsedFormatString(formatStr) { 1520 return parsedFormatStrCache[formatStr] || 1521 (parsedFormatStrCache[formatStr] = parseFormatString(formatStr)); 1522} 1523 1524/* 1525Parses a format string into the following: 1526- fakeFormatString: a momentJS formatting string, littered with special control characters that get post-processed. 1527- sameUnits: for every part in fakeFormatString, if the part is a token, the value will be a unit string (like "day"), 1528 that indicates how similar a range's start & end must be in order to share the same formatted text. 1529 If not a token, then the value is null. 1530 Always a flat array (not nested liked "chunks"). 1531*/ 1532function parseFormatString(formatStr) { 1533 var chunks = chunkFormatString(formatStr); 1534 1535 return { 1536 fakeFormatString: buildFakeFormatString(chunks), 1537 sameUnits: buildSameUnits(chunks) 1538 }; 1539} 1540 1541/* 1542Break the formatting string into an array of chunks. 1543A 'maybe' chunk will have nested chunks. 1544*/ 1545function chunkFormatString(formatStr) { 1546 var chunks = []; 1547 var match; 1548 1549 // TODO: more descrimination 1550 // \4 is a backreference to the first character of a multi-character set. 1551 var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g; 1552 1553 while ((match = chunker.exec(formatStr))) { 1554 if (match[1]) { // a literal string inside [ ... ] 1555 chunks.push.apply(chunks, // append 1556 splitStringLiteral(match[1]) 1557 ); 1558 } 1559 else if (match[2]) { // non-zero formatting inside ( ... ) 1560 chunks.push({ maybe: chunkFormatString(match[2]) }); 1561 } 1562 else if (match[3]) { // a formatting token 1563 chunks.push({ token: match[3] }); 1564 } 1565 else if (match[5]) { // an unenclosed literal string 1566 chunks.push.apply(chunks, // append 1567 splitStringLiteral(match[5]) 1568 ); 1569 } 1570 } 1571 1572 return chunks; 1573} 1574 1575/* 1576Potentially splits a literal-text string into multiple parts. For special cases. 1577*/ 1578function splitStringLiteral(s) { 1579 if (s === '. ') { 1580 return [ '.', ' ' ]; // for locales with periods bound to the end of each year/month/date 1581 } 1582 else { 1583 return [ s ]; 1584 } 1585} 1586 1587/* 1588Given chunks parsed from a real format string, generate a fake (aka "intermediate") format string with special control 1589characters that will eventually be given to moment for formatting, and then post-processed. 1590*/ 1591function buildFakeFormatString(chunks) { 1592 var parts = []; 1593 var i, chunk; 1594 1595 for (i = 0; i < chunks.length; i++) { 1596 chunk = chunks[i]; 1597 1598 if (typeof chunk === 'string') { 1599 parts.push('[' + chunk + ']'); 1600 } 1601 else if (chunk.token) { 1602 if (chunk.token in specialTokens) { 1603 parts.push( 1604 SPECIAL_TOKEN_MARKER + // useful during post-processing 1605 '[' + chunk.token + ']' // preserve as literal text 1606 ); 1607 } 1608 else { 1609 parts.push(chunk.token); // unprotected text implies a format string 1610 } 1611 } 1612 else if (chunk.maybe) { 1613 parts.push( 1614 MAYBE_MARKER + // useful during post-processing 1615 buildFakeFormatString(chunk.maybe) + 1616 MAYBE_MARKER 1617 ); 1618 } 1619 } 1620 1621 return parts.join(PART_SEPARATOR); 1622} 1623 1624/* 1625Given parsed chunks from a real formatting string, generates an array of unit strings (like "day") that indicate 1626in which regard two dates must be similar in order to share range formatting text. 1627The `chunks` can be nested (because of "maybe" chunks), however, the returned array will be flat. 1628*/ 1629function buildSameUnits(chunks) { 1630 var units = []; 1631 var i, chunk; 1632 var tokenInfo; 1633 1634 for (i = 0; i < chunks.length; i++) { 1635 chunk = chunks[i]; 1636 1637 if (chunk.token) { 1638 tokenInfo = largeTokenMap[chunk.token.charAt(0)]; 1639 units.push(tokenInfo ? tokenInfo.unit : 'second'); // default to a very strict same-second 1640 } 1641 else if (chunk.maybe) { 1642 units.push.apply(units, // append 1643 buildSameUnits(chunk.maybe) 1644 ); 1645 } 1646 else { 1647 units.push(null); 1648 } 1649 } 1650 1651 return units; 1652} 1653 1654 1655// Rendering to text 1656// --------------------------------------------------------------------------------------------------------------------- 1657 1658/* 1659Formats a date with a fake format string, post-processes the control characters, then returns. 1660*/ 1661function renderFakeFormatString(fakeFormatString, date) { 1662 return processMaybeMarkers( 1663 renderFakeFormatStringParts(fakeFormatString, date).join('') 1664 ); 1665} 1666 1667/* 1668Formats a date into parts that will have been post-processed, EXCEPT for the "maybe" markers. 1669*/ 1670function renderFakeFormatStringParts(fakeFormatString, date) { 1671 var parts = []; 1672 var fakeRender = oldMomentFormat(date, fakeFormatString);
1673 var fakeParts = fakeRender.split(PART_SEPARATOR); 1674 var i, fakePart; 1675 1676 for (i = 0; i < fakeParts.length; i++) { 1677 fakePart = fakeParts[i]; 1678 1679 if (fakePart.charAt(0) === SPECIAL_TOKEN_MARKER) { 1680 parts.push( 1681 // the literal string IS the token's name. 1682 // call special token's registered function. 1683 specialTokens[fakePart.substring(1)](date) 1684 ); 1685 } 1686 else { 1687 parts.push(fakePart); 1688 } 1689 } 1690 1691 return parts; 1692} 1693 1694/* 1695Accepts an almost-finally-formatted string and processes the "maybe" control characters, returning a new string. 1696*/ 1697function processMaybeMarkers(s) { 1698 return s.replace(MAYBE_REGEXP, function(m0, m1) { // regex assumed to have 'g' flag 1699 if (m1.match(/[1-9]/)) { // any non-zero numeric characters? 1700 return m1; 1701 } 1702 else { 1703 return ''; 1704 } 1705 }); 1706} 1707 1708 1709// Misc Utils 1710// ------------------------------------------------------------------------------------------------- 1711 1712/* 1713Returns a unit string, either 'year', 'month', 'day', or null for the most granular formatting token in the string. 1714*/ 1715function queryMostGranularFormatUnit(formatStr) { 1716 var chunks = chunkFormatString(formatStr); 1717 var i, chunk; 1718 var candidate; 1719 var best; 1720 1721 for (i = 0; i < chunks.length; i++) { 1722 chunk = chunks[i]; 1723 1724 if (chunk.token) { 1725 candidate = largeTokenMap[chunk.token.charAt(0)]; 1726 if (candidate) { 1727 if (!best || candidate.value > best.value) { 1728 best = candidate; 1729 } 1730 } 1731 } 1732 } 1733 1734 if (best) { 1735 return best.unit; 1736 } 1737 1738 return null; 1739}; 1740 1741})(); 1742 1743// quick local references 1744var formatDate = FC.formatDate; 1745var formatRange = FC.formatRange; 1746var oldMomentFormat = FC.oldMomentFormat; 1747 1748;; 1749 1750FC.Class = Class; // export 1751 1752// Class that all other classes will inherit from 1753function Class() { } 1754 1755 1756// Called on a class to create a subclass. 1757// Last argument contains instance methods. Any argument before the last are considered mixins. 1758Class.extend = function() { 1759 var len = arguments.length; 1760 var i; 1761 var members; 1762 1763 for (i = 0; i < len; i++) { 1764 members = arguments[i]; 1765 if (i < len - 1) { // not the last argument? 1766 mixIntoClass(this, members); 1767 } 1768 } 1769 1770 return extendClass(this, members || {}); // members will be undefined if no arguments 1771}; 1772 1773 1774// Adds new member variables/methods to the class's prototype. 1775// Can be called with another class, or a plain object hash containing new members. 1776Class.mixin = function(members) { 1777 mixIntoClass(this, members); 1778}; 1779 1780 1781function extendClass(superClass, members) { 1782 var subClass; 1783 1784 // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist 1785 if (hasOwnProp(members, 'constructor')) { 1786 subClass = members.constructor; 1787 } 1788 if (typeof subClass !== 'function') { 1789 subClass = members.constructor = function() { 1790 superClass.apply(this, arguments); 1791 }; 1792 } 1793 1794 // build the base prototype for the subclass, which is an new object chained to the superclass's prototype 1795 subClass.prototype = createObject(superClass.prototype); 1796 1797 // copy each member variable/method onto the the subclass's prototype 1798 copyOwnProps(members, subClass.prototype); 1799 1800 // copy over all class variables/methods to the subclass, such as `extend` and `mixin` 1801 copyOwnProps(superClass, subClass); 1802 1803 return subClass; 1804} 1805 1806 1807function mixIntoClass(theClass, members) { 1808 copyOwnProps(members, theClass.prototype); 1809} 1810;; 1811 1812/* 1813Wrap jQuery's Deferred Promise object to be slightly more Promise/A+ compliant. 1814With the added non-standard feature of synchronously executing handlers on resolved promises, 1815which doesn't always happen otherwise (esp with nested .then handlers!?), 1816so, this makes things a lot easier, esp because jQuery 3 changed the synchronicity for Deferred objects. 1817 1818TODO: write tests and more comments 1819*/ 1820 1821function Promise(executor) { 1822 var deferred = $.Deferred(); 1823 var promise = deferred.promise(); 1824 1825 if (typeof executor === 'function') { 1826 executor( 1827 function(value) { // resolve 1828 if (Promise.immediate) { 1829 promise._value = value; 1830 } 1831 deferred.resolve(value); 1832 }, 1833 function() { // reject 1834 deferred.reject(); 1835 } 1836 ); 1837 } 1838 1839 if (Promise.immediate) { 1840 var origThen = promise.then; 1841 1842 promise.then = function(onFulfilled, onRejected) { 1843 var state = promise.state(); 1844 1845 if (state === 'resolved') { 1846 if (typeof onFulfilled === 'function') { 1847 return Promise.resolve(onFulfilled(promise._value)); 1848 } 1849 } 1850 else if (state === 'rejected') { 1851 if (typeof onRejected === 'function') { 1852 onRejected(); 1853 return promise; // already rejected 1854 } 1855 } 1856 1857 return origThen.call(promise, onFulfilled, onRejected); 1858 }; 1859 } 1860 1861 return promise; // instanceof Promise will break :( TODO: make Promise a real class 1862} 1863 1864FC.Promise = Promise; 1865 1866Promise.immediate = true; 1867 1868 1869Promise.resolve = function(value) { 1870 if (value && typeof value.resolve === 'function') { 1871 return value.promise(); 1872 } 1873 if (value && typeof value.then === 'function') { 1874 return value; 1875 } 1876 else { 1877 var deferred = $.Deferred().resolve(value); 1878 var promise = deferred.promise(); 1879 1880 if (Promise.immediate) { 1881 var origThen = promise.then; 1882 1883 promise._value = value; 1884 1885 promise.then = function(onFulfilled, onRejected) { 1886 if (typeof onFulfilled === 'function') { 1887 return Promise.resolve(onFulfilled(value)); 1888 } 1889 return origThen.call(promise, onFulfilled, onRejected); 1890 }; 1891 } 1892 1893 return promise; 1894 } 1895}; 1896 1897 1898Promise.reject = function() { 1899 return $.Deferred().reject().promise(); 1900}; 1901 1902 1903Promise.all = function(inputs) { 1904 var hasAllValues = false;
1905 var values; 1906 var i, input; 1907 1908 if (Promise.immediate) { 1909 hasAllValues = true; 1910 values = []; 1911 1912 for (i = 0; i < inputs.length; i++) { 1913 input = inputs[i]; 1914 1915 if (input && typeof input.state === 'function' && input.state() === 'resolved' && ('_value' in input)) { 1916 values.push(input._value); 1917 } 1918 else if (input && typeof input.then === 'function') { 1919 hasAllValues = false; 1920 break; 1921 } 1922 else { 1923 values.push(input); 1924 } 1925 } 1926 } 1927 1928 if (hasAllValues) { 1929 return Promise.resolve(values); 1930 } 1931 else { 1932 return $.when.apply($.when, inputs).then(function() { 1933 return $.when($.makeArray(arguments)); 1934 }); 1935 } 1936}; 1937 1938;; 1939 1940// TODO: write tests and clean up code 1941 1942function TaskQueue(debounceWait) { 1943 var q = []; // array of runFuncs 1944 1945 function addTask(taskFunc) { 1946 return new Promise(function(resolve) { 1947 1948 // should run this function when it's taskFunc's turn to run. 1949 // responsible for popping itself off the queue. 1950 var runFunc = function() { 1951 Promise.resolve(taskFunc()) // result might be async, coerce to promise 1952 .then(resolve) // resolve TaskQueue::push's promise, for the caller. will receive result of taskFunc. 1953 .then(function() { 1954 q.shift(); // pop itself off 1955 1956 // run the next task, if any 1957 if (q.length) { 1958 q[0](); 1959 } 1960 }); 1961 }; 1962 1963 // always put the task at the end of the queue, BEFORE running the task 1964 q.push(runFunc); 1965 1966 // if it's the only task in the queue, run immediately 1967 if (q.length === 1) { 1968 runFunc(); 1969 } 1970 }); 1971 } 1972 1973 this.add = // potentially debounce, for the public method 1974 typeof debounceWait === 'number' ? 1975 debounce(addTask, debounceWait) : 1976 addTask; // if not a number (null/undefined/false), no debounce at all 1977 1978 this.addQuickly = addTask; // guaranteed no debounce 1979} 1980 1981FC.TaskQueue = TaskQueue; 1982 1983/* 1984q = new TaskQueue(); 1985 1986function work(i) { 1987 return q.push(function() { 1988 trigger(); 1989 console.log('work' + i); 1990 }); 1991} 1992 1993var cnt = 0; 1994 1995function trigger() { 1996 if (cnt < 5) { 1997 cnt++; 1998 work(cnt); 1999 } 2000} 2001 2002work(9); 2003*/ 2004 2005;; 2006 2007var EmitterMixin = FC.EmitterMixin = { 2008 2009 // jQuery-ification via $(this) allows a non-DOM object to have 2010 // the same event handling capabilities (including namespaces). 2011 2012 2013 on: function(types, handler) { 2014 $(this).on(types, this._prepareIntercept(handler)); 2015 return this; // for chaining 2016 }, 2017 2018 2019 one: function(types, handler) { 2020 $(this).one(types, this._prepareIntercept(handler)); 2021 return this; // for chaining 2022 }, 2023 2024 2025 _prepareIntercept: function(handler) { 2026 // handlers are always called with an "event" object as their first param. 2027 // sneak the `this` context and arguments into the extra parameter object 2028 // and forward them on to the original handler. 2029 var intercept = function(ev, extra) { 2030 return handler.apply( 2031 extra.context || this, 2032 extra.args || [] 2033 ); 2034 }; 2035 2036 // mimick jQuery's internal "proxy" system (risky, I know) 2037 // causing all functions with the same .guid to appear to be the same. 2038 // https://github.com/jquery/jquery/blob/2.2.4/src/core.js#L448 2039 // this is needed for calling .off with the original non-intercept handler. 2040 if (!handler.guid) { 2041 handler.guid = $.guid++; 2042 } 2043 intercept.guid = handler.guid; 2044 2045 return intercept; 2046 }, 2047 2048 2049 off: function(types, handler) { 2050 $(this).off(types, handler); 2051 2052 return this; // for chaining 2053 }, 2054 2055 2056 trigger: function(types) { 2057 var args = Array.prototype.slice.call(arguments, 1); // arguments after the first 2058 2059 // pass in "extra" info to the intercept 2060 $(this).triggerHandler(types, { args: args }); 2061 2062 return this; // for chaining 2063 }, 2064 2065 2066 triggerWith: function(types, context, args) { 2067 2068 // `triggerHandler` is less reliant on the DOM compared to `trigger`. 2069 // pass in "extra" info to the intercept. 2070 $(this).triggerHandler(types, { context: context, args: args }); 2071 2072 return this; // for chaining 2073 } 2074 2075}; 2076 2077;; 2078 2079/* 2080Utility methods for easily listening to events on another object, 2081and more importantly, easily unlistening from them. 2082*/ 2083var ListenerMixin = FC.ListenerMixin = (function() { 2084 var guid = 0; 2085 var ListenerMixin = { 2086 2087 listenerId: null, 2088 2089 /* 2090 Given an `other` object that has on/off methods, bind the given `callback` to an event by the given name. 2091 The `callback` will be called with the `this` context of the object that .listenTo is being called on. 2092 Can be called: 2093 .listenTo(other, eventName, callback) 2094 OR 2095 .listenTo(other, { 2096 eventName1: callback1, 2097 eventName2: callback2 2098 }) 2099 */ 2100 listenTo: function(other, arg, callback) { 2101 if (typeof arg === 'object') { // given dictionary of callbacks 2102 for (var eventName in arg) { 2103 if (arg.hasOwnProperty(eventName)) { 2104 this.listenTo(other, eventName, arg[eventName]); 2105 } 2106 } 2107 } 2108 else if (typeof arg === 'string') { 2109 other.on( 2110 arg + '.' + this.getListenerNamespace(), // use event namespacing to identify this object 2111 $.proxy(callback, this) // always use `this` context 2112 // the usually-undesired jQuery guid behavior doesn't matter, 2113 // because we always unbind via namespace 2114 ); 2115 } 2116 }, 2117 2118 /* 2119 Causes the current object to stop listening to events on the `other` object. 2120 `eventName` is optional. If omitted, will stop listening to ALL events on `other`. 2121 */ 2122 stopListeningTo: function(other, eventName) { 2123 other.off((eventName || '') + '.' + this.getListenerNamespace()); 2124 }, 2125 2126 /* 2127 Returns a string, unique to this object, to be used for event namespacing 2128 */ 2129 getListenerNamespace: function() { 2130 if (this.listenerId == null) { 2131 this.listenerId = guid++; 2132 } 2133 return '_listener' + this.listenerId; 2134 } 2135 2136 }; 2137 return ListenerMixin; 2138})(); 2139;; 2140 2141/* A rectangular panel that is absolutely positioned over other content 2142------------------------------------------------------------------------------------------------------------------------ 2143Options: 2144 - className (string) 2145 - content (HTML string or jQuery element set) 2146 - parentEl 2147 - top 2148 - left 2149 - right (the x coord of where the right edge should be. not a "CSS" right) 2150 - autoHide (boolean) 2151 - show (callback) 2152 - hide (callback) 2153*/ 2154 2155var Popover = Class.extend(ListenerMixin, { 2156 2157 isHidden: true, 2158 options: null, 2159 el: null, // the container element for the popover. generated by this object 2160 margin: 10, // the space required between the popover and the edges of the scroll container 2161 2162 2163 constructor: function(options) { 2164 this.options = options || {}; 2165 }, 2166 2167
2168 // Shows the popover on the specified position. Renders it if not already 2169 show: function() { 2170 if (this.isHidden) { 2171 if (!this.el) { 2172 this.render(); 2173 } 2174 this.el.show(); 2175 this.position(); 2176 this.isHidden = false; 2177 this.trigger('show'); 2178 } 2179 }, 2180 2181 2182 // Hides the popover, through CSS, but does not remove it from the DOM 2183 hide: function() { 2184 if (!this.isHidden) { 2185 this.el.hide(); 2186 this.isHidden = true; 2187 this.trigger('hide'); 2188 } 2189 }, 2190 2191 2192 // Creates `this.el` and renders content inside of it 2193 render: function() { 2194 var _this = this; 2195 var options = this.options; 2196 2197 this.el = $('<div class="fc-popover"/>') 2198 .addClass(options.className || '') 2199 .css({ 2200 // position initially to the top left to avoid creating scrollbars 2201 top: 0, 2202 left: 0 2203 }) 2204 .append(options.content) 2205 .appendTo(options.parentEl); 2206 2207 // when a click happens on anything inside with a 'fc-close' className, hide the popover 2208 this.el.on('click', '.fc-close', function() { 2209 _this.hide(); 2210 }); 2211 2212 if (options.autoHide) { 2213 this.listenTo($(document), 'mousedown', this.documentMousedown); 2214 } 2215 }, 2216 2217 2218 // Triggered when the user clicks *anywhere* in the document, for the autoHide feature 2219 documentMousedown: function(ev) { 2220 // only hide the popover if the click happened outside the popover 2221 if (this.el && !$(ev.target).closest(this.el).length) { 2222 this.hide(); 2223 } 2224 }, 2225 2226 2227 // Hides and unregisters any handlers 2228 removeElement: function() { 2229 this.hide(); 2230 2231 if (this.el) { 2232 this.el.remove(); 2233 this.el = null; 2234 } 2235 2236 this.stopListeningTo($(document), 'mousedown'); 2237 }, 2238 2239 2240 // Positions the popover optimally, using the top/left/right options 2241 position: function() { 2242 var options = this.options; 2243 var origin = this.el.offsetParent().offset(); 2244 var width = this.el.outerWidth(); 2245 var height = this.el.outerHeight(); 2246 var windowEl = $(window); 2247 var viewportEl = getScrollParent(this.el); 2248 var viewportTop; 2249 var viewportLeft; 2250 var viewportOffset; 2251 var top; // the "position" (not "offset") values for the popover 2252 var left; // 2253 2254 // compute top and left 2255 top = options.top || 0; 2256 if (options.left !== undefined) { 2257 left = options.left; 2258 } 2259 else if (options.right !== undefined) { 2260 left = options.right - width; // derive the left value from the right value 2261 } 2262 else { 2263 left = 0; 2264 } 2265 2266 if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result 2267 viewportEl = windowEl; 2268 viewportTop = 0; // the window is always at the top left 2269 viewportLeft = 0; // (and .offset() won't work if called here) 2270 } 2271 else { 2272 viewportOffset = viewportEl.offset(); 2273 viewportTop = viewportOffset.top; 2274 viewportLeft = viewportOffset.left; 2275 } 2276 2277 // if the window is scrolled, it causes the visible area to be further down 2278 viewportTop += windowEl.scrollTop(); 2279 viewportLeft += windowEl.scrollLeft(); 2280 2281 // constrain to the view port. if constrained by two edges, give precedence to top/left 2282 if (options.viewportConstrain !== false) { 2283 top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin); 2284 top = Math.max(top, viewportTop + this.margin); 2285 left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin); 2286 left = Math.max(left, viewportLeft + this.margin); 2287 } 2288 2289 this.el.css({ 2290 top: top - origin.top, 2291 left: left - origin.left 2292 }); 2293 }, 2294 2295 2296 // Triggers a callback. Calls a function in the option hash of the same name. 2297 // Arguments beyond the first `name` are forwarded on. 2298 // TODO: better code reuse for this. Repeat code 2299 trigger: function(name) { 2300 if (this.options[name]) { 2301 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); 2302 } 2303 } 2304 2305}); 2306 2307;; 2308 2309/* 2310A cache for the left/right/top/bottom/width/height values for one or more elements. 2311Works with both offset (from topleft document) and position (from offsetParent). 2312 2313options: 2314- els 2315- isHorizontal 2316- isVertical 2317*/ 2318var CoordCache = FC.CoordCache = Class.extend({ 2319 2320 els: null, // jQuery set (assumed to be siblings) 2321 forcedOffsetParentEl: null, // options can override the natural offsetParent 2322 origin: null, // {left,top} position of offsetParent of els 2323 boundingRect: null, // constrain cordinates to this rectangle. {left,right,top,bottom} or null 2324 isHorizontal: false, // whether to query for left/right/width 2325 isVertical: false, // whether to query for top/bottom/height 2326 2327 // arrays of coordinates (offsets from topleft of document) 2328 lefts: null, 2329 rights: null, 2330 tops: null, 2331 bottoms: null, 2332 2333 2334 constructor: function(options) { 2335 this.els = $(options.els); 2336 this.isHorizontal = options.isHorizontal; 2337 this.isVertical = options.isVertical; 2338 this.forcedOffsetParentEl = options.offsetParent ? $(options.offsetParent) : null; 2339 }, 2340 2341 2342 // Queries the els for coordinates and stores them. 2343 // Call this method before using and of the get* methods below. 2344 build: function() { 2345 var offsetParentEl = this.forcedOffsetParentEl; 2346 if (!offsetParentEl && this.els.length > 0) { 2347 offsetParentEl = this.els.eq(0).offsetParent(); 2348 } 2349 2350 this.origin = offsetParentEl ? 2351 offsetParentEl.offset() : 2352 null; 2353
2354 this.boundingRect = this.queryBoundingRect(); 2355 2356 if (this.isHorizontal) { 2357 this.buildElHorizontals(); 2358 } 2359 if (this.isVertical) { 2360 this.buildElVerticals(); 2361 } 2362 }, 2363 2364 2365 // Destroys all internal data about coordinates, freeing memory 2366 clear: function() { 2367 this.origin = null; 2368 this.boundingRect = null; 2369 this.lefts = null; 2370 this.rights = null; 2371 this.tops = null; 2372 this.bottoms = null; 2373 }, 2374 2375 2376 // When called, if coord caches aren't built, builds them 2377 ensureBuilt: function() { 2378 if (!this.origin) { 2379 this.build(); 2380 } 2381 }, 2382 2383 2384 // Populates the left/right internal coordinate arrays 2385 buildElHorizontals: function() { 2386 var lefts = []; 2387 var rights = []; 2388 2389 this.els.each(function(i, node) { 2390 var el = $(node); 2391 var left = el.offset().left; 2392 var width = el.outerWidth(); 2393 2394 lefts.push(left); 2395 rights.push(left + width); 2396 }); 2397 2398 this.lefts = lefts; 2399 this.rights = rights; 2400 }, 2401 2402 2403 // Populates the top/bottom internal coordinate arrays 2404 buildElVerticals: function() { 2405 var tops = []; 2406 var bottoms = []; 2407 2408 this.els.each(function(i, node) { 2409 var el = $(node); 2410 var top = el.offset().top; 2411 var height = el.outerHeight(); 2412 2413 tops.push(top); 2414 bottoms.push(top + height); 2415 }); 2416 2417 this.tops = tops; 2418 this.bottoms = bottoms; 2419 }, 2420 2421 2422 // Given a left offset (from document left), returns the index of the el that it horizontally intersects. 2423 // If no intersection is made, returns undefined. 2424 getHorizontalIndex: function(leftOffset) { 2425 this.ensureBuilt(); 2426 2427 var lefts = this.lefts; 2428 var rights = this.rights; 2429 var len = lefts.length; 2430 var i; 2431 2432 for (i = 0; i < len; i++) { 2433 if (leftOffset >= lefts[i] && leftOffset < rights[i]) { 2434 return i; 2435 } 2436 } 2437 }, 2438 2439 2440 // Given a top offset (from document top), returns the index of the el that it vertically intersects. 2441 // If no intersection is made, returns undefined. 2442 getVerticalIndex: function(topOffset) { 2443 this.ensureBuilt(); 2444 2445 var tops = this.tops; 2446 var bottoms = this.bottoms; 2447 var len = tops.length; 2448 var i; 2449 2450 for (i = 0; i < len; i++) { 2451 if (topOffset >= tops[i] && topOffset < bottoms[i]) { 2452 return i; 2453 } 2454 } 2455 }, 2456 2457 2458 // Gets the left offset (from document left) of the element at the given index 2459 getLeftOffset: function(leftIndex) { 2460 this.ensureBuilt(); 2461 return this.lefts[leftIndex]; 2462 }, 2463 2464 2465 // Gets the left position (from offsetParent left) of the element at the given index 2466 getLeftPosition: function(leftIndex) { 2467 this.ensureBuilt(); 2468 return this.lefts[leftIndex] - this.origin.left; 2469 }, 2470 2471 2472 // Gets the right offset (from document left) of the element at the given index. 2473 // This value is NOT relative to the document's right edge, like the CSS concept of "right" would be. 2474 getRightOffset: function(leftIndex) { 2475 this.ensureBuilt(); 2476 return this.rights[leftIndex]; 2477 }, 2478 2479 2480 // Gets the right position (from offsetParent left) of the element at the given index. 2481 // This value is NOT relative to the offsetParent's right edge, like the CSS concept of "right" would be. 2482 getRightPosition: function(leftIndex) { 2483 this.ensureBuilt(); 2484 return this.rights[leftIndex] - this.origin.left; 2485 }, 2486 2487 2488 // Gets the width of the element at the given index 2489 getWidth: function(leftIndex) { 2490 this.ensureBuilt(); 2491 return this.rights[leftIndex] - this.lefts[leftIndex]; 2492 }, 2493 2494 2495 // Gets the top offset (from document top) of the element at the given index 2496 getTopOffset: function(topIndex) { 2497 this.ensureBuilt(); 2498 return this.tops[topIndex]; 2499 }, 2500 2501 2502 // Gets the top position (from offsetParent top) of the element at the given position 2503 getTopPosition: function(topIndex) { 2504 this.ensureBuilt(); 2505 return this.tops[topIndex] - this.origin.top; 2506 }, 2507 2508 // Gets the bottom offset (from the document top) of the element at the given index. 2509 // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be. 2510 getBottomOffset: function(topIndex) { 2511 this.ensureBuilt(); 2512 return this.bottoms[topIndex]; 2513 }, 2514 2515 2516 // Gets the bottom position (from the offsetParent top) of the element at the given index. 2517 // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be. 2518 getBottomPosition: function(topIndex) { 2519 this.ensureBuilt(); 2520 return this.bottoms[topIndex] - this.origin.top; 2521 }, 2522 2523 2524 // Gets the height of the element at the given index 2525 getHeight: function(topIndex) { 2526 this.ensureBuilt(); 2527 return this.bottoms[topIndex] - this.tops[topIndex]; 2528 }, 2529 2530 2531 // Bounding Rect 2532 // TODO: decouple this from CoordCache 2533 2534 // Compute and return what the elements' bounding rectangle is, from the user's perspective. 2535 // Right now, only returns a rectangle if constrained by an overflow:scroll element. 2536 // Returns null if there are no elements 2537 queryBoundingRect: function() { 2538 var scrollParentEl; 2539 2540 if (this.els.length > 0) { 2541 scrollParentEl = getScrollParent(this.els.eq(0)); 2542 2543 if (!scrollParentEl.is(document)) { 2544 return getClientRect(scrollParentEl); 2545 } 2546 } 2547 2548 return null; 2549 }, 2550 2551 isPointInBounds: function(leftOffset, topOffset) { 2552 return this.isLeftInBounds(leftOffset) && this.isTopInBounds(topOffset); 2553 }, 2554 2555 isLeftInBounds: function(leftOffset) { 2556 return !this.boundingRect || (leftOffset >= this.boundingRect.left && leftOffset < this.boundingRect.right); 2557 }, 2558 2559 isTopInBounds: function(topOffset) { 2560 return !this.boundingRect || (topOffset >= this.boundingRect.top && topOffset < this.boundingRect.bottom); 2561 } 2562 2563}); 2564 2565;; 2566 2567/* Tracks a drag's mouse movement, firing various handlers 2568----------------------------------------------------------------------------------------------------------------------*/ 2569// TODO: use Emitter 2570 2571var DragListener = FC.DragListener = Class.extend(ListenerMixin, { 2572 2573 options: null, 2574 subjectEl: null, 2575
2576 // coordinates of the initial mousedown 2577 originX: null, 2578 originY: null, 2579 2580 // the wrapping element that scrolls, or MIGHT scroll if there's overflow. 2581 // TODO: do this for wrappers that have overflow:hidden as well. 2582 scrollEl: null, 2583 2584 isInteracting: false, 2585 isDistanceSurpassed: false, 2586 isDelayEnded: false, 2587 isDragging: false, 2588 isTouch: false, 2589 2590 delay: null, 2591 delayTimeoutId: null, 2592 minDistance: null, 2593 2594 shouldCancelTouchScroll: true, 2595 scrollAlwaysKills: false, 2596 2597 2598 constructor: function(options) { 2599 this.options = options || {}; 2600 }, 2601 2602 2603 // Interaction (high-level) 2604 // ----------------------------------------------------------------------------------------------------------------- 2605 2606 2607 startInteraction: function(ev, extraOptions) { 2608 var isTouch = getEvIsTouch(ev); 2609 2610 if (ev.type === 'mousedown') { 2611 if (GlobalEmitter.get().shouldIgnoreMouse()) { 2612 return; 2613 } 2614 else if (!isPrimaryMouseButton(ev)) { 2615 return; 2616 } 2617 else { 2618 ev.preventDefault(); // prevents native selection in most browsers 2619 } 2620 } 2621 2622 if (!this.isInteracting) { 2623 2624 // process options 2625 extraOptions = extraOptions || {}; 2626 this.delay = firstDefined(extraOptions.delay, this.options.delay, 0); 2627 this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0); 2628 this.subjectEl = this.options.subjectEl; 2629 2630 preventSelection($('body')); 2631 2632 this.isInteracting = true; 2633 this.isTouch = isTouch; 2634 this.isDelayEnded = false; 2635 this.isDistanceSurpassed = false; 2636 2637 this.originX = getEvX(ev); 2638 this.originY = getEvY(ev); 2639 this.scrollEl = getScrollParent($(ev.target)); 2640 2641 this.bindHandlers(); 2642 this.initAutoScroll(); 2643 this.handleInteractionStart(ev); 2644 this.startDelay(ev); 2645 2646 if (!this.minDistance) { 2647 this.handleDistanceSurpassed(ev); 2648 } 2649 } 2650 }, 2651 2652 2653 handleInteractionStart: function(ev) { 2654 this.trigger('interactionStart', ev); 2655 }, 2656 2657 2658 endInteraction: function(ev, isCancelled) { 2659 if (this.isInteracting) { 2660 this.endDrag(ev); 2661 2662 if (this.delayTimeoutId) { 2663 clearTimeout(this.delayTimeoutId); 2664 this.delayTimeoutId = null; 2665 } 2666 2667 this.destroyAutoScroll(); 2668 this.unbindHandlers(); 2669 2670 this.isInteracting = false; 2671 this.handleInteractionEnd(ev, isCancelled); 2672 2673 allowSelection($('body')); 2674 } 2675 }, 2676 2677 2678 handleInteractionEnd: function(ev, isCancelled) { 2679 this.trigger('interactionEnd', ev, isCancelled || false); 2680 }, 2681 2682 2683 // Binding To DOM 2684 // ----------------------------------------------------------------------------------------------------------------- 2685 2686 2687 bindHandlers: function() { 2688 // some browsers (Safari in iOS 10) don't allow preventDefault on touch events that are bound after touchstart, 2689 // so listen to the GlobalEmitter singleton, which is always bound, instead of the document directly. 2690 var globalEmitter = GlobalEmitter.get(); 2691 2692 if (this.isTouch) { 2693 this.listenTo(globalEmitter, { 2694 touchmove: this.handleTouchMove, 2695 touchend: this.endInteraction, 2696 scroll: this.handleTouchScroll 2697 }); 2698 } 2699 else { 2700 this.listenTo(globalEmitter, { 2701 mousemove: this.handleMouseMove, 2702 mouseup: this.endInteraction 2703 }); 2704 } 2705 2706 this.listenTo(globalEmitter, { 2707 selectstart: preventDefault, // don't allow selection while dragging 2708 contextmenu: preventDefault // long taps would open menu on Chrome dev tools 2709 }); 2710 }, 2711 2712 2713 unbindHandlers: function() { 2714 this.stopListeningTo(GlobalEmitter.get()); 2715 }, 2716 2717 2718 // Drag (high-level) 2719 // ----------------------------------------------------------------------------------------------------------------- 2720 2721 2722 // extraOptions ignored if drag already started 2723 startDrag: function(ev, extraOptions) { 2724 this.startInteraction(ev, extraOptions); // ensure interaction began 2725 2726 if (!this.isDragging) { 2727 this.isDragging = true; 2728 this.handleDragStart(ev); 2729 } 2730 }, 2731 2732 2733 handleDragStart: function(ev) { 2734 this.trigger('dragStart', ev); 2735 }, 2736 2737 2738 handleMove: function(ev) { 2739 var dx = getEvX(ev) - this.originX; 2740 var dy = getEvY(ev) - this.originY; 2741 var minDistance = this.minDistance; 2742 var distanceSq; // current distance from the origin, squared 2743 2744 if (!this.isDistanceSurpassed) { 2745 distanceSq = dx * dx + dy * dy; 2746 if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem 2747 this.handleDistanceSurpassed(ev); 2748 } 2749 } 2750 2751 if (this.isDragging) { 2752 this.handleDrag(dx, dy, ev); 2753 } 2754 }, 2755 2756 2757 // Called while the mouse is being moved and when we know a legitimate drag is taking place 2758 handleDrag: function(dx, dy, ev) { 2759 this.trigger('drag', dx, dy, ev); 2760 this.updateAutoScroll(ev); // will possibly cause scrolling 2761 }, 2762 2763 2764 endDrag: function(ev) { 2765 if (this.isDragging) { 2766 this.isDragging = false;
2767 this.handleDragEnd(ev); 2768 } 2769 }, 2770 2771 2772 handleDragEnd: function(ev) { 2773 this.trigger('dragEnd', ev); 2774 }, 2775 2776 2777 // Delay 2778 // ----------------------------------------------------------------------------------------------------------------- 2779 2780 2781 startDelay: function(initialEv) { 2782 var _this = this; 2783 2784 if (this.delay) { 2785 this.delayTimeoutId = setTimeout(function() { 2786 _this.handleDelayEnd(initialEv); 2787 }, this.delay); 2788 } 2789 else { 2790 this.handleDelayEnd(initialEv); 2791 } 2792 }, 2793 2794 2795 handleDelayEnd: function(initialEv) { 2796 this.isDelayEnded = true; 2797 2798 if (this.isDistanceSurpassed) { 2799 this.startDrag(initialEv); 2800 } 2801 }, 2802 2803 2804 // Distance 2805 // ----------------------------------------------------------------------------------------------------------------- 2806 2807 2808 handleDistanceSurpassed: function(ev) { 2809 this.isDistanceSurpassed = true; 2810 2811 if (this.isDelayEnded) { 2812 this.startDrag(ev); 2813 } 2814 }, 2815 2816 2817 // Mouse / Touch 2818 // ----------------------------------------------------------------------------------------------------------------- 2819 2820 2821 handleTouchMove: function(ev) { 2822 2823 // prevent inertia and touchmove-scrolling while dragging 2824 if (this.isDragging && this.shouldCancelTouchScroll) { 2825 ev.preventDefault(); 2826 } 2827 2828 this.handleMove(ev); 2829 }, 2830 2831 2832 handleMouseMove: function(ev) { 2833 this.handleMove(ev); 2834 }, 2835 2836 2837 // Scrolling (unrelated to auto-scroll) 2838 // ----------------------------------------------------------------------------------------------------------------- 2839 2840 2841 handleTouchScroll: function(ev) { 2842 // if the drag is being initiated by touch, but a scroll happens before 2843 // the drag-initiating delay is over, cancel the drag 2844 if (!this.isDragging || this.scrollAlwaysKills) { 2845 this.endInteraction(ev, true); // isCancelled=true 2846 } 2847 }, 2848 2849 2850 // Utils 2851 // ----------------------------------------------------------------------------------------------------------------- 2852 2853 2854 // Triggers a callback. Calls a function in the option hash of the same name. 2855 // Arguments beyond the first `name` are forwarded on. 2856 trigger: function(name) { 2857 if (this.options[name]) { 2858 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); 2859 } 2860 // makes _methods callable by event name. TODO: kill this 2861 if (this['_' + name]) { 2862 this['_' + name].apply(this, Array.prototype.slice.call(arguments, 1)); 2863 } 2864 } 2865 2866 2867}); 2868 2869;; 2870/* 2871this.scrollEl is set in DragListener 2872*/ 2873DragListener.mixin({ 2874 2875 isAutoScroll: false, 2876 2877 scrollBounds: null, // { top, bottom, left, right } 2878 scrollTopVel: null, // pixels per second 2879 scrollLeftVel: null, // pixels per second 2880 scrollIntervalId: null, // ID of setTimeout for scrolling animation loop 2881 2882 // defaults 2883 scrollSensitivity: 30, // pixels from edge for scrolling to start 2884 scrollSpeed: 200, // pixels per second, at maximum speed 2885 scrollIntervalMs: 50, // millisecond wait between scroll increment 2886 2887 2888 initAutoScroll: function() { 2889 var scrollEl = this.scrollEl; 2890 2891 this.isAutoScroll = 2892 this.options.scroll && 2893 scrollEl && 2894 !scrollEl.is(window) && 2895 !scrollEl.is(document); 2896 2897 if (this.isAutoScroll) { 2898 // debounce makes sure rapid calls don't happen 2899 this.listenTo(scrollEl, 'scroll', debounce(this.handleDebouncedScroll, 100)); 2900 } 2901 }, 2902 2903 2904 destroyAutoScroll: function() { 2905 this.endAutoScroll(); // kill any animation loop 2906 2907 // remove the scroll handler if there is a scrollEl 2908 if (this.isAutoScroll) { 2909 this.stopListeningTo(this.scrollEl, 'scroll'); // will probably get removed by unbindHandlers too :( 2910 } 2911 }, 2912 2913 2914 // Computes and stores the bounding rectangle of scrollEl 2915 computeScrollBounds: function() { 2916 if (this.isAutoScroll) { 2917 this.scrollBounds = getOuterRect(this.scrollEl); 2918 // TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars 2919 } 2920 }, 2921 2922 2923 // Called when the dragging is in progress and scrolling should be updated 2924 updateAutoScroll: function(ev) { 2925 var sensitivity = this.scrollSensitivity; 2926 var bounds = this.scrollBounds; 2927 var topCloseness, bottomCloseness; 2928 var leftCloseness, rightCloseness; 2929 var topVel = 0; 2930 var leftVel = 0; 2931 2932 if (bounds) { // only scroll if scrollEl exists 2933
2934 // compute closeness to edges. valid range is from 0.0 - 1.0 2935 topCloseness = (sensitivity - (getEvY(ev) - bounds.top)) / sensitivity; 2936 bottomCloseness = (sensitivity - (bounds.bottom - getEvY(ev))) / sensitivity; 2937 leftCloseness = (sensitivity - (getEvX(ev) - bounds.left)) / sensitivity; 2938 rightCloseness = (sensitivity - (bounds.right - getEvX(ev))) / sensitivity; 2939 2940 // translate vertical closeness into velocity. 2941 // mouse must be completely in bounds for velocity to happen. 2942 if (topCloseness >= 0 && topCloseness <= 1) { 2943 topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up 2944 } 2945 else if (bottomCloseness >= 0 && bottomCloseness <= 1) { 2946 topVel = bottomCloseness * this.scrollSpeed; 2947 } 2948 2949 // translate horizontal closeness into velocity 2950 if (leftCloseness >= 0 && leftCloseness <= 1) { 2951 leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left 2952 } 2953 else if (rightCloseness >= 0 && rightCloseness <= 1) { 2954 leftVel = rightCloseness * this.scrollSpeed; 2955 } 2956 } 2957 2958 this.setScrollVel(topVel, leftVel); 2959 }, 2960 2961 2962 // Sets the speed-of-scrolling for the scrollEl 2963 setScrollVel: function(topVel, leftVel) { 2964 2965 this.scrollTopVel = topVel; 2966 this.scrollLeftVel = leftVel; 2967 2968 this.constrainScrollVel(); // massages into realistic values 2969 2970 // if there is non-zero velocity, and an animation loop hasn't already started, then START 2971 if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) { 2972 this.scrollIntervalId = setInterval( 2973 proxy(this, 'scrollIntervalFunc'), // scope to `this` 2974 this.scrollIntervalMs 2975 ); 2976 } 2977 }, 2978 2979 2980 // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way 2981 constrainScrollVel: function() { 2982 var el = this.scrollEl; 2983 2984 if (this.scrollTopVel < 0) { // scrolling up? 2985 if (el.scrollTop() <= 0) { // already scrolled all the way up? 2986 this.scrollTopVel = 0; 2987 } 2988 } 2989 else if (this.scrollTopVel > 0) { // scrolling down? 2990 if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down? 2991 this.scrollTopVel = 0; 2992 } 2993 } 2994 2995 if (this.scrollLeftVel < 0) { // scrolling left? 2996 if (el.scrollLeft() <= 0) { // already scrolled all the left? 2997 this.scrollLeftVel = 0; 2998 } 2999 } 3000 else if (this.scrollLeftVel > 0) { // scrolling right? 3001 if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right? 3002 this.scrollLeftVel = 0; 3003 } 3004 } 3005 }, 3006 3007 3008 // This function gets called during every iteration of the scrolling animation loop 3009 scrollIntervalFunc: function() { 3010 var el = this.scrollEl; 3011 var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by 3012 3013 // change the value of scrollEl's scroll 3014 if (this.scrollTopVel) { 3015 el.scrollTop(el.scrollTop() + this.scrollTopVel * frac); 3016 } 3017 if (this.scrollLeftVel) { 3018 el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac); 3019 } 3020 3021 this.constrainScrollVel(); // since the scroll values changed, recompute the velocities 3022 3023 // if scrolled all the way, which causes the vels to be zero, stop the animation loop 3024 if (!this.scrollTopVel && !this.scrollLeftVel) { 3025 this.endAutoScroll(); 3026 } 3027 }, 3028 3029 3030 // Kills any existing scrolling animation loop 3031 endAutoScroll: function() { 3032 if (this.scrollIntervalId) { 3033 clearInterval(this.scrollIntervalId); 3034 this.scrollIntervalId = null; 3035 3036 this.handleScrollEnd(); 3037 } 3038 }, 3039 3040 3041 // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce) 3042 handleDebouncedScroll: function() { 3043 // recompute all coordinates, but *only* if this is *not* part of our scrolling animation 3044 if (!this.scrollIntervalId) { 3045 this.handleScrollEnd(); 3046 } 3047 }, 3048 3049 3050 // Called when scrolling has stopped, whether through auto scroll, or the user scrolling 3051 handleScrollEnd: function() { 3052 } 3053 3054}); 3055;; 3056 3057/* Tracks mouse movements over a component and raises events about which hit the mouse is over. 3058------------------------------------------------------------------------------------------------------------------------ 3059options: 3060- subjectEl 3061- subjectCenter 3062*/ 3063 3064var HitDragListener = DragListener.extend({ 3065 3066 component: null, // converts coordinates to hits 3067 // methods: hitsNeeded, hitsNotNeeded, queryHit 3068 3069 origHit: null, // the hit the mouse was over when listening started 3070 hit: null, // the hit the mouse is over
3071 coordAdjust: null, // delta that will be added to the mouse coordinates when computing collisions 3072 3073 3074 constructor: function(component, options) { 3075 DragListener.call(this, options); // call the super-constructor 3076 3077 this.component = component; 3078 }, 3079 3080 3081 // Called when drag listening starts (but a real drag has not necessarily began). 3082 // ev might be undefined if dragging was started manually. 3083 handleInteractionStart: function(ev) { 3084 var subjectEl = this.subjectEl; 3085 var subjectRect; 3086 var origPoint; 3087 var point; 3088 3089 this.component.hitsNeeded(); 3090 this.computeScrollBounds(); // for autoscroll 3091 3092 if (ev) { 3093 origPoint = { left: getEvX(ev), top: getEvY(ev) }; 3094 point = origPoint; 3095 3096 // constrain the point to bounds of the element being dragged 3097 if (subjectEl) { 3098 subjectRect = getOuterRect(subjectEl); // used for centering as well 3099 point = constrainPoint(point, subjectRect); 3100 } 3101 3102 this.origHit = this.queryHit(point.left, point.top); 3103 3104 // treat the center of the subject as the collision point? 3105 if (subjectEl && this.options.subjectCenter) { 3106 3107 // only consider the area the subject overlaps the hit. best for large subjects. 3108 // TODO: skip this if hit didn't supply left/right/top/bottom 3109 if (this.origHit) { 3110 subjectRect = intersectRects(this.origHit, subjectRect) || 3111 subjectRect; // in case there is no intersection 3112 } 3113 3114 point = getRectCenter(subjectRect); 3115 } 3116 3117 this.coordAdjust = diffPoints(point, origPoint); // point - origPoint 3118 } 3119 else { 3120 this.origHit = null; 3121 this.coordAdjust = null; 3122 } 3123 3124 // call the super-method. do it after origHit has been computed 3125 DragListener.prototype.handleInteractionStart.apply(this, arguments); 3126 }, 3127 3128 3129 // Called when the actual drag has started 3130 handleDragStart: function(ev) { 3131 var hit; 3132 3133 DragListener.prototype.handleDragStart.apply(this, arguments); // call the super-method 3134 3135 // might be different from this.origHit if the min-distance is large 3136 hit = this.queryHit(getEvX(ev), getEvY(ev)); 3137 3138 // report the initial hit the mouse is over 3139 // especially important if no min-distance and drag starts immediately 3140 if (hit) { 3141 this.handleHitOver(hit); 3142 } 3143 }, 3144 3145 3146 // Called when the drag moves 3147 handleDrag: function(dx, dy, ev) { 3148 var hit; 3149 3150 DragListener.prototype.handleDrag.apply(this, arguments); // call the super-method 3151 3152 hit = this.queryHit(getEvX(ev), getEvY(ev)); 3153 3154 if (!isHitsEqual(hit, this.hit)) { // a different hit than before? 3155 if (this.hit) { 3156 this.handleHitOut(); 3157 } 3158 if (hit) { 3159 this.handleHitOver(hit); 3160 } 3161 } 3162 }, 3163 3164 3165 // Called when dragging has been stopped 3166 handleDragEnd: function() { 3167 this.handleHitDone(); 3168 DragListener.prototype.handleDragEnd.apply(this, arguments); // call the super-method 3169 }, 3170 3171 3172 // Called when a the mouse has just moved over a new hit 3173 handleHitOver: function(hit) { 3174 var isOrig = isHitsEqual(hit, this.origHit); 3175 3176 this.hit = hit; 3177 3178 this.trigger('hitOver', this.hit, isOrig, this.origHit); 3179 }, 3180 3181 3182 // Called when the mouse has just moved out of a hit 3183 handleHitOut: function() { 3184 if (this.hit) { 3185 this.trigger('hitOut', this.hit); 3186 this.handleHitDone(); 3187 this.hit = null; 3188 } 3189 }, 3190 3191 3192 // Called after a hitOut. Also called before a dragStop 3193 handleHitDone: function() { 3194 if (this.hit) { 3195 this.trigger('hitDone', this.hit); 3196 } 3197 }, 3198 3199 3200 // Called when the interaction ends, whether there was a real drag or not 3201 handleInteractionEnd: function() { 3202 DragListener.prototype.handleInteractionEnd.apply(this, arguments); // call the super-method 3203 3204 this.origHit = null; 3205 this.hit = null; 3206 3207 this.component.hitsNotNeeded(); 3208 }, 3209 3210 3211 // Called when scrolling has stopped, whether through auto scroll, or the user scrolling 3212 handleScrollEnd: function() { 3213 DragListener.prototype.handleScrollEnd.apply(this, arguments); // call the super-method 3214 3215 // hits' absolute positions will be in new places after a user's scroll. 3216 // HACK for recomputing. 3217 if (this.isDragging) { 3218 this.component.releaseHits(); 3219 this.component.prepareHits(); 3220 } 3221 }, 3222 3223 3224 // Gets the hit underneath the coordinates for the given mouse event 3225 queryHit: function(left, top) { 3226 3227 if (this.coordAdjust) { 3228 left += this.coordAdjust.left; 3229 top += this.coordAdjust.top; 3230 } 3231 3232 return this.component.queryHit(left, top); 3233 } 3234 3235}); 3236 3237 3238// Returns `true` if the hits are identically equal. `false` otherwise. Must be from the same component. 3239// Two null values will be considered equal, as two "out of the component" states are the same. 3240function isHitsEqual(hit0, hit1) { 3241 3242 if (!hit0 && !hit1) { 3243 return true; 3244 } 3245 3246 if (hit0 && hit1) { 3247 return hit0.component === hit1.component && 3248 isHitPropsWithin(hit0, hit1) && 3249 isHitPropsWithin(hit1, hit0); // ensures all props are identical 3250 } 3251 3252 return false;
3253} 3254 3255 3256// Returns true if all of subHit's non-standard properties are within superHit 3257function isHitPropsWithin(subHit, superHit) { 3258 for (var propName in subHit) { 3259 if (!/^(component|left|right|top|bottom)$/.test(propName)) { 3260 if (subHit[propName] !== superHit[propName]) { 3261 return false; 3262 } 3263 } 3264 } 3265 return true; 3266} 3267 3268;; 3269 3270/* 3271Listens to document and window-level user-interaction events, like touch events and mouse events, 3272and fires these events as-is to whoever is observing a GlobalEmitter. 3273Best when used as a singleton via GlobalEmitter.get() 3274 3275Normalizes mouse/touch events. For examples: 3276- ignores the the simulated mouse events that happen after a quick tap: mousemove+mousedown+mouseup+click 3277- compensates for various buggy scenarios where a touchend does not fire 3278*/ 3279 3280FC.touchMouseIgnoreWait = 500; 3281 3282var GlobalEmitter = Class.extend(ListenerMixin, EmitterMixin, { 3283 3284 isTouching: false, 3285 mouseIgnoreDepth: 0, 3286 handleScrollProxy: null, 3287 3288 3289 bind: function() { 3290 var _this = this; 3291 3292 this.listenTo($(document), { 3293 touchstart: this.handleTouchStart, 3294 touchcancel: this.handleTouchCancel, 3295 touchend: this.handleTouchEnd, 3296 mousedown: this.handleMouseDown, 3297 mousemove: this.handleMouseMove, 3298 mouseup: this.handleMouseUp, 3299 click: this.handleClick, 3300 selectstart: this.handleSelectStart, 3301 contextmenu: this.handleContextMenu 3302 }); 3303 3304 // because we need to call preventDefault 3305 // because https://www.chromestatus.com/features/5093566007214080 3306 // TODO: investigate performance because this is a global handler 3307 window.addEventListener( 3308 'touchmove', 3309 this.handleTouchMoveProxy = function(ev) { 3310 _this.handleTouchMove($.Event(ev)); 3311 }, 3312 { passive: false } // allows preventDefault() 3313 ); 3314 3315 // attach a handler to get called when ANY scroll action happens on the page. 3316 // this was impossible to do with normal on/off because 'scroll' doesn't bubble. 3317 // http://stackoverflow.com/a/32954565/96342 3318 window.addEventListener( 3319 'scroll', 3320 this.handleScrollProxy = function(ev) { 3321 _this.handleScroll($.Event(ev)); 3322 }, 3323 true // useCapture 3324 ); 3325 }, 3326 3327 unbind: function() { 3328 this.stopListeningTo($(document)); 3329 3330 window.removeEventListener( 3331 'touchmove', 3332 this.handleTouchMoveProxy 3333 ); 3334 3335 window.removeEventListener( 3336 'scroll', 3337 this.handleScrollProxy, 3338 true // useCapture 3339 ); 3340 }, 3341 3342 3343 // Touch Handlers 3344 // ----------------------------------------------------------------------------------------------------------------- 3345 3346 handleTouchStart: function(ev) { 3347 3348 // if a previous touch interaction never ended with a touchend, then implicitly end it, 3349 // but since a new touch interaction is about to begin, don't start the mouse ignore period. 3350 this.stopTouch(ev, true); // skipMouseIgnore=true 3351 3352 this.isTouching = true; 3353 this.trigger('touchstart', ev); 3354 }, 3355 3356 handleTouchMove: function(ev) { 3357 if (this.isTouching) { 3358 this.trigger('touchmove', ev); 3359 } 3360 }, 3361 3362 handleTouchCancel: function(ev) { 3363 if (this.isTouching) { 3364 this.trigger('touchcancel', ev); 3365 3366 // Have touchcancel fire an artificial touchend. That way, handlers won't need to listen to both. 3367 // If touchend fires later, it won't have any effect b/c isTouching will be false. 3368 this.stopTouch(ev); 3369 } 3370 }, 3371 3372 handleTouchEnd: function(ev) { 3373 this.stopTouch(ev); 3374 }, 3375 3376 3377 // Mouse Handlers 3378 // ----------------------------------------------------------------------------------------------------------------- 3379 3380 handleMouseDown: function(ev) { 3381 if (!this.shouldIgnoreMouse()) { 3382 this.trigger('mousedown', ev); 3383 } 3384 }, 3385 3386 handleMouseMove: function(ev) { 3387 if (!this.shouldIgnoreMouse()) { 3388 this.trigger('mousemove', ev); 3389 } 3390 }, 3391 3392 handleMouseUp: function(ev) { 3393 if (!this.shouldIgnoreMouse()) { 3394 this.trigger('mouseup', ev); 3395 } 3396 }, 3397 3398 handleClick: function(ev) { 3399 if (!this.shouldIgnoreMouse()) { 3400 this.trigger('click', ev); 3401 } 3402 }, 3403 3404 3405 // Misc Handlers 3406 // ----------------------------------------------------------------------------------------------------------------- 3407 3408 handleSelectStart: function(ev) { 3409 this.trigger('selectstart', ev); 3410 }, 3411 3412 handleContextMenu: function(ev) { 3413 this.trigger('contextmenu', ev); 3414 }, 3415 3416 handleScroll: function(ev) { 3417 this.trigger('scroll', ev); 3418 }, 3419 3420 3421 // Utils 3422 // ----------------------------------------------------------------------------------------------------------------- 3423 3424 stopTouch: function(ev, skipMouseIgnore) { 3425 if (this.isTouching) { 3426 this.isTouching = false;
3427 this.trigger('touchend', ev); 3428 3429 if (!skipMouseIgnore) { 3430 this.startTouchMouseIgnore(); 3431 } 3432 } 3433 }, 3434 3435 startTouchMouseIgnore: function() { 3436 var _this = this; 3437 var wait = FC.touchMouseIgnoreWait; 3438 3439 if (wait) { 3440 this.mouseIgnoreDepth++; 3441 setTimeout(function() { 3442 _this.mouseIgnoreDepth--; 3443 }, wait); 3444 } 3445 }, 3446 3447 shouldIgnoreMouse: function() { 3448 return this.isTouching || Boolean(this.mouseIgnoreDepth); 3449 } 3450 3451}); 3452 3453 3454// Singleton 3455// --------------------------------------------------------------------------------------------------------------------- 3456 3457(function() { 3458 var globalEmitter = null; 3459 var neededCount = 0; 3460 3461 3462 // gets the singleton 3463 GlobalEmitter.get = function() { 3464 3465 if (!globalEmitter) { 3466 globalEmitter = new GlobalEmitter(); 3467 globalEmitter.bind(); 3468 } 3469 3470 return globalEmitter; 3471 }; 3472 3473 3474 // called when an object knows it will need a GlobalEmitter in the near future. 3475 GlobalEmitter.needed = function() { 3476 GlobalEmitter.get(); // ensures globalEmitter 3477 neededCount++; 3478 }; 3479 3480 3481 // called when the object that originally called needed() doesn't need a GlobalEmitter anymore. 3482 GlobalEmitter.unneeded = function() { 3483 neededCount--; 3484 3485 if (!neededCount) { // nobody else needs it 3486 globalEmitter.unbind(); 3487 globalEmitter = null; 3488 } 3489 }; 3490 3491})(); 3492 3493;; 3494 3495/* Creates a clone of an element and lets it track the mouse as it moves 3496----------------------------------------------------------------------------------------------------------------------*/ 3497 3498var MouseFollower = Class.extend(ListenerMixin, { 3499 3500 options: null, 3501 3502 sourceEl: null, // the element that will be cloned and made to look like it is dragging 3503 el: null, // the clone of `sourceEl` that will track the mouse 3504 parentEl: null, // the element that `el` (the clone) will be attached to 3505 3506 // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl 3507 top0: null, 3508 left0: null, 3509 3510 // the absolute coordinates of the initiating touch/mouse action 3511 y0: null, 3512 x0: null, 3513 3514 // the number of pixels the mouse has moved from its initial position 3515 topDelta: null, 3516 leftDelta: null, 3517 3518 isFollowing: false, 3519 isHidden: false, 3520 isAnimating: false, // doing the revert animation? 3521 3522 constructor: function(sourceEl, options) { 3523 this.options = options = options || {}; 3524 this.sourceEl = sourceEl; 3525 this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent 3526 }, 3527 3528 3529 // Causes the element to start following the mouse 3530 start: function(ev) { 3531 if (!this.isFollowing) { 3532 this.isFollowing = true; 3533 3534 this.y0 = getEvY(ev); 3535 this.x0 = getEvX(ev); 3536 this.topDelta = 0; 3537 this.leftDelta = 0; 3538 3539 if (!this.isHidden) { 3540 this.updatePosition(); 3541 } 3542 3543 if (getEvIsTouch(ev)) { 3544 this.listenTo($(document), 'touchmove', this.handleMove); 3545 } 3546 else { 3547 this.listenTo($(document), 'mousemove', this.handleMove); 3548 } 3549 } 3550 }, 3551 3552 3553 // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position. 3554 // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately. 3555 stop: function(shouldRevert, callback) { 3556 var _this = this; 3557 var revertDuration = this.options.revertDuration; 3558 3559 function complete() { // might be called by .animate(), which might change `this` context 3560 _this.isAnimating = false; 3561 _this.removeElement(); 3562 3563 _this.top0 = _this.left0 = null; // reset state for future updatePosition calls 3564 3565 if (callback) { 3566 callback(); 3567 } 3568 } 3569 3570 if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time 3571 this.isFollowing = false; 3572 3573 this.stopListeningTo($(document)); 3574 3575 if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation? 3576 this.isAnimating = true; 3577 this.el.animate({ 3578 top: this.top0, 3579 left: this.left0 3580 }, { 3581 duration: revertDuration, 3582 complete: complete 3583 }); 3584 } 3585 else { 3586 complete(); 3587 } 3588 } 3589 }, 3590 3591 3592 // Gets the tracking element. Create it if necessary 3593 getEl: function() { 3594 var el = this.el; 3595 3596 if (!el) { 3597 el = this.el = this.sourceEl.clone() 3598 .addClass(this.options.additionalClass || '') 3599 .css({ 3600 position: 'absolute', 3601 visibility: '', // in case original element was hidden (commonly through hideEvents()) 3602 display: this.isHidden ? 'none' : '', // for when initially hidden 3603 margin: 0, 3604 right: 'auto', // erase and set width instead 3605 bottom: 'auto', // erase and set height instead 3606 width: this.sourceEl.width(), // explicit height in case there was a 'right' value 3607 height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value 3608 opacity: this.options.opacity || '', 3609 zIndex: this.options.zIndex 3610 }); 3611 3612 // we don't want long taps or any mouse interaction causing selection/menus. 3613 // would use preventSelection(), but that prevents selectstart, causing problems. 3614 el.addClass('fc-unselectable'); 3615 3616 el.appendTo(this.parentEl); 3617 } 3618 3619 return el; 3620 }, 3621 3622 3623 // Removes the tracking element if it has already been created 3624 removeElement: function() { 3625 if (this.el) { 3626 this.el.remove(); 3627 this.el = null; 3628 } 3629 }, 3630 3631 3632 // Update the CSS position of the tracking element 3633 updatePosition: function() { 3634 var sourceOffset; 3635 var origin; 3636 3637 this.getEl(); // ensure this.el 3638
3639 // make sure origin info was computed 3640 if (this.top0 === null) { 3641 sourceOffset = this.sourceEl.offset(); 3642 origin = this.el.offsetParent().offset(); 3643 this.top0 = sourceOffset.top - origin.top; 3644 this.left0 = sourceOffset.left - origin.left; 3645 } 3646 3647 this.el.css({ 3648 top: this.top0 + this.topDelta, 3649 left: this.left0 + this.leftDelta 3650 }); 3651 }, 3652 3653 3654 // Gets called when the user moves the mouse 3655 handleMove: function(ev) { 3656 this.topDelta = getEvY(ev) - this.y0; 3657 this.leftDelta = getEvX(ev) - this.x0; 3658 3659 if (!this.isHidden) { 3660 this.updatePosition(); 3661 } 3662 }, 3663 3664 3665 // Temporarily makes the tracking element invisible. Can be called before following starts 3666 hide: function() { 3667 if (!this.isHidden) { 3668 this.isHidden = true; 3669 if (this.el) { 3670 this.el.hide(); 3671 } 3672 } 3673 }, 3674 3675 3676 // Show the tracking element after it has been temporarily hidden 3677 show: function() { 3678 if (this.isHidden) { 3679 this.isHidden = false; 3680 this.updatePosition(); 3681 this.getEl().show(); 3682 } 3683 } 3684 3685}); 3686 3687;; 3688 3689/* An abstract class comprised of a "grid" of areas that each represent a specific datetime 3690----------------------------------------------------------------------------------------------------------------------*/ 3691 3692var Grid = FC.Grid = Class.extend(ListenerMixin, { 3693 3694 // self-config, overridable by subclasses 3695 hasDayInteractions: true, // can user click/select ranges of time? 3696 3697 view: null, // a View object 3698 isRTL: null, // shortcut to the view's isRTL option 3699 3700 start: null, 3701 end: null, 3702 3703 el: null, // the containing element 3704 elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name. 3705 3706 // derived from options 3707 eventTimeFormat: null, 3708 displayEventTime: null, 3709 displayEventEnd: null, 3710 3711 minResizeDuration: null, // TODO: hack. set by subclasses. minumum event resize duration 3712 3713 // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity 3714 // of the date areas. if not defined, assumes to be day and time granularity. 3715 // TODO: port isTimeScale into same system? 3716 largeUnit: null, 3717 3718 dayClickListener: null, 3719 daySelectListener: null, 3720 segDragListener: null, 3721 segResizeListener: null, 3722 externalDragListener: null, 3723 3724 3725 constructor: function(view) { 3726 this.view = view; 3727 this.isRTL = view.opt('isRTL'); 3728 this.elsByFill = {}; 3729
3730 this.dayClickListener = this.buildDayClickListener(); 3731 this.daySelectListener = this.buildDaySelectListener(); 3732 }, 3733 3734 3735 /* Options 3736 ------------------------------------------------------------------------------------------------------------------*/ 3737 3738 3739 // Generates the format string used for event time text, if not explicitly defined by 'timeFormat' 3740 computeEventTimeFormat: function() { 3741 return this.view.opt('smallTimeFormat'); 3742 }, 3743 3744 3745 // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventTime'. 3746 // Only applies to non-all-day events. 3747 computeDisplayEventTime: function() { 3748 return true; 3749 }, 3750 3751 3752 // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd' 3753 computeDisplayEventEnd: function() { 3754 return true; 3755 }, 3756 3757 3758 /* Dates 3759 ------------------------------------------------------------------------------------------------------------------*/ 3760 3761 3762 // Tells the grid about what period of time to display. 3763 // Any date-related internal data should be generated. 3764 setRange: function(range) { 3765 this.start = range.start.clone(); 3766 this.end = range.end.clone(); 3767 3768 this.rangeUpdated(); 3769 this.processRangeOptions(); 3770 }, 3771 3772 3773 // Called when internal variables that rely on the range should be updated 3774 rangeUpdated: function() { 3775 }, 3776 3777 3778 // Updates values that rely on options and also relate to range 3779 processRangeOptions: function() { 3780 var view = this.view; 3781 var displayEventTime; 3782 var displayEventEnd; 3783 3784 this.eventTimeFormat = 3785 view.opt('eventTimeFormat') || 3786 view.opt('timeFormat') || // deprecated 3787 this.computeEventTimeFormat(); 3788 3789 displayEventTime = view.opt('displayEventTime'); 3790 if (displayEventTime == null) { 3791 displayEventTime = this.computeDisplayEventTime(); // might be based off of range 3792 } 3793 3794 displayEventEnd = view.opt('displayEventEnd'); 3795 if (displayEventEnd == null) { 3796 displayEventEnd = this.computeDisplayEventEnd(); // might be based off of range 3797 } 3798 3799 this.displayEventTime = displayEventTime; 3800 this.displayEventEnd = displayEventEnd; 3801 }, 3802 3803 3804 // Converts a span (has unzoned start/end and any other grid-specific location information) 3805 // into an array of segments (pieces of events whose format is decided by the grid). 3806 spanToSegs: function(span) { 3807 // subclasses must implement 3808 }, 3809 3810 3811 // Diffs the two dates, returning a duration, based on granularity of the grid 3812 // TODO: port isTimeScale into this system? 3813 diffDates: function(a, b) { 3814 if (this.largeUnit) { 3815 return diffByUnit(a, b, this.largeUnit); 3816 } 3817 else { 3818 return diffDayTime(a, b); 3819 } 3820 }, 3821 3822 3823 /* Hit Area 3824 ------------------------------------------------------------------------------------------------------------------*/ 3825 3826 hitsNeededDepth: 0, // necessary because multiple callers might need the same hits 3827 3828 hitsNeeded: function() { 3829 if (!(this.hitsNeededDepth++)) { 3830 this.prepareHits(); 3831 } 3832 }, 3833 3834 hitsNotNeeded: function() { 3835 if (this.hitsNeededDepth && !(--this.hitsNeededDepth)) { 3836 this.releaseHits(); 3837 } 3838 }, 3839 3840 3841 // Called before one or more queryHit calls might happen. Should prepare any cached coordinates for queryHit 3842 prepareHits: function() { 3843 }, 3844 3845 3846 // Called when queryHit calls have subsided. Good place to clear any coordinate caches. 3847 releaseHits: function() { 3848 }, 3849 3850 3851 // Given coordinates from the topleft of the document, return data about the date-related area underneath. 3852 // Can return an object with arbitrary properties (although top/right/left/bottom are encouraged). 3853 // Must have a `grid` property, a reference to this current grid. TODO: avoid this 3854 // The returned object will be processed by getHitSpan and getHitEl. 3855 queryHit: function(leftOffset, topOffset) { 3856 }, 3857 3858 3859 // Given position-level information about a date-related area within the grid, 3860 // should return an object with at least a start/end date. Can provide other information as well. 3861 getHitSpan: function(hit) { 3862 }, 3863 3864 3865 // Given position-level information about a date-related area within the grid, 3866 // should return a jQuery element that best represents it. passed to dayClick callback. 3867 getHitEl: function(hit) { 3868 }, 3869 3870 3871 /* Rendering 3872 ------------------------------------------------------------------------------------------------------------------*/ 3873 3874 3875 // Sets the container element that the grid should render inside of. 3876 // Does other DOM-related initializations. 3877 setElement: function(el) { 3878 this.el = el; 3879 3880 if (this.hasDayInteractions) { 3881 preventSelection(el); 3882 3883 this.bindDayHandler('touchstart', this.dayTouchStart); 3884 this.bindDayHandler('mousedown', this.dayMousedown); 3885 } 3886 3887 // attach event-element-related handlers. in Grid.events 3888 // same garbage collection note as above. 3889 this.bindSegHandlers(); 3890 3891 this.bindGlobalHandlers(); 3892 }, 3893 3894 3895 bindDayHandler: function(name, handler) { 3896 var _this = this; 3897 3898 // attach a handler to the grid's root element. 3899 // jQuery will take care of unregistering them when removeElement gets called. 3900 this.el.on(name, function(ev) { 3901 if ( 3902 !$(ev.target).is( 3903 _this.segSelector + ',' + // directly on an event element 3904 _this.segSelector + ' *,' + // within an event element 3905 '.fc-more,' + // a "more.." link 3906 'a[data-goto]' // a clickable nav link 3907 ) 3908 ) { 3909 return handler.call(_this, ev); 3910 } 3911 }); 3912 }, 3913 3914 3915 // Removes the grid's container element from the DOM. Undoes any other DOM-related attachments.
3916 // DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View 3917 removeElement: function() { 3918 this.unbindGlobalHandlers(); 3919 this.clearDragListeners(); 3920 3921 this.el.remove(); 3922 3923 // NOTE: we don't null-out this.el for the same reasons we don't do it within View::removeElement 3924 }, 3925 3926 3927 // Renders the basic structure of grid view before any content is rendered 3928 renderSkeleton: function() { 3929 // subclasses should implement 3930 }, 3931 3932 3933 // Renders the grid's date-related content (like areas that represent days/times). 3934 // Assumes setRange has already been called and the skeleton has already been rendered. 3935 renderDates: function() { 3936 // subclasses should implement 3937 }, 3938 3939 3940 // Unrenders the grid's date-related content 3941 unrenderDates: function() { 3942 // subclasses should implement 3943 }, 3944 3945 3946 /* Handlers 3947 ------------------------------------------------------------------------------------------------------------------*/ 3948 3949 3950 // Binds DOM handlers to elements that reside outside the grid, such as the document 3951 bindGlobalHandlers: function() { 3952 this.listenTo($(document), { 3953 dragstart: this.externalDragStart, // jqui 3954 sortstart: this.externalDragStart // jqui 3955 }); 3956 }, 3957 3958 3959 // Unbinds DOM handlers from elements that reside outside the grid 3960 unbindGlobalHandlers: function() { 3961 this.stopListeningTo($(document)); 3962 }, 3963 3964 3965 // Process a mousedown on an element that represents a day. For day clicking and selecting. 3966 dayMousedown: function(ev) { 3967 var view = this.view; 3968 3969 // prevent a user's clickaway for unselecting a range or an event from 3970 // causing a dayClick or starting an immediate new selection. 3971 if (view.isSelected || view.selectedEvent) { 3972 return; 3973 } 3974 3975 this.dayClickListener.startInteraction(ev); 3976 3977 if (view.opt('selectable')) { 3978 this.daySelectListener.startInteraction(ev, { 3979 distance: view.opt('selectMinDistance') 3980 }); 3981 } 3982 }, 3983 3984 3985 dayTouchStart: function(ev) { 3986 var view = this.view; 3987 var selectLongPressDelay; 3988 3989 // prevent a user's clickaway for unselecting a range or an event from 3990 // causing a dayClick or starting an immediate new selection. 3991 if (view.isSelected || view.selectedEvent) { 3992 return; 3993 } 3994 3995 selectLongPressDelay = view.opt('selectLongPressDelay'); 3996 if (selectLongPressDelay == null) { 3997 selectLongPressDelay = view.opt('longPressDelay'); // fallback 3998 } 3999 4000 this.dayClickListener.startInteraction(ev); 4001 4002 if (view.opt('selectable')) { 4003 this.daySelectListener.startInteraction(ev, { 4004 delay: selectLongPressDelay 4005 }); 4006 } 4007 }, 4008 4009 4010 // Creates a listener that tracks the user's drag across day elements, for day clicking. 4011 buildDayClickListener: function() { 4012 var _this = this; 4013 var view = this.view; 4014 var dayClickHit; // null if invalid dayClick 4015 4016 var dragListener = new HitDragListener(this, { 4017 scroll: view.opt('dragScroll'), 4018 interactionStart: function() { 4019 dayClickHit = dragListener.origHit; 4020 }, 4021 hitOver: function(hit, isOrig, origHit) { 4022 // if user dragged to another cell at any point, it can no longer be a dayClick 4023 if (!isOrig) { 4024 dayClickHit = null; 4025 } 4026 }, 4027 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits 4028 dayClickHit = null; 4029 }, 4030 interactionEnd: function(ev, isCancelled) { 4031 if (!isCancelled && dayClickHit) { 4032 view.triggerDayClick( 4033 _this.getHitSpan(dayClickHit), 4034 _this.getHitEl(dayClickHit), 4035 ev 4036 ); 4037 } 4038 } 4039 }); 4040 4041 // because dayClickListener won't be called with any time delay, "dragging" will begin immediately, 4042 // which will kill any touchmoving/scrolling. Prevent this. 4043 dragListener.shouldCancelTouchScroll = false; 4044 4045 dragListener.scrollAlwaysKills = true; 4046 4047 return dragListener; 4048 }, 4049 4050 4051 // Creates a listener that tracks the user's drag across day elements, for day selecting. 4052 buildDaySelectListener: function() { 4053 var _this = this; 4054 var view = this.view; 4055 var selectionSpan; // null if invalid selection 4056 4057 var dragListener = new HitDragListener(this, { 4058 scroll: view.opt('dragScroll'), 4059 interactionStart: function() { 4060 selectionSpan = null; 4061 }, 4062 dragStart: function() { 4063 view.unselect(); // since we could be rendering a new selection, we want to clear any old one 4064 }, 4065 hitOver: function(hit, isOrig, origHit) { 4066 if (origHit) { // click needs to have started on a hit 4067 4068 selectionSpan = _this.computeSelection( 4069 _this.getHitSpan(origHit), 4070 _this.getHitSpan(hit) 4071 ); 4072 4073 if (selectionSpan) { 4074 _this.renderSelection(selectionSpan); 4075 } 4076 else if (selectionSpan === false) {
4077 disableCursor(); 4078 } 4079 } 4080 }, 4081 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits 4082 selectionSpan = null; 4083 _this.unrenderSelection(); 4084 }, 4085 hitDone: function() { // called after a hitOut OR before a dragEnd 4086 enableCursor(); 4087 }, 4088 interactionEnd: function(ev, isCancelled) { 4089 if (!isCancelled && selectionSpan) { 4090 // the selection will already have been rendered. just report it 4091 view.reportSelection(selectionSpan, ev); 4092 } 4093 } 4094 }); 4095 4096 return dragListener; 4097 }, 4098 4099 4100 // Kills all in-progress dragging. 4101 // Useful for when public API methods that result in re-rendering are invoked during a drag. 4102 // Also useful for when touch devices misbehave and don't fire their touchend. 4103 clearDragListeners: function() { 4104 this.dayClickListener.endInteraction(); 4105 this.daySelectListener.endInteraction(); 4106 4107 if (this.segDragListener) { 4108 this.segDragListener.endInteraction(); // will clear this.segDragListener 4109 } 4110 if (this.segResizeListener) { 4111 this.segResizeListener.endInteraction(); // will clear this.segResizeListener 4112 } 4113 if (this.externalDragListener) { 4114 this.externalDragListener.endInteraction(); // will clear this.externalDragListener 4115 } 4116 }, 4117 4118 4119 /* Event Helper 4120 ------------------------------------------------------------------------------------------------------------------*/ 4121 // TODO: should probably move this to Grid.events, like we did event dragging / resizing 4122 4123 4124 // Renders a mock event at the given event location, which contains zoned start/end properties. 4125 // Returns all mock event elements. 4126 renderEventLocationHelper: function(eventLocation, sourceSeg) { 4127 var fakeEvent = this.fabricateHelperEvent(eventLocation, sourceSeg); 4128 4129 return this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering 4130 }, 4131 4132 4133 // Builds a fake event given zoned event date properties and a segment is should be inspired from. 4134 // The range's end can be null, in which case the mock event that is rendered will have a null end time. 4135 // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging. 4136 fabricateHelperEvent: function(eventLocation, sourceSeg) { 4137 var fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible 4138 4139 fakeEvent.start = eventLocation.start.clone(); 4140 fakeEvent.end = eventLocation.end ? eventLocation.end.clone() : null; 4141 fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventDates 4142 this.view.calendar.normalizeEventDates(fakeEvent); 4143 4144 // this extra className will be useful for differentiating real events from mock events in CSS 4145 fakeEvent.className = (fakeEvent.className || []).concat('fc-helper'); 4146 4147 // if something external is being dragged in, don't render a resizer 4148 if (!sourceSeg) { 4149 fakeEvent.editable = false; 4150 } 4151 4152 return fakeEvent; 4153 }, 4154 4155 4156 // Renders a mock event. Given zoned event date properties. 4157 // Must return all mock event elements. 4158 renderHelper: function(eventLocation, sourceSeg) { 4159 // subclasses must implement 4160 }, 4161 4162 4163 // Unrenders a mock event 4164 unrenderHelper: function() { 4165 // subclasses must implement 4166 }, 4167 4168 4169 /* Selection 4170 ------------------------------------------------------------------------------------------------------------------*/ 4171 4172 4173 // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses. 4174 // Given a span (unzoned start/end and other misc data) 4175 renderSelection: function(span) { 4176 this.renderHighlight(span); 4177 }, 4178 4179 4180 // Unrenders any visual indications of a selection. Will unrender a highlight by default. 4181 unrenderSelection: function() { 4182 this.unrenderHighlight(); 4183 }, 4184 4185 4186 // Given the first and last date-spans of a selection, returns another date-span object. 4187 // Subclasses can override and provide additional data in the span object. Will be passed to renderSelection(). 4188 // Will return false if the selection is invalid and this should be indicated to the user. 4189 // Will return null/undefined if a selection invalid but no error should be reported. 4190 computeSelection: function(span0, span1) { 4191 var span = this.computeSelectionSpan(span0, span1); 4192
4193 if (span && !this.view.calendar.isSelectionSpanAllowed(span)) { 4194 return false; 4195 } 4196 4197 return span; 4198 }, 4199 4200 4201 // Given two spans, must return the combination of the two. 4202 // TODO: do this separation of concerns (combining VS validation) for event dnd/resize too. 4203 computeSelectionSpan: function(span0, span1) { 4204 var dates = [ span0.start, span0.end, span1.start, span1.end ]; 4205 4206 dates.sort(compareNumbers); // sorts chronologically. works with Moments 4207 4208 return { start: dates[0].clone(), end: dates[3].clone() }; 4209 }, 4210 4211 4212 /* Highlight 4213 ------------------------------------------------------------------------------------------------------------------*/ 4214 4215 4216 // Renders an emphasis on the given date range. Given a span (unzoned start/end and other misc data) 4217 renderHighlight: function(span) { 4218 this.renderFill('highlight', this.spanToSegs(span)); 4219 }, 4220 4221 4222 // Unrenders the emphasis on a date range 4223 unrenderHighlight: function() { 4224 this.unrenderFill('highlight'); 4225 }, 4226 4227 4228 // Generates an array of classNames for rendering the highlight. Used by the fill system. 4229 highlightSegClasses: function() { 4230 return [ 'fc-highlight' ]; 4231 }, 4232 4233 4234 /* Business Hours 4235 ------------------------------------------------------------------------------------------------------------------*/ 4236 4237 4238 renderBusinessHours: function() { 4239 }, 4240 4241 4242 unrenderBusinessHours: function() { 4243 }, 4244 4245 4246 /* Now Indicator 4247 ------------------------------------------------------------------------------------------------------------------*/ 4248 4249 4250 getNowIndicatorUnit: function() { 4251 }, 4252 4253 4254 renderNowIndicator: function(date) { 4255 }, 4256 4257 4258 unrenderNowIndicator: function() { 4259 }, 4260 4261 4262 /* Fill System (highlight, background events, business hours) 4263 -------------------------------------------------------------------------------------------------------------------- 4264 TODO: remove this system. like we did in TimeGrid 4265 */ 4266 4267 4268 // Renders a set of rectangles over the given segments of time. 4269 // MUST RETURN a subset of segs, the segs that were actually rendered. 4270 // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement 4271 renderFill: function(type, segs) { 4272 // subclasses must implement 4273 }, 4274 4275 4276 // Unrenders a specific type of fill that is currently rendered on the grid 4277 unrenderFill: function(type) { 4278 var el = this.elsByFill[type]; 4279 4280 if (el) { 4281 el.remove(); 4282 delete this.elsByFill[type]; 4283 } 4284 }, 4285 4286 4287 // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types. 4288 // Only returns segments that successfully rendered. 4289 // To be harnessed by renderFill (implemented by subclasses). 4290 // Analagous to renderFgSegEls. 4291 renderFillSegEls: function(type, segs) { 4292 var _this = this; 4293 var segElMethod = this[type + 'SegEl']; 4294 var html = ''; 4295 var renderedSegs = []; 4296 var i; 4297 4298 if (segs.length) { 4299 4300 // build a large concatenation of segment HTML 4301 for (i = 0; i < segs.length; i++) { 4302 html += this.fillSegHtml(type, segs[i]); 4303 } 4304 4305 // Grab individual elements from the combined HTML string. Use each as the default rendering. 4306 // Then, compute the 'el' for each segment. 4307 $(html).each(function(i, node) { 4308 var seg = segs[i]; 4309 var el = $(node); 4310 4311 // allow custom filter methods per-type 4312 if (segElMethod) { 4313 el = segElMethod.call(_this, seg, el); 4314 } 4315 4316 if (el) { // custom filters did not cancel the render 4317 el = $(el); // allow custom filter to return raw DOM node 4318 4319 // correct element type? (would be bad if a non-TD were inserted into a table for example) 4320 if (el.is(_this.fillSegTag)) { 4321 seg.el = el; 4322 renderedSegs.push(seg); 4323 } 4324 } 4325 }); 4326 } 4327 4328 return renderedSegs; 4329 }, 4330 4331 4332 fillSegTag: 'div', // subclasses can override 4333 4334 4335 // Builds the HTML needed for one fill segment. Generic enough to work with different types. 4336 fillSegHtml: function(type, seg) { 4337 4338 // custom hooks per-type 4339 var classesMethod = this[type + 'SegClasses']; 4340 var cssMethod = this[type + 'SegCss']; 4341 4342 var classes = classesMethod ? classesMethod.call(this, seg) : []; 4343 var css = cssToStr(cssMethod ? cssMethod.call(this, seg) : {}); 4344 4345 return '<' + this.fillSegTag + 4346 (classes.length ? ' class="' + classes.join(' ') + '"' : '') + 4347 (css ? ' style="' + css + '"' : '') + 4348 ' />'; 4349 }, 4350 4351 4352 4353 /* Generic rendering utilities for subclasses 4354 ------------------------------------------------------------------------------------------------------------------*/ 4355 4356 4357 // Computes HTML classNames for a single-day element 4358 getDayClasses: function(date, noThemeHighlight) { 4359 var view = this.view; 4360 var today = view.calendar.getNow(); 4361 var classes = [ 'fc-' + dayIDs[date.day()] ]; 4362 4363 if ( 4364 view.intervalDuration.as('months') == 1 && 4365 date.month() != view.intervalStart.month() 4366 ) { 4367 classes.push('fc-other-month'); 4368 } 4369 4370 if (date.isSame(today, 'day')) { 4371 classes.push('fc-today'); 4372 4373 if (noThemeHighlight !== true) { 4374 classes.push(view.highlightStateClass); 4375 } 4376 } 4377 else if (date < today) { 4378 classes.push('fc-past'); 4379 } 4380 else { 4381 classes.push('fc-future'); 4382 } 4383 4384 return classes; 4385 } 4386 4387}); 4388 4389;; 4390 4391/* Event-rendering and event-interaction methods for the abstract Grid class 4392----------------------------------------------------------------------------------------------------------------------*/ 4393 4394Grid.mixin({ 4395 4396 // self-config, overridable by subclasses 4397 segSelector: '.fc-event-container > *', // what constitutes an event element? 4398 4399 mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing 4400 isDraggingSeg: false, // is a segment being dragged? boolean 4401 isResizingSeg: false, // is a segment being resized? boolean
4402 isDraggingExternal: false, // jqui-dragging an external element? boolean 4403 segs: null, // the *event* segments currently rendered in the grid. TODO: rename to `eventSegs` 4404 4405 4406 // Renders the given events onto the grid 4407 renderEvents: function(events) { 4408 var bgEvents = []; 4409 var fgEvents = []; 4410 var i; 4411 4412 for (i = 0; i < events.length; i++) { 4413 (isBgEvent(events[i]) ? bgEvents : fgEvents).push(events[i]); 4414 } 4415 4416 this.segs = [].concat( // record all segs 4417 this.renderBgEvents(bgEvents), 4418 this.renderFgEvents(fgEvents) 4419 ); 4420 }, 4421 4422 4423 renderBgEvents: function(events) { 4424 var segs = this.eventsToSegs(events); 4425 4426 // renderBgSegs might return a subset of segs, segs that were actually rendered 4427 return this.renderBgSegs(segs) || segs; 4428 }, 4429 4430 4431 renderFgEvents: function(events) { 4432 var segs = this.eventsToSegs(events); 4433 4434 // renderFgSegs might return a subset of segs, segs that were actually rendered 4435 return this.renderFgSegs(segs) || segs; 4436 }, 4437 4438 4439 // Unrenders all events currently rendered on the grid 4440 unrenderEvents: function() { 4441 this.handleSegMouseout(); // trigger an eventMouseout if user's mouse is over an event 4442 this.clearDragListeners(); 4443 4444 this.unrenderFgSegs(); 4445 this.unrenderBgSegs(); 4446 4447 this.segs = null; 4448 }, 4449 4450 4451 // Retrieves all rendered segment objects currently rendered on the grid 4452 getEventSegs: function() { 4453 return this.segs || []; 4454 }, 4455 4456 4457 /* Foreground Segment Rendering 4458 ------------------------------------------------------------------------------------------------------------------*/ 4459 4460 4461 // Renders foreground event segments onto the grid. May return a subset of segs that were rendered. 4462 renderFgSegs: function(segs) { 4463 // subclasses must implement 4464 }, 4465 4466 4467 // Unrenders all currently rendered foreground segments 4468 unrenderFgSegs: function() { 4469 // subclasses must implement 4470 }, 4471 4472 4473 // Renders and assigns an `el` property for each foreground event segment. 4474 // Only returns segments that successfully rendered. 4475 // A utility that subclasses may use. 4476 renderFgSegEls: function(segs, disableResizing) { 4477 var view = this.view; 4478 var html = ''; 4479 var renderedSegs = []; 4480 var i; 4481 4482 if (segs.length) { // don't build an empty html string 4483 4484 // build a large concatenation of event segment HTML 4485 for (i = 0; i < segs.length; i++) { 4486 html += this.fgSegHtml(segs[i], disableResizing); 4487 } 4488 4489 // Grab individual elements from the combined HTML string. Use each as the default rendering. 4490 // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false. 4491 $(html).each(function(i, node) { 4492 var seg = segs[i]; 4493 var el = view.resolveEventEl(seg.event, $(node)); 4494 4495 if (el) { 4496 el.data('fc-seg', seg); // used by handlers 4497 seg.el = el; 4498 renderedSegs.push(seg); 4499 } 4500 }); 4501 } 4502 4503 return renderedSegs; 4504 }, 4505 4506 4507 // Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls() 4508 fgSegHtml: function(seg, disableResizing) { 4509 // subclasses should implement 4510 }, 4511 4512 4513 /* Background Segment Rendering 4514 ------------------------------------------------------------------------------------------------------------------*/ 4515 4516 4517 // Renders the given background event segments onto the grid. 4518 // Returns a subset of the segs that were actually rendered. 4519 renderBgSegs: function(segs) { 4520 return this.renderFill('bgEvent', segs); 4521 }, 4522 4523 4524 // Unrenders all the currently rendered background event segments 4525 unrenderBgSegs: function() { 4526 this.unrenderFill('bgEvent'); 4527 }, 4528 4529 4530 // Renders a background event element, given the default rendering. Called by the fill system. 4531 bgEventSegEl: function(seg, el) { 4532 return this.view.resolveEventEl(seg.event, el); // will filter through eventRender 4533 }, 4534 4535 4536 // Generates an array of classNames to be used for the default rendering of a background event. 4537 // Called by fillSegHtml. 4538 bgEventSegClasses: function(seg) { 4539 var event = seg.event; 4540 var source = event.source || {}; 4541 4542 return [ 'fc-bgevent' ].concat( 4543 event.className, 4544 source.className || [] 4545 ); 4546 }, 4547 4548 4549 // Generates a semicolon-separated CSS string to be used for the default rendering of a background event. 4550 // Called by fillSegHtml. 4551 bgEventSegCss: function(seg) { 4552 return {
4553 'background-color': this.getSegSkinCss(seg)['background-color'] 4554 }; 4555 }, 4556 4557 4558 // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system. 4559 // Called by fillSegHtml. 4560 businessHoursSegClasses: function(seg) { 4561 return [ 'fc-nonbusiness', 'fc-bgevent' ]; 4562 }, 4563 4564 4565 /* Business Hours 4566 ------------------------------------------------------------------------------------------------------------------*/ 4567 4568 4569 // Compute business hour segs for the grid's current date range. 4570 // Caller must ask if whole-day business hours are needed. 4571 // If no `businessHours` configuration value is specified, assumes the calendar default. 4572 buildBusinessHourSegs: function(wholeDay, businessHours) { 4573 return this.eventsToSegs( 4574 this.buildBusinessHourEvents(wholeDay, businessHours) 4575 ); 4576 }, 4577 4578 4579 // Compute business hour *events* for the grid's current date range. 4580 // Caller must ask if whole-day business hours are needed. 4581 // If no `businessHours` configuration value is specified, assumes the calendar default. 4582 buildBusinessHourEvents: function(wholeDay, businessHours) { 4583 var calendar = this.view.calendar; 4584 var events; 4585 4586 if (businessHours == null) { 4587 // fallback 4588 // access from calendawr. don't access from view. doesn't update with dynamic options. 4589 businessHours = calendar.options.businessHours; 4590 } 4591 4592 events = calendar.computeBusinessHourEvents(wholeDay, businessHours); 4593 4594 // HACK. Eventually refactor business hours "events" system. 4595 // If no events are given, but businessHours is activated, this means the entire visible range should be 4596 // marked as *not* business-hours, via inverse-background rendering. 4597 if (!events.length && businessHours) { 4598 events = [ 4599 $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, { 4600 start: this.view.end, // guaranteed out-of-range 4601 end: this.view.end, // " 4602 dow: null 4603 }) 4604 ]; 4605 } 4606 4607 return events; 4608 }, 4609 4610 4611 /* Handlers 4612 ------------------------------------------------------------------------------------------------------------------*/ 4613 4614 4615 // Attaches event-element-related handlers for *all* rendered event segments of the view. 4616 bindSegHandlers: function() { 4617 this.bindSegHandlersToEl(this.el); 4618 }, 4619 4620 4621 // Attaches event-element-related handlers to an arbitrary container element. leverages bubbling. 4622 bindSegHandlersToEl: function(el) { 4623 this.bindSegHandlerToEl(el, 'touchstart', this.handleSegTouchStart); 4624 this.bindSegHandlerToEl(el, 'mouseenter', this.handleSegMouseover); 4625 this.bindSegHandlerToEl(el, 'mouseleave', this.handleSegMouseout); 4626 this.bindSegHandlerToEl(el, 'mousedown', this.handleSegMousedown); 4627 this.bindSegHandlerToEl(el, 'click', this.handleSegClick); 4628 }, 4629 4630 4631 // Executes a handler for any a user-interaction on a segment. 4632 // Handler gets called with (seg, ev), and with the `this` context of the Grid 4633 bindSegHandlerToEl: function(el, name, handler) { 4634 var _this = this; 4635 4636 el.on(name, this.segSelector, function(ev) { 4637 var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents 4638 4639 // only call the handlers if there is not a drag/resize in progress 4640 if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) { 4641 return handler.call(_this, seg, ev); // context will be the Grid 4642 } 4643 }); 4644 }, 4645 4646 4647 handleSegClick: function(seg, ev) { 4648 var res = this.view.publiclyTrigger('eventClick', seg.el[0], seg.event, ev); // can return `false` to cancel 4649 if (res === false) { 4650 ev.preventDefault(); 4651 } 4652 }, 4653 4654 4655 // Updates internal state and triggers handlers for when an event element is moused over 4656 handleSegMouseover: function(seg, ev) { 4657 if ( 4658 !GlobalEmitter.get().shouldIgnoreMouse() && 4659 !this.mousedOverSeg 4660 ) { 4661 this.mousedOverSeg = seg; 4662 if (this.view.isEventResizable(seg.event)) { 4663 seg.el.addClass('fc-allow-mouse-resize'); 4664 } 4665 this.view.publiclyTrigger('eventMouseover', seg.el[0], seg.event, ev); 4666 } 4667 }, 4668 4669 4670 // Updates internal state and triggers handlers for when an event element is moused out. 4671 // Can be given no arguments, in which case it will mouseout the segment that was previously moused over. 4672 handleSegMouseout: function(seg, ev) { 4673 ev = ev || {}; // if given no args, make a mock mouse event 4674 4675 if (this.mousedOverSeg) { 4676 seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment 4677 this.mousedOverSeg = null; 4678 if (this.view.isEventResizable(seg.event)) { 4679 seg.el.removeClass('fc-allow-mouse-resize'); 4680 } 4681 this.view.publiclyTrigger('eventMouseout', seg.el[0], seg.event, ev); 4682 } 4683 }, 4684 4685 4686 handleSegMousedown: function(seg, ev) { 4687 var isResizing = this.startSegResize(seg, ev, { distance: 5 }); 4688 4689 if (!isResizing && this.view.isEventDraggable(seg.event)) { 4690 this.buildSegDragListener(seg) 4691 .startInteraction(ev, { 4692 distance: 5 4693 }); 4694 } 4695 }, 4696 4697 4698 handleSegTouchStart: function(seg, ev) { 4699 var view = this.view; 4700 var event = seg.event; 4701 var isSelected = view.isEventSelected(event); 4702 var isDraggable = view.isEventDraggable(event); 4703 var isResizable = view.isEventResizable(event); 4704 var isResizing = false;
4705 var dragListener; 4706 var eventLongPressDelay; 4707 4708 if (isSelected && isResizable) { 4709 // only allow resizing of the event is selected 4710 isResizing = this.startSegResize(seg, ev); 4711 } 4712 4713 if (!isResizing && (isDraggable || isResizable)) { // allowed to be selected? 4714 4715 eventLongPressDelay = view.opt('eventLongPressDelay'); 4716 if (eventLongPressDelay == null) { 4717 eventLongPressDelay = view.opt('longPressDelay'); // fallback 4718 } 4719 4720 dragListener = isDraggable ? 4721 this.buildSegDragListener(seg) : 4722 this.buildSegSelectListener(seg); // seg isn't draggable, but still needs to be selected 4723 4724 dragListener.startInteraction(ev, { // won't start if already started 4725 delay: isSelected ? 0 : eventLongPressDelay // do delay if not already selected 4726 }); 4727 } 4728 }, 4729 4730 4731 // returns boolean whether resizing actually started or not. 4732 // assumes the seg allows resizing. 4733 // `dragOptions` are optional. 4734 startSegResize: function(seg, ev, dragOptions) { 4735 if ($(ev.target).is('.fc-resizer')) { 4736 this.buildSegResizeListener(seg, $(ev.target).is('.fc-start-resizer')) 4737 .startInteraction(ev, dragOptions); 4738 return true; 4739 } 4740 return false; 4741 }, 4742 4743 4744 4745 /* Event Dragging 4746 ------------------------------------------------------------------------------------------------------------------*/ 4747 4748 4749 // Builds a listener that will track user-dragging on an event segment. 4750 // Generic enough to work with any type of Grid. 4751 // Has side effect of setting/unsetting `segDragListener` 4752 buildSegDragListener: function(seg) { 4753 var _this = this; 4754 var view = this.view; 4755 var calendar = view.calendar; 4756 var el = seg.el; 4757 var event = seg.event; 4758 var isDragging; 4759 var mouseFollower; // A clone of the original element that will move with the mouse 4760 var dropLocation; // zoned event date properties 4761 4762 if (this.segDragListener) { 4763 return this.segDragListener; 4764 } 4765 4766 // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents 4767 // of the view. 4768 var dragListener = this.segDragListener = new HitDragListener(view, { 4769 scroll: view.opt('dragScroll'), 4770 subjectEl: el, 4771 subjectCenter: true, 4772 interactionStart: function(ev) { 4773 seg.component = _this; // for renderDrag 4774 isDragging = false; 4775 mouseFollower = new MouseFollower(seg.el, { 4776 additionalClass: 'fc-dragging', 4777 parentEl: view.el, 4778 opacity: dragListener.isTouch ? null : view.opt('dragOpacity'), 4779 revertDuration: view.opt('dragRevertDuration'), 4780 zIndex: 2 // one above the .fc-view 4781 }); 4782 mouseFollower.hide(); // don't show until we know this is a real drag 4783 mouseFollower.start(ev); 4784 }, 4785 dragStart: function(ev) { 4786 if (dragListener.isTouch && !view.isEventSelected(event)) { 4787 // if not previously selected, will fire after a delay. then, select the event 4788 view.selectEvent(event); 4789 } 4790 isDragging = true; 4791 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported 4792 _this.segDragStart(seg, ev); 4793 view.hideEvent(event); // hide all event segments. our mouseFollower will take over 4794 }, 4795 hitOver: function(hit, isOrig, origHit) { 4796 var dragHelperEls; 4797 4798 // starting hit could be forced (DayGrid.limit) 4799 if (seg.hit) { 4800 origHit = seg.hit; 4801 } 4802 4803 // since we are querying the parent view, might not belong to this grid 4804 dropLocation = _this.computeEventDrop( 4805 origHit.component.getHitSpan(origHit), 4806 hit.component.getHitSpan(hit), 4807 event 4808 ); 4809 4810 if (dropLocation && !calendar.isEventSpanAllowed(_this.eventToSpan(dropLocation), event)) { 4811 disableCursor(); 4812 dropLocation = null; 4813 } 4814 4815 // if a valid drop location, have the subclass render a visual indication 4816 if (dropLocation && (dragHelperEls = view.renderDrag(dropLocation, seg))) { 4817 4818 dragHelperEls.addClass('fc-dragging'); 4819 if (!dragListener.isTouch) { 4820 _this.applyDragOpacity(dragHelperEls); 4821 } 4822 4823 mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own 4824 } 4825 else { 4826 mouseFollower.show(); // otherwise, have the helper follow the mouse (no snapping) 4827 } 4828 4829 if (isOrig) { 4830 dropLocation = null; // needs to have moved hits to be a valid drop 4831 } 4832 }, 4833 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits 4834 view.unrenderDrag(); // unrender whatever was done in renderDrag 4835 mouseFollower.show(); // show in case we are moving out of all hits 4836 dropLocation = null; 4837 }, 4838 hitDone: function() { // Called after a hitOut OR before a dragEnd
4839 enableCursor(); 4840 }, 4841 interactionEnd: function(ev) { 4842 delete seg.component; // prevent side effects 4843 4844 // do revert animation if hasn't changed. calls a callback when finished (whether animation or not) 4845 mouseFollower.stop(!dropLocation, function() { 4846 if (isDragging) { 4847 view.unrenderDrag(); 4848 _this.segDragStop(seg, ev); 4849 } 4850 4851 if (dropLocation) { 4852 // no need to re-show original, will rerender all anyways. esp important if eventRenderWait 4853 view.reportSegDrop(seg, dropLocation, _this.largeUnit, el, ev); 4854 } 4855 else { 4856 view.showEvent(event); 4857 } 4858 }); 4859 _this.segDragListener = null; 4860 } 4861 }); 4862 4863 return dragListener; 4864 }, 4865 4866 4867 // seg isn't draggable, but let's use a generic DragListener 4868 // simply for the delay, so it can be selected. 4869 // Has side effect of setting/unsetting `segDragListener` 4870 buildSegSelectListener: function(seg) { 4871 var _this = this; 4872 var view = this.view; 4873 var event = seg.event; 4874 4875 if (this.segDragListener) { 4876 return this.segDragListener; 4877 } 4878 4879 var dragListener = this.segDragListener = new DragListener({ 4880 dragStart: function(ev) { 4881 if (dragListener.isTouch && !view.isEventSelected(event)) { 4882 // if not previously selected, will fire after a delay. then, select the event 4883 view.selectEvent(event); 4884 } 4885 }, 4886 interactionEnd: function(ev) { 4887 _this.segDragListener = null; 4888 } 4889 }); 4890 4891 return dragListener; 4892 }, 4893 4894 4895 // Called before event segment dragging starts 4896 segDragStart: function(seg, ev) { 4897 this.isDraggingSeg = true; 4898 this.view.publiclyTrigger('eventDragStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy 4899 }, 4900 4901 4902 // Called after event segment dragging stops 4903 segDragStop: function(seg, ev) { 4904 this.isDraggingSeg = false; 4905 this.view.publiclyTrigger('eventDragStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy 4906 }, 4907 4908 4909 // Given the spans an event drag began, and the span event was dropped, calculates the new zoned start/end/allDay 4910 // values for the event. Subclasses may override and set additional properties to be used by renderDrag. 4911 // A falsy returned value indicates an invalid drop. 4912 // DOES NOT consider overlap/constraint. 4913 computeEventDrop: function(startSpan, endSpan, event) { 4914 var calendar = this.view.calendar; 4915 var dragStart = startSpan.start; 4916 var dragEnd = endSpan.start; 4917 var delta; 4918 var dropLocation; // zoned event date properties 4919 4920 if (dragStart.hasTime() === dragEnd.hasTime()) { 4921 delta = this.diffDates(dragEnd, dragStart); 4922 4923 // if an all-day event was in a timed area and it was dragged to a different time, 4924 // guarantee an end and adjust start/end to have times 4925 if (event.allDay && durationHasTime(delta)) { 4926 dropLocation = { 4927 start: event.start.clone(), 4928 end: calendar.getEventEnd(event), // will be an ambig day 4929 allDay: false // for normalizeEventTimes 4930 }; 4931 calendar.normalizeEventTimes(dropLocation); 4932 } 4933 // othewise, work off existing values 4934 else { 4935 dropLocation = pluckEventDateProps(event); 4936 } 4937 4938 dropLocation.start.add(delta); 4939 if (dropLocation.end) { 4940 dropLocation.end.add(delta); 4941 } 4942 } 4943 else { 4944 // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared 4945 dropLocation = { 4946 start: dragEnd.clone(), 4947 end: null, // end should be cleared 4948 allDay: !dragEnd.hasTime() 4949 }; 4950 } 4951 4952 return dropLocation; 4953 }, 4954 4955 4956 // Utility for apply dragOpacity to a jQuery set 4957 applyDragOpacity: function(els) { 4958 var opacity = this.view.opt('dragOpacity'); 4959 4960 if (opacity != null) { 4961 els.css('opacity', opacity); 4962 } 4963 }, 4964 4965 4966 /* External Element Dragging 4967 ------------------------------------------------------------------------------------------------------------------*/ 4968 4969 4970 // Called when a jQuery UI drag is initiated anywhere in the DOM 4971 externalDragStart: function(ev, ui) { 4972 var view = this.view; 4973 var el; 4974 var accept; 4975 4976 if (view.opt('droppable')) { // only listen if this setting is on 4977 el = $((ui ? ui.item : null) || ev.target); 4978 4979 // Test that the dragged element passes the dropAccept selector or filter function. 4980 // FYI, the default is "*" (matches all) 4981 accept = view.opt('dropAccept'); 4982 if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) {
4983 if (!this.isDraggingExternal) { // prevent double-listening if fired twice 4984 this.listenToExternalDrag(el, ev, ui); 4985 } 4986 } 4987 } 4988 }, 4989 4990 4991 // Called when a jQuery UI drag starts and it needs to be monitored for dropping 4992 listenToExternalDrag: function(el, ev, ui) { 4993 var _this = this; 4994 var calendar = this.view.calendar; 4995 var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create 4996 var dropLocation; // a null value signals an unsuccessful drag 4997 4998 // listener that tracks mouse movement over date-associated pixel regions 4999 var dragListener = _this.externalDragListener = new HitDragListener(this, { 5000 interactionStart: function() { 5001 _this.isDraggingExternal = true; 5002 }, 5003 hitOver: function(hit) { 5004 dropLocation = _this.computeExternalDrop( 5005 hit.component.getHitSpan(hit), // since we are querying the parent view, might not belong to this grid 5006 meta 5007 ); 5008 5009 if ( // invalid hit? 5010 dropLocation && 5011 !calendar.isExternalSpanAllowed(_this.eventToSpan(dropLocation), dropLocation, meta.eventProps) 5012 ) { 5013 disableCursor(); 5014 dropLocation = null; 5015 } 5016 5017 if (dropLocation) { 5018 _this.renderDrag(dropLocation); // called without a seg parameter 5019 } 5020 }, 5021 hitOut: function() { 5022 dropLocation = null; // signal unsuccessful 5023 }, 5024 hitDone: function() { // Called after a hitOut OR before a dragEnd 5025 enableCursor(); 5026 _this.unrenderDrag(); 5027 }, 5028 interactionEnd: function(ev) { 5029 if (dropLocation) { // element was dropped on a valid hit 5030 _this.view.reportExternalDrop(meta, dropLocation, el, ev, ui); 5031 }
5032 _this.isDraggingExternal = false; 5033 _this.externalDragListener = null; 5034 } 5035 }); 5036 5037 dragListener.startDrag(ev); // start listening immediately 5038 }, 5039 5040 5041 // Given a hit to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object), 5042 // returns the zoned start/end dates for the event that would result from the hypothetical drop. end might be null. 5043 // Returning a null value signals an invalid drop hit. 5044 // DOES NOT consider overlap/constraint. 5045 computeExternalDrop: function(span, meta) { 5046 var calendar = this.view.calendar; 5047 var dropLocation = { 5048 start: calendar.applyTimezone(span.start), // simulate a zoned event start date 5049 end: null 5050 }; 5051 5052 // if dropped on an all-day span, and element's metadata specified a time, set it 5053 if (meta.startTime && !dropLocation.start.hasTime()) { 5054 dropLocation.start.time(meta.startTime); 5055 } 5056 5057 if (meta.duration) { 5058 dropLocation.end = dropLocation.start.clone().add(meta.duration); 5059 } 5060 5061 return dropLocation; 5062 }, 5063 5064 5065 5066 /* Drag Rendering (for both events and an external elements) 5067 ------------------------------------------------------------------------------------------------------------------*/ 5068 5069 5070 // Renders a visual indication of an event or external element being dragged. 5071 // `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null. 5072 // `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null. 5073 // A truthy returned value indicates this method has rendered a helper element. 5074 // Must return elements used for any mock events. 5075 renderDrag: function(dropLocation, seg) { 5076 // subclasses must implement 5077 }, 5078 5079 5080 // Unrenders a visual indication of an event or external element being dragged 5081 unrenderDrag: function() { 5082 // subclasses must implement 5083 }, 5084 5085 5086 /* Resizing 5087 ------------------------------------------------------------------------------------------------------------------*/ 5088 5089 5090 // Creates a listener that tracks the user as they resize an event segment. 5091 // Generic enough to work with any type of Grid. 5092 buildSegResizeListener: function(seg, isStart) { 5093 var _this = this; 5094 var view = this.view; 5095 var calendar = view.calendar; 5096 var el = seg.el; 5097 var event = seg.event; 5098 var eventEnd = calendar.getEventEnd(event); 5099 var isDragging; 5100 var resizeLocation; // zoned event date properties. falsy if invalid resize 5101 5102 // Tracks mouse movement over the *grid's* coordinate map 5103 var dragListener = this.segResizeListener = new HitDragListener(this, { 5104 scroll: view.opt('dragScroll'), 5105 subjectEl: el, 5106 interactionStart: function() { 5107 isDragging = false; 5108 }, 5109 dragStart: function(ev) { 5110 isDragging = true; 5111 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported 5112 _this.segResizeStart(seg, ev); 5113 }, 5114 hitOver: function(hit, isOrig, origHit) { 5115 var origHitSpan = _this.getHitSpan(origHit); 5116 var hitSpan = _this.getHitSpan(hit); 5117 5118 resizeLocation = isStart ? 5119 _this.computeEventStartResize(origHitSpan, hitSpan, event) : 5120 _this.computeEventEndResize(origHitSpan, hitSpan, event); 5121 5122 if (resizeLocation) { 5123 if (!calendar.isEventSpanAllowed(_this.eventToSpan(resizeLocation), event)) { 5124 disableCursor(); 5125 resizeLocation = null; 5126 } 5127 // no change? (FYI, event dates might have zones) 5128 else if ( 5129 resizeLocation.start.isSame(event.start.clone().stripZone()) && 5130 resizeLocation.end.isSame(eventEnd.clone().stripZone()) 5131 ) { 5132 resizeLocation = null; 5133 } 5134 } 5135 5136 if (resizeLocation) { 5137 view.hideEvent(event); 5138 _this.renderEventResize(resizeLocation, seg); 5139 } 5140 }, 5141 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits 5142 resizeLocation = null; 5143 view.showEvent(event); // for when out-of-bounds. show original 5144 }, 5145 hitDone: function() { // resets the rendering to show the original event 5146 _this.unrenderEventResize(); 5147 enableCursor(); 5148 }, 5149 interactionEnd: function(ev) { 5150 if (isDragging) { 5151 _this.segResizeStop(seg, ev); 5152 } 5153 5154 if (resizeLocation) { // valid date to resize to? 5155 // no need to re-show original, will rerender all anyways. esp important if eventRenderWait 5156 view.reportSegResize(seg, resizeLocation, _this.largeUnit, el, ev); 5157 } 5158 else { 5159 view.showEvent(event); 5160 } 5161 _this.segResizeListener = null; 5162 } 5163 }); 5164 5165 return dragListener; 5166 }, 5167 5168 5169 // Called before event segment resizing starts 5170 segResizeStart: function(seg, ev) { 5171 this.isResizingSeg = true; 5172 this.view.publiclyTrigger('eventResizeStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy 5173 }, 5174 5175 5176 // Called after event segment resizing stops 5177 segResizeStop: function(seg, ev) { 5178 this.isResizingSeg = false;
5179 this.view.publiclyTrigger('eventResizeStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy 5180 }, 5181 5182 5183 // Returns new date-information for an event segment being resized from its start 5184 computeEventStartResize: function(startSpan, endSpan, event) { 5185 return this.computeEventResize('start', startSpan, endSpan, event); 5186 }, 5187 5188 5189 // Returns new date-information for an event segment being resized from its end 5190 computeEventEndResize: function(startSpan, endSpan, event) { 5191 return this.computeEventResize('end', startSpan, endSpan, event); 5192 }, 5193 5194 5195 // Returns new zoned date information for an event segment being resized from its start OR end 5196 // `type` is either 'start' or 'end'. 5197 // DOES NOT consider overlap/constraint. 5198 computeEventResize: function(type, startSpan, endSpan, event) { 5199 var calendar = this.view.calendar; 5200 var delta = this.diffDates(endSpan[type], startSpan[type]); 5201 var resizeLocation; // zoned event date properties 5202 var defaultDuration; 5203 5204 // build original values to work from, guaranteeing a start and end 5205 resizeLocation = { 5206 start: event.start.clone(), 5207 end: calendar.getEventEnd(event), 5208 allDay: event.allDay 5209 }; 5210 5211 // if an all-day event was in a timed area and was resized to a time, adjust start/end to have times 5212 if (resizeLocation.allDay && durationHasTime(delta)) { 5213 resizeLocation.allDay = false; 5214 calendar.normalizeEventTimes(resizeLocation); 5215 } 5216 5217 resizeLocation[type].add(delta); // apply delta to start or end 5218 5219 // if the event was compressed too small, find a new reasonable duration for it 5220 if (!resizeLocation.start.isBefore(resizeLocation.end)) { 5221 5222 defaultDuration = 5223 this.minResizeDuration || // TODO: hack 5224 (event.allDay ? 5225 calendar.defaultAllDayEventDuration : 5226 calendar.defaultTimedEventDuration); 5227 5228 if (type == 'start') { // resizing the start? 5229 resizeLocation.start = resizeLocation.end.clone().subtract(defaultDuration); 5230 } 5231 else { // resizing the end? 5232 resizeLocation.end = resizeLocation.start.clone().add(defaultDuration); 5233 } 5234 } 5235 5236 return resizeLocation; 5237 }, 5238 5239 5240 // Renders a visual indication of an event being resized. 5241 // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag. 5242 // Must return elements used for any mock events. 5243 renderEventResize: function(range, seg) { 5244 // subclasses must implement 5245 }, 5246 5247 5248 // Unrenders a visual indication of an event being resized. 5249 unrenderEventResize: function() { 5250 // subclasses must implement 5251 }, 5252 5253 5254 /* Rendering Utils 5255 ------------------------------------------------------------------------------------------------------------------*/ 5256 5257 5258 // Compute the text that should be displayed on an event's element. 5259 // `range` can be the Event object itself, or something range-like, with at least a `start`. 5260 // If event times are disabled, or the event has no time, will return a blank string. 5261 // If not specified, formatStr will default to the eventTimeFormat setting, 5262 // and displayEnd will default to the displayEventEnd setting. 5263 getEventTimeText: function(range, formatStr, displayEnd) { 5264 5265 if (formatStr == null) { 5266 formatStr = this.eventTimeFormat; 5267 } 5268 5269 if (displayEnd == null) { 5270 displayEnd = this.displayEventEnd; 5271 } 5272 5273 if (this.displayEventTime && range.start.hasTime()) { 5274 if (displayEnd && range.end) { 5275 return this.view.formatRange(range, formatStr); 5276 } 5277 else { 5278 return range.start.format(formatStr); 5279 } 5280 } 5281 5282 return ''; 5283 }, 5284 5285 5286 // Generic utility for generating the HTML classNames for an event segment's element 5287 getSegClasses: function(seg, isDraggable, isResizable) { 5288 var view = this.view; 5289 var classes = [ 5290 'fc-event', 5291 seg.isStart ? 'fc-start' : 'fc-not-start', 5292 seg.isEnd ? 'fc-end' : 'fc-not-end' 5293 ].concat(this.getSegCustomClasses(seg)); 5294 5295 if (isDraggable) { 5296 classes.push('fc-draggable'); 5297 } 5298 if (isResizable) { 5299 classes.push('fc-resizable'); 5300 } 5301 5302 // event is currently selected? attach a className. 5303 if (view.isEventSelected(seg.event)) { 5304 classes.push('fc-selected'); 5305 } 5306 5307 return classes; 5308 }, 5309 5310 5311 // List of classes that were defined by the caller of the API in some way 5312 getSegCustomClasses: function(seg) { 5313 var event = seg.event; 5314 5315 return [].concat( 5316 event.className, // guaranteed to be an array 5317 event.source ? event.source.className : [] 5318 ); 5319 }, 5320 5321 5322 // Utility for generating event skin-related CSS properties 5323 getSegSkinCss: function(seg) { 5324 return {
5325 'background-color': this.getSegBackgroundColor(seg), 5326 'border-color': this.getSegBorderColor(seg), 5327 color: this.getSegTextColor(seg) 5328 }; 5329 }, 5330 5331 5332 // Queries for caller-specified color, then falls back to default 5333 getSegBackgroundColor: function(seg) { 5334 return seg.event.backgroundColor || 5335 seg.event.color || 5336 this.getSegDefaultBackgroundColor(seg); 5337 }, 5338 5339 5340 getSegDefaultBackgroundColor: function(seg) { 5341 var source = seg.event.source || {}; 5342 5343 return source.backgroundColor || 5344 source.color || 5345 this.view.opt('eventBackgroundColor') || 5346 this.view.opt('eventColor'); 5347 }, 5348 5349 5350 // Queries for caller-specified color, then falls back to default 5351 getSegBorderColor: function(seg) { 5352 return seg.event.borderColor || 5353 seg.event.color || 5354 this.getSegDefaultBorderColor(seg); 5355 }, 5356 5357 5358 getSegDefaultBorderColor: function(seg) { 5359 var source = seg.event.source || {}; 5360 5361 return source.borderColor || 5362 source.color || 5363 this.view.opt('eventBorderColor') || 5364 this.view.opt('eventColor'); 5365 }, 5366 5367 5368 // Queries for caller-specified color, then falls back to default 5369 getSegTextColor: function(seg) { 5370 return seg.event.textColor || 5371 this.getSegDefaultTextColor(seg); 5372 }, 5373 5374 5375 getSegDefaultTextColor: function(seg) { 5376 var source = seg.event.source || {}; 5377 5378 return source.textColor || 5379 this.view.opt('eventTextColor'); 5380 }, 5381 5382 5383 /* Converting events -> eventRange -> eventSpan -> eventSegs 5384 ------------------------------------------------------------------------------------------------------------------*/ 5385 5386 5387 // Generates an array of segments for the given single event 5388 // Can accept an event "location" as well (which only has start/end and no allDay) 5389 eventToSegs: function(event) { 5390 return this.eventsToSegs([ event ]); 5391 }, 5392 5393 5394 eventToSpan: function(event) { 5395 return this.eventToSpans(event)[0]; 5396 }, 5397 5398 5399 // Generates spans (always unzoned) for the given event. 5400 // Does not do any inverting for inverse-background events. 5401 // Can accept an event "location" as well (which only has start/end and no allDay) 5402 eventToSpans: function(event) { 5403 var range = this.eventToRange(event); 5404 return this.eventRangeToSpans(range, event); 5405 }, 5406 5407 5408 5409 // Converts an array of event objects into an array of event segment objects. 5410 // A custom `segSliceFunc` may be given for arbitrarily slicing up events. 5411 // Doesn't guarantee an order for the resulting array. 5412 eventsToSegs: function(allEvents, segSliceFunc) { 5413 var _this = this; 5414 var eventsById = groupEventsById(allEvents); 5415 var segs = []; 5416 5417 $.each(eventsById, function(id, events) { 5418 var ranges = []; 5419 var i; 5420 5421 for (i = 0; i < events.length; i++) { 5422 ranges.push(_this.eventToRange(events[i])); 5423 } 5424 5425 // inverse-background events (utilize only the first event in calculations) 5426 if (isInverseBgEvent(events[0])) { 5427 ranges = _this.invertRanges(ranges); 5428 5429 for (i = 0; i < ranges.length; i++) { 5430 segs.push.apply(segs, // append to 5431 _this.eventRangeToSegs(ranges[i], events[0], segSliceFunc)); 5432 } 5433 } 5434 // normal event ranges 5435 else { 5436 for (i = 0; i < ranges.length; i++) { 5437 segs.push.apply(segs, // append to 5438 _this.eventRangeToSegs(ranges[i], events[i], segSliceFunc)); 5439 } 5440 } 5441 }); 5442 5443 return segs; 5444 }, 5445 5446 5447 // Generates the unzoned start/end dates an event appears to occupy 5448 // Can accept an event "location" as well (which only has start/end and no allDay) 5449 eventToRange: function(event) { 5450 var calendar = this.view.calendar; 5451 var start = event.start.clone().stripZone(); 5452 var end = ( 5453 event.end ? 5454 event.end.clone() : 5455 // derive the end from the start and allDay. compute allDay if necessary 5456 calendar.getDefaultEventEnd( 5457 event.allDay != null ? 5458 event.allDay : 5459 !event.start.hasTime(), 5460 event.start 5461 ) 5462 ).stripZone(); 5463 5464 // hack: dynamic locale change forgets to upate stored event localed 5465 calendar.localizeMoment(start);
5466 calendar.localizeMoment(end); 5467 5468 return { start: start, end: end }; 5469 }, 5470 5471 5472 // Given an event's range (unzoned start/end), and the event itself, 5473 // slice into segments (using the segSliceFunc function if specified) 5474 eventRangeToSegs: function(range, event, segSliceFunc) { 5475 var spans = this.eventRangeToSpans(range, event); 5476 var segs = []; 5477 var i; 5478 5479 for (i = 0; i < spans.length; i++) { 5480 segs.push.apply(segs, // append to 5481 this.eventSpanToSegs(spans[i], event, segSliceFunc)); 5482 } 5483 5484 return segs; 5485 }, 5486 5487 5488 // Given an event's unzoned date range, return an array of "span" objects. 5489 // Subclasses can override. 5490 eventRangeToSpans: function(range, event) { 5491 return [ $.extend({}, range) ]; // copy into a single-item array 5492 }, 5493 5494 5495 // Given an event's span (unzoned start/end and other misc data), and the event itself, 5496 // slices into segments and attaches event-derived properties to them. 5497 eventSpanToSegs: function(span, event, segSliceFunc) { 5498 var segs = segSliceFunc ? segSliceFunc(span) : this.spanToSegs(span); 5499 var i, seg; 5500 5501 for (i = 0; i < segs.length; i++) { 5502 seg = segs[i]; 5503 seg.event = event; 5504 seg.eventStartMS = +span.start; // TODO: not the best name after making spans unzoned 5505 seg.eventDurationMS = span.end - span.start; 5506 } 5507 5508 return segs; 5509 }, 5510 5511 5512 // Produces a new array of range objects that will cover all the time NOT covered by the given ranges. 5513 // SIDE EFFECT: will mutate the given array and will use its date references. 5514 invertRanges: function(ranges) { 5515 var view = this.view; 5516 var viewStart = view.start.clone(); // need a copy 5517 var viewEnd = view.end.clone(); // need a copy 5518 var inverseRanges = []; 5519 var start = viewStart; // the end of the previous range. the start of the new range 5520 var i, range; 5521 5522 // ranges need to be in order. required for our date-walking algorithm 5523 ranges.sort(compareRanges); 5524 5525 for (i = 0; i < ranges.length; i++) { 5526 range = ranges[i]; 5527 5528 // add the span of time before the event (if there is any) 5529 if (range.start > start) { // compare millisecond time (skip any ambig logic) 5530 inverseRanges.push({ 5531 start: start, 5532 end: range.start 5533 }); 5534 } 5535 5536 start = range.end; 5537 } 5538 5539 // add the span of time after the last event (if there is any) 5540 if (start < viewEnd) { // compare millisecond time (skip any ambig logic) 5541 inverseRanges.push({ 5542 start: start, 5543 end: viewEnd 5544 }); 5545 } 5546 5547 return inverseRanges; 5548 }, 5549 5550 5551 sortEventSegs: function(segs) { 5552 segs.sort(proxy(this, 'compareEventSegs')); 5553 }, 5554 5555 5556 // A cmp function for determining which segments should take visual priority 5557 compareEventSegs: function(seg1, seg2) { 5558 return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first 5559 seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first 5560 seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1) 5561 compareByFieldSpecs(seg1.event, seg2.event, this.view.eventOrderSpecs); 5562 } 5563 5564}); 5565 5566 5567/* Utilities 5568----------------------------------------------------------------------------------------------------------------------*/ 5569 5570 5571function pluckEventDateProps(event) { 5572 return { 5573 start: event.start.clone(), 5574 end: event.end ? event.end.clone() : null, 5575 allDay: event.allDay // keep it the same 5576 }; 5577} 5578FC.pluckEventDateProps = pluckEventDateProps; 5579 5580 5581function isBgEvent(event) { // returns true if background OR inverse-background 5582 var rendering = getEventRendering(event); 5583 return rendering === 'background' || rendering === 'inverse-background'; 5584} 5585FC.isBgEvent = isBgEvent; // export 5586 5587 5588function isInverseBgEvent(event) { 5589 return getEventRendering(event) === 'inverse-background'; 5590} 5591 5592 5593function getEventRendering(event) { 5594 return firstDefined((event.source || {}).rendering, event.rendering); 5595} 5596 5597 5598function groupEventsById(events) { 5599 var eventsById = {}; 5600 var i, event; 5601 5602 for (i = 0; i < events.length; i++) { 5603 event = events[i]; 5604 (eventsById[event._id] || (eventsById[event._id] = [])).push(event); 5605 } 5606 5607 return eventsById; 5608} 5609 5610 5611// A cmp function for determining which non-inverted "ranges" (see above) happen earlier 5612function compareRanges(range1, range2) { 5613 return range1.start - range2.start; // earlier ranges go first 5614} 5615 5616 5617/* External-Dragging-Element Data 5618----------------------------------------------------------------------------------------------------------------------*/ 5619 5620// Require all HTML5 data-* attributes used by FullCalendar to have this prefix. 5621// A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event. 5622FC.dataAttrPrefix = ''; 5623 5624// Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure 5625// to be used for Event Object creation. 5626// A defined `.eventProps`, even when empty, indicates that an event should be created. 5627function getDraggedElMeta(el) { 5628 var prefix = FC.dataAttrPrefix; 5629 var eventProps; // properties for creating the event, not related to date/time 5630 var startTime; // a Duration 5631 var duration; 5632 var stick; 5633 5634 if (prefix) { prefix += '-'; } 5635 eventProps = el.data(prefix + 'event') || null; 5636 5637 if (eventProps) { 5638 if (typeof eventProps === 'object') { 5639 eventProps = $.extend({}, eventProps); // make a copy 5640 } 5641 else { // something like 1 or true. still signal event creation 5642 eventProps = {}; 5643 } 5644 5645 // pluck special-cased date/time properties 5646 startTime = eventProps.start; 5647 if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well 5648 duration = eventProps.duration; 5649 stick = eventProps.stick; 5650 delete eventProps.start; 5651 delete eventProps.time; 5652 delete eventProps.duration; 5653 delete eventProps.stick; 5654 } 5655 5656 // fallback to standalone attribute values for each of the date/time properties 5657 if (startTime == null) { startTime = el.data(prefix + 'start'); } 5658 if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well 5659 if (duration == null) { duration = el.data(prefix + 'duration'); } 5660 if (stick == null) { stick = el.data(prefix + 'stick'); } 5661 5662 // massage into correct data types 5663 startTime = startTime != null ? moment.duration(startTime) : null; 5664 duration = duration != null ? moment.duration(duration) : null; 5665 stick = Boolean(stick); 5666 5667 return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick }; 5668} 5669 5670 5671;; 5672 5673/* 5674A set of rendering and date-related methods for a visual component comprised of one or more rows of day columns. 5675Prerequisite: the object being mixed into needs to be a *Grid* 5676*/ 5677var DayTableMixin = FC.DayTableMixin = { 5678 5679 breakOnWeeks: false, // should create a new row for each week? 5680 dayDates: null, // whole-day dates for each column. left to right 5681 dayIndices: null, // for each day from start, the offset 5682 daysPerRow: null, 5683 rowCnt: null, 5684 colCnt: null, 5685 colHeadFormat: null, 5686 5687 5688 // Populates internal variables used for date calculation and rendering 5689 updateDayTable: function() { 5690 var view = this.view; 5691 var date = this.start.clone(); 5692 var dayIndex = -1; 5693 var dayIndices = []; 5694 var dayDates = []; 5695 var daysPerRow; 5696 var firstDay; 5697 var rowCnt; 5698 5699 while (date.isBefore(this.end)) { // loop each day from start to end
5700 if (view.isHiddenDay(date)) { 5701 dayIndices.push(dayIndex + 0.5); // mark that it's between indices 5702 } 5703 else { 5704 dayIndex++; 5705 dayIndices.push(dayIndex); 5706 dayDates.push(date.clone()); 5707 } 5708 date.add(1, 'days'); 5709 } 5710 5711 if (this.breakOnWeeks) { 5712 // count columns until the day-of-week repeats 5713 firstDay = dayDates[0].day(); 5714 for (daysPerRow = 1; daysPerRow < dayDates.length; daysPerRow++) { 5715 if (dayDates[daysPerRow].day() == firstDay) { 5716 break; 5717 } 5718 } 5719 rowCnt = Math.ceil(dayDates.length / daysPerRow); 5720 } 5721 else { 5722 rowCnt = 1; 5723 daysPerRow = dayDates.length; 5724 } 5725 5726 this.dayDates = dayDates; 5727 this.dayIndices = dayIndices; 5728 this.daysPerRow = daysPerRow; 5729 this.rowCnt = rowCnt; 5730 5731 this.updateDayTableCols(); 5732 }, 5733 5734 5735 // Computes and assigned the colCnt property and updates any options that may be computed from it 5736 updateDayTableCols: function() { 5737 this.colCnt = this.computeColCnt(); 5738 this.colHeadFormat = this.view.opt('columnFormat') || this.computeColHeadFormat(); 5739 }, 5740 5741 5742 // Determines how many columns there should be in the table 5743 computeColCnt: function() { 5744 return this.daysPerRow; 5745 }, 5746 5747 5748 // Computes the ambiguously-timed moment for the given cell 5749 getCellDate: function(row, col) { 5750 return this.dayDates[ 5751 this.getCellDayIndex(row, col) 5752 ].clone(); 5753 }, 5754 5755 5756 // Computes the ambiguously-timed date range for the given cell 5757 getCellRange: function(row, col) { 5758 var start = this.getCellDate(row, col); 5759 var end = start.clone().add(1, 'days'); 5760 5761 return { start: start, end: end }; 5762 }, 5763 5764 5765 // Returns the number of day cells, chronologically, from the first of the grid (0-based) 5766 getCellDayIndex: function(row, col) { 5767 return row * this.daysPerRow + this.getColDayIndex(col); 5768 }, 5769 5770 5771 // Returns the numner of day cells, chronologically, from the first cell in *any given row* 5772 getColDayIndex: function(col) { 5773 if (this.isRTL) { 5774 return this.colCnt - 1 - col; 5775 } 5776 else { 5777 return col; 5778 } 5779 }, 5780 5781 5782 // Given a date, returns its chronolocial cell-index from the first cell of the grid. 5783 // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets. 5784 // If before the first offset, returns a negative number. 5785 // If after the last offset, returns an offset past the last cell offset. 5786 // Only works for *start* dates of cells. Will not work for exclusive end dates for cells. 5787 getDateDayIndex: function(date) { 5788 var dayIndices = this.dayIndices; 5789 var dayOffset = date.diff(this.start, 'days'); 5790 5791 if (dayOffset < 0) { 5792 return dayIndices[0] - 1; 5793 } 5794 else if (dayOffset >= dayIndices.length) { 5795 return dayIndices[dayIndices.length - 1] + 1; 5796 } 5797 else { 5798 return dayIndices[dayOffset]; 5799 } 5800 }, 5801 5802 5803 /* Options 5804 ------------------------------------------------------------------------------------------------------------------*/ 5805 5806 5807 // Computes a default column header formatting string if `colFormat` is not explicitly defined 5808 computeColHeadFormat: function() { 5809 // if more than one week row, or if there are a lot of columns with not much space, 5810 // put just the day numbers will be in each cell 5811 if (this.rowCnt > 1 || this.colCnt > 10) { 5812 return 'ddd'; // "Sat" 5813 } 5814 // multiple days, so full single date string WON'T be in title text 5815 else if (this.colCnt > 1) { 5816 return this.view.opt('dayOfMonthFormat'); // "Sat 12/10" 5817 } 5818 // single day, so full single date string will probably be in title text 5819 else { 5820 return 'dddd'; // "Saturday" 5821 } 5822 }, 5823 5824 5825 /* Slicing 5826 ------------------------------------------------------------------------------------------------------------------*/ 5827 5828 5829 // Slices up a date range into a segment for every week-row it intersects with 5830 sliceRangeByRow: function(range) { 5831 var daysPerRow = this.daysPerRow; 5832 var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold 5833 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index 5834 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index 5835 var segs = []; 5836 var row; 5837 var rowFirst, rowLast; // inclusive day-index range for current row 5838 var segFirst, segLast; // inclusive day-index range for segment 5839 5840 for (row = 0; row < this.rowCnt; row++) { 5841 rowFirst = row * daysPerRow; 5842 rowLast = rowFirst + daysPerRow - 1; 5843 5844 // intersect segment's offset range with the row's 5845 segFirst = Math.max(rangeFirst, rowFirst); 5846 segLast = Math.min(rangeLast, rowLast); 5847 5848 // deal with in-between indices
5849 segFirst = Math.ceil(segFirst); // in-between starts round to next cell 5850 segLast = Math.floor(segLast); // in-between ends round to prev cell 5851 5852 if (segFirst <= segLast) { // was there any intersection with the current row? 5853 segs.push({ 5854 row: row, 5855 5856 // normalize to start of row 5857 firstRowDayIndex: segFirst - rowFirst, 5858 lastRowDayIndex: segLast - rowFirst, 5859 5860 // must be matching integers to be the segment's start/end 5861 isStart: segFirst === rangeFirst, 5862 isEnd: segLast === rangeLast 5863 }); 5864 } 5865 } 5866 5867 return segs; 5868 }, 5869 5870 5871 // Slices up a date range into a segment for every day-cell it intersects with. 5872 // TODO: make more DRY with sliceRangeByRow somehow. 5873 sliceRangeByDay: function(range) { 5874 var daysPerRow = this.daysPerRow; 5875 var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold 5876 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index 5877 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index 5878 var segs = []; 5879 var row; 5880 var rowFirst, rowLast; // inclusive day-index range for current row 5881 var i; 5882 var segFirst, segLast; // inclusive day-index range for segment 5883 5884 for (row = 0; row < this.rowCnt; row++) { 5885 rowFirst = row * daysPerRow; 5886 rowLast = rowFirst + daysPerRow - 1; 5887 5888 for (i = rowFirst; i <= rowLast; i++) { 5889 5890 // intersect segment's offset range with the row's 5891 segFirst = Math.max(rangeFirst, i); 5892 segLast = Math.min(rangeLast, i); 5893 5894 // deal with in-between indices 5895 segFirst = Math.ceil(segFirst); // in-between starts round to next cell 5896 segLast = Math.floor(segLast); // in-between ends round to prev cell 5897 5898 if (segFirst <= segLast) { // was there any intersection with the current row? 5899 segs.push({ 5900 row: row, 5901 5902 // normalize to start of row 5903 firstRowDayIndex: segFirst - rowFirst, 5904 lastRowDayIndex: segLast - rowFirst, 5905 5906 // must be matching integers to be the segment's start/end 5907 isStart: segFirst === rangeFirst, 5908 isEnd: segLast === rangeLast 5909 }); 5910 } 5911 } 5912 } 5913 5914 return segs; 5915 }, 5916 5917 5918 /* Header Rendering 5919 ------------------------------------------------------------------------------------------------------------------*/ 5920 5921 5922 renderHeadHtml: function() { 5923 var view = this.view; 5924 5925 return '' + 5926 '<div class="fc-row ' + view.widgetHeaderClass + '">' + 5927 '<table>' + 5928 '<thead>' + 5929 this.renderHeadTrHtml() + 5930 '</thead>' + 5931 '</table>' + 5932 '</div>'; 5933 }, 5934 5935 5936 renderHeadIntroHtml: function() { 5937 return this.renderIntroHtml(); // fall back to generic 5938 }, 5939 5940 5941 renderHeadTrHtml: function() { 5942 return '' + 5943 '<tr>' + 5944 (this.isRTL ? '' : this.renderHeadIntroHtml()) + 5945 this.renderHeadDateCellsHtml() + 5946 (this.isRTL ? this.renderHeadIntroHtml() : '') + 5947 '</tr>'; 5948 }, 5949 5950 5951 renderHeadDateCellsHtml: function() { 5952 var htmls = []; 5953 var col, date; 5954 5955 for (col = 0; col < this.colCnt; col++) { 5956 date = this.getCellDate(0, col); 5957 htmls.push(this.renderHeadDateCellHtml(date)); 5958 } 5959 5960 return htmls.join(''); 5961 }, 5962 5963 5964 // TODO: when internalApiVersion, accept an object for HTML attributes 5965 // (colspan should be no different) 5966 renderHeadDateCellHtml: function(date, colspan, otherAttrs) { 5967 var view = this.view; 5968 var classNames = [ 5969 'fc-day-header', 5970 view.widgetHeaderClass 5971 ]; 5972 5973 // if only one row of days, the classNames on the header can represent the specific days beneath 5974 if (this.rowCnt === 1) { 5975 classNames = classNames.concat( 5976 // includes the day-of-week class 5977 // noThemeHighlight=true (don't highlight the header) 5978 this.getDayClasses(date, true) 5979 ); 5980 } 5981 else { 5982 classNames.push('fc-' + dayIDs[date.day()]); // only add the day-of-week class 5983 } 5984 5985 return '' + 5986 '<th class="' + classNames.join(' ') + '"' + 5987 (this.rowCnt === 1 ? 5988 ' data-date="' + date.format('YYYY-MM-DD') + '"' : 5989 '') + 5990 (colspan > 1 ? 5991 ' colspan="' + colspan + '"' : 5992 '') + 5993 (otherAttrs ? 5994 ' ' + otherAttrs : 5995 '') + 5996 '>' + 5997 // don't make a link if the heading could represent multiple days, or if there's only one day (forceOff) 5998 view.buildGotoAnchorHtml( 5999 { date: date, forceOff: this.rowCnt > 1 || this.colCnt === 1 }, 6000 htmlEscape(date.format(this.colHeadFormat)) // inner HTML 6001 ) + 6002 '</th>'; 6003 }, 6004 6005
6006 /* Background Rendering 6007 ------------------------------------------------------------------------------------------------------------------*/ 6008 6009 6010 renderBgTrHtml: function(row) { 6011 return '' + 6012 '<tr>' + 6013 (this.isRTL ? '' : this.renderBgIntroHtml(row)) + 6014 this.renderBgCellsHtml(row) + 6015 (this.isRTL ? this.renderBgIntroHtml(row) : '') + 6016 '</tr>'; 6017 }, 6018 6019 6020 renderBgIntroHtml: function(row) { 6021 return this.renderIntroHtml(); // fall back to generic 6022 }, 6023 6024 6025 renderBgCellsHtml: function(row) { 6026 var htmls = []; 6027 var col, date; 6028 6029 for (col = 0; col < this.colCnt; col++) { 6030 date = this.getCellDate(row, col); 6031 htmls.push(this.renderBgCellHtml(date)); 6032 } 6033 6034 return htmls.join(''); 6035 }, 6036 6037 6038 renderBgCellHtml: function(date, otherAttrs) { 6039 var view = this.view; 6040 var classes = this.getDayClasses(date); 6041 6042 classes.unshift('fc-day', view.widgetContentClass); 6043 6044 return '<td class="' + classes.join(' ') + '"' + 6045 ' data-date="' + date.format('YYYY-MM-DD') + '"' + // if date has a time, won't format it 6046 (otherAttrs ? 6047 ' ' + otherAttrs : 6048 '') + 6049 '></td>'; 6050 }, 6051 6052 6053 /* Generic 6054 ------------------------------------------------------------------------------------------------------------------*/ 6055 6056 6057 // Generates the default HTML intro for any row. User classes should override 6058 renderIntroHtml: function() { 6059 }, 6060 6061 6062 // TODO: a generic method for dealing with <tr>, RTL, intro 6063 // when increment internalApiVersion 6064 // wrapTr (scheduler) 6065 6066 6067 /* Utils 6068 ------------------------------------------------------------------------------------------------------------------*/ 6069 6070 6071 // Applies the generic "intro" and "outro" HTML to the given cells. 6072 // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro. 6073 bookendCells: function(trEl) { 6074 var introHtml = this.renderIntroHtml(); 6075 6076 if (introHtml) { 6077 if (this.isRTL) { 6078 trEl.append(introHtml); 6079 } 6080 else { 6081 trEl.prepend(introHtml); 6082 } 6083 } 6084 } 6085 6086}; 6087 6088;; 6089 6090/* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week. 6091----------------------------------------------------------------------------------------------------------------------*/ 6092 6093var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { 6094 6095 numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal 6096 bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid 6097
6098 rowEls: null, // set of fake row elements 6099 cellEls: null, // set of whole-day elements comprising the row's background 6100 helperEls: null, // set of cell skeleton elements for rendering the mock event "helper" 6101 6102 rowCoordCache: null, 6103 colCoordCache: null, 6104 6105 6106 // Renders the rows and columns into the component's `this.el`, which should already be assigned. 6107 // isRigid determins whether the individual rows should ignore the contents and be a constant height. 6108 // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient. 6109 renderDates: function(isRigid) { 6110 var view = this.view; 6111 var rowCnt = this.rowCnt; 6112 var colCnt = this.colCnt; 6113 var html = ''; 6114 var row; 6115 var col; 6116 6117 for (row = 0; row < rowCnt; row++) { 6118 html += this.renderDayRowHtml(row, isRigid); 6119 } 6120 this.el.html(html); 6121 6122 this.rowEls = this.el.find('.fc-row'); 6123 this.cellEls = this.el.find('.fc-day'); 6124 6125 this.rowCoordCache = new CoordCache({ 6126 els: this.rowEls, 6127 isVertical: true 6128 }); 6129 this.colCoordCache = new CoordCache({ 6130 els: this.cellEls.slice(0, this.colCnt), // only the first row 6131 isHorizontal: true 6132 }); 6133 6134 // trigger dayRender with each cell's element 6135 for (row = 0; row < rowCnt; row++) { 6136 for (col = 0; col < colCnt; col++) { 6137 view.publiclyTrigger( 6138 'dayRender', 6139 null, 6140 this.getCellDate(row, col), 6141 this.getCellEl(row, col) 6142 ); 6143 } 6144 } 6145 }, 6146 6147 6148 unrenderDates: function() { 6149 this.removeSegPopover(); 6150 }, 6151 6152 6153 renderBusinessHours: function() { 6154 var segs = this.buildBusinessHourSegs(true); // wholeDay=true 6155 this.renderFill('businessHours', segs, 'bgevent'); 6156 }, 6157 6158 6159 unrenderBusinessHours: function() { 6160 this.unrenderFill('businessHours'); 6161 }, 6162 6163 6164 // Generates the HTML for a single row, which is a div that wraps a table. 6165 // `row` is the row number. 6166 renderDayRowHtml: function(row, isRigid) { 6167 var view = this.view; 6168 var classes = [ 'fc-row', 'fc-week', view.widgetContentClass ]; 6169 6170 if (isRigid) { 6171 classes.push('fc-rigid'); 6172 } 6173 6174 return '' + 6175 '<div class="' + classes.join(' ') + '">' + 6176 '<div class="fc-bg">' + 6177 '<table>' + 6178 this.renderBgTrHtml(row) + 6179 '</table>' + 6180 '</div>' + 6181 '<div class="fc-content-skeleton">' + 6182 '<table>' + 6183 (this.numbersVisible ? 6184 '<thead>' + 6185 this.renderNumberTrHtml(row) + 6186 '</thead>' : 6187 '' 6188 ) + 6189 '</table>' + 6190 '</div>' + 6191 '</div>'; 6192 }, 6193 6194 6195 /* Grid Number Rendering 6196 ------------------------------------------------------------------------------------------------------------------*/ 6197 6198 6199 renderNumberTrHtml: function(row) { 6200 return '' + 6201 '<tr>' + 6202 (this.isRTL ? '' : this.renderNumberIntroHtml(row)) + 6203 this.renderNumberCellsHtml(row) + 6204 (this.isRTL ? this.renderNumberIntroHtml(row) : '') + 6205 '</tr>'; 6206 }, 6207 6208 6209 renderNumberIntroHtml: function(row) { 6210 return this.renderIntroHtml(); 6211 }, 6212 6213 6214 renderNumberCellsHtml: function(row) { 6215 var htmls = []; 6216 var col, date; 6217 6218 for (col = 0; col < this.colCnt; col++) { 6219 date = this.getCellDate(row, col); 6220 htmls.push(this.renderNumberCellHtml(date)); 6221 } 6222 6223 return htmls.join(''); 6224 }, 6225 6226 6227 // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton. 6228 // The number row will only exist if either day numbers or week numbers are turned on. 6229 renderNumberCellHtml: function(date) { 6230 var html = ''; 6231 var classes; 6232 var weekCalcFirstDoW; 6233 6234 if (!this.view.dayNumbersVisible && !this.view.cellWeekNumbersVisible) { 6235 // no numbers in day cell (week number must be along the side) 6236 return '<td/>'; // will create an empty space above events :( 6237 } 6238 6239 classes = this.getDayClasses(date); 6240 classes.unshift('fc-day-top'); 6241 6242 if (this.view.cellWeekNumbersVisible) { 6243 // To determine the day of week number change under ISO, we cannot 6244 // rely on moment.js methods such as firstDayOfWeek() or weekday(), 6245 // because they rely on the locale's dow (possibly overridden by 6246 // our firstDay option), which may not be Monday. We cannot change 6247 // dow, because that would affect the calendar start day as well. 6248 if (date._locale._fullCalendar_weekCalc === 'ISO') { 6249 weekCalcFirstDoW = 1; // Monday by ISO 8601 definition 6250 } 6251 else { 6252 weekCalcFirstDoW = date._locale.firstDayOfWeek(); 6253 } 6254 } 6255 6256 html += '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '">'; 6257 6258 if (this.view.cellWeekNumbersVisible && (date.day() == weekCalcFirstDoW)) { 6259 html += this.view.buildGotoAnchorHtml( 6260 { date: date, type: 'week' }, 6261 { 'class': 'fc-week-number' }, 6262 date.format('w') // inner HTML 6263 ); 6264 } 6265 6266 if (this.view.dayNumbersVisible) { 6267 html += this.view.buildGotoAnchorHtml( 6268 date, 6269 { 'class': 'fc-day-number' }, 6270 date.date() // inner HTML 6271 ); 6272 } 6273 6274 html += '</td>'; 6275 6276 return html; 6277 }, 6278 6279 6280 /* Options 6281 ------------------------------------------------------------------------------------------------------------------*/ 6282 6283 6284 // Computes a default event time formatting string if `timeFormat` is not explicitly defined 6285 computeEventTimeFormat: function() { 6286 return this.view.opt('extraSmallTimeFormat'); // like "6p" or "6:30p" 6287 }, 6288 6289 6290 // Computes a default `displayEventEnd` value if one is not expliclty defined 6291 computeDisplayEventEnd: function() { 6292 return this.colCnt == 1; // we'll likely have space if there's only one day 6293 }, 6294 6295 6296 /* Dates 6297 ------------------------------------------------------------------------------------------------------------------*/ 6298 6299 6300 rangeUpdated: function() { 6301 this.updateDayTable(); 6302 }, 6303 6304
6305 // Slices up the given span (unzoned start/end with other misc data) into an array of segments 6306 spanToSegs: function(span) { 6307 var segs = this.sliceRangeByRow(span); 6308 var i, seg; 6309 6310 for (i = 0; i < segs.length; i++) { 6311 seg = segs[i]; 6312 if (this.isRTL) { 6313 seg.leftCol = this.daysPerRow - 1 - seg.lastRowDayIndex; 6314 seg.rightCol = this.daysPerRow - 1 - seg.firstRowDayIndex; 6315 } 6316 else { 6317 seg.leftCol = seg.firstRowDayIndex; 6318 seg.rightCol = seg.lastRowDayIndex; 6319 } 6320 } 6321 6322 return segs; 6323 }, 6324 6325 6326 /* Hit System 6327 ------------------------------------------------------------------------------------------------------------------*/ 6328 6329 6330 prepareHits: function() { 6331 this.colCoordCache.build(); 6332 this.rowCoordCache.build(); 6333 this.rowCoordCache.bottoms[this.rowCnt - 1] += this.bottomCoordPadding; // hack 6334 }, 6335 6336 6337 releaseHits: function() { 6338 this.colCoordCache.clear(); 6339 this.rowCoordCache.clear(); 6340 }, 6341 6342 6343 queryHit: function(leftOffset, topOffset) { 6344 if (this.colCoordCache.isLeftInBounds(leftOffset) && this.rowCoordCache.isTopInBounds(topOffset)) { 6345 var col = this.colCoordCache.getHorizontalIndex(leftOffset); 6346 var row = this.rowCoordCache.getVerticalIndex(topOffset); 6347 6348 if (row != null && col != null) { 6349 return this.getCellHit(row, col); 6350 } 6351 } 6352 }, 6353 6354 6355 getHitSpan: function(hit) { 6356 return this.getCellRange(hit.row, hit.col); 6357 }, 6358 6359 6360 getHitEl: function(hit) { 6361 return this.getCellEl(hit.row, hit.col); 6362 }, 6363 6364 6365 /* Cell System 6366 ------------------------------------------------------------------------------------------------------------------*/ 6367 // FYI: the first column is the leftmost column, regardless of date 6368 6369 6370 getCellHit: function(row, col) { 6371 return { 6372 row: row, 6373 col: col, 6374 component: this, // needed unfortunately :( 6375 left: this.colCoordCache.getLeftOffset(col), 6376 right: this.colCoordCache.getRightOffset(col), 6377 top: this.rowCoordCache.getTopOffset(row), 6378 bottom: this.rowCoordCache.getBottomOffset(row) 6379 }; 6380 }, 6381 6382 6383 getCellEl: function(row, col) { 6384 return this.cellEls.eq(row * this.colCnt + col); 6385 }, 6386 6387 6388 /* Event Drag Visualization 6389 ------------------------------------------------------------------------------------------------------------------*/ 6390 // TODO: move to DayGrid.event, similar to what we did with Grid's drag methods 6391 6392 6393 // Renders a visual indication of an event or external element being dragged. 6394 // `eventLocation` has zoned start and end (optional) 6395 renderDrag: function(eventLocation, seg) { 6396 6397 // always render a highlight underneath 6398 this.renderHighlight(this.eventToSpan(eventLocation)); 6399 6400 // if a segment from the same calendar but another component is being dragged, render a helper event 6401 if (seg && seg.component !== this) { 6402 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements 6403 } 6404 }, 6405 6406 6407 // Unrenders any visual indication of a hovering event 6408 unrenderDrag: function() { 6409 this.unrenderHighlight(); 6410 this.unrenderHelper(); 6411 }, 6412 6413 6414 /* Event Resize Visualization 6415 ------------------------------------------------------------------------------------------------------------------*/ 6416 6417 6418 // Renders a visual indication of an event being resized 6419 renderEventResize: function(eventLocation, seg) { 6420 this.renderHighlight(this.eventToSpan(eventLocation)); 6421 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements 6422 }, 6423 6424 6425 // Unrenders a visual indication of an event being resized 6426 unrenderEventResize: function() { 6427 this.unrenderHighlight(); 6428 this.unrenderHelper(); 6429 }, 6430 6431 6432 /* Event Helper 6433 ------------------------------------------------------------------------------------------------------------------*/ 6434 6435 6436 // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null. 6437 renderHelper: function(event, sourceSeg) { 6438 var helperNodes = []; 6439 var segs = this.eventToSegs(event); 6440 var rowStructs; 6441 6442 segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered 6443 rowStructs = this.renderSegRows(segs); 6444 6445 // inject each new event skeleton into each associated row 6446 this.rowEls.each(function(row, rowNode) { 6447 var rowEl = $(rowNode); // the .fc-row
6448 var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned 6449 var skeletonTop; 6450 6451 // If there is an original segment, match the top position. Otherwise, put it at the row's top level 6452 if (sourceSeg && sourceSeg.row === row) { 6453 skeletonTop = sourceSeg.el.position().top; 6454 } 6455 else { 6456 skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top; 6457 } 6458 6459 skeletonEl.css('top', skeletonTop) 6460 .find('table') 6461 .append(rowStructs[row].tbodyEl); 6462 6463 rowEl.append(skeletonEl); 6464 helperNodes.push(skeletonEl[0]); 6465 }); 6466 6467 return ( // must return the elements rendered 6468 this.helperEls = $(helperNodes) // array -> jQuery set 6469 ); 6470 }, 6471 6472 6473 // Unrenders any visual indication of a mock helper event 6474 unrenderHelper: function() { 6475 if (this.helperEls) { 6476 this.helperEls.remove(); 6477 this.helperEls = null; 6478 } 6479 }, 6480 6481 6482 /* Fill System (highlight, background events, business hours) 6483 ------------------------------------------------------------------------------------------------------------------*/ 6484 6485 6486 fillSegTag: 'td', // override the default tag name 6487 6488 6489 // Renders a set of rectangles over the given segments of days. 6490 // Only returns segments that successfully rendered. 6491 renderFill: function(type, segs, className) { 6492 var nodes = []; 6493 var i, seg; 6494 var skeletonEl; 6495 6496 segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs 6497 6498 for (i = 0; i < segs.length; i++) { 6499 seg = segs[i]; 6500 skeletonEl = this.renderFillRow(type, seg, className); 6501 this.rowEls.eq(seg.row).append(skeletonEl); 6502 nodes.push(skeletonEl[0]); 6503 } 6504 6505 this.elsByFill[type] = $(nodes); 6506 6507 return segs; 6508 }, 6509 6510 6511 // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered. 6512 renderFillRow: function(type, seg, className) { 6513 var colCnt = this.colCnt; 6514 var startCol = seg.leftCol; 6515 var endCol = seg.rightCol + 1; 6516 var skeletonEl; 6517 var trEl; 6518 6519 className = className || type.toLowerCase(); 6520 6521 skeletonEl = $( 6522 '<div class="fc-' + className + '-skeleton">' + 6523 '<table><tr/></table>' + 6524 '</div>' 6525 ); 6526 trEl = skeletonEl.find('tr'); 6527 6528 if (startCol > 0) { 6529 trEl.append('<td colspan="' + startCol + '"/>'); 6530 } 6531 6532 trEl.append( 6533 seg.el.attr('colspan', endCol - startCol) 6534 ); 6535 6536 if (endCol < colCnt) { 6537 trEl.append('<td colspan="' + (colCnt - endCol) + '"/>'); 6538 } 6539 6540 this.bookendCells(trEl); 6541 6542 return skeletonEl; 6543 } 6544 6545}); 6546 6547;; 6548 6549/* Event-rendering methods for the DayGrid class 6550----------------------------------------------------------------------------------------------------------------------*/ 6551 6552DayGrid.mixin({ 6553 6554 rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering 6555 6556 6557 // Unrenders all events currently rendered on the grid 6558 unrenderEvents: function() { 6559 this.removeSegPopover(); // removes the "more.." events popover 6560 Grid.prototype.unrenderEvents.apply(this, arguments); // calls the super-method 6561 }, 6562 6563 6564 // Retrieves all rendered segment objects currently rendered on the grid 6565 getEventSegs: function() { 6566 return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method 6567 .concat(this.popoverSegs || []); // append the segments from the "more..." popover 6568 }, 6569 6570 6571 // Renders the given background event segments onto the grid 6572 renderBgSegs: function(segs) { 6573 6574 // don't render timed background events 6575 var allDaySegs = $.grep(segs, function(seg) { 6576 return seg.event.allDay; 6577 }); 6578 6579 return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method 6580 }, 6581 6582 6583 // Renders the given foreground event segments onto the grid 6584 renderFgSegs: function(segs) { 6585 var rowStructs; 6586 6587 // render an `.el` on each seg 6588 // returns a subset of the segs. segs that were actually rendered 6589 segs = this.renderFgSegEls(segs); 6590 6591 rowStructs = this.rowStructs = this.renderSegRows(segs); 6592 6593 // append to each row's content skeleton 6594 this.rowEls.each(function(i, rowNode) { 6595 $(rowNode).find('.fc-content-skeleton > table').append( 6596 rowStructs[i].tbodyEl 6597 ); 6598 }); 6599 6600 return segs; // return only the segs that were actually rendered 6601 }, 6602 6603 6604 // Unrenders all currently rendered foreground event segments 6605 unrenderFgSegs: function() { 6606 var rowStructs = this.rowStructs || []; 6607 var rowStruct; 6608 6609 while ((rowStruct = rowStructs.pop())) { 6610 rowStruct.tbodyEl.remove(); 6611 } 6612 6613 this.rowStructs = null; 6614 }, 6615 6616 6617 // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton. 6618 // Returns an array of rowStruct objects (see the bottom of `renderSegRow`). 6619 // PRECONDITION: each segment shoud already have a rendered and assigned `.el` 6620 renderSegRows: function(segs) { 6621 var rowStructs = []; 6622 var segRows; 6623 var row; 6624 6625 segRows = this.groupSegRows(segs); // group into nested arrays 6626 6627 // iterate each row of segment groupings 6628 for (row = 0; row < segRows.length; row++) { 6629 rowStructs.push( 6630 this.renderSegRow(row, segRows[row]) 6631 ); 6632 } 6633 6634 return rowStructs; 6635 }, 6636 6637 6638 // Builds the HTML to be used for the default element for an individual segment 6639 fgSegHtml: function(seg, disableResizing) { 6640 var view = this.view; 6641 var event = seg.event; 6642 var isDraggable = view.isEventDraggable(event); 6643 var isResizableFromStart = !disableResizing && event.allDay && 6644 seg.isStart && view.isEventResizableFromStart(event); 6645 var isResizableFromEnd = !disableResizing && event.allDay && 6646 seg.isEnd && view.isEventResizableFromEnd(event);
6647 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); 6648 var skinCss = cssToStr(this.getSegSkinCss(seg)); 6649 var timeHtml = ''; 6650 var timeText; 6651 var titleHtml; 6652 6653 classes.unshift('fc-day-grid-event', 'fc-h-event'); 6654 6655 // Only display a timed events time if it is the starting segment 6656 if (seg.isStart) { 6657 timeText = this.getEventTimeText(event); 6658 if (timeText) { 6659 timeHtml = '<span class="fc-time">' + htmlEscape(timeText) + '</span>'; 6660 } 6661 } 6662 6663 titleHtml = 6664 '<span class="fc-title">' + 6665 (htmlEscape(event.title || '') || ' ') + // we always want one line of height 6666 '</span>'; 6667 6668 return '<a class="' + classes.join(' ') + '"' + 6669 (event.url ? 6670 ' href="' + htmlEscape(event.url) + '"' : 6671 '' 6672 ) + 6673 (skinCss ? 6674 ' style="' + skinCss + '"' : 6675 '' 6676 ) + 6677 '>' + 6678 '<div class="fc-content">' + 6679 (this.isRTL ? 6680 titleHtml + ' ' + timeHtml : // put a natural space in between 6681 timeHtml + ' ' + titleHtml // 6682 ) + 6683 '</div>' + 6684 (isResizableFromStart ? 6685 '<div class="fc-resizer fc-start-resizer" />' : 6686 '' 6687 ) + 6688 (isResizableFromEnd ? 6689 '<div class="fc-resizer fc-end-resizer" />' : 6690 '' 6691 ) + 6692 '</a>'; 6693 }, 6694 6695 6696 // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains 6697 // the segments. Returns object with a bunch of internal data about how the render was calculated. 6698 // NOTE: modifies rowSegs 6699 renderSegRow: function(row, rowSegs) { 6700 var colCnt = this.colCnt; 6701 var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels 6702 var levelCnt = Math.max(1, segLevels.length); // ensure at least one level 6703 var tbody = $('<tbody/>'); 6704 var segMatrix = []; // lookup for which segments are rendered into which level+col cells 6705 var cellMatrix = []; // lookup for all <td> elements of the level+col matrix 6706 var loneCellMatrix = []; // lookup for <td> elements that only take up a single column 6707 var i, levelSegs; 6708 var col; 6709 var tr; 6710 var j, seg; 6711 var td; 6712 6713 // populates empty cells from the current column (`col`) to `endCol` 6714 function emptyCellsUntil(endCol) { 6715 while (col < endCol) { 6716 // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell 6717 td = (loneCellMatrix[i - 1] || [])[col]; 6718 if (td) { 6719 td.attr( 6720 'rowspan', 6721 parseInt(td.attr('rowspan') || 1, 10) + 1 6722 ); 6723 } 6724 else { 6725 td = $('<td/>'); 6726 tr.append(td); 6727 } 6728 cellMatrix[i][col] = td; 6729 loneCellMatrix[i][col] = td; 6730 col++; 6731 } 6732 } 6733 6734 for (i = 0; i < levelCnt; i++) { // iterate through all levels 6735 levelSegs = segLevels[i]; 6736 col = 0; 6737 tr = $('<tr/>'); 6738 6739 segMatrix.push([]); 6740 cellMatrix.push([]); 6741 loneCellMatrix.push([]); 6742 6743 // levelCnt might be 1 even though there are no actual levels. protect against this. 6744 // this single empty row is useful for styling. 6745 if (levelSegs) { 6746 for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level 6747 seg = levelSegs[j]; 6748 6749 emptyCellsUntil(seg.leftCol); 6750 6751 // create a container that occupies or more columns. append the event element. 6752 td = $('<td class="fc-event-container"/>').append(seg.el); 6753 if (seg.leftCol != seg.rightCol) { 6754 td.attr('colspan', seg.rightCol - seg.leftCol + 1); 6755 } 6756 else { // a single-column segment 6757 loneCellMatrix[i][col] = td; 6758 } 6759 6760 while (col <= seg.rightCol) { 6761 cellMatrix[i][col] = td; 6762 segMatrix[i][col] = seg; 6763 col++; 6764 } 6765 6766 tr.append(td); 6767 } 6768 } 6769 6770 emptyCellsUntil(colCnt); // finish off the row 6771 this.bookendCells(tr); 6772 tbody.append(tr); 6773 } 6774 6775 return { // a "rowStruct" 6776 row: row, // the row number 6777 tbodyEl: tbody, 6778 cellMatrix: cellMatrix, 6779 segMatrix: segMatrix, 6780 segLevels: segLevels, 6781 segs: rowSegs 6782 }; 6783 }, 6784 6785 6786 // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels. 6787 // NOTE: modifies segs 6788 buildSegLevels: function(segs) { 6789 var levels = []; 6790 var i, seg; 6791 var j; 6792 6793 // Give preference to elements with certain criteria, so they have 6794 // a chance to be closer to the top. 6795 this.sortEventSegs(segs); 6796 6797 for (i = 0; i < segs.length; i++) { 6798 seg = segs[i]; 6799 6800 // loop through levels, starting with the topmost, until the segment doesn't collide with other segments 6801 for (j = 0; j < levels.length; j++) {
6802 if (!isDaySegCollision(seg, levels[j])) { 6803 break; 6804 } 6805 } 6806 // `j` now holds the desired subrow index 6807 seg.level = j; 6808 6809 // create new level array if needed and append segment 6810 (levels[j] || (levels[j] = [])).push(seg); 6811 } 6812 6813 // order segments left-to-right. very important if calendar is RTL 6814 for (j = 0; j < levels.length; j++) { 6815 levels[j].sort(compareDaySegCols); 6816 } 6817 6818 return levels; 6819 }, 6820 6821 6822 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row 6823 groupSegRows: function(segs) { 6824 var segRows = []; 6825 var i; 6826 6827 for (i = 0; i < this.rowCnt; i++) { 6828 segRows.push([]); 6829 } 6830 6831 for (i = 0; i < segs.length; i++) { 6832 segRows[segs[i].row].push(segs[i]); 6833 } 6834 6835 return segRows; 6836 } 6837 6838}); 6839 6840 6841// Computes whether two segments' columns collide. They are assumed to be in the same row. 6842function isDaySegCollision(seg, otherSegs) { 6843 var i, otherSeg; 6844 6845 for (i = 0; i < otherSegs.length; i++) { 6846 otherSeg = otherSegs[i]; 6847 6848 if ( 6849 otherSeg.leftCol <= seg.rightCol && 6850 otherSeg.rightCol >= seg.leftCol 6851 ) { 6852 return true; 6853 } 6854 } 6855 6856 return false; 6857} 6858 6859 6860// A cmp function for determining the leftmost event 6861function compareDaySegCols(a, b) { 6862 return a.leftCol - b.leftCol; 6863} 6864 6865;; 6866 6867/* Methods relate to limiting the number events for a given day on a DayGrid 6868----------------------------------------------------------------------------------------------------------------------*/ 6869// NOTE: all the segs being passed around in here are foreground segs 6870 6871DayGrid.mixin({ 6872 6873 segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible 6874 popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible 6875 6876 6877 removeSegPopover: function() { 6878 if (this.segPopover) { 6879 this.segPopover.hide(); // in handler, will call segPopover's removeElement 6880 } 6881 }, 6882 6883 6884 // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid. 6885 // `levelLimit` can be false (don't limit), a number, or true (should be computed). 6886 limitRows: function(levelLimit) { 6887 var rowStructs = this.rowStructs || []; 6888 var row; // row # 6889 var rowLevelLimit; 6890 6891 for (row = 0; row < rowStructs.length; row++) { 6892 this.unlimitRow(row); 6893 6894 if (!levelLimit) { 6895 rowLevelLimit = false; 6896 } 6897 else if (typeof levelLimit === 'number') { 6898 rowLevelLimit = levelLimit; 6899 } 6900 else { 6901 rowLevelLimit = this.computeRowLevelLimit(row); 6902 } 6903 6904 if (rowLevelLimit !== false) { 6905 this.limitRow(row, rowLevelLimit); 6906 } 6907 } 6908 }, 6909 6910 6911 // Computes the number of levels a row will accomodate without going outside its bounds. 6912 // Assumes the row is "rigid" (maintains a constant height regardless of what is inside). 6913 // `row` is the row number. 6914 computeRowLevelLimit: function(row) { 6915 var rowEl = this.rowEls.eq(row); // the containing "fake" row div 6916 var rowHeight = rowEl.height(); // TODO: cache somehow? 6917 var trEls = this.rowStructs[row].tbodyEl.children(); 6918 var i, trEl; 6919 var trHeight; 6920 6921 function iterInnerHeights(i, childNode) { 6922 trHeight = Math.max(trHeight, $(childNode).outerHeight()); 6923 } 6924 6925 // Reveal one level <tr> at a time and stop when we find one out of bounds 6926 for (i = 0; i < trEls.length; i++) { 6927 trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal) 6928 6929 // with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell, 6930 // so instead, find the tallest inner content element. 6931 trHeight = 0; 6932 trEl.find('> td > :first-child').each(iterInnerHeights); 6933 6934 if (trEl.position().top + trHeight > rowHeight) { 6935 return i; 6936 } 6937 } 6938 6939 return false; // should not limit at all 6940 }, 6941 6942 6943 // Limits the given grid row to the maximum number of levels and injects "more" links if necessary. 6944 // `row` is the row number. 6945 // `levelLimit` is a number for the maximum (inclusive) number of levels allowed. 6946 limitRow: function(row, levelLimit) { 6947 var _this = this; 6948 var rowStruct = this.rowStructs[row]; 6949 var moreNodes = []; // array of "more" <a> links and <td> DOM nodes 6950 var col = 0; // col #, left-to-right (not chronologically) 6951 var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right 6952 var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row 6953 var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes 6954 var i, seg; 6955 var segsBelow; // array of segment objects below `seg` in the current `col` 6956 var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies 6957 var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column) 6958 var td, rowspan; 6959 var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell 6960 var j; 6961 var moreTd, moreWrap, moreLink; 6962 6963 // Iterates through empty level cells and places "more" links inside if need be 6964 function emptyCellsUntil(endCol) { // goes from current `col` to `endCol` 6965 while (col < endCol) { 6966 segsBelow = _this.getCellSegs(row, col, levelLimit); 6967 if (segsBelow.length) { 6968 td = cellMatrix[levelLimit - 1][col]; 6969 moreLink = _this.renderMoreLink(row, col, segsBelow); 6970 moreWrap = $('<div/>').append(moreLink); 6971 td.append(moreWrap); 6972 moreNodes.push(moreWrap[0]); 6973 } 6974 col++; 6975 } 6976 } 6977 6978 if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit? 6979 levelSegs = rowStruct.segLevels[levelLimit - 1]; 6980 cellMatrix = rowStruct.cellMatrix; 6981 6982 limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit 6983 .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array 6984 6985 // iterate though segments in the last allowable level 6986 for (i = 0; i < levelSegs.length; i++) { 6987 seg = levelSegs[i]; 6988 emptyCellsUntil(seg.leftCol); // process empty cells before the segment 6989 6990 // determine *all* segments below `seg` that occupy the same columns 6991 colSegsBelow = []; 6992 totalSegsBelow = 0; 6993 while (col <= seg.rightCol) { 6994 segsBelow = this.getCellSegs(row, col, levelLimit); 6995 colSegsBelow.push(segsBelow); 6996 totalSegsBelow += segsBelow.length; 6997 col++; 6998 } 6999 7000 if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links? 7001 td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell 7002 rowspan = td.attr('rowspan') || 1; 7003 segMoreNodes = []; 7004 7005 // make a replacement <td> for each column the segment occupies. will be one for each colspan 7006 for (j = 0; j < colSegsBelow.length; j++) { 7007 moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan); 7008 segsBelow = colSegsBelow[j]; 7009 moreLink = this.renderMoreLink( 7010 row, 7011 seg.leftCol + j, 7012 [ seg ].concat(segsBelow) // count seg as hidden too 7013 ); 7014 moreWrap = $('<div/>').append(moreLink); 7015 moreTd.append(moreWrap); 7016 segMoreNodes.push(moreTd[0]); 7017 moreNodes.push(moreTd[0]); 7018 } 7019 7020 td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements 7021 limitedNodes.push(td[0]); 7022 } 7023 } 7024 7025 emptyCellsUntil(this.colCnt); // finish off the level 7026 rowStruct.moreEls = $(moreNodes); // for easy undoing later 7027 rowStruct.limitedEls = $(limitedNodes); // for easy undoing later 7028 } 7029 }, 7030 7031 7032 // Reveals all levels and removes all "more"-related elements for a grid's row. 7033 // `row` is a row number. 7034 unlimitRow: function(row) { 7035 var rowStruct = this.rowStructs[row]; 7036 7037 if (rowStruct.moreEls) { 7038 rowStruct.moreEls.remove(); 7039 rowStruct.moreEls = null; 7040 } 7041 7042 if (rowStruct.limitedEls) { 7043 rowStruct.limitedEls.removeClass('fc-limited'); 7044 rowStruct.limitedEls = null; 7045 } 7046 }, 7047 7048 7049 // Renders an <a> element that represents hidden event element for a cell. 7050 // Responsible for attaching click handler as well. 7051 renderMoreLink: function(row, col, hiddenSegs) { 7052 var _this = this; 7053 var view = this.view; 7054 7055 return $('<a class="fc-more"/>') 7056 .text( 7057 this.getMoreLinkText(hiddenSegs.length) 7058 ) 7059 .on('click', function(ev) { 7060 var clickOption = view.opt('eventLimitClick'); 7061 var date = _this.getCellDate(row, col); 7062 var moreEl = $(this); 7063 var dayEl = _this.getCellEl(row, col); 7064 var allSegs = _this.getCellSegs(row, col); 7065 7066 // rescope the segments to be within the cell's date
7067 var reslicedAllSegs = _this.resliceDaySegs(allSegs, date); 7068 var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date); 7069 7070 if (typeof clickOption === 'function') { 7071 // the returned value can be an atomic option 7072 clickOption = view.publiclyTrigger('eventLimitClick', null, { 7073 date: date, 7074 dayEl: dayEl, 7075 moreEl: moreEl, 7076 segs: reslicedAllSegs, 7077 hiddenSegs: reslicedHiddenSegs 7078 }, ev); 7079 } 7080 7081 if (clickOption === 'popover') { 7082 _this.showSegPopover(row, col, moreEl, reslicedAllSegs); 7083 } 7084 else if (typeof clickOption === 'string') { // a view name 7085 view.calendar.zoomTo(date, clickOption); 7086 } 7087 }); 7088 }, 7089 7090 7091 // Reveals the popover that displays all events within a cell 7092 showSegPopover: function(row, col, moreLink, segs) { 7093 var _this = this; 7094 var view = this.view; 7095 var moreWrap = moreLink.parent(); // the <div> wrapper around the <a> 7096 var topEl; // the element we want to match the top coordinate of 7097 var options; 7098 7099 if (this.rowCnt == 1) { 7100 topEl = view.el; // will cause the popover to cover any sort of header 7101 } 7102 else { 7103 topEl = this.rowEls.eq(row); // will align with top of row 7104 } 7105 7106 options = { 7107 className: 'fc-more-popover', 7108 content: this.renderSegPopoverContent(row, col, segs), 7109 parentEl: this.view.el, // attach to root of view. guarantees outside of scrollbars. 7110 top: topEl.offset().top, 7111 autoHide: true, // when the user clicks elsewhere, hide the popover 7112 viewportConstrain: view.opt('popoverViewportConstrain'), 7113 hide: function() { 7114 // kill everything when the popover is hidden 7115 // notify events to be removed 7116 if (_this.popoverSegs) { 7117 var seg; 7118 for (var i = 0; i < _this.popoverSegs.length; ++i) { 7119 seg = _this.popoverSegs[i]; 7120 view.publiclyTrigger('eventDestroy', seg.event, seg.event, seg.el); 7121 } 7122 } 7123 _this.segPopover.removeElement(); 7124 _this.segPopover = null; 7125 _this.popoverSegs = null; 7126 } 7127 }; 7128 7129 // Determine horizontal coordinate. 7130 // We use the moreWrap instead of the <td> to avoid border confusion. 7131 if (this.isRTL) { 7132 options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border 7133 } 7134 else { 7135 options.left = moreWrap.offset().left - 1; // -1 to be over cell border 7136 } 7137 7138 this.segPopover = new Popover(options); 7139 this.segPopover.show(); 7140 7141 // the popover doesn't live within the grid's container element, and thus won't get the event 7142 // delegated-handlers for free. attach event-related handlers to the popover. 7143 this.bindSegHandlersToEl(this.segPopover.el); 7144 }, 7145 7146 7147 // Builds the inner DOM contents of the segment popover 7148 renderSegPopoverContent: function(row, col, segs) { 7149 var view = this.view; 7150 var isTheme = view.opt('theme'); 7151 var title = this.getCellDate(row, col).format(view.opt('dayPopoverFormat')); 7152 var content = $( 7153 '<div class="fc-header ' + view.widgetHeaderClass + '">' + 7154 '<span class="fc-close ' + 7155 (isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') + 7156 '"></span>' + 7157 '<span class="fc-title">' + 7158 htmlEscape(title) + 7159 '</span>' + 7160 '<div class="fc-clear"/>' + 7161 '</div>' + 7162 '<div class="fc-body ' + view.widgetContentClass + '">' + 7163 '<div class="fc-event-container"></div>' + 7164 '</div>' 7165 ); 7166 var segContainer = content.find('.fc-event-container'); 7167 var i; 7168 7169 // render each seg's `el` and only return the visible segs 7170 segs = this.renderFgSegEls(segs, true); // disableResizing=true 7171 this.popoverSegs = segs; 7172 7173 for (i = 0; i < segs.length; i++) { 7174 7175 // because segments in the popover are not part of a grid coordinate system, provide a hint to any 7176 // grids that want to do drag-n-drop about which cell it came from 7177 this.hitsNeeded(); 7178 segs[i].hit = this.getCellHit(row, col); 7179 this.hitsNotNeeded(); 7180 7181 segContainer.append(segs[i].el); 7182 } 7183 7184 return content; 7185 }, 7186 7187 7188 // Given the events within an array of segment objects, reslice them to be in a single day 7189 resliceDaySegs: function(segs, dayDate) { 7190 7191 // build an array of the original events 7192 var events = $.map(segs, function(seg) { 7193 return seg.event; 7194 }); 7195 7196 var dayStart = dayDate.clone(); 7197 var dayEnd = dayStart.clone().add(1, 'days'); 7198 var dayRange = { start: dayStart, end: dayEnd }; 7199 7200 // slice the events with a custom slicing function 7201 segs = this.eventsToSegs( 7202 events, 7203 function(range) { 7204 var seg = intersectRanges(range, dayRange); // undefind if no intersection 7205 return seg ? [ seg ] : []; // must return an array of segments 7206 } 7207 ); 7208 7209 // force an order because eventsToSegs doesn't guarantee one 7210 this.sortEventSegs(segs); 7211 7212 return segs; 7213 }, 7214 7215 7216 // Generates the text that should be inside a "more" link, given the number of events it represents 7217 getMoreLinkText: function(num) { 7218 var opt = this.view.opt('eventLimitText'); 7219 7220 if (typeof opt === 'function') { 7221 return opt(num); 7222 } 7223 else { 7224 return '+' + num + ' ' + opt; 7225 } 7226 }, 7227 7228 7229 // Returns segments within a given cell. 7230 // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs. 7231 getCellSegs: function(row, col, startLevel) { 7232 var segMatrix = this.rowStructs[row].segMatrix; 7233 var level = startLevel || 0; 7234 var segs = []; 7235 var seg; 7236 7237 while (level < segMatrix.length) { 7238 seg = segMatrix[level][col]; 7239 if (seg) { 7240 segs.push(seg); 7241 } 7242 level++; 7243 } 7244 7245 return segs; 7246 } 7247 7248}); 7249 7250;; 7251 7252/* A component that renders one or more columns of vertical time slots 7253----------------------------------------------------------------------------------------------------------------------*/ 7254// We mixin DayTable, even though there is only a single row of days 7255 7256var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { 7257 7258 slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines 7259 snapDuration: null, // granularity of time for dragging and selecting 7260 snapsPerSlot: null, 7261 minTime: null, // Duration object that denotes the first visible time of any given day 7262 maxTime: null, // Duration object that denotes the exclusive visible end time of any given day 7263 labelFormat: null, // formatting string for times running along vertical axis 7264 labelInterval: null, // duration of how often a label should be displayed for a slot 7265 7266 colEls: null, // cells elements in the day-row background 7267 slatContainerEl: null, // div that wraps all the slat rows 7268 slatEls: null, // elements running horizontally across all columns 7269 nowIndicatorEls: null, 7270 7271 colCoordCache: null, 7272 slatCoordCache: null, 7273 7274 7275 constructor: function() { 7276 Grid.apply(this, arguments); // call the super-constructor 7277 7278 this.processOptions(); 7279 }, 7280 7281 7282 // Renders the time grid into `this.el`, which should already be assigned. 7283 // Relies on the view's colCnt. In the future, this component should probably be self-sufficient. 7284 renderDates: function() { 7285 this.el.html(this.renderHtml()); 7286 this.colEls = this.el.find('.fc-day');
7287 this.slatContainerEl = this.el.find('.fc-slats'); 7288 this.slatEls = this.slatContainerEl.find('tr'); 7289 7290 this.colCoordCache = new CoordCache({ 7291 els: this.colEls, 7292 isHorizontal: true 7293 }); 7294 this.slatCoordCache = new CoordCache({ 7295 els: this.slatEls, 7296 isVertical: true 7297 }); 7298 7299 this.renderContentSkeleton(); 7300 }, 7301 7302 7303 // Renders the basic HTML skeleton for the grid 7304 renderHtml: function() { 7305 return '' + 7306 '<div class="fc-bg">' + 7307 '<table>' + 7308 this.renderBgTrHtml(0) + // row=0 7309 '</table>' + 7310 '</div>' + 7311 '<div class="fc-slats">' + 7312 '<table>' + 7313 this.renderSlatRowHtml() + 7314 '</table>' + 7315 '</div>'; 7316 }, 7317 7318 7319 // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL. 7320 renderSlatRowHtml: function() { 7321 var view = this.view; 7322 var isRTL = this.isRTL; 7323 var html = ''; 7324 var slotTime = moment.duration(+this.minTime); // wish there was .clone() for durations 7325 var slotDate; // will be on the view's first day, but we only care about its time 7326 var isLabeled; 7327 var axisHtml; 7328 7329 // Calculate the time for each slot 7330 while (slotTime < this.maxTime) { 7331 slotDate = this.start.clone().time(slotTime); 7332 isLabeled = isInt(divideDurationByDuration(slotTime, this.labelInterval)); 7333 7334 axisHtml = 7335 '<td class="fc-axis fc-time ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' + 7336 (isLabeled ? 7337 '<span>' + // for matchCellWidths 7338 htmlEscape(slotDate.format(this.labelFormat)) + 7339 '</span>' : 7340 '' 7341 ) + 7342 '</td>'; 7343 7344 html += 7345 '<tr data-time="' + slotDate.format('HH:mm:ss') + '"' + 7346 (isLabeled ? '' : ' class="fc-minor"') + 7347 '>' + 7348 (!isRTL ? axisHtml : '') + 7349 '<td class="' + view.widgetContentClass + '"/>' + 7350 (isRTL ? axisHtml : '') + 7351 "</tr>"; 7352 7353 slotTime.add(this.slotDuration); 7354 } 7355 7356 return html; 7357 }, 7358 7359 7360 /* Options 7361 ------------------------------------------------------------------------------------------------------------------*/ 7362 7363 7364 // Parses various options into properties of this object 7365 processOptions: function() { 7366 var view = this.view; 7367 var slotDuration = view.opt('slotDuration'); 7368 var snapDuration = view.opt('snapDuration'); 7369 var input; 7370 7371 slotDuration = moment.duration(slotDuration); 7372 snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration; 7373 7374 this.slotDuration = slotDuration; 7375 this.snapDuration = snapDuration; 7376 this.snapsPerSlot = slotDuration / snapDuration; // TODO: ensure an integer multiple? 7377 7378 this.minResizeDuration = snapDuration; // hack 7379 7380 this.minTime = moment.duration(view.opt('minTime')); 7381 this.maxTime = moment.duration(view.opt('maxTime')); 7382 7383 // might be an array value (for TimelineView). 7384 // if so, getting the most granular entry (the last one probably). 7385 input = view.opt('slotLabelFormat'); 7386 if ($.isArray(input)) { 7387 input = input[input.length - 1]; 7388 } 7389 7390 this.labelFormat = 7391 input || 7392 view.opt('smallTimeFormat'); // the computed default 7393 7394 input = view.opt('slotLabelInterval'); 7395 this.labelInterval = input ? 7396 moment.duration(input) : 7397 this.computeLabelInterval(slotDuration); 7398 }, 7399 7400 7401 // Computes an automatic value for slotLabelInterval 7402 computeLabelInterval: function(slotDuration) { 7403 var i; 7404 var labelInterval; 7405 var slotsPerLabel; 7406 7407 // find the smallest stock label interval that results in more than one slots-per-label 7408 for (i = AGENDA_STOCK_SUB_DURATIONS.length - 1; i >= 0; i--) { 7409 labelInterval = moment.duration(AGENDA_STOCK_SUB_DURATIONS[i]); 7410 slotsPerLabel = divideDurationByDuration(labelInterval, slotDuration); 7411 if (isInt(slotsPerLabel) && slotsPerLabel > 1) { 7412 return labelInterval; 7413 } 7414 } 7415 7416 return moment.duration(slotDuration); // fall back. clone 7417 }, 7418 7419 7420 // Computes a default event time formatting string if `timeFormat` is not explicitly defined 7421 computeEventTimeFormat: function() { 7422 return this.view.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM) 7423 }, 7424 7425 7426 // Computes a default `displayEventEnd` value if one is not expliclty defined 7427 computeDisplayEventEnd: function() { 7428 return true; 7429 }, 7430 7431 7432 /* Hit System 7433 ------------------------------------------------------------------------------------------------------------------*/ 7434 7435 7436 prepareHits: function() { 7437 this.colCoordCache.build();
7438 this.slatCoordCache.build(); 7439 }, 7440 7441 7442 releaseHits: function() { 7443 this.colCoordCache.clear(); 7444 // NOTE: don't clear slatCoordCache because we rely on it for computeTimeTop 7445 }, 7446 7447 7448 queryHit: function(leftOffset, topOffset) { 7449 var snapsPerSlot = this.snapsPerSlot; 7450 var colCoordCache = this.colCoordCache; 7451 var slatCoordCache = this.slatCoordCache; 7452 7453 if (colCoordCache.isLeftInBounds(leftOffset) && slatCoordCache.isTopInBounds(topOffset)) { 7454 var colIndex = colCoordCache.getHorizontalIndex(leftOffset); 7455 var slatIndex = slatCoordCache.getVerticalIndex(topOffset); 7456 7457 if (colIndex != null && slatIndex != null) { 7458 var slatTop = slatCoordCache.getTopOffset(slatIndex); 7459 var slatHeight = slatCoordCache.getHeight(slatIndex); 7460 var partial = (topOffset - slatTop) / slatHeight; // floating point number between 0 and 1 7461 var localSnapIndex = Math.floor(partial * snapsPerSlot); // the snap # relative to start of slat 7462 var snapIndex = slatIndex * snapsPerSlot + localSnapIndex; 7463 var snapTop = slatTop + (localSnapIndex / snapsPerSlot) * slatHeight; 7464 var snapBottom = slatTop + ((localSnapIndex + 1) / snapsPerSlot) * slatHeight; 7465 7466 return { 7467 col: colIndex, 7468 snap: snapIndex, 7469 component: this, // needed unfortunately :( 7470 left: colCoordCache.getLeftOffset(colIndex), 7471 right: colCoordCache.getRightOffset(colIndex), 7472 top: snapTop, 7473 bottom: snapBottom 7474 }; 7475 } 7476 } 7477 }, 7478 7479 7480 getHitSpan: function(hit) { 7481 var start = this.getCellDate(0, hit.col); // row=0 7482 var time = this.computeSnapTime(hit.snap); // pass in the snap-index 7483 var end; 7484 7485 start.time(time); 7486 end = start.clone().add(this.snapDuration); 7487 7488 return { start: start, end: end }; 7489 }, 7490 7491 7492 getHitEl: function(hit) { 7493 return this.colEls.eq(hit.col); 7494 }, 7495 7496 7497 /* Dates 7498 ------------------------------------------------------------------------------------------------------------------*/ 7499 7500 7501 rangeUpdated: function() { 7502 this.updateDayTable(); 7503 }, 7504 7505 7506 // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day 7507 computeSnapTime: function(snapIndex) { 7508 return moment.duration(this.minTime + this.snapDuration * snapIndex); 7509 }, 7510 7511 7512 // Slices up the given span (unzoned start/end with other misc data) into an array of segments 7513 spanToSegs: function(span) { 7514 var segs = this.sliceRangeByTimes(span); 7515 var i; 7516 7517 for (i = 0; i < segs.length; i++) { 7518 if (this.isRTL) { 7519 segs[i].col = this.daysPerRow - 1 - segs[i].dayIndex; 7520 } 7521 else { 7522 segs[i].col = segs[i].dayIndex; 7523 } 7524 } 7525 7526 return segs; 7527 }, 7528 7529 7530 sliceRangeByTimes: function(range) { 7531 var segs = []; 7532 var seg; 7533 var dayIndex; 7534 var dayDate; 7535 var dayRange; 7536 7537 for (dayIndex = 0; dayIndex < this.daysPerRow; dayIndex++) { 7538 dayDate = this.dayDates[dayIndex].clone(); // TODO: better API for this? 7539 dayRange = { 7540 start: dayDate.clone().time(this.minTime), 7541 end: dayDate.clone().time(this.maxTime) 7542 }; 7543 seg = intersectRanges(range, dayRange); // both will be ambig timezone 7544 if (seg) { 7545 seg.dayIndex = dayIndex; 7546 segs.push(seg); 7547 } 7548 } 7549 7550 return segs; 7551 }, 7552 7553 7554 /* Coordinates 7555 ------------------------------------------------------------------------------------------------------------------*/ 7556 7557 7558 updateSize: function(isResize) { // NOT a standard Grid method 7559 this.slatCoordCache.build(); 7560 7561 if (isResize) { 7562 this.updateSegVerticals( 7563 [].concat(this.fgSegs || [], this.bgSegs || [], this.businessSegs || []) 7564 ); 7565 } 7566 }, 7567 7568 7569 getTotalSlatHeight: function() {
7570 return this.slatContainerEl.outerHeight(); 7571 }, 7572 7573 7574 // Computes the top coordinate, relative to the bounds of the grid, of the given date. 7575 // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight. 7576 computeDateTop: function(date, startOfDayDate) { 7577 return this.computeTimeTop( 7578 moment.duration( 7579 date - startOfDayDate.clone().stripTime() 7580 ) 7581 ); 7582 }, 7583 7584 7585 // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration). 7586 computeTimeTop: function(time) { 7587 var len = this.slatEls.length; 7588 var slatCoverage = (time - this.minTime) / this.slotDuration; // floating-point value of # of slots covered 7589 var slatIndex; 7590 var slatRemainder; 7591 7592 // compute a floating-point number for how many slats should be progressed through. 7593 // from 0 to number of slats (inclusive) 7594 // constrained because minTime/maxTime might be customized. 7595 slatCoverage = Math.max(0, slatCoverage); 7596 slatCoverage = Math.min(len, slatCoverage); 7597 7598 // an integer index of the furthest whole slat 7599 // from 0 to number slats (*exclusive*, so len-1) 7600 slatIndex = Math.floor(slatCoverage); 7601 slatIndex = Math.min(slatIndex, len - 1); 7602 7603 // how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition. 7604 // could be 1.0 if slatCoverage is covering *all* the slots 7605 slatRemainder = slatCoverage - slatIndex; 7606
7607 return this.slatCoordCache.getTopPosition(slatIndex) + 7608 this.slatCoordCache.getHeight(slatIndex) * slatRemainder; 7609 }, 7610 7611 7612 7613 /* Event Drag Visualization 7614 ------------------------------------------------------------------------------------------------------------------*/ 7615 7616 7617 // Renders a visual indication of an event being dragged over the specified date(s). 7618 // A returned value of `true` signals that a mock "helper" event has been rendered. 7619 renderDrag: function(eventLocation, seg) { 7620 7621 if (seg) { // if there is event information for this drag, render a helper event 7622 7623 // returns mock event elements 7624 // signal that a helper has been rendered 7625 return this.renderEventLocationHelper(eventLocation, seg); 7626 } 7627 else { 7628 // otherwise, just render a highlight 7629 this.renderHighlight(this.eventToSpan(eventLocation)); 7630 } 7631 }, 7632 7633 7634 // Unrenders any visual indication of an event being dragged 7635 unrenderDrag: function() { 7636 this.unrenderHelper(); 7637 this.unrenderHighlight(); 7638 }, 7639 7640 7641 /* Event Resize Visualization 7642 ------------------------------------------------------------------------------------------------------------------*/ 7643 7644 7645 // Renders a visual indication of an event being resized 7646 renderEventResize: function(eventLocation, seg) { 7647 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements 7648 }, 7649 7650 7651 // Unrenders any visual indication of an event being resized 7652 unrenderEventResize: function() { 7653 this.unrenderHelper(); 7654 }, 7655 7656 7657 /* Event Helper 7658 ------------------------------------------------------------------------------------------------------------------*/ 7659 7660 7661 // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag) 7662 renderHelper: function(event, sourceSeg) { 7663 return this.renderHelperSegs(this.eventToSegs(event), sourceSeg); // returns mock event elements 7664 }, 7665 7666 7667 // Unrenders any mock helper event 7668 unrenderHelper: function() { 7669 this.unrenderHelperSegs(); 7670 }, 7671 7672 7673 /* Business Hours 7674 ------------------------------------------------------------------------------------------------------------------*/ 7675 7676 7677 renderBusinessHours: function() { 7678 this.renderBusinessSegs( 7679 this.buildBusinessHourSegs() 7680 ); 7681 }, 7682 7683 7684 unrenderBusinessHours: function() { 7685 this.unrenderBusinessSegs(); 7686 }, 7687 7688 7689 /* Now Indicator 7690 ------------------------------------------------------------------------------------------------------------------*/ 7691 7692 7693 getNowIndicatorUnit: function() { 7694 return 'minute'; // will refresh on the minute 7695 }, 7696 7697 7698 renderNowIndicator: function(date) { 7699 // seg system might be overkill, but it handles scenario where line needs to be rendered 7700 // more than once because of columns with the same date (resources columns for example) 7701 var segs = this.spanToSegs({ start: date, end: date }); 7702 var top = this.computeDateTop(date, date); 7703 var nodes = []; 7704 var i; 7705 7706 // render lines within the columns 7707 for (i = 0; i < segs.length; i++) { 7708 nodes.push($('<div class="fc-now-indicator fc-now-indicator-line"></div>') 7709 .css('top', top) 7710 .appendTo(this.colContainerEls.eq(segs[i].col))[0]); 7711 } 7712 7713 // render an arrow over the axis 7714 if (segs.length > 0) { // is the current time in view? 7715 nodes.push($('<div class="fc-now-indicator fc-now-indicator-arrow"></div>') 7716 .css('top', top) 7717 .appendTo(this.el.find('.fc-content-skeleton'))[0]); 7718 } 7719 7720 this.nowIndicatorEls = $(nodes); 7721 }, 7722 7723 7724 unrenderNowIndicator: function() { 7725 if (this.nowIndicatorEls) { 7726 this.nowIndicatorEls.remove(); 7727 this.nowIndicatorEls = null; 7728 } 7729 }, 7730 7731 7732 /* Selection 7733 ------------------------------------------------------------------------------------------------------------------*/ 7734 7735 7736 // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight. 7737 renderSelection: function(span) { 7738 if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered 7739 7740 // normally acceps an eventLocation, span has a start/end, which is good enough 7741 this.renderEventLocationHelper(span); 7742 } 7743 else { 7744 this.renderHighlight(span); 7745 } 7746 }, 7747 7748 7749 // Unrenders any visual indication of a selection 7750 unrenderSelection: function() { 7751 this.unrenderHelper(); 7752 this.unrenderHighlight(); 7753 }, 7754 7755 7756 /* Highlight 7757 ------------------------------------------------------------------------------------------------------------------*/ 7758 7759 7760 renderHighlight: function(span) { 7761 this.renderHighlightSegs(this.spanToSegs(span)); 7762 }, 7763 7764 7765 unrenderHighlight: function() { 7766 this.unrenderHighlightSegs(); 7767 } 7768 7769}); 7770 7771;; 7772 7773/* Methods for rendering SEGMENTS, pieces of content that live on the view 7774 ( this file is no longer just for events ) 7775----------------------------------------------------------------------------------------------------------------------*/ 7776 7777TimeGrid.mixin({ 7778 7779 colContainerEls: null, // containers for each column 7780 7781 // inner-containers for each column where different types of segs live 7782 fgContainerEls: null, 7783 bgContainerEls: null, 7784 helperContainerEls: null, 7785 highlightContainerEls: null, 7786 businessContainerEls: null, 7787 7788 // arrays of different types of displayed segments 7789 fgSegs: null, 7790 bgSegs: null, 7791 helperSegs: null, 7792 highlightSegs: null,
7793 businessSegs: null, 7794 7795 7796 // Renders the DOM that the view's content will live in 7797 renderContentSkeleton: function() { 7798 var cellHtml = ''; 7799 var i; 7800 var skeletonEl; 7801 7802 for (i = 0; i < this.colCnt; i++) { 7803 cellHtml += 7804 '<td>' + 7805 '<div class="fc-content-col">' + 7806 '<div class="fc-event-container fc-helper-container"></div>' + 7807 '<div class="fc-event-container"></div>' + 7808 '<div class="fc-highlight-container"></div>' + 7809 '<div class="fc-bgevent-container"></div>' + 7810 '<div class="fc-business-container"></div>' + 7811 '</div>' + 7812 '</td>'; 7813 } 7814 7815 skeletonEl = $( 7816 '<div class="fc-content-skeleton">' + 7817 '<table>' + 7818 '<tr>' + cellHtml + '</tr>' + 7819 '</table>' + 7820 '</div>' 7821 ); 7822 7823 this.colContainerEls = skeletonEl.find('.fc-content-col'); 7824 this.helperContainerEls = skeletonEl.find('.fc-helper-container'); 7825 this.fgContainerEls = skeletonEl.find('.fc-event-container:not(.fc-helper-container)'); 7826 this.bgContainerEls = skeletonEl.find('.fc-bgevent-container'); 7827 this.highlightContainerEls = skeletonEl.find('.fc-highlight-container'); 7828 this.businessContainerEls = skeletonEl.find('.fc-business-container'); 7829 7830 this.bookendCells(skeletonEl.find('tr')); // TODO: do this on string level 7831 this.el.append(skeletonEl); 7832 }, 7833 7834 7835 /* Foreground Events 7836 ------------------------------------------------------------------------------------------------------------------*/ 7837 7838 7839 renderFgSegs: function(segs) { 7840 segs = this.renderFgSegsIntoContainers(segs, this.fgContainerEls); 7841 this.fgSegs = segs; 7842 return segs; // needed for Grid::renderEvents 7843 }, 7844 7845 7846 unrenderFgSegs: function() { 7847 this.unrenderNamedSegs('fgSegs'); 7848 }, 7849 7850 7851 /* Foreground Helper Events 7852 ------------------------------------------------------------------------------------------------------------------*/ 7853 7854 7855 renderHelperSegs: function(segs, sourceSeg) { 7856 var helperEls = []; 7857 var i, seg; 7858 var sourceEl; 7859 7860 segs = this.renderFgSegsIntoContainers(segs, this.helperContainerEls); 7861 7862 // Try to make the segment that is in the same row as sourceSeg look the same 7863 for (i = 0; i < segs.length; i++) { 7864 seg = segs[i]; 7865 if (sourceSeg && sourceSeg.col === seg.col) { 7866 sourceEl = sourceSeg.el; 7867 seg.el.css({ 7868 left: sourceEl.css('left'), 7869 right: sourceEl.css('right'), 7870 'margin-left': sourceEl.css('margin-left'), 7871 'margin-right': sourceEl.css('margin-right') 7872 }); 7873 } 7874 helperEls.push(seg.el[0]); 7875 } 7876 7877 this.helperSegs = segs; 7878 7879 return $(helperEls); // must return rendered helpers 7880 }, 7881 7882 7883 unrenderHelperSegs: function() { 7884 this.unrenderNamedSegs('helperSegs'); 7885 }, 7886 7887 7888 /* Background Events 7889 ------------------------------------------------------------------------------------------------------------------*/ 7890 7891 7892 renderBgSegs: function(segs) { 7893 segs = this.renderFillSegEls('bgEvent', segs); // TODO: old fill system 7894 this.updateSegVerticals(segs); 7895 this.attachSegsByCol(this.groupSegsByCol(segs), this.bgContainerEls); 7896 this.bgSegs = segs; 7897 return segs; // needed for Grid::renderEvents 7898 }, 7899 7900 7901 unrenderBgSegs: function() { 7902 this.unrenderNamedSegs('bgSegs'); 7903 }, 7904 7905 7906 /* Highlight 7907 ------------------------------------------------------------------------------------------------------------------*/ 7908 7909 7910 renderHighlightSegs: function(segs) { 7911 segs = this.renderFillSegEls('highlight', segs); // TODO: old fill system 7912 this.updateSegVerticals(segs); 7913 this.attachSegsByCol(this.groupSegsByCol(segs), this.highlightContainerEls); 7914 this.highlightSegs = segs; 7915 }, 7916 7917 7918 unrenderHighlightSegs: function() { 7919 this.unrenderNamedSegs('highlightSegs'); 7920 }, 7921 7922 7923 /* Business Hours 7924 ------------------------------------------------------------------------------------------------------------------*/ 7925 7926 7927 renderBusinessSegs: function(segs) { 7928 segs = this.renderFillSegEls('businessHours', segs); // TODO: old fill system 7929 this.updateSegVerticals(segs); 7930 this.attachSegsByCol(this.groupSegsByCol(segs), this.businessContainerEls); 7931 this.businessSegs = segs; 7932 }, 7933 7934 7935 unrenderBusinessSegs: function() { 7936 this.unrenderNamedSegs('businessSegs'); 7937 }, 7938 7939 7940 /* Seg Rendering Utils 7941 ------------------------------------------------------------------------------------------------------------------*/ 7942 7943 7944 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col 7945 groupSegsByCol: function(segs) { 7946 var segsByCol = []; 7947 var i; 7948 7949 for (i = 0; i < this.colCnt; i++) { 7950 segsByCol.push([]); 7951 } 7952 7953 for (i = 0; i < segs.length; i++) { 7954 segsByCol[segs[i].col].push(segs[i]); 7955 } 7956 7957 return segsByCol; 7958 }, 7959 7960 7961 // Given segments grouped by column, insert the segments' elements into a parallel array of container 7962 // elements, each living within a column. 7963 attachSegsByCol: function(segsByCol, containerEls) { 7964 var col; 7965 var segs; 7966 var i; 7967 7968 for (col = 0; col < this.colCnt; col++) { // iterate each column grouping 7969 segs = segsByCol[col]; 7970 7971 for (i = 0; i < segs.length; i++) { 7972 containerEls.eq(col).append(segs[i].el); 7973 } 7974 } 7975 }, 7976 7977 7978 // Given the name of a property of `this` object, assumed to be an array of segments, 7979 // loops through each segment and removes from DOM. Will null-out the property afterwards. 7980 unrenderNamedSegs: function(propName) { 7981 var segs = this[propName]; 7982 var i; 7983 7984 if (segs) { 7985 for (i = 0; i < segs.length; i++) { 7986 segs[i].el.remove(); 7987 } 7988 this[propName] = null; 7989 } 7990 }, 7991 7992 7993 7994 /* Foreground Event Rendering Utils 7995 ------------------------------------------------------------------------------------------------------------------*/ 7996 7997 7998 // Given an array of foreground segments, render a DOM element for each, computes position, 7999 // and attaches to the column inner-container elements. 8000 renderFgSegsIntoContainers: function(segs, containerEls) { 8001 var segsByCol; 8002 var col; 8003 8004 segs = this.renderFgSegEls(segs); // will call fgSegHtml 8005 segsByCol = this.groupSegsByCol(segs); 8006 8007 for (col = 0; col < this.colCnt; col++) { 8008 this.updateFgSegCoords(segsByCol[col]); 8009 } 8010 8011 this.attachSegsByCol(segsByCol, containerEls); 8012 8013 return segs; 8014 }, 8015 8016 8017 // Renders the HTML for a single event segment's default rendering 8018 fgSegHtml: function(seg, disableResizing) { 8019 var view = this.view; 8020 var event = seg.event; 8021 var isDraggable = view.isEventDraggable(event); 8022 var isResizableFromStart = !disableResizing && seg.isStart && view.isEventResizableFromStart(event); 8023 var isResizableFromEnd = !disableResizing && seg.isEnd && view.isEventResizableFromEnd(event);
8024 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); 8025 var skinCss = cssToStr(this.getSegSkinCss(seg)); 8026 var timeText; 8027 var fullTimeText; // more verbose time text. for the print stylesheet 8028 var startTimeText; // just the start time text 8029 8030 classes.unshift('fc-time-grid-event', 'fc-v-event'); 8031 8032 if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day... 8033 // Don't display time text on segments that run entirely through a day. 8034 // That would appear as midnight-midnight and would look dumb. 8035 // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am) 8036 if (seg.isStart || seg.isEnd) { 8037 timeText = this.getEventTimeText(seg); 8038 fullTimeText = this.getEventTimeText(seg, 'LT'); 8039 startTimeText = this.getEventTimeText(seg, null, false); // displayEnd=false 8040 } 8041 } else { 8042 // Display the normal time text for the *event's* times 8043 timeText = this.getEventTimeText(event); 8044 fullTimeText = this.getEventTimeText(event, 'LT'); 8045 startTimeText = this.getEventTimeText(event, null, false); // displayEnd=false 8046 } 8047 8048 return '<a class="' + classes.join(' ') + '"' + 8049 (event.url ? 8050 ' href="' + htmlEscape(event.url) + '"' : 8051 '' 8052 ) + 8053 (skinCss ? 8054 ' style="' + skinCss + '"' : 8055 '' 8056 ) + 8057 '>' + 8058 '<div class="fc-content">' + 8059 (timeText ? 8060 '<div class="fc-time"' + 8061 ' data-start="' + htmlEscape(startTimeText) + '"' + 8062 ' data-full="' + htmlEscape(fullTimeText) + '"' + 8063 '>' + 8064 '<span>' + htmlEscape(timeText) + '</span>' + 8065 '</div>' : 8066 '' 8067 ) + 8068 (event.title ? 8069 '<div class="fc-title">' + 8070 htmlEscape(event.title) + 8071 '</div>' : 8072 '' 8073 ) + 8074 '</div>' + 8075 '<div class="fc-bg"/>' + 8076 /* TODO: write CSS for this 8077 (isResizableFromStart ? 8078 '<div class="fc-resizer fc-start-resizer" />' : 8079 '' 8080 ) + 8081 */ 8082 (isResizableFromEnd ? 8083 '<div class="fc-resizer fc-end-resizer" />' : 8084 '' 8085 ) + 8086 '</a>'; 8087 }, 8088 8089 8090 /* Seg Position Utils 8091 ------------------------------------------------------------------------------------------------------------------*/ 8092 8093 8094 // Refreshes the CSS top/bottom coordinates for each segment element. 8095 // Works when called after initial render, after a window resize/zoom for example. 8096 updateSegVerticals: function(segs) { 8097 this.computeSegVerticals(segs); 8098 this.assignSegVerticals(segs); 8099 }, 8100 8101 8102 // For each segment in an array, computes and assigns its top and bottom properties 8103 computeSegVerticals: function(segs) { 8104 var i, seg; 8105 8106 for (i = 0; i < segs.length; i++) { 8107 seg = segs[i]; 8108 seg.top = this.computeDateTop(seg.start, seg.start); 8109 seg.bottom = this.computeDateTop(seg.end, seg.start); 8110 } 8111 }, 8112 8113 8114 // Given segments that already have their top/bottom properties computed, applies those values to 8115 // the segments' elements. 8116 assignSegVerticals: function(segs) { 8117 var i, seg; 8118 8119 for (i = 0; i < segs.length; i++) { 8120 seg = segs[i]; 8121 seg.el.css(this.generateSegVerticalCss(seg)); 8122 } 8123 }, 8124 8125 8126 // Generates an object with CSS properties for the top/bottom coordinates of a segment element 8127 generateSegVerticalCss: function(seg) { 8128 return { 8129 top: seg.top, 8130 bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container 8131 }; 8132 }, 8133 8134 8135 /* Foreground Event Positioning Utils 8136 ------------------------------------------------------------------------------------------------------------------*/ 8137 8138 8139 // Given segments that are assumed to all live in the *same column*, 8140 // compute their verical/horizontal coordinates and assign to their elements. 8141 updateFgSegCoords: function(segs) { 8142 this.computeSegVerticals(segs); // horizontals relies on this 8143 this.computeFgSegHorizontals(segs); // compute horizontal coordinates, z-index's, and reorder the array 8144 this.assignSegVerticals(segs); 8145 this.assignFgSegHorizontals(segs); 8146 }, 8147 8148 8149 // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each. 8150 // NOTE: Also reorders the given array by date! 8151 computeFgSegHorizontals: function(segs) { 8152 var levels; 8153 var level0; 8154 var i; 8155 8156 this.sortEventSegs(segs); // order by certain criteria 8157 levels = buildSlotSegLevels(segs); 8158 computeForwardSlotSegs(levels); 8159 8160 if ((level0 = levels[0])) { 8161 8162 for (i = 0; i < level0.length; i++) { 8163 computeSlotSegPressures(level0[i]); 8164 } 8165 8166 for (i = 0; i < level0.length; i++) { 8167 this.computeFgSegForwardBack(level0[i], 0, 0); 8168 } 8169 } 8170 }, 8171 8172 8173 // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range 8174 // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and 8175 // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left. 8176 // 8177 // The segment might be part of a "series", which means consecutive segments with the same pressure 8178 // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of 8179 // segments behind this one in the current series, and `seriesBackwardCoord` is the starting 8180 // coordinate of the first segment in the series. 8181 computeFgSegForwardBack: function(seg, seriesBackwardPressure, seriesBackwardCoord) { 8182 var forwardSegs = seg.forwardSegs; 8183 var i; 8184 8185 if (seg.forwardCoord === undefined) { // not already computed 8186 8187 if (!forwardSegs.length) { 8188 8189 // if there are no forward segments, this segment should butt up against the edge 8190 seg.forwardCoord = 1; 8191 } 8192 else { 8193 8194 // sort highest pressure first 8195 this.sortForwardSegs(forwardSegs); 8196 8197 // this segment's forwardCoord will be calculated from the backwardCoord of the 8198 // highest-pressure forward segment. 8199 this.computeFgSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord); 8200 seg.forwardCoord = forwardSegs[0].backwardCoord; 8201 } 8202 8203 // calculate the backwardCoord from the forwardCoord. consider the series 8204 seg.backwardCoord = seg.forwardCoord - 8205 (seg.forwardCoord - seriesBackwardCoord) / // available width for series 8206 (seriesBackwardPressure + 1); // # of segments in the series 8207
8208 // use this segment's coordinates to computed the coordinates of the less-pressurized 8209 // forward segments 8210 for (i=0; i<forwardSegs.length; i++) { 8211 this.computeFgSegForwardBack(forwardSegs[i], 0, seg.forwardCoord); 8212 } 8213 } 8214 }, 8215 8216 8217 sortForwardSegs: function(forwardSegs) { 8218 forwardSegs.sort(proxy(this, 'compareForwardSegs')); 8219 }, 8220 8221 8222 // A cmp function for determining which forward segment to rely on more when computing coordinates. 8223 compareForwardSegs: function(seg1, seg2) { 8224 // put higher-pressure first 8225 return seg2.forwardPressure - seg1.forwardPressure || 8226 // put segments that are closer to initial edge first (and favor ones with no coords yet) 8227 (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) || 8228 // do normal sorting... 8229 this.compareEventSegs(seg1, seg2); 8230 }, 8231 8232 8233 // Given foreground event segments that have already had their position coordinates computed, 8234 // assigns position-related CSS values to their elements. 8235 assignFgSegHorizontals: function(segs) { 8236 var i, seg; 8237 8238 for (i = 0; i < segs.length; i++) { 8239 seg = segs[i]; 8240 seg.el.css(this.generateFgSegHorizontalCss(seg)); 8241 8242 // if the height is short, add a className for alternate styling 8243 if (seg.bottom - seg.top < 30) { 8244 seg.el.addClass('fc-short'); 8245 } 8246 } 8247 }, 8248 8249 8250 // Generates an object with CSS properties/values that should be applied to an event segment element. 8251 // Contains important positioning-related properties that should be applied to any event element, customized or not. 8252 generateFgSegHorizontalCss: function(seg) { 8253 var shouldOverlap = this.view.opt('slotEventOverlap'); 8254 var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point 8255 var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point 8256 var props = this.generateSegVerticalCss(seg); // get top/bottom first 8257 var left; // amount of space from left edge, a fraction of the total width 8258 var right; // amount of space from right edge, a fraction of the total width 8259 8260 if (shouldOverlap) { 8261 // double the width, but don't go beyond the maximum forward coordinate (1.0) 8262 forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2); 8263 } 8264 8265 if (this.isRTL) { 8266 left = 1 - forwardCoord; 8267 right = backwardCoord; 8268 } 8269 else { 8270 left = backwardCoord; 8271 right = 1 - forwardCoord; 8272 } 8273 8274 props.zIndex = seg.level + 1; // convert from 0-base to 1-based 8275 props.left = left * 100 + '%'; 8276 props.right = right * 100 + '%'; 8277 8278 if (shouldOverlap && seg.forwardPressure) { 8279 // add padding to the edge so that forward stacked events don't cover the resizer's icon 8280 props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width 8281 } 8282 8283 return props; 8284 } 8285 8286}); 8287 8288 8289// Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is 8290// left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date. 8291function buildSlotSegLevels(segs) { 8292 var levels = []; 8293 var i, seg; 8294 var j; 8295 8296 for (i=0; i<segs.length; i++) { 8297 seg = segs[i]; 8298 8299 // go through all the levels and stop on the first level where there are no collisions 8300 for (j=0; j<levels.length; j++) { 8301 if (!computeSlotSegCollisions(seg, levels[j]).length) { 8302 break; 8303 } 8304 } 8305 8306 seg.level = j; 8307 8308 (levels[j] || (levels[j] = [])).push(seg); 8309 } 8310 8311 return levels; 8312} 8313 8314 8315// For every segment, figure out the other segments that are in subsequent 8316// levels that also occupy the same vertical space. Accumulate in seg.forwardSegs 8317function computeForwardSlotSegs(levels) { 8318 var i, level; 8319 var j, seg; 8320 var k; 8321 8322 for (i=0; i<levels.length; i++) { 8323 level = levels[i]; 8324 8325 for (j=0; j<level.length; j++) { 8326 seg = level[j]; 8327 8328 seg.forwardSegs = []; 8329 for (k=i+1; k<levels.length; k++) { 8330 computeSlotSegCollisions(seg, levels[k], seg.forwardSegs); 8331 } 8332 } 8333 } 8334} 8335 8336 8337// Figure out which path forward (via seg.forwardSegs) results in the longest path until 8338// the furthest edge is reached. The number of segments in this path will be seg.forwardPressure 8339function computeSlotSegPressures(seg) { 8340 var forwardSegs = seg.forwardSegs; 8341 var forwardPressure = 0; 8342 var i, forwardSeg; 8343 8344 if (seg.forwardPressure === undefined) { // not already computed 8345 8346 for (i=0; i<forwardSegs.length; i++) { 8347 forwardSeg = forwardSegs[i]; 8348 8349 // figure out the child's maximum forward path 8350 computeSlotSegPressures(forwardSeg); 8351 8352 // either use the existing maximum, or use the child's forward pressure 8353 // plus one (for the forwardSeg itself) 8354 forwardPressure = Math.max( 8355 forwardPressure, 8356 1 + forwardSeg.forwardPressure 8357 ); 8358 } 8359 8360 seg.forwardPressure = forwardPressure; 8361 } 8362} 8363 8364 8365// Find all the segments in `otherSegs` that vertically collide with `seg`. 8366// Append into an optionally-supplied `results` array and return. 8367function computeSlotSegCollisions(seg, otherSegs, results) { 8368 results = results || []; 8369 8370 for (var i=0; i<otherSegs.length; i++) { 8371 if (isSlotSegCollision(seg, otherSegs[i])) { 8372 results.push(otherSegs[i]); 8373 } 8374 } 8375 8376 return results; 8377} 8378 8379 8380// Do these segments occupy the same vertical space? 8381function isSlotSegCollision(seg1, seg2) { 8382 return seg1.bottom > seg2.top && seg1.top < seg2.bottom; 8383} 8384 8385;; 8386 8387/* An abstract class from which other views inherit from 8388----------------------------------------------------------------------------------------------------------------------*/ 8389 8390var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, { 8391 8392 type: null, // subclass' view name (string) 8393 name: null, // deprecated. use `type` instead 8394 title: null, // the text that will be displayed in the header's title 8395 8396 calendar: null, // owner Calendar object 8397 options: null, // hash containing all options. already merged with view-specific-options 8398 el: null, // the view's containing element. set by Calendar 8399 8400 isDateSet: false, 8401 isDateRendered: false, 8402 dateRenderQueue: null, 8403 8404 isEventsBound: false, 8405 isEventsSet: false, 8406 isEventsRendered: false, 8407 eventRenderQueue: null, 8408 8409 // range the view is actually displaying (moments) 8410 start: null, 8411 end: null, // exclusive 8412 8413 // range the view is formally responsible for (moments) 8414 // may be different from start/end. for example, a month view might have 1st-31st, excluding padded dates 8415 intervalStart: null, 8416 intervalEnd: null, // exclusive 8417 intervalDuration: null, 8418 intervalUnit: null, // name of largest unit being displayed, like "month" or "week" 8419 8420 isRTL: false, 8421 isSelected: false, // boolean whether a range of time is user-selected or not 8422 selectedEvent: null, 8423 8424 eventOrderSpecs: null, // criteria for ordering events when they have same date/time 8425 8426 // classNames styled by jqui themes 8427 widgetHeaderClass: null, 8428 widgetContentClass: null, 8429 highlightStateClass: null, 8430 8431 // for date utils, computed from options 8432 nextDayThreshold: null,
8433 isHiddenDayHash: null, 8434 8435 // now indicator 8436 isNowIndicatorRendered: null, 8437 initialNowDate: null, // result first getNow call 8438 initialNowQueriedMs: null, // ms time the getNow was called 8439 nowIndicatorTimeoutID: null, // for refresh timing of now indicator 8440 nowIndicatorIntervalID: null, // " 8441 8442 8443 constructor: function(calendar, type, options, intervalDuration) { 8444 8445 this.calendar = calendar; 8446 this.type = this.name = type; // .name is deprecated 8447 this.options = options; 8448 this.intervalDuration = intervalDuration || moment.duration(1, 'day'); 8449 8450 this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold')); 8451 this.initThemingProps(); 8452 this.initHiddenDays(); 8453 this.isRTL = this.opt('isRTL'); 8454 8455 this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder')); 8456 8457 this.dateRenderQueue = new TaskQueue(); 8458 this.eventRenderQueue = new TaskQueue(this.opt('eventRenderWait')); 8459 8460 this.initialize(); 8461 }, 8462 8463 8464 // A good place for subclasses to initialize member variables 8465 initialize: function() { 8466 // subclasses can implement 8467 }, 8468 8469 8470 // Retrieves an option with the given name 8471 opt: function(name) { 8472 return this.options[name]; 8473 }, 8474 8475 8476 // Triggers handlers that are view-related. Modifies args before passing to calendar. 8477 publiclyTrigger: function(name, thisObj) { // arguments beyond thisObj are passed along 8478 var calendar = this.calendar; 8479 8480 return calendar.publiclyTrigger.apply( 8481 calendar, 8482 [name, thisObj || this].concat( 8483 Array.prototype.slice.call(arguments, 2), // arguments beyond thisObj 8484 [ this ] // always make the last argument a reference to the view. TODO: deprecate 8485 ) 8486 ); 8487 }, 8488 8489 8490 // Returns a proxy of the given promise that will be rejected if the given event fires 8491 // before the promise resolves. 8492 rejectOn: function(eventName, promise) { 8493 var _this = this; 8494 8495 return new Promise(function(resolve, reject) { 8496 _this.one(eventName, reject); 8497 8498 function cleanup() { 8499 _this.off(eventName, reject); 8500 } 8501 8502 promise.then(function(res) { // success 8503 cleanup(); 8504 resolve(res); 8505 }, function() { // failure 8506 cleanup(); 8507 reject(); 8508 }); 8509 }); 8510 }, 8511 8512 8513 /* Date Computation 8514 ------------------------------------------------------------------------------------------------------------------*/ 8515 8516 8517 // Updates all internal dates for displaying the given unzoned range. 8518 setRange: function(range) { 8519 $.extend(this, range); // assigns every property to this object's member variables 8520 this.updateTitle(); 8521 }, 8522 8523 8524 // Given a single current unzoned date, produce information about what range to display. 8525 // Subclasses can override. Must return all properties. 8526 computeRange: function(date) { 8527 var intervalUnit = computeIntervalUnit(this.intervalDuration); 8528 var intervalStart = date.clone().startOf(intervalUnit); 8529 var intervalEnd = intervalStart.clone().add(this.intervalDuration); 8530 var start, end; 8531 8532 // normalize the range's time-ambiguity 8533 if (/year|month|week|day/.test(intervalUnit)) { // whole-days? 8534 intervalStart.stripTime(); 8535 intervalEnd.stripTime(); 8536 } 8537 else { // needs to have a time? 8538 if (!intervalStart.hasTime()) { 8539 intervalStart = this.calendar.time(0); // give 00:00 time 8540 } 8541 if (!intervalEnd.hasTime()) { 8542 intervalEnd = this.calendar.time(0); // give 00:00 time 8543 } 8544 } 8545 8546 start = intervalStart.clone(); 8547 start = this.skipHiddenDays(start); 8548 end = intervalEnd.clone(); 8549 end = this.skipHiddenDays(end, -1, true); // exclusively move backwards 8550 8551 return { 8552 intervalUnit: intervalUnit, 8553 intervalStart: intervalStart, 8554 intervalEnd: intervalEnd, 8555 start: start, 8556 end: end 8557 }; 8558 }, 8559 8560 8561 // Computes the new date when the user hits the prev button, given the current date 8562 computePrevDate: function(date) { 8563 return this.massageCurrentDate( 8564 date.clone().startOf(this.intervalUnit).subtract(this.intervalDuration), -1 8565 ); 8566 }, 8567 8568 8569 // Computes the new date when the user hits the next button, given the current date 8570 computeNextDate: function(date) { 8571 return this.massageCurrentDate( 8572 date.clone().startOf(this.intervalUnit).add(this.intervalDuration) 8573 ); 8574 }, 8575 8576 8577 // Given an arbitrarily calculated current date of the calendar, returns a date that is ensured to be completely 8578 // visible. `direction` is optional and indicates which direction the current date was being 8579 // incremented or decremented (1 or -1). 8580 massageCurrentDate: function(date, direction) { 8581 if (this.intervalDuration.as('days') <= 1) { // if the view displays a
8581single day or smaller 8582 if (this.isHiddenDay(date)) { 8583 date = this.skipHiddenDays(date, direction); 8584 date.startOf('day'); 8585 } 8586 } 8587 8588 return date; 8589 }, 8590 8591 8592 /* Title and Date Formatting 8593 ------------------------------------------------------------------------------------------------------------------*/ 8594 8595 8596 // Sets the view's title property to the most updated computed value 8597 updateTitle: function() { 8598 this.title = this.computeTitle(); 8599 this.calendar.setToolbarsTitle(this.title); 8600 }, 8601 8602 8603 // Computes what the title at the top of the calendar should be for this view 8604 computeTitle: function() { 8605 var start, end; 8606 8607 // for views that span a large unit of time, show the proper interval, ignoring stray days before and after 8608 if (this.intervalUnit === 'year' || this.intervalUnit === 'month') { 8609 start = this.intervalStart; 8610 end = this.intervalEnd; 8611 } 8612 else { // for day units or smaller, use the actual day range 8613 start = this.start; 8614 end = this.end; 8615 } 8616 8617 return this.formatRange( 8618 { 8619 // in case intervalStart/End has a time, make sure timezone is correct 8620 start: this.calendar.applyTimezone(start), 8621 end: this.calendar.applyTimezone(end) 8622 }, 8623 this.opt('titleFormat') || this.computeTitleFormat(), 8624 this.opt('titleRangeSeparator') 8625 ); 8626 }, 8627 8628 8629 // Generates the format string that should be used to generate the title for the current date range. 8630 // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`. 8631 computeTitleFormat: function() { 8632 if (this.intervalUnit == 'year') { 8633 return 'YYYY'; 8634 } 8635 else if (this.intervalUnit == 'month') { 8636 return this.opt('monthYearFormat'); // like "September 2014" 8637 } 8638 else if (this.intervalDuration.as('days') > 1) { 8639 return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014" 8640 } 8641 else { 8642 return 'LL'; // one day. longer, like "September 9 2014" 8643 } 8644 }, 8645 8646 8647 // Utility for formatting a range. Accepts a range object, formatting string, and optional separator. 8648 // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account. 8649 // The timezones of the dates within `range` will be respected. 8650 formatRange: function(range, formatStr, separator) { 8651 var end = range.end; 8652 8653 if (!end.hasTime()) { // all-day? 8654 end = end.clone().subtract(1); // convert to inclusive. last ms of previous day 8655 } 8656 8657 return formatRange(range.start, end, formatStr, separator, this.opt('isRTL')); 8658 }, 8659 8660 8661 getAllDayHtml: function() { 8662 return this.opt('allDayHtml') || htmlEscape(this.opt('allDayText')); 8663 }, 8664 8665 8666 /* Navigation 8667 ------------------------------------------------------------------------------------------------------------------*/ 8668 8669 8670 // Generates HTML for an anchor to another view into the calendar. 8671 // Will either generate an <a> tag or a non-clickable <span> tag, depending on enabled settings. 8672 // `gotoOptions` can either be a moment input, or an object with the form: 8673 // { date, type, forceOff } 8674 // `type` is a view-type like "day" or "week". default value is "day". 8675 // `attrs` and `innerHtml` are use to generate the rest of the HTML tag. 8676 buildGotoAnchorHtml: function(gotoOptions, attrs, innerHtml) { 8677 var date, type, forceOff; 8678 var finalOptions; 8679 8680 if ($.isPlainObject(gotoOptions)) { 8681 date = gotoOptions.date; 8682 type = gotoOptions.type; 8683 forceOff = gotoOptions.forceOff; 8684 } 8685 else { 8686 date = gotoOptions; // a single moment input 8687 } 8688 date = FC.moment(date); // if a string, parse it 8689 8690 finalOptions = { // for serialization into the link 8691 date: date.format('YYYY-MM-DD'), 8692 type: type || 'day' 8693 }; 8694 8695 if (typeof attrs === 'string') { 8696 innerHtml = attrs; 8697 attrs = null; 8698 } 8699 8700 attrs = attrs ? ' ' + attrsToStr(attrs) : ''; // will have a leading space 8701 innerHtml = innerHtml || ''; 8702 8703 if (!forceOff && this.opt('navLinks')) { 8704 return '<a' + attrs + 8705 ' data-goto="' + htmlEscape(JSON.stringify(finalOptions)) + '">' + 8706 innerHtml + 8707 '</a>'; 8708 } 8709 else { 8710 return '<span' + attrs + '>' + 8711 innerHtml + 8712 '</span>'; 8713 } 8714 }, 8715 8716 8717 // Rendering Non-date-related Content 8718 // ----------------------------------------------------------------------------------------------------------------- 8719 8720 8721 // Sets the container element that the view should render inside of, does global DOM-related initializations, 8722 // and renders all the non-date-related content inside. 8723 setElement: function(el) { 8724 this.el = el; 8725 this.bindGlobalHandlers(); 8726 this.renderSkeleton(); 8727 }, 8728 8729 8730 // Removes the view's container element from the DOM, clearing any content beforehand. 8731 // Undoes any other DOM-related attachments. 8732 removeElement: function() { 8733 this.unsetDate(); 8734 this.unrenderSkeleton(); 8735 8736 this.unbindGlobalHandlers(); 8737 8738 this.el.remove(); 8739 // NOTE: don't null-out this.el in case the View was destroyed within an API callback. 8740 // We don't null-out the View's other jQuery element references upon destroy, 8741 // so we shouldn't kill this.el either. 8742 }, 8743 8744 8745 // Renders the basic structure of the view before any content is rendered
8746 renderSkeleton: function() { 8747 // subclasses should implement 8748 }, 8749 8750 8751 // Unrenders the basic structure of the view 8752 unrenderSkeleton: function() { 8753 // subclasses should implement 8754 }, 8755 8756 8757 // Date Setting/Unsetting 8758 // ----------------------------------------------------------------------------------------------------------------- 8759 8760 8761 setDate: function(date) { 8762 var isReset = this.isDateSet; 8763 8764 this.isDateSet = true; 8765 this.handleDate(date, isReset); 8766 this.trigger(isReset ? 'dateReset' : 'dateSet', date); 8767 }, 8768 8769 8770 unsetDate: function() { 8771 if (this.isDateSet) { 8772 this.isDateSet = false; 8773 this.handleDateUnset(); 8774 this.trigger('dateUnset'); 8775 } 8776 }, 8777 8778 8779 // Date Handling 8780 // ----------------------------------------------------------------------------------------------------------------- 8781 8782 8783 handleDate: function(date, isReset) { 8784 var _this = this; 8785 8786 this.unbindEvents(); // will do nothing if not already bound 8787 this.requestDateRender(date).then(function() { 8788 // wish we could start earlier, but setRange/computeRange needs to execute first 8789 _this.bindEvents(); // will request events 8790 }); 8791 }, 8792 8793 8794 handleDateUnset: function() { 8795 this.unbindEvents(); 8796 this.requestDateUnrender(); 8797 }, 8798 8799 8800 // Date Render Queuing 8801 // ----------------------------------------------------------------------------------------------------------------- 8802 8803 8804 // if date not specified, uses current 8805 requestDateRender: function(date) { 8806 var _this = this; 8807 8808 return this.dateRenderQueue.add(function() { 8809 return _this.executeDateRender(date); 8810 }); 8811 }, 8812 8813 8814 requestDateUnrender: function() { 8815 var _this = this; 8816 8817 return this.dateRenderQueue.add(function() { 8818 return _this.executeDateUnrender(); 8819 }); 8820 }, 8821 8822 8823 // Date High-level Rendering 8824 // ----------------------------------------------------------------------------------------------------------------- 8825 8826 8827 // if date not specified, uses current 8828 executeDateRender: function(date) { 8829 var _this = this; 8830 8831 // if rendering a new date, reset scroll to initial state (scrollTime) 8832 if (date) { 8833 this.captureInitialScroll(); 8834 } 8835 else { 8836 this.captureScroll(); // a rerender of the current date 8837 } 8838 8839 this.freezeHeight(); 8840 8841 return this.executeDateUnrender().then(function() { 8842 8843 if (date) { 8844 _this.setRange(_this.computeRange(date)); 8845 } 8846 8847 if (_this.render) { 8848 _this.render(); // TODO: deprecate 8849 } 8850 8851 _this.renderDates(); 8852 _this.updateSize(); 8853 _this.renderBusinessHours(); // might need coordinates, so should go after updateSize() 8854 _this.startNowIndicator(); 8855 8856 _this.thawHeight(); 8857 _this.releaseScroll(); 8858 8859 _this.isDateRendered = true; 8860 _this.onDateRender(); 8861 _this.trigger('dateRender'); 8862 }); 8863 }, 8864 8865 8866 executeDateUnrender: function() { 8867 var _this = this; 8868 8869 if (_this.isDateRendered) { 8870 return this.requestEventsUnrender().then(function() { 8871 8872 _this.unselect(); 8873 _this.stopNowIndicator(); 8874 _this.triggerUnrender(); 8875 _this.unrenderBusinessHours(); 8876 _this.unrenderDates(); 8877 8878 if (_this.destroy) { 8879 _this.destroy(); // TODO: deprecate 8880 } 8881 8882 _this.isDateRendered = false; 8883 _this.trigger('dateUnrender'); 8884 }); 8885 } 8886 else { 8887 return Promise.resolve(); 8888 } 8889 }, 8890 8891 8892 // Date Rendering Triggers 8893 // ----------------------------------------------------------------------------------------------------------------- 8894 8895 8896 onDateRender: function() { 8897 this.triggerRender(); 8898 }, 8899 8900 8901 // Date Low-level Rendering 8902 // ----------------------------------------------------------------------------------------------------------------- 8903 8904 8905 // date-cell content only 8906 renderDates: function() { 8907 // subclasses should implement 8908 }, 8909 8910 8911 // date-cell content only 8912 unrenderDates: function() { 8913 // subclasses should override 8914 }, 8915 8916 8917 // Misc view rendering utils 8918 // ------------------------- 8919 8920 8921 // Signals that the view's content has been rendered 8922 triggerRender: function() { 8923 this.publiclyTrigger('viewRender', this, this, this.el); 8924 }, 8925 8926 8927 // Signals that the view's content is about to be unrendered 8928 triggerUnrender: function() { 8929 this.publiclyTrigger('viewDestroy', this, this, this.el); 8930 }, 8931 8932 8933 // Binds DOM handlers to elements that reside outside the view container, such as the document 8934 bindGlobalHandlers: function() { 8935 this.listenTo(GlobalEmitter.get(), { 8936 touchstart: this.processUnselect, 8937 mousedown: this.handleDocumentMousedown 8938 }); 8939 }, 8940 8941 8942 // Unbinds DOM handlers from elements that reside outside the view container 8943 unbindGlobalHandlers: function() { 8944 this.stopListeningTo(GlobalEmitter.get()); 8945 }, 8946 8947 8948 // Initializes internal variables related to theming 8949 initThemingProps: function() { 8950 var tm = this.opt('theme') ? 'ui' : 'fc'; 8951 8952 this.widgetHeaderClass = tm + '-widget-header'; 8953 this.widgetContentClass = tm + '-widget-content'; 8954 this.highlightStateClass = tm + '-state-highlight';
8955 }, 8956 8957 8958 /* Business Hours 8959 ------------------------------------------------------------------------------------------------------------------*/ 8960 8961 8962 // Renders business-hours onto the view. Assumes updateSize has already been called. 8963 renderBusinessHours: function() { 8964 // subclasses should implement 8965 }, 8966 8967 8968 // Unrenders previously-rendered business-hours 8969 unrenderBusinessHours: function() { 8970 // subclasses should implement 8971 }, 8972 8973 8974 /* Now Indicator 8975 ------------------------------------------------------------------------------------------------------------------*/ 8976 8977 8978 // Immediately render the current time indicator and begins re-rendering it at an interval, 8979 // which is defined by this.getNowIndicatorUnit(). 8980 // TODO: somehow do this for the current whole day's background too 8981 startNowIndicator: function() { 8982 var _this = this; 8983 var unit; 8984 var update; 8985 var delay; // ms wait value 8986 8987 if (this.opt('nowIndicator')) { 8988 unit = this.getNowIndicatorUnit(); 8989 if (unit) { 8990 update = proxy(this, 'updateNowIndicator'); // bind to `this` 8991 8992 this.initialNowDate = this.calendar.getNow(); 8993 this.initialNowQueriedMs = +new Date(); 8994 this.renderNowIndicator(this.initialNowDate); 8995 this.isNowIndicatorRendered = true; 8996 8997 // wait until the beginning of the next interval 8998 delay = this.initialNowDate.clone().startOf(unit).add(1, unit) - this.initialNowDate; 8999 this.nowIndicatorTimeoutID = setTimeout(function() { 9000 _this.nowIndicatorTimeoutID = null; 9001 update();
9002 delay = +moment.duration(1, unit); 9003 delay = Math.max(100, delay); // prevent too frequent 9004 _this.nowIndicatorIntervalID = setInterval(update, delay); // update every interval 9005 }, delay); 9006 } 9007 } 9008 }, 9009 9010 9011 // rerenders the now indicator, computing the new current time from the amount of time that has passed 9012 // since the initial getNow call. 9013 updateNowIndicator: function() { 9014 if (this.isNowIndicatorRendered) { 9015 this.unrenderNowIndicator(); 9016 this.renderNowIndicator( 9017 this.initialNowDate.clone().add(new Date() - this.initialNowQueriedMs) // add ms 9018 ); 9019 } 9020 }, 9021 9022 9023 // Immediately unrenders the view's current time indicator and stops any re-rendering timers. 9024 // Won't cause side effects if indicator isn't rendered. 9025 stopNowIndicator: function() { 9026 if (this.isNowIndicatorRendered) { 9027 9028 if (this.nowIndicatorTimeoutID) { 9029 clearTimeout(this.nowIndicatorTimeoutID); 9030 this.nowIndicatorTimeoutID = null; 9031 } 9032 if (this.nowIndicatorIntervalID) { 9033 clearTimeout(this.nowIndicatorIntervalID); 9034 this.nowIndicatorIntervalID = null; 9035 } 9036 9037 this.unrenderNowIndicator(); 9038 this.isNowIndicatorRendered = false; 9039 } 9040 }, 9041 9042 9043 // Returns a string unit, like 'second' or 'minute' that defined how often the current time indicator 9044 // should be refreshed. If something falsy is returned, no time indicator is rendered at all. 9045 getNowIndicatorUnit: function() { 9046 // subclasses should implement 9047 }, 9048 9049 9050 // Renders a current time indicator at the given datetime 9051 renderNowIndicator: function(date) { 9052 // subclasses should implement 9053 }, 9054 9055 9056 // Undoes the rendering actions from renderNowIndicator 9057 unrenderNowIndicator: function() { 9058 // subclasses should implement 9059 }, 9060 9061 9062 /* Dimensions 9063 ------------------------------------------------------------------------------------------------------------------*/ 9064 9065 9066 // Refreshes anything dependant upon sizing of the container element of the grid 9067 updateSize: function(isResize) { 9068 9069 if (isResize) { 9070 this.captureScroll(); 9071 } 9072 9073 this.updateHeight(isResize); 9074 this.updateWidth(isResize); 9075 this.updateNowIndicator(); 9076 9077 if (isResize) { 9078 this.releaseScroll(); 9079 } 9080 }, 9081 9082 9083 // Refreshes the horizontal dimensions of the calendar 9084 updateWidth: function(isResize) { 9085 // subclasses should implement 9086 }, 9087 9088 9089 // Refreshes the vertical dimensions of the calendar 9090 updateHeight: function(isResize) { 9091 var calendar = this.calendar; // we poll the calendar for height information 9092 9093 this.setHeight( 9094 calendar.getSuggestedViewHeight(), 9095 calendar.isHeightAuto() 9096 ); 9097 }, 9098 9099 9100 // Updates the vertical dimensions of the calendar to the specified height. 9101 // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height. 9102 setHeight: function(height, isAuto) { 9103 // subclasses should implement 9104 }, 9105 9106 9107 /* Scroller 9108 ------------------------------------------------------------------------------------------------------------------*/ 9109 9110 9111 capturedScroll: null, 9112 capturedScrollDepth: 0, 9113 9114 9115 captureScroll: function() { 9116 if (!(this.capturedScrollDepth++)) { 9117 this.capturedScroll = this.isDateRendered ? this.queryScroll() : {}; // require a render first 9118 return true; // root? 9119 } 9120 return false; 9121 }, 9122 9123 9124 captureInitialScroll: function(forcedScroll) { 9125 if (this.captureScroll()) { // root? 9126 this.capturedScroll.isInitial = true; 9127 9128 if (forcedScroll) { 9129 $.extend(this.capturedScroll, forcedScroll); 9130 } 9131 else { 9132 this.capturedScroll.isComputed = true; 9133 } 9134 } 9135 }, 9136 9137 9138 releaseScroll: function() { 9139 var scroll = this.capturedScroll; 9140 var isRoot = this.discardScroll(); 9141 9142 if (scroll.isComputed) { 9143 if (isRoot) { 9144 // only compute initial scroll if it will actually be used (is the root capture) 9145 $.extend(scroll, this.computeInitialScroll()); 9146 } 9147 else { 9148 scroll = null; // scroll couldn't be computed. don't apply it to the DOM 9149 } 9150 } 9151 9152 if (scroll) { 9153 // we act immediately on a releaseScroll operation, as opposed to captureScroll. 9154 // if capture/release wraps a render operation that screws up the scroll, 9155 // we still want to restore it a good state after, regardless of depth. 9156 9157 if (scroll.isInitial) { 9158 this.hardSetScroll(scroll); // outsmart how browsers set scroll on initial DOM 9159 } 9160 else { 9161 this.setScroll(scroll); 9162 } 9163 } 9164 }, 9165 9166 9167 discardScroll: function() { 9168 if (!(--this.capturedScrollDepth)) { 9169 this.capturedScroll = null; 9170 return true; // root? 9171 } 9172 return false;
9173 }, 9174 9175 9176 computeInitialScroll: function() { 9177 return {}; 9178 }, 9179 9180 9181 queryScroll: function() { 9182 return {}; 9183 }, 9184 9185 9186 hardSetScroll: function(scroll) { 9187 var _this = this; 9188 var exec = function() { _this.setScroll(scroll); }; 9189 exec(); 9190 setTimeout(exec, 0); // to surely clear the browser's initial scroll for the DOM 9191 }, 9192 9193 9194 setScroll: function(scroll) { 9195 }, 9196 9197 9198 /* Height Freezing 9199 ------------------------------------------------------------------------------------------------------------------*/ 9200 9201 9202 freezeHeight: function() { 9203 this.calendar.freezeContentHeight(); 9204 }, 9205 9206 9207 thawHeight: function() { 9208 this.calendar.thawContentHeight(); 9209 }, 9210 9211 9212 // Event Binding/Unbinding 9213 // ----------------------------------------------------------------------------------------------------------------- 9214 9215 9216 bindEvents: function() { 9217 var _this = this; 9218 9219 if (!this.isEventsBound) { 9220 this.isEventsBound = true; 9221 this.rejectOn('eventsUnbind', this.requestEvents()).then(function(events) { // TODO: test rejection 9222 _this.listenTo(_this.calendar, 'eventsReset', _this.setEvents); 9223 _this.setEvents(events); 9224 }); 9225 } 9226 }, 9227 9228 9229 unbindEvents: function() { 9230 if (this.isEventsBound) { 9231 this.isEventsBound = false; 9232 this.stopListeningTo(this.calendar, 'eventsReset'); 9233 this.unsetEvents(); 9234 this.trigger('eventsUnbind'); 9235 } 9236 }, 9237 9238 9239 // Event Setting/Unsetting 9240 // ----------------------------------------------------------------------------------------------------------------- 9241 9242 9243 setEvents: function(events) { 9244 var isReset = this.isEventSet; 9245 9246 this.isEventsSet = true; 9247 this.handleEvents(events, isReset); 9248 this.trigger(isReset ? 'eventsReset' : 'eventsSet', events); 9249 }, 9250 9251 9252 unsetEvents: function() { 9253 if (this.isEventsSet) { 9254 this.isEventsSet = false; 9255 this.handleEventsUnset(); 9256 this.trigger('eventsUnset'); 9257 } 9258 }, 9259 9260 9261 whenEventsSet: function() { 9262 var _this = this; 9263 9264 if (this.isEventsSet) { 9265 return Promise.resolve(this.getCurrentEvents()); 9266 } 9267 else { 9268 return new Promise(function(resolve) { 9269 _this.one('eventsSet', resolve); 9270 }); 9271 } 9272 }, 9273 9274 9275 // Event Handling 9276 // ----------------------------------------------------------------------------------------------------------------- 9277 9278 9279 handleEvents: function(events, isReset) { 9280 this.requestEventsRender(events); 9281 }, 9282 9283 9284 handleEventsUnset: function() { 9285 this.requestEventsUnrender(); 9286 }, 9287 9288 9289 // Event Render Queuing 9290 // ----------------------------------------------------------------------------------------------------------------- 9291 9292 9293 // assumes any previous event renders have been cleared already 9294 requestEventsRender: function(events) { 9295 var _this = this; 9296 9297 return this.eventRenderQueue.add(function() { // might not return a promise if debounced!? bad 9298 return _this.executeEventsRender(events); 9299 }); 9300 }, 9301 9302 9303 requestEventsUnrender: function() { 9304 var _this = this; 9305 9306 if (this.isEventsRendered) { 9307 return this.eventRenderQueue.addQuickly(function() { 9308 return _this.executeEventsUnrender(); 9309 }); 9310 } 9311 else { 9312 return Promise.resolve(); 9313 } 9314 }, 9315 9316 9317 requestCurrentEventsRender: function() { 9318 if (this.isEventsSet) { 9319 this.requestEventsRender(this.getCurrentEvents()); 9320 } 9321 else { 9322 return Promise.reject(); 9323 } 9324 }, 9325 9326 9327 // Event High-level Rendering 9328 // ----------------------------------------------------------------------------------------------------------------- 9329 9330 9331 executeEventsRender: function(events) { 9332 var _this = this; 9333 9334 this.captureScroll(); 9335 this.freezeHeight(); 9336 9337 return this.executeEventsUnrender().then(function() { 9338 _this.renderEvents(events); 9339 9340 _this.thawHeight(); 9341 _this.releaseScroll(); 9342 9343 _this.isEventsRendered = true; 9344 _this.onEventsRender(); 9345 _this.trigger('eventsRender'); 9346 }); 9347 }, 9348 9349 9350 executeEventsUnrender: function() { 9351 if (this.isEventsRendered) { 9352 this.onBeforeEventsUnrender(); 9353 9354 this.captureScroll(); 9355 this.freezeHeight(); 9356 9357 if (this.destroyEvents) { 9358 this.destroyEvents(); // TODO: deprecate 9359 } 9360 9361 this.unrenderEvents(); 9362 9363 this.thawHeight(); 9364 this.releaseScroll(); 9365 9366 this.isEventsRendered = false; 9367 this.trigger('eventsUnrender'); 9368 } 9369 9370 return Promise.resolve(); // always synchronous 9371 }, 9372 9373 9374 // Event Rendering Triggers 9375 // ----------------------------------------------------------------------------------------------------------------- 9376 9377 9378 // Signals that all events have been rendered 9379 onEventsRender: function() { 9380 this.renderedEventSegEach(function(seg) { 9381 this.publiclyTrigger('eventAfterRender', seg.event, seg.event, seg.el); 9382 }); 9383 this.publiclyTrigger('eventAfterAllRender'); 9384 }, 9385 9386
9387 // Signals that all event elements are about to be removed 9388 onBeforeEventsUnrender: function() { 9389 this.renderedEventSegEach(function(seg) { 9390 this.publiclyTrigger('eventDestroy', seg.event, seg.event, seg.el); 9391 }); 9392 }, 9393 9394 9395 // Event Low-level Rendering 9396 // ----------------------------------------------------------------------------------------------------------------- 9397 9398 9399 // Renders the events onto the view. 9400 renderEvents: function(events) { 9401 // subclasses should implement 9402 }, 9403 9404 9405 // Removes event elements from the view. 9406 unrenderEvents: function() { 9407 // subclasses should implement 9408 }, 9409 9410 9411 // Event Data Access 9412 // ----------------------------------------------------------------------------------------------------------------- 9413 9414 9415 requestEvents: function() { 9416 return this.calendar.requestEvents(this.start, this.end); 9417 }, 9418 9419 9420 getCurrentEvents: function() { 9421 return this.calendar.getPrunedEventCache(); 9422 }, 9423 9424 9425 // Event Rendering Utils 9426 // ----------------------------------------------------------------------------------------------------------------- 9427 9428 9429 // Given an event and the default element used for rendering, returns the element that should actually be used. 9430 // Basically runs events and elements through the eventRender hook. 9431 resolveEventEl: function(event, el) { 9432 var custom = this.publiclyTrigger('eventRender', event, event, el); 9433 9434 if (custom === false) { // means don't render at all 9435 el = null; 9436 } 9437 else if (custom && custom !== true) { 9438 el = $(custom); 9439 } 9440 9441 return el; 9442 }, 9443 9444 9445 // Hides all rendered event segments linked to the given event 9446 showEvent: function(event) { 9447 this.renderedEventSegEach(function(seg) { 9448 seg.el.css('visibility', ''); 9449 }, event); 9450 }, 9451 9452 9453 // Shows all rendered event segments linked to the given event 9454 hideEvent: function(event) { 9455 this.renderedEventSegEach(function(seg) { 9456 seg.el.css('visibility', 'hidden'); 9457 }, event); 9458 }, 9459 9460 9461 // Iterates through event segments that have been rendered (have an el). Goes through all by default. 9462 // If the optional `event` argument is specified, only iterates through segments linked to that event. 9463 // The `this` value of the callback function will be the view. 9464 renderedEventSegEach: function(func, event) { 9465 var segs = this.getEventSegs(); 9466 var i; 9467 9468 for (i = 0; i < segs.length; i++) { 9469 if (!event || segs[i].event._id === event._id) { 9470 if (segs[i].el) { 9471 func.call(this, segs[i]); 9472 } 9473 } 9474 } 9475 }, 9476 9477 9478 // Retrieves all the rendered segment objects for the view 9479 getEventSegs: function() { 9480 // subclasses must implement 9481 return []; 9482 }, 9483 9484 9485 /* Event Drag-n-Drop 9486 ------------------------------------------------------------------------------------------------------------------*/ 9487 9488 9489 // Computes if the given event is allowed to be dragged by the user 9490 isEventDraggable: function(event) { 9491 return this.isEventStartEditable(event); 9492 }, 9493 9494 9495 isEventStartEditable: function(event) { 9496 return firstDefined( 9497 event.startEditable, 9498 (event.source || {}).startEditable, 9499 this.opt('eventStartEditable'), 9500 this.isEventGenerallyEditable(event) 9501 ); 9502 }, 9503 9504 9505 isEventGenerallyEditable: function(event) { 9506 return firstDefined( 9507 event.editable, 9508 (event.source || {}).editable, 9509 this.opt('editable') 9510 ); 9511 }, 9512 9513 9514 // Must be called when an event in the view is dropped onto new location. 9515 // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event. 9516 reportSegDrop: function(seg, dropLocation, largeUnit, el, ev) { 9517 var calendar = this.calendar; 9518 var mutateResult = calendar.mutateSeg(seg, dropLocation, largeUnit); 9519 var undoFunc = function() { 9520 mutateResult.undo(); 9521 calendar.reportEventChange(); 9522 }; 9523 9524 this.triggerEventDrop(seg.event, mutateResult.dateDelta, undoFunc, el, ev); 9525 calendar.reportEventChange(); // will rerender events 9526 }, 9527 9528 9529 // Triggers event-drop handlers that have subscribed via the API 9530 triggerEventDrop: function(event, dateDelta, undoFunc, el, ev) { 9531 this.publiclyTrigger('eventDrop', el[0], event, dateDelta, undoFunc, ev, {}); // {} = jqui dummy 9532 }, 9533 9534 9535 /* External Element Drag-n-Drop 9536 ------------------------------------------------------------------------------------------------------------------*/ 9537 9538 9539 // Must be called when an external element, via jQuery UI, has been dropped onto the calendar. 9540 // `meta` is the parsed data that has been embedded into the dragging event. 9541 // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event. 9542 reportExternalDrop: function(meta, dropLocation, el, ev, ui) { 9543 var eventProps = meta.eventProps; 9544 var eventInput; 9545 var event; 9546 9547 // Try to build an event object and render it. TODO: decouple the two 9548 if (eventProps) { 9549 eventInput = $.extend({}, eventProps, dropLocation); 9550 event = this.calendar.renderEvent(eventInput, meta.stick)[0]; // renderEvent returns an array 9551 } 9552 9553 this.triggerExternalDrop(event, dropLocation, el, ev, ui); 9554 }, 9555 9556 9557 // Triggers external-drop handlers that have subscribed via the API 9558 triggerExternalDrop: function(event, dropLocation, el, ev, ui) { 9559 9560 // trigger 'drop' regardless of whether element represents an event 9561 this.publiclyTrigger('drop', el[0], dropLocation.start, ev, ui); 9562 9563 if (event) { 9564 this.publiclyTrigger('eventReceive', null, event); // signal an external event landed 9565 } 9566 }, 9567 9568 9569 /* Drag-n-Drop Rendering (for both events and external elements) 9570 ------------------------------------------------------------------------------------------------------------------*/ 9571 9572 9573 // Renders a visual indication of a event or external-element drag over the given drop zone. 9574 // If an external-element, seg will be `null`. 9575 // Must return elements used for any mock events. 9576 renderDrag: function(dropLocation, seg) { 9577 // subclasses must implement 9578 }, 9579 9580 9581 // Unrenders a visual indication of an event or external-element being dragged. 9582 unrenderDrag: function() { 9583 // subclasses must implement 9584 }, 9585 9586 9587 /* Event Resizing 9588 ------------------------------------------------------------------------------------------------------------------*/ 9589 9590 9591 // Computes if the given event is allowed to be resized from its starting edge 9592 isEventResizableFromStart: function(event) { 9593 return this.opt('eventResizableFromStart') && this.isEventResizable(event); 9594 }, 9595 9596 9597 // Computes if the given event is allowed to be resized from its ending edge 9598 isEventResizableFromEnd: function(event) { 9599 return this.isEventResizable(event); 9600 }, 9601 9602 9603 // Computes if the given event is allowed to be resized by the user at all 9604 isEventResizable: function(event) { 9605 var source = event.source || {}; 9606 9607 return firstDefined( 9608 event.durationEditable, 9609 source.durationEditable, 9610 this.opt('eventDurationEditable'), 9611 event.editable, 9612 source.editable, 9613 this.opt('editable') 9614 ); 9615 }, 9616 9617 9618 // Must be called when an event in the view has been resized to a new length 9619 reportSegResize: function(seg, resizeLocation, largeUnit, el, ev) { 9620 var calendar = this.calendar; 9621 var mutateResult = calendar.mutateSeg(seg, resizeLocation, largeUnit);
9622 var undoFunc = function() { 9623 mutateResult.undo(); 9624 calendar.reportEventChange(); 9625 }; 9626 9627 this.triggerEventResize(seg.event, mutateResult.durationDelta, undoFunc, el, ev); 9628 calendar.reportEventChange(); // will rerender events 9629 }, 9630 9631 9632 // Triggers event-resize handlers that have subscribed via the API 9633 triggerEventResize: function(event, durationDelta, undoFunc, el, ev) { 9634 this.publiclyTrigger('eventResize', el[0], event, durationDelta, undoFunc, ev, {}); // {} = jqui dummy 9635 }, 9636 9637 9638 /* Selection (time range) 9639 ------------------------------------------------------------------------------------------------------------------*/ 9640 9641 9642 // Selects a date span on the view. `start` and `end` are both Moments. 9643 // `ev` is the native mouse event that begin the interaction. 9644 select: function(span, ev) { 9645 this.unselect(ev); 9646 this.renderSelection(span); 9647 this.reportSelection(span, ev); 9648 }, 9649 9650 9651 // Renders a visual indication of the selection 9652 renderSelection: function(span) { 9653 // subclasses should implement 9654 }, 9655 9656 9657 // Called when a new selection is made. Updates internal state and triggers handlers. 9658 reportSelection: function(span, ev) { 9659 this.isSelected = true; 9660 this.triggerSelect(span, ev); 9661 }, 9662 9663 9664 // Triggers handlers to 'select' 9665 triggerSelect: function(span, ev) { 9666 this.publiclyTrigger( 9667 'select', 9668 null, 9669 this.calendar.applyTimezone(span.start), // convert to calendar's tz for external API 9670 this.calendar.applyTimezone(span.end), // " 9671 ev 9672 ); 9673 }, 9674 9675 9676 // Undoes a selection. updates in the internal state and triggers handlers. 9677 // `ev` is the native mouse event that began the interaction. 9678 unselect: function(ev) { 9679 if (this.isSelected) { 9680 this.isSelected = false; 9681 if (this.destroySelection) { 9682 this.destroySelection(); // TODO: deprecate 9683 } 9684 this.unrenderSelection(); 9685 this.publiclyTrigger('unselect', null, ev); 9686 } 9687 }, 9688 9689 9690 // Unrenders a visual indication of selection 9691 unrenderSelection: function() { 9692 // subclasses should implement 9693 }, 9694 9695 9696 /* Event Selection 9697 ------------------------------------------------------------------------------------------------------------------*/ 9698 9699 9700 selectEvent: function(event) { 9701 if (!this.selectedEvent || this.selectedEvent !== event) { 9702 this.unselectEvent(); 9703 this.renderedEventSegEach(function(seg) { 9704 seg.el.addClass('fc-selected'); 9705 }, event); 9706 this.selectedEvent = event; 9707 } 9708 }, 9709 9710 9711 unselectEvent: function() { 9712 if (this.selectedEvent) { 9713 this.renderedEventSegEach(function(seg) { 9714 seg.el.removeClass('fc-selected'); 9715 }, this.selectedEvent); 9716 this.selectedEvent = null; 9717 } 9718 }, 9719 9720 9721 isEventSelected: function(event) { 9722 // event references might change on refetchEvents(), while selectedEvent doesn't, 9723 // so compare IDs 9724 return this.selectedEvent && this.selectedEvent._id === event._id; 9725 }, 9726 9727 9728 /* Mouse / Touch Unselecting (time range & event unselection) 9729 ------------------------------------------------------------------------------------------------------------------*/ 9730 // TODO: move consistently to down/start or up/end? 9731 // TODO: don't kill previous selection if touch scrolling 9732 9733 9734 handleDocumentMousedown: function(ev) { 9735 if (isPrimaryMouseButton(ev)) { 9736 this.processUnselect(ev); 9737 } 9738 }, 9739 9740 9741 processUnselect: function(ev) { 9742 this.processRangeUnselect(ev); 9743 this.processEventUnselect(ev); 9744 }, 9745 9746 9747 processRangeUnselect: function(ev) { 9748 var ignore; 9749 9750 // is there a time-range selection? 9751 if (this.isSelected && this.opt('unselectAuto')) { 9752 // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element 9753 ignore = this.opt('unselectCancel'); 9754 if (!ignore || !$(ev.target).closest(ignore).length) { 9755 this.unselect(ev); 9756 } 9757 } 9758 }, 9759 9760 9761 processEventUnselect: function(ev) { 9762 if (this.selectedEvent) { 9763 if (!$(ev.target).closest('.fc-selected').length) { 9764 this.unselectEvent(); 9765 } 9766 } 9767 }, 9768 9769 9770 /* Day Click 9771 ------------------------------------------------------------------------------------------------------------------*/ 9772 9773 9774 // Triggers handlers to 'dayClick' 9775 // Span has start/end of the clicked area. Only the start is useful. 9776 triggerDayClick: function(span, dayEl, ev) { 9777 this.publiclyTrigger( 9778 'dayClick', 9779 dayEl, 9780 this.calendar.applyTimezone(span.start), // convert to calendar's timezone for external API 9781 ev 9782 ); 9783 }, 9784 9785 9786 /* Date Utils 9787 ------------------------------------------------------------------------------------------------------------------*/ 9788 9789 9790 // Initializes internal variables related to calculating hidden days-of-week 9791 initHiddenDays: function() { 9792 var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden
9793 var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool) 9794 var dayCnt = 0; 9795 var i; 9796 9797 if (this.opt('weekends') === false) { 9798 hiddenDays.push(0, 6); // 0=sunday, 6=saturday 9799 } 9800 9801 for (i = 0; i < 7; i++) { 9802 if ( 9803 !(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1) 9804 ) { 9805 dayCnt++; 9806 } 9807 } 9808 9809 if (!dayCnt) { 9810 throw 'invalid hiddenDays'; // all days were hidden? bad. 9811 } 9812 9813 this.isHiddenDayHash = isHiddenDayHash; 9814 }, 9815 9816 9817 // Is the current day hidden? 9818 // `day` is a day-of-week index (0-6), or a Moment 9819 isHiddenDay: function(day) { 9820 if (moment.isMoment(day)) { 9821 day = day.day(); 9822 } 9823 return this.isHiddenDayHash[day]; 9824 }, 9825 9826 9827 // Incrementing the current day until it is no longer a hidden day, returning a copy. 9828 // If the initial value of `date` is not a hidden day, don't do anything. 9829 // Pass `isExclusive` as `true` if you are dealing with an end date. 9830 // `inc` defaults to `1` (increment one day forward each time) 9831 skipHiddenDays: function(date, inc, isExclusive) { 9832 var out = date.clone(); 9833 inc = inc || 1; 9834 while ( 9835 this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7] 9836 ) { 9837 out.add(inc, 'days'); 9838 } 9839 return out; 9840 }, 9841 9842 9843 // Returns the date range of the full days the given range visually appears to occupy. 9844 // Returns a new range object. 9845 computeDayRange: function(range) { 9846 var startDay = range.start.clone().stripTime(); // the beginning of the day the range starts 9847 var end = range.end; 9848 var endDay = null; 9849 var endTimeMS; 9850 9851 if (end) { 9852 endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends 9853 endTimeMS = +end.time(); // # of milliseconds into `endDay` 9854 9855 // If the end time is actually inclusively part of the next day and is equal to or 9856 // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`. 9857 // Otherwise, leaving it as inclusive will cause it to exclude `endDay`. 9858 if (endTimeMS && endTimeMS >= this.nextDayThreshold) { 9859 endDay.add(1, 'days'); 9860 } 9861 } 9862 9863 // If no end was specified, or if it is within `startDay` but not past nextDayThreshold, 9864 // assign the default duration of one day. 9865 if (!end || endDay <= startDay) { 9866 endDay = startDay.clone().add(1, 'days'); 9867 } 9868 9869 return { start: startDay, end: endDay }; 9870 }, 9871 9872 9873 // Does the given event visually appear to occupy more than one day? 9874 isMultiDayEvent: function(event) { 9875 var range = this.computeDayRange(event); // event is range-ish 9876 9877 return range.end.diff(range.start, 'days') > 1; 9878 } 9879 9880}); 9881 9882;; 9883 9884/* 9885Embodies a div that has potential scrollbars 9886*/ 9887var Scroller = FC.Scroller = Class.extend({ 9888 9889 el: null, // the guaranteed outer element 9890 scrollEl: null, // the element with the scrollbars 9891 overflowX: null, 9892 overflowY: null, 9893 9894 9895 constructor: function(options) { 9896 options = options || {}; 9897 this.overflowX = options.overflowX || options.overflow || 'auto'; 9898 this.overflowY = options.overflowY || options.overflow || 'auto'; 9899 }, 9900 9901 9902 render: function() { 9903 this.el = this.renderEl(); 9904 this.applyOverflow(); 9905 }, 9906 9907 9908 renderEl: function() { 9909 return (this.scrollEl = $('<div class="fc-scroller"></div>')); 9910 }, 9911 9912 9913 // sets to natural height, unlocks overflow 9914 clear: function() { 9915 this.setHeight('auto'); 9916 this.applyOverflow(); 9917 }, 9918 9919 9920 destroy: function() { 9921 this.el.remove(); 9922 }, 9923 9924 9925 // Overflow 9926 // ----------------------------------------------------------------------------------------------------------------- 9927 9928 9929 applyOverflow: function() { 9930 this.scrollEl.css({ 9931 'overflow-x': this.overflowX, 9932 'overflow-y': this.overflowY 9933 }); 9934 }, 9935 9936 9937 // Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'. 9938 // Useful for preserving scrollbar widths regardless of future resizes. 9939 // Can pass in scrollbarWidths for optimization. 9940 lockOverflow: function(scrollbarWidths) { 9941 var overflowX = this.overflowX; 9942 var overflowY = this.overflowY; 9943
9944 scrollbarWidths = scrollbarWidths || this.getScrollbarWidths(); 9945 9946 if (overflowX === 'auto') { 9947 overflowX = ( 9948 scrollbarWidths.top || scrollbarWidths.bottom || // horizontal scrollbars? 9949 // OR scrolling pane with massless scrollbars? 9950 this.scrollEl[0].scrollWidth - 1 > this.scrollEl[0].clientWidth 9951 // subtract 1 because of IE off-by-one issue 9952 ) ? 'scroll' : 'hidden'; 9953 } 9954 9955 if (overflowY === 'auto') { 9956 overflowY = ( 9957 scrollbarWidths.left || scrollbarWidths.right || // vertical scrollbars? 9958 // OR scrolling pane with massless scrollbars? 9959 this.scrollEl[0].scrollHeight - 1 > this.scrollEl[0].clientHeight 9960 // subtract 1 because of IE off-by-one issue 9961 ) ? 'scroll' : 'hidden'; 9962 } 9963 9964 this.scrollEl.css({ 'overflow-x': overflowX, 'overflow-y': overflowY }); 9965 }, 9966 9967 9968 // Getters / Setters 9969 // ----------------------------------------------------------------------------------------------------------------- 9970 9971 9972 setHeight: function(height) { 9973 this.scrollEl.height(height); 9974 }, 9975 9976 9977 getScrollTop: function() { 9978 return this.scrollEl.scrollTop(); 9979 }, 9980 9981 9982 setScrollTop: function(top) { 9983 this.scrollEl.scrollTop(top); 9984 }, 9985 9986 9987 getClientWidth: function() { 9988 return this.scrollEl[0].clientWidth; 9989 }, 9990 9991 9992 getClientHeight: function() { 9993 return this.scrollEl[0].clientHeight; 9994 }, 9995 9996 9997 getScrollbarWidths: function() { 9998 return getScrollbarWidths(this.scrollEl); 9999 } 10000 10001}); 10002 10003;; 10004function Iterator(items) { 10005 this.items = items || []; 10006} 10007 10008 10009/* Calls a method on every item passing the arguments through */ 10010Iterator.prototype.proxyCall = function(methodName) { 10011 var args = Array.prototype.slice.call(arguments, 1); 10012 var results = []; 10013 10014 this.items.forEach(function(item) { 10015 results.push(item[methodName].apply(item, args)); 10016 }); 10017 10018 return results; 10019}; 10020 10021;; 10022 10023/* Toolbar with buttons and title 10024----------------------------------------------------------------------------------------------------------------------*/ 10025 10026function Toolbar(calendar, toolbarOptions) { 10027 var t = this; 10028 10029 // exports 10030 t.setToolbarOptions = setToolbarOptions; 10031 t.render = render; 10032 t.removeElement = removeElement; 10033 t.updateTitle = updateTitle; 10034 t.activateButton = activateButton; 10035 t.deactivateButton = deactivateButton; 10036 t.disableButton = disableButton; 10037 t.enableButton = enableButton; 10038 t.getViewsWithButtons = getViewsWithButtons; 10039 t.el = null; // mirrors local `el` 10040 10041 // locals 10042 var el; 10043 var viewsWithButtons = []; 10044 var tm; 10045 10046 // method to update toolbar-specific options, not calendar-wide options 10047 function setToolbarOptions(newToolbarOptions) { 10048 toolbarOptions = newToolbarOptions; 10049 } 10050 10051 // can be called repeatedly and will rerender 10052 function render() { 10053 var sections = toolbarOptions.layout; 10054 10055 tm = calendar.options.theme ? 'ui' : 'fc'; 10056 10057 if (sections) { 10058 if (!el) { 10059 el = this.el = $("<div class='fc-toolbar "+ toolbarOptions.extraClasses + "'/>"); 10060 } 10061 else { 10062 el.empty(); 10063 } 10064 el.append(renderSection('left')) 10065 .append(renderSection('right')) 10066 .append(renderSection('center')) 10067 .append('<div class="fc-clear"/>'); 10068 } 10069 else { 10070 removeElement(); 10071 } 10072 } 10073 10074 10075 function removeElement() { 10076 if (el) { 10077 el.remove(); 10078 el = t.el = null; 10079 } 10080 } 10081 10082
10083 function renderSection(position) { 10084 var sectionEl = $('<div class="fc-' + position + '"/>'); 10085 var buttonStr = toolbarOptions.layout[position]; 10086 10087 if (buttonStr) { 10088 $.each(buttonStr.split(' '), function(i) { 10089 var groupChildren = $(); 10090 var isOnlyButtons = true; 10091 var groupEl; 10092 10093 $.each(this.split(','), function(j, buttonName) { 10094 var customButtonProps; 10095 var viewSpec; 10096 var buttonClick; 10097 var overrideText; // text explicitly set by calendar's constructor options. overcomes icons 10098 var defaultText; 10099 var themeIcon; 10100 var normalIcon; 10101 var innerHtml; 10102 var classes; 10103 var button; // the element 10104 10105 if (buttonName == 'title') { 10106 groupChildren = groupChildren.add($('<h2> </h2>')); // we always want it to take up height 10107 isOnlyButtons = false; 10108 } 10109 else { 10110 if ((customButtonProps = (calendar.options.customButtons || {})[buttonName])) { 10111 buttonClick = function(ev) { 10112 if (customButtonProps.click) { 10113 customButtonProps.click.call(button[0], ev); 10114 } 10115 }; 10116 overrideText = ''; // icons will override text 10117 defaultText = customButtonProps.text; 10118 } 10119 else if ((viewSpec = calendar.getViewSpec(buttonName))) { 10120 buttonClick = function() { 10121 calendar.changeView(buttonName); 10122 }; 10123 viewsWithButtons.push(buttonName); 10124 overrideText = viewSpec.buttonTextOverride; 10125 defaultText = viewSpec.buttonTextDefault; 10126 } 10127 else if (calendar[buttonName]) { // a calendar method 10128 buttonClick = function() { 10129 calendar[buttonName](); 10130 }; 10131 overrideText = (calendar.overrides.buttonText || {})[buttonName]; 10132 defaultText = calendar.options.buttonText[buttonName]; // everything else is considered default 10133 } 10134 10135 if (buttonClick) { 10136 10137 themeIcon = 10138 customButtonProps ? 10139 customButtonProps.themeIcon : 10140 calendar.options.themeButtonIcons[buttonName]; 10141 10142 normalIcon = 10143 customButtonProps ? 10144 customButtonProps.icon : 10145 calendar.options.buttonIcons[buttonName]; 10146 10147 if (overrideText) { 10148 innerHtml = htmlEscape(overrideText); 10149 } 10150 else if (themeIcon && calendar.options.theme) { 10151 innerHtml = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>"; 10152 } 10153 else if (normalIcon && !calendar.options.theme) { 10154 innerHtml = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>"; 10155 } 10156 else { 10157 innerHtml = htmlEscape(defaultText); 10158 } 10159 10160 classes = [ 10161 'fc-' + buttonName + '-button', 10162 tm + '-button', 10163 tm + '-state-default' 10164 ]; 10165 10166 button = $( // type="button" so that it doesn't submit a form 10167 '<button type="button" class="' + classes.join(' ') + '">' + 10168 innerHtml + 10169 '</button>' 10170 ) 10171 .click(function(ev) { 10172 // don't process clicks for disabled buttons 10173 if (!button.hasClass(tm + '-state-disabled')) { 10174 10175 buttonClick(ev); 10176 10177 // after the click action, if the button becomes the "active" tab, or disabled, 10178 // it should never have a hover class, so remove it now. 10179 if ( 10180 button.hasClass(tm + '-state-active') || 10181 button.hasClass(tm + '-state-disabled') 10182 ) { 10183 button.removeClass(tm + '-state-hover'); 10184 } 10185 } 10186 }) 10187 .mousedown(function() { 10188 // the *down* effect (mouse pressed in). 10189 // only on buttons that are not the "active" tab, or disabled 10190 button 10191 .not('.' + tm + '-state-active') 10192 .not('.' + tm + '-state-disabled') 10193 .addClass(tm + '-state-down'); 10194 }) 10195 .mouseup(function() { 10196 // undo the *down* effect 10197 button.removeClass(tm + '-state-down'); 10198 }) 10199 .hover( 10200 function() { 10201 // the *hover* effect. 10202 // only on buttons that are not the "active" tab, or disabled 10203 button 10204 .not('.' + tm + '-state-active') 10205 .not('.' + tm + '-state-disabled') 10206 .addClass(tm + '-state-hover'); 10207 }, 10208 function() {
10209 // undo the *hover* effect 10210 button 10211 .removeClass(tm + '-state-hover') 10212 .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup 10213 } 10214 ); 10215 10216 groupChildren = groupChildren.add(button); 10217 } 10218 } 10219 }); 10220 10221 if (isOnlyButtons) { 10222 groupChildren 10223 .first().addClass(tm + '-corner-left').end() 10224 .last().addClass(tm + '-corner-right').end(); 10225 } 10226 10227 if (groupChildren.length > 1) { 10228 groupEl = $('<div/>'); 10229 if (isOnlyButtons) { 10230 groupEl.addClass('fc-button-group'); 10231 } 10232 groupEl.append(groupChildren); 10233 sectionEl.append(groupEl); 10234 } 10235 else { 10236 sectionEl.append(groupChildren); // 1 or 0 children 10237 } 10238 }); 10239 } 10240 10241 return sectionEl; 10242 } 10243 10244 10245 function updateTitle(text) { 10246 if (el) { 10247 el.find('h2').text(text); 10248 } 10249 } 10250 10251 10252 function activateButton(buttonName) { 10253 if (el) { 10254 el.find('.fc-' + buttonName + '-button') 10255 .addClass(tm + '-state-active'); 10256 } 10257 } 10258 10259 10260 function deactivateButton(buttonName) { 10261 if (el) { 10262 el.find('.fc-' + buttonName + '-button') 10263 .removeClass(tm + '-state-active'); 10264 } 10265 } 10266 10267 10268 function disableButton(buttonName) { 10269 if (el) { 10270 el.find('.fc-' + buttonName + '-button') 10271 .prop('disabled', true) 10272 .addClass(tm + '-state-disabled'); 10273 } 10274 } 10275 10276 10277 function enableButton(buttonName) { 10278 if (el) { 10279 el.find('.fc-' + buttonName + '-button') 10280 .prop('disabled', false) 10281 .removeClass(tm + '-state-disabled'); 10282 } 10283 } 10284 10285 10286 function getViewsWithButtons() { 10287 return viewsWithButtons; 10288 } 10289 10290} 10291 10292;; 10293 10294var Calendar = FC.Calendar = Class.extend({ 10295 10296 dirDefaults: null, // option defaults related to LTR or RTL 10297 localeDefaults: null, // option defaults related to current locale 10298 overrides: null, // option overrides given to the fullCalendar constructor 10299 dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides. 10300 options: null, // all defaults combined with overrides 10301 viewSpecCache: null, // cache of view definitions 10302 view: null, // current View object 10303 header: null, 10304 footer: null, 10305 loadingLevel: 0, // number of simultaneous loading tasks 10306 10307 10308 // a lot of this class' OOP logic is scoped within this constructor function, 10309 // but in the future, write individual methods on the prototype. 10310 constructor: Calendar_constructor, 10311 10312 10313 // Subclasses can override this for initialization logic after the constructor has been called 10314 initialize: function() { 10315 }, 10316 10317 10318 // Computes the flattened options hash for the calendar and assigns to `this.options`. 10319 // Assumes this.overrides and this.dynamicOverrides have already been initialized. 10320 populateOptionsHash: function() { 10321 var locale, localeDefaults; 10322 var isRTL, dirDefaults; 10323 10324 locale = firstDefined( // explicit locale option given? 10325 this.dynamicOverrides.locale, 10326 this.overrides.locale 10327 ); 10328 localeDefaults = localeOptionHash[locale]; 10329 if (!localeDefaults) { // explicit locale option not given or invalid? 10330 locale = Calendar.defaults.locale; 10331 localeDefaults = localeOptionHash[locale] || {}; 10332 } 10333 10334 isRTL = firstDefined( // based on options computed so far, is direction RTL? 10335 this.dynamicOverrides.isRTL, 10336 this.overrides.isRTL, 10337 localeDefaults.isRTL, 10338 Calendar.defaults.isRTL 10339 ); 10340 dirDefaults = isRTL ? Calendar.rtlDefaults : {}; 10341 10342 this.dirDefaults = dirDefaults; 10343 this.localeDefaults = localeDefaults; 10344 this.options = mergeOptions([ // merge defaults and overrides. lowest to highest precedence 10345 Calendar.defaults, // global defaults 10346 dirDefaults, 10347 localeDefaults, 10348 this.overrides, 10349 this.dynamicOverrides 10350 ]); 10351 populateInstanceComputableOptions(this.options); // fill in gaps with computed options 10352 }, 10353 10354 10355 // Gets information about how to create a view. Will use a cache. 10356 getViewSpec: function(viewType) { 10357 var cache = this.viewSpecCache; 10358 10359 return cache[viewType] || (cache[viewType] = this.buildViewSpec(viewType)); 10360 }, 10361 10362 10363 // Given a duration singular unit, like "week" or "day", finds a matching view spec. 10364 // Preference is given to views that have corresponding buttons. 10365 getUnitViewSpec: function(unit) { 10366 var viewTypes; 10367 var i; 10368 var spec; 10369 10370 if ($.inArray(unit, intervalUnits) != -1) { 10371 10372 // put views that have buttons first. there will be duplicates, but oh well 10373 viewTypes = this.header.getViewsWithButtons(); // TODO: include footer as well? 10374 $.each(FC.views, function(viewType) { // all views 10375 viewTypes.push(viewType); 10376 }); 10377 10378 for (i = 0; i < viewTypes.length; i++) { 10379 spec = this.getViewSpec(viewTypes[i]); 10380 if (spec) { 10381 if (spec.singleUnit == unit) { 10382 return spec; 10383 } 10384 } 10385 } 10386 } 10387 }, 10388 10389 10390 // Builds an object with information on how to create a given view 10391 buildViewSpec: function(requestedViewType) { 10392 var viewOverrides = this.overrides.views || {}; 10393 var specChain = []; // for the view. lowest to highest priority 10394 var defaultsChain = []; // for the view. lowest to highest priority 10395 var overridesChain = []; // for the view. lowest to highest priority 10396 var viewType = requestedViewType; 10397 var spec; // for the view 10398 var overrides; // for the view 10399 var duration; 10400 var unit; 10401 10402 // iterate from the specific view definition to a more general one until we hit an actual View class 10403 while (viewType) { 10404 spec = fcViews[viewType];
10405 overrides = viewOverrides[viewType]; 10406 viewType = null; // clear. might repopulate for another iteration 10407 10408 if (typeof spec === 'function') { // TODO: deprecate 10409 spec = { 'class': spec }; 10410 } 10411 10412 if (spec) { 10413 specChain.unshift(spec); 10414 defaultsChain.unshift(spec.defaults || {}); 10415 duration = duration || spec.duration; 10416 viewType = viewType || spec.type; 10417 } 10418 10419 if (overrides) { 10420 overridesChain.unshift(overrides); // view-specific option hashes have options at zero-level 10421 duration = duration || overrides.duration; 10422 viewType = viewType || overrides.type; 10423 } 10424 } 10425 10426 spec = mergeProps(specChain); 10427 spec.type = requestedViewType; 10428 if (!spec['class']) { 10429 return false; 10430 } 10431 10432 if (duration) { 10433 duration = moment.duration(duration); 10434 if (duration.valueOf()) { // valid? 10435 spec.duration = duration; 10436 unit = computeIntervalUnit(duration); 10437 10438 // view is a single-unit duration, like "week" or "day" 10439 // incorporate options for this. lowest priority 10440 if (duration.as(unit) === 1) { 10441 spec.singleUnit = unit; 10442 overridesChain.unshift(viewOverrides[unit] || {}); 10443 } 10444 } 10445 } 10446 10447 spec.defaults = mergeOptions(defaultsChain); 10448 spec.overrides = mergeOptions(overridesChain); 10449 10450 this.buildViewSpecOptions(spec); 10451 this.buildViewSpecButtonText(spec, requestedViewType); 10452 10453 return spec; 10454 }, 10455 10456 10457 // Builds and assigns a view spec's options object from its already-assigned defaults and overrides 10458 buildViewSpecOptions: function(spec) { 10459 spec.options = mergeOptions([ // lowest to highest priority 10460 Calendar.defaults, // global defaults 10461 spec.defaults, // view's defaults (from ViewSubclass.defaults) 10462 this.dirDefaults, 10463 this.localeDefaults, // locale and dir take precedence over view's defaults! 10464 this.overrides, // calendar's overrides (options given to constructor) 10465 spec.overrides, // view's overrides (view-specific options) 10466 this.dynamicOverrides // dynamically set via setter. highest precedence 10467 ]); 10468 populateInstanceComputableOptions(spec.options); 10469 }, 10470 10471 10472 // Computes and assigns a view spec's buttonText-related options 10473 buildViewSpecButtonText: function(spec, requestedViewType) { 10474 10475 // given an options object with a possible `buttonText` hash, lookup the buttonText for the 10476 // requested view, falling back to a generic unit entry like "week" or "day" 10477 function queryButtonText(options) { 10478 var buttonText = options.buttonText || {}; 10479 return buttonText[requestedViewType] || 10480 // view can decide to look up a certain key 10481 (spec.buttonTextKey ? buttonText[spec.buttonTextKey] : null) || 10482 // a key like "month" 10483 (spec.singleUnit ? buttonText[spec.singleUnit] : null); 10484 } 10485 10486 // highest to lowest priority 10487 spec.buttonTextOverride = 10488 queryButtonText(this.dynamicOverrides) || 10489 queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence 10490 spec.overrides.buttonText; // `buttonText` for view-specific options is a string 10491 10492 // highest to lowest priority. mirrors buildViewSpecOptions 10493 spec.buttonTextDefault = 10494 queryButtonText(this.localeDefaults) || 10495 queryButtonText(this.dirDefaults) || 10496 spec.defaults.buttonText || // a single string. from ViewSubclass.defaults 10497 queryButtonText(Calendar.defaults) || 10498 (spec.duration ? this.humanizeDuration(spec.duration) : null) || // like "3 days" 10499 requestedViewType; // fall back to given view name 10500 }, 10501 10502 10503 // Given a view name for a custom view or a standard view, creates a ready-to-go View object 10504 instantiateView: function(viewType) { 10505 var spec = this.getViewSpec(viewType); 10506 10507 return new spec['class'](this, viewType, spec.options, spec.duration); 10508 }, 10509 10510 10511 // Returns a boolean about whether the view is okay to instantiate at some point 10512 isValidViewType: function(viewType) { 10513 return Boolean(this.getViewSpec(viewType)); 10514 }, 10515 10516 10517 // Should be called when any type of async data fetching begins 10518 pushLoading: function() { 10519 if (!(this.loadingLevel++)) { 10520 this.publiclyTrigger('loading', null, true, this.view); 10521 } 10522 }, 10523 10524 10525 // Should be called when any type of async data fetching completes 10526 popLoading: function() { 10527 if (!(--this.loadingLevel)) { 10528 this.publiclyTrigger('loading', null, false, this.view); 10529 } 10530 }, 10531 10532 10533 // Given arguments to the select method in the API, returns a span (unzoned start/end and other info) 10534 buildSelectSpan: function(zonedStartInput, zonedEndInput) { 10535 var start = this.moment(zonedStartInput).stripZone(); 10536 var end; 10537 10538 if (zonedEndInput) { 10539 end = this.moment(zonedEndInput).stripZone(); 10540 } 10541 else if (start.hasTime()) { 10542 end = start.clone().add(this.defaultTimedEventDuration); 10543 } 10544 else { 10545 end = start.clone().add(this.defaultAllDayEventDuration); 10546 } 10547 10548 return { start: start, end: end }; 10549 } 10550 10551}); 10552 10553 10554Calendar.mixin(EmitterMixin); 10555 10556 10557function Calendar_constructor(element, overrides) { 10558 var t = this; 10559 10560 // declare the current calendar instance relies on GlobalEmitter. needed for garbage collection. 10561 GlobalEmitter.needed(); 10562 10563 10564 // Exports 10565 // ----------------------------------------------------------------------------------- 10566 10567 t.render = render; 10568 t.destroy = destroy; 10569 t.rerenderEvents = rerenderEvents; 10570 t.changeView = renderView; // `renderView` will switch to another view 10571 t.select = select; 10572 t.unselect = unselect; 10573 t.prev = prev; 10574 t.next = next; 10575 t.prevYear = prevYear; 10576 t.nextYear = nextYear; 10577 t.today = today; 10578 t.gotoDate = gotoDate; 10579 t.incrementDate = incrementDate; 10580 t.zoomTo = zoomTo; 10581 t.getDate = getDate; 10582 t.getCalendar = getCalendar; 10583 t.getView = getView; 10584 t.option = option; // getter/setter method 10585 t.publiclyTrigger = publiclyTrigger; 10586 10587 10588 // Options 10589 // ----------------------------------------------------------------------------------- 10590 10591 t.dynamicOverrides = {}; 10592 t.viewSpecCache = {}; 10593 t.optionHandlers = {}; // for Calendar.options.js 10594 t.overrides = $.extend({}, overrides); // make a copy 10595 10596 t.populateOptionsHash(); // sets this.options 10597 10598 10599 10600 // Locale-data Internals 10601 // ----------------------------------------------------------------------------------- 10602 // Apply overrides to the current locale's data 10603 10604 var localeData; 10605
10606 // Called immediately, and when any of the options change. 10607 // Happens before any internal objects rebuild or rerender, because this is very core. 10608 t.bindOptions([ 10609 'locale', 'monthNames', 'monthNamesShort', 'dayNames', 'dayNamesShort', 'firstDay', 'weekNumberCalculation' 10610 ], function(locale, monthNames, monthNamesShort, dayNames, dayNamesShort, firstDay, weekNumberCalculation) { 10611 10612 // normalize 10613 if (weekNumberCalculation === 'iso') { 10614 weekNumberCalculation = 'ISO'; // normalize 10615 } 10616 10617 localeData = createObject( // make a cheap copy 10618 getMomentLocaleData(locale) // will fall back to en 10619 ); 10620 10621 if (monthNames) { 10622 localeData._months = monthNames; 10623 } 10624 if (monthNamesShort) { 10625 localeData._monthsShort = monthNamesShort; 10626 } 10627 if (dayNames) { 10628 localeData._weekdays = dayNames; 10629 } 10630 if (dayNamesShort) { 10631 localeData._weekdaysShort = dayNamesShort; 10632 } 10633 10634 if (firstDay == null && weekNumberCalculation === 'ISO') { 10635 firstDay = 1; 10636 } 10637 if (firstDay != null) { 10638 var _week = createObject(localeData._week); // _week: { dow: # } 10639 _week.dow = firstDay; 10640 localeData._week = _week; 10641 } 10642 10643 if ( // whitelist certain kinds of input 10644 weekNumberCalculation === 'ISO' || 10645 weekNumberCalculation === 'local' || 10646 typeof weekNumberCalculation === 'function' 10647 ) { 10648 localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it 10649 } 10650 10651 // If the internal current date object already exists, move to new locale. 10652 // We do NOT need to do this technique for event dates, because this happens when converting to "segments". 10653 if (date) { 10654 localizeMoment(date); // sets to localeData 10655 } 10656 }); 10657 10658 10659 // Calendar-specific Date Utilities 10660 // ----------------------------------------------------------------------------------- 10661 10662 10663 t.defaultAllDayEventDuration = moment.duration(t.options.defaultAllDayEventDuration); 10664 t.defaultTimedEventDuration = moment.duration(t.options.defaultTimedEventDuration); 10665 10666 10667 // Builds a moment using the settings of the current calendar: timezone and locale. 10668 // Accepts anything the vanilla moment() constructor accepts. 10669 t.moment = function() { 10670 var mom; 10671 10672 if (t.options.timezone === 'local') { 10673 mom = FC.moment.apply(null, arguments); 10674 10675 // Force the moment to be local, because FC.moment doesn't guarantee it. 10676 if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone 10677 mom.local(); 10678 } 10679 } 10680 else if (t.options.timezone === 'UTC') { 10681 mom = FC.moment.utc.apply(null, arguments); // process as UTC 10682 } 10683 else { 10684 mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone 10685 } 10686 10687 localizeMoment(mom); 10688 10689 return mom; 10690 }; 10691 10692 10693 // Updates the given moment's locale settings to the current calendar locale settings. 10694 function localizeMoment(mom) { 10695 mom._locale = localeData; 10696 } 10697 t.localizeMoment = localizeMoment; 10698 10699 10700 // Returns a boolean about whether or not the calendar knows how to calculate 10701 // the timezone offset of arbitrary dates in the current timezone. 10702 t.getIsAmbigTimezone = function() { 10703 return t.options.timezone !== 'local' && t.options.timezone !== 'UTC'; 10704 }; 10705 10706 10707 // Returns a copy of the given date in the current timezone. Has no effect on dates without times. 10708 t.applyTimezone = function(date) { 10709 if (!date.hasTime()) { 10710 return date.clone(); 10711 } 10712 10713 var zonedDate = t.moment(date.toArray()); 10714 var timeAdjust = date.time() - zonedDate.time(); 10715 var adjustedZonedDate; 10716 10717 // Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396) 10718 if (timeAdjust) { // is the time result different than expected? 10719 adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds 10720 if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now? 10721 zonedDate = adjustedZonedDate; 10722 } 10723 } 10724 10725 return zonedDate; 10726 }; 10727 10728 10729 // Returns a moment for the current date, as defined by the client's computer or from the `now` option. 10730 // Will return an moment with an ambiguous timezone. 10731 t.getNow = function() { 10732 var now = t.options.now; 10733 if (typeof now === 'function') { 10734 now = now(); 10735 } 10736 return t.moment(now).stripZone(); 10737 }; 10738 10739 10740 // Get an event's normalized end date. If not present, calculate it from the defaults. 10741 t.getEventEnd = function(event) {
10742 if (event.end) { 10743 return event.end.clone(); 10744 } 10745 else { 10746 return t.getDefaultEventEnd(event.allDay, event.start); 10747 } 10748 }; 10749 10750 10751 // Given an event's allDay status and start date, return what its fallback end date should be. 10752 // TODO: rename to computeDefaultEventEnd 10753 t.getDefaultEventEnd = function(allDay, zonedStart) { 10754 var end = zonedStart.clone(); 10755 10756 if (allDay) { 10757 end.stripTime().add(t.defaultAllDayEventDuration); 10758 } 10759 else { 10760 end.add(t.defaultTimedEventDuration); 10761 } 10762 10763 if (t.getIsAmbigTimezone()) { 10764 end.stripZone(); // we don't know what the tzo should be 10765 } 10766 10767 return end; 10768 }; 10769 10770 10771 // Produces a human-readable string for the given duration. 10772 // Side-effect: changes the locale of the given duration. 10773 t.humanizeDuration = function(duration) { 10774 return duration.locale(t.options.locale).humanize(); 10775 }; 10776 10777 10778 10779 // Imports 10780 // ----------------------------------------------------------------------------------- 10781 10782 10783 EventManager.call(t); 10784 10785 10786 10787 // Locals 10788 // ----------------------------------------------------------------------------------- 10789 10790 10791 var _element = element[0]; 10792 var toolbarsManager; 10793 var header; 10794 var footer; 10795 var content; 10796 var tm; // for making theme classes 10797 var currentView; // NOTE: keep this in sync with this.view 10798 var viewsByType = {}; // holds all instantiated view instances, current or not 10799 var suggestedViewHeight; 10800 var windowResizeProxy; // wraps the windowResize function 10801 var ignoreWindowResize = 0; 10802 var date; // unzoned 10803 10804 10805 10806 // Main Rendering 10807 // ----------------------------------------------------------------------------------- 10808 10809 10810 // compute the initial ambig-timezone date 10811 if (t.options.defaultDate != null) { 10812 date = t.moment(t.options.defaultDate).stripZone(); 10813 } 10814 else { 10815 date = t.getNow(); // getNow already returns unzoned 10816 } 10817 10818 10819 function render() { 10820 if (!content) { 10821 initialRender(); 10822 } 10823 else if (elementVisible()) { 10824 // mainly for the public API 10825 calcSize(); 10826 renderView(); 10827 } 10828 } 10829 10830 10831 function initialRender() { 10832 element.addClass('fc'); 10833 10834 // event delegation for nav links 10835 element.on('click.fc', 'a[data-goto]', function(ev) { 10836 var anchorEl = $(this); 10837 var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON 10838 var date = t.moment(gotoOptions.date); 10839 var viewType = gotoOptions.type; 10840 10841 // property like "navLinkDayClick". might be a string or a function 10842 var customAction = currentView.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click'); 10843 10844 if (typeof customAction === 'function') { 10845 customAction(date, ev); 10846 } 10847 else { 10848 if (typeof customAction === 'string') { 10849 viewType = customAction; 10850 } 10851 zoomTo(date, viewType); 10852 } 10853 }); 10854 10855 // called immediately, and upon option change 10856 t.bindOption('theme', function(theme) { 10857 tm = theme ? 'ui' : 'fc'; // affects a larger scope 10858 element.toggleClass('ui-widget', theme); 10859 element.toggleClass('fc-unthemed', !theme); 10860 }); 10861 10862 // called immediately, and upon option change. 10863 // HACK: locale often affects isRTL, so we explicitly listen to that too. 10864 t.bindOptions([ 'isRTL', 'locale' ], function(isRTL) { 10865 element.toggleClass('fc-ltr', !isRTL); 10866 element.toggleClass('fc-rtl', isRTL); 10867 }); 10868 10869 content = $("<div class='fc-view-container'/>").prependTo(element); 10870 10871 var toolbars = buildToolbars(); 10872 toolbarsManager = new Iterator(toolbars); 10873 10874 header = t.header = toolbars[0]; 10875 footer = t.footer = toolbars[1]; 10876 10877 renderHeader(); 10878 renderFooter(); 10879 renderView(t.options.defaultView); 10880 10881 if (t.options.handleWindowResize) { 10882 windowResizeProxy = debounce(windowResize, t.options.windowResizeDelay); // prevents rapid calls 10883 $(window).resize(windowResizeProxy); 10884 } 10885 } 10886 10887 10888 function destroy() { 10889 10890 if (currentView) { 10891 currentView.removeElement(); 10892 10893 // NOTE: don't null-out currentView/t.view in case API methods are called after destroy. 10894 // It is still the "current" view, just not rendered. 10895 } 10896 10897 toolbarsManager.proxyCall('removeElement'); 10898 content.remove(); 10899 element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget'); 10900 10901 element.off('.fc'); // unbind nav link handlers 10902 10903 if (windowResizeProxy) { 10904 $(window).unbind('resize', windowResizeProxy); 10905 } 10906 10907 GlobalEmitter.unneeded(); 10908 } 10909 10910 10911 function elementVisible() { 10912 return element.is(':visible'); 10913 } 10914 10915 10916 10917 // View Rendering 10918 // ----------------------------------------------------------------------------------- 10919 10920 10921 // Renders a view because of a date change, view-type change, or for the first time. 10922 // If not given a viewType, keep the current view but render different dates. 10923 // Accepts an optional scroll state to restore to. 10924 function renderView(viewType, forcedScroll) { 10925 ignoreWindowResize++; 10926 10927 var needsClearView = currentView && viewType && currentView.type !== viewType; 10928 10929 // if viewType is changing, remove the old view's rendering 10930 if (needsClearView) { 10931 freezeContentHeight(); // prevent a scroll jump when view element is removed 10932 clearView(); 10933 } 10934 10935 // if viewType changed, or the view was never created, create a fresh view 10936 if (!currentView && viewType) { 10937 currentView = t.view = 10938 viewsByType[viewType] || 10939 (viewsByType[viewType] = t.instantiateView(viewType)); 10940 10941 currentView.setElement( 10942 $("<div class='fc-view fc-" + viewType + "-view' />").appendTo(content) 10943 ); 10944 toolbarsManager.proxyCall('activateButton', viewType); 10945 } 10946 10947 if (currentView) { 10948 10949 // in case the view should render a period of time that is completely hidden 10950 date = currentView.massageCurrentDate(date); 10951 10952 // render or rerender the view 10953 if ( 10954 !currentView.isDateSet || 10955 !( // NOT within interval range signals an implicit date window change 10956 date >= currentView.intervalStart && 10957 date < currentView.intervalEnd 10958 ) 10959 ) { 10960 if (elementVisible()) { 10961 10962 if (forcedScroll) { 10963 currentView.captureInitialScroll(forcedScroll); 10964 } 10965 10966 currentView.setDate(date, forcedScroll); 10967 10968 if (forcedScroll) { 10969 currentView.releaseScroll(); 10970 } 10971 10972 // need to do this after View::render, so dates are calculated 10973 // NOTE: view updates title text proactively 10974 updateToolbarsTodayButton(); 10975 } 10976 } 10977 } 10978 10979 if (needsClearView) { 10980 thawContentHeight(); 10981 } 10982 10983 ignoreWindowResize--; 10984 } 10985 10986 10987 // Unrenders the current view and reflects this change in the Header. 10988 // Unregsiters the `currentView`, but does not remove from viewByType hash. 10989 function clearView() { 10990 toolbarsManager.proxyCall('deactivateButton', currentView.type);
10991 currentView.removeElement(); 10992 currentView = t.view = null; 10993 } 10994 10995 10996 // Destroys the view, including the view object. Then, re-instantiates it and renders it. 10997 // Maintains the same scroll state. 10998 // TODO: maintain any other user-manipulated state. 10999 function reinitView() { 11000 ignoreWindowResize++; 11001 freezeContentHeight(); 11002 11003 var viewType = currentView.type; 11004 var scrollState = currentView.queryScroll(); 11005 clearView(); 11006 calcSize(); 11007 renderView(viewType, scrollState); 11008 11009 thawContentHeight(); 11010 ignoreWindowResize--; 11011 } 11012 11013 11014 11015 // Resizing 11016 // ----------------------------------------------------------------------------------- 11017 11018 11019 t.getSuggestedViewHeight = function() { 11020 if (suggestedViewHeight === undefined) { 11021 calcSize(); 11022 } 11023 return suggestedViewHeight; 11024 }; 11025 11026 11027 t.isHeightAuto = function() { 11028 return t.options.contentHeight === 'auto' || t.options.height === 'auto'; 11029 }; 11030 11031 11032 function updateSize(shouldRecalc) { 11033 if (elementVisible()) { 11034 11035 if (shouldRecalc) { 11036 _calcSize(); 11037 } 11038 11039 ignoreWindowResize++; 11040 currentView.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto() 11041 ignoreWindowResize--; 11042 11043 return true; // signal success 11044 } 11045 } 11046 11047 11048 function calcSize() { 11049 if (elementVisible()) { 11050 _calcSize(); 11051 } 11052 } 11053 11054 11055 function _calcSize() { // assumes elementVisible 11056 var contentHeightInput = t.options.contentHeight; 11057 var heightInput = t.options.height; 11058 11059 if (typeof contentHeightInput === 'number') { // exists and not 'auto' 11060 suggestedViewHeight = contentHeightInput; 11061 } 11062 else if (typeof contentHeightInput === 'function') { // exists and is a function 11063 suggestedViewHeight = contentHeightInput(); 11064 } 11065 else if (typeof heightInput === 'number') { // exists and not 'auto' 11066 suggestedViewHeight = heightInput - queryToolbarsHeight(); 11067 } 11068 else if (typeof heightInput === 'function') { // exists and is a function 11069 suggestedViewHeight = heightInput() - queryToolbarsHeight(); 11070 } 11071 else if (heightInput === 'parent') { // set to height of parent element 11072 suggestedViewHeight = element.parent().height() - queryToolbarsHeight(); 11073 } 11074 else { 11075 suggestedViewHeight = Math.round(content.width() / Math.max(t.options.aspectRatio, .5)); 11076 } 11077 } 11078 11079 11080 function queryToolbarsHeight() { 11081 return toolbarsManager.items.reduce(function(accumulator, toolbar) { 11082 var toolbarHeight = toolbar.el ? toolbar.el.outerHeight(true) : 0; // includes margin 11083 return accumulator + toolbarHeight; 11084 }, 0); 11085 } 11086 11087 11088 function windowResize(ev) { 11089 if ( 11090 !ignoreWindowResize && 11091 ev.target === window && // so we don't process jqui "resize" events that have bubbled up 11092 currentView.start // view has already been rendered 11093 ) { 11094 if (updateSize(true)) { 11095 currentView.publiclyTrigger('windowResize', _element); 11096 } 11097 } 11098 } 11099 11100 11101 11102 /* Event Rendering 11103 -----------------------------------------------------------------------------*/ 11104 11105 11106 function rerenderEvents() { // API method. destroys old events if previously rendered. 11107 if (elementVisible()) { 11108 t.reportEventChange(); // will re-trasmit events to the view, causing a rerender 11109 } 11110 } 11111 11112 11113 11114 /* Toolbars 11115 -----------------------------------------------------------------------------*/ 11116 11117 11118 function buildToolbars() { 11119 return [ 11120 new Toolbar(t, computeHeaderOptions()), 11121 new Toolbar(t, computeFooterOptions()) 11122 ]; 11123 } 11124 11125 11126 function computeHeaderOptions() { 11127 return { 11128 extraClasses: 'fc-header-toolbar', 11129 layout: t.options.header 11130 }; 11131 } 11132 11133 11134 function computeFooterOptions() { 11135 return { 11136 extraClasses: 'fc-footer-toolbar', 11137 layout: t.options.footer 11138 }; 11139 } 11140 11141 11142 // can be called repeatedly and Header will rerender 11143 function renderHeader() { 11144 header.setToolbarOptions(computeHeaderOptions()); 11145 header.render(); 11146 if (header.el) { 11147 element.prepend(header.el); 11148 } 11149 } 11150 11151 11152 // can be called repeatedly and Footer will rerender 11153 function renderFooter() { 11154 footer.setToolbarOptions(computeFooterOptions()); 11155 footer.render(); 11156 if (footer.el) { 11157 element.append(footer.el); 11158 } 11159 } 11160 11161 11162 t.setToolbarsTitle = function(title) { 11163 toolbarsManager.proxyCall('updateTitle', title); 11164 }; 11165 11166 11167 function updateToolbarsTodayButton() { 11168 var now = t.getNow(); 11169 if (now >= currentView.intervalStart && now < currentView.intervalEnd) { 11170 toolbarsManager.proxyCall('disableButton', 'today'); 11171 } 11172 else { 11173 toolbarsManager.proxyCall('enableButton', 'today'); 11174 } 11175 } 11176 11177 11178 11179 /* Selection 11180 -----------------------------------------------------------------------------*/ 11181 11182 11183 // this public method receives start/end dates in any format, with any timezone 11184 function select(zonedStartInput, zonedEndInput) { 11185 currentView.select( 11186 t.buildSelectSpan.apply(t, arguments) 11187 ); 11188 } 11189 11190 11191 function unselect() { // safe to be called before renderView 11192 if (currentView) { 11193 currentView.unselect(); 11194 } 11195 } 11196 11197 11198 11199 /* Date 11200 -----------------------------------------------------------------------------*/ 11201 11202 11203 function prev() { 11204 date = currentView.computePrevDate(date); 11205 renderView(); 11206 } 11207 11208 11209 function next() { 11210 date = currentView.computeNextDate(date); 11211 renderView(); 11212 } 11213 11214 11215 function prevYear() { 11216 date.add(-1, 'years'); 11217 renderView(); 11218 } 11219 11220 11221 function nextYear() { 11222 date.add(1, 'years'); 11223 renderView(); 11224 } 11225 11226 11227 function today() { 11228 date = t.getNow(); 11229 renderView(); 11230 } 11231 11232 11233 function gotoDate(zonedDateInput) { 11234 date = t.moment(zonedDateInput).stripZone(); 11235 renderView(); 11236 } 11237 11238 11239 function incrementDate(delta) { 11240 date.add(moment.duration(delta)); 11241 renderView(); 11242 } 11243 11244
11245 // Forces navigation to a view for the given date. 11246 // `viewType` can be a specific view name or a generic one like "week" or "day". 11247 function zoomTo(newDate, viewType) { 11248 var spec; 11249 11250 viewType = viewType || 'day'; // day is default zoom 11251 spec = t.getViewSpec(viewType) || t.getUnitViewSpec(viewType); 11252 11253 date = newDate.clone(); 11254 renderView(spec ? spec.type : null); 11255 } 11256 11257 11258 // for external API 11259 function getDate() { 11260 return t.applyTimezone(date); // infuse the calendar's timezone 11261 } 11262 11263 11264 11265 /* Height "Freezing" 11266 -----------------------------------------------------------------------------*/ 11267 11268 11269 t.freezeContentHeight = freezeContentHeight; 11270 t.thawContentHeight = thawContentHeight; 11271 11272 var freezeContentHeightDepth = 0; 11273 11274 11275 function freezeContentHeight() { 11276 if (!(freezeContentHeightDepth++)) { 11277 content.css({ 11278 width: '100%', 11279 height: content.height(), 11280 overflow: 'hidden' 11281 }); 11282 } 11283 } 11284 11285 11286 function thawContentHeight() { 11287 if (!(--freezeContentHeightDepth)) { 11288 content.css({ 11289 width: '', 11290 height: '', 11291 overflow: '' 11292 }); 11293 } 11294 } 11295 11296 11297 11298 /* Misc 11299 -----------------------------------------------------------------------------*/ 11300 11301 11302 function getCalendar() { 11303 return t; 11304 } 11305 11306 11307 function getView() { 11308 return currentView; 11309 } 11310 11311 11312 function option(name, value) { 11313 var newOptionHash; 11314 11315 if (typeof name === 'string') { 11316 if (value === undefined) { // getter 11317 return t.options[name]; 11318 } 11319 else { // setter for individual option 11320 newOptionHash = {}; 11321 newOptionHash[name] = value; 11322 setOptions(newOptionHash); 11323 } 11324 } 11325 else if (typeof name === 'object') { // compound setter with object input 11326 setOptions(name); 11327 } 11328 } 11329 11330 11331 function setOptions(newOptionHash) { 11332 var optionCnt = 0; 11333 var optionName; 11334 11335 for (optionName in newOptionHash) { 11336 t.dynamicOverrides[optionName] = newOptionHash[optionName]; 11337 } 11338 11339 t.viewSpecCache = {}; // the dynamic override invalidates the options in this cache, so just clear it 11340 t.populateOptionsHash(); // this.options needs to be recomputed after the dynamic override 11341 11342 // trigger handlers after this.options has been updated 11343 for (optionName in newOptionHash) { 11344 t.triggerOptionHandlers(optionName); // recall bindOption/bindOptions 11345 optionCnt++; 11346 } 11347 11348 // special-case handling of single option change. 11349 // if only one option change, `optionName` will be its name. 11350 if (optionCnt === 1) { 11351 if (optionName === 'height' || optionName === 'contentHeight' || optionName === 'aspectRatio') { 11352 updateSize(true); // true = allow recalculation of height 11353 return; 11354 } 11355 else if (optionName === 'defaultDate') { 11356 return; // can't change date this way. use gotoDate instead 11357 } 11358 else if (optionName === 'businessHours') { 11359 if (currentView) { 11360 currentView.unrenderBusinessHours(); 11361 currentView.renderBusinessHours(); 11362 } 11363 return; 11364 } 11365 else if (optionName === 'timezone') { 11366 t.rezoneArrayEventSources(); 11367 t.refetchEvents(); 11368 return; 11369 } 11370 } 11371 11372 // catch-all. rerender the header and footer and rebuild/rerender the current view 11373 renderHeader(); 11374 renderFooter(); 11375 viewsByType = {}; // even non-current views will be affected by this option change. do before rerender 11376 reinitView(); 11377 } 11378 11379 11380 function publiclyTrigger(name, thisObj) { 11381 var args = Array.prototype.slice.call(arguments, 2); 11382 11383 thisObj = thisObj || _element; 11384 this.triggerWith(name, thisObj, args); // Emitter's method 11385 11386 if (t.options[name]) { 11387 return t.options[name].apply(thisObj, args); 11388 } 11389 } 11390 11391 t.initialize(); 11392} 11393 11394;; 11395/* 11396Options binding/triggering system. 11397*/ 11398Calendar.mixin({ 11399 11400 // A map of option names to arrays of handler objects. Initialized to {} in Calendar. 11401 // Format for a handler object: 11402 // { 11403 // func // callback function to be called upon change 11404 // names // option names whose values should be given to func 11405 // } 11406 optionHandlers: null, 11407 11408 // Calls handlerFunc immediately, and when the given option has changed. 11409 // handlerFunc will be given the option value. 11410 bindOption: function(optionName, handlerFunc) { 11411 this.bindOptions([ optionName ], handlerFunc); 11412 }, 11413 11414 // Calls handlerFunc immediately, and when any of the given options change. 11415 // handlerFunc will be given each option value as ordered function arguments. 11416 bindOptions: function(optionNames, handlerFunc) { 11417 var handlerObj = { func: handlerFunc, names: optionNames }; 11418 var i; 11419 11420 for (i = 0; i < optionNames.length; i++) { 11421 this.registerOptionHandlerObj(optionNames[i], handlerObj); 11422 } 11423 11424 this.triggerOptionHandlerObj(handlerObj); 11425 }, 11426 11427 // Puts the given handler object into the internal hash 11428 registerOptionHandlerObj: function(optionName, handlerObj) { 11429 (this.optionHandlers[optionName] || (this.optionHandlers[optionName] = [])) 11430 .push(handlerObj); 11431 }, 11432 11433 // Reports that the given option has changed, and calls all appropriate handlers. 11434 triggerOptionHandlers: function(optionName) { 11435 var handlerObjs = this.optionHandlers[optionName] || []; 11436 var i; 11437 11438 for (i = 0; i < handlerObjs.length; i++) { 11439 this.triggerOptionHandlerObj(handlerObjs[i]); 11440 } 11441 }, 11442 11443 // Calls the callback for a specific handler object, passing in the appropriate arguments. 11444 triggerOptionHandlerObj: function(handlerObj) { 11445 var optionNames = handlerObj.names; 11446 var optionValues = []; 11447 var i; 11448 11449 for (i = 0; i < optionNames.length; i++) { 11450 optionValues.push(this.options[optionNames[i]]); 11451 } 11452 11453 handlerObj.func.apply(this, optionValues); // maintain the Calendar's `this` context 11454 } 11455 11456}); 11457 11458;; 11459 11460Calendar.defaults = { 11461 11462 titleRangeSeparator: ' \u2013 ', // en dash 11463 monthYearFormat: 'MMMM YYYY', // required for en. other locales rely on datepicker computable option 11464 11465 defaultTimedEventDuration: '02:00:00', 11466 defaultAllDayEventDuration: { days: 1 }, 11467 forceEventDuration: false, 11468 nextDayThreshold: '09:00:00', // 9am 11469 11470 // display 11471 defaultView: 'month', 11472 aspectRatio: 1.35, 11473 header: { 11474 left: 'title', 11475 center: '', 11476 right: 'today prev,next' 11477 }, 11478 weekends: true, 11479 weekNumbers: false, 11480 11481 weekNumberTitle: 'W', 11482 weekNumberCalculation: 'local', 11483 11484 //editable: false, 11485 11486 //nowIndicator: false, 11487 11488 scrollTime: '06:00:00', 11489 11490 // event ajax 11491 lazyFetching: true, 11492 startParam: 'start', 11493 endParam: 'end', 11494 timezoneParam: 'timezone', 11495 11496 timezone: false, 11497 11498 //allDayDefault: undefined, 11499 11500 // locale 11501 isRTL: false, 11502 buttonText: { 11503 prev: "prev", 11504 next: "next", 11505 prevYear: "prev year", 11506 nextYear: "next year", 11507 year: 'year', // TODO: locale files need to specify this 11508 today: 'today', 11509 month: 'month', 11510 week: 'week', 11511 day: 'day' 11512 }, 11513 11514 buttonIcons: { 11515 prev: 'left-single-arrow', 11516 next: 'right-single-arrow', 11517 prevYear: 'left-double-arrow', 11518 nextYear: 'right-double-arrow' 11519 }, 11520 11521 allDayText: 'all-day', 11522 11523 // jquery-ui theming 11524 theme: false, 11525 themeButtonIcons: { 11526 prev: 'circle-triangle-w', 11527 next: 'circle-triangle-e', 11528 prevYear: 'seek-prev', 11529 nextYear: 'seek-next' 11530 }, 11531 11532 //eventResizableFromStart: false, 11533 dragOpacity: .75, 11534 dragRevertDuration: 500, 11535 dragScroll: true, 11536 11537 //selectable: false, 11538 unselectAuto: true, 11539 //selectMinDistance: 0, 11540 11541 dropAccept: '*', 11542 11543 eventOrder: 'title',
11544 //eventRenderWait: null, 11545 11546 eventLimit: false, 11547 eventLimitText: 'more', 11548 eventLimitClick: 'popover', 11549 dayPopoverFormat: 'LL', 11550 11551 handleWindowResize: true, 11552 windowResizeDelay: 100, // milliseconds before an updateSize happens 11553 11554 longPressDelay: 1000 11555 11556}; 11557 11558 11559Calendar.englishDefaults = { // used by locale.js 11560 dayPopoverFormat: 'dddd, MMMM D' 11561}; 11562 11563 11564Calendar.rtlDefaults = { // right-to-left defaults 11565 header: { // TODO: smarter solution (first/center/last ?) 11566 left: 'next,prev today', 11567 center: '', 11568 right: 'title' 11569 }, 11570 buttonIcons: { 11571 prev: 'right-single-arrow', 11572 next: 'left-single-arrow', 11573 prevYear: 'right-double-arrow', 11574 nextYear: 'left-double-arrow' 11575 }, 11576 themeButtonIcons: { 11577 prev: 'circle-triangle-e', 11578 next: 'circle-triangle-w', 11579 nextYear: 'seek-prev', 11580 prevYear: 'seek-next' 11581 } 11582}; 11583 11584;; 11585 11586var localeOptionHash = FC.locales = {}; // initialize and expose 11587 11588 11589// TODO: document the structure and ordering of a FullCalendar locale file 11590 11591 11592// Initialize jQuery UI datepicker translations while using some of the translations 11593// Will set this as the default locales for datepicker. 11594FC.datepickerLocale = function(localeCode, dpLocaleCode, dpOptions) { 11595 11596 // get the FullCalendar internal option hash for this locale. create if necessary 11597 var fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {}); 11598 11599 // transfer some simple options from datepicker to fc 11600 fcOptions.isRTL = dpOptions.isRTL; 11601 fcOptions.weekNumberTitle = dpOptions.weekHeader; 11602 11603 // compute some more complex options from datepicker 11604 $.each(dpComputableOptions, function(name, func) { 11605 fcOptions[name] = func(dpOptions); 11606 }); 11607 11608 // is jQuery UI Datepicker is on the page? 11609 if ($.datepicker) { 11610 11611 // Register the locale data. 11612 // FullCalendar and MomentJS use locale codes like "pt-br" but Datepicker 11613 // does it like "pt-BR" or if it doesn't have the locale, maybe just "pt". 11614 // Make an alias so the locale can be referenced either way. 11615 $.datepicker.regional[dpLocaleCode] = 11616 $.datepicker.regional[localeCode] = // alias 11617 dpOptions; 11618 11619 // Alias 'en' to the default locale data. Do this every time. 11620 $.datepicker.regional.en = $.datepicker.regional['']; 11621 11622 // Set as Datepicker's global defaults. 11623 $.datepicker.setDefaults(dpOptions); 11624 } 11625}; 11626 11627 11628// Sets FullCalendar-specific translations. Will set the locales as the global default. 11629FC.locale = function(localeCode, newFcOptions) { 11630 var fcOptions; 11631 var momOptions; 11632 11633 // get the FullCalendar internal option hash for this locale. create if necessary 11634 fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {}); 11635 11636 // provided new options for this locales? merge them in 11637 if (newFcOptions) { 11638 fcOptions = localeOptionHash[localeCode] = mergeOptions([ fcOptions, newFcOptions ]); 11639 } 11640 11641 // compute locale options that weren't defined. 11642 // always do this. newFcOptions can be undefined when initializing from i18n file, 11643 // so no way to tell if this is an initialization or a default-setting. 11644 momOptions = getMomentLocaleData(localeCode); // will fall back to en 11645 $.each(momComputableOptions, function(name, func) { 11646 if (fcOptions[name] == null) { 11647 fcOptions[name] = func(momOptions, fcOptions); 11648 } 11649 }); 11650 11651 // set it as the default locale for FullCalendar 11652 Calendar.defaults.locale = localeCode; 11653}; 11654 11655 11656// NOTE: can't guarantee any of these computations will run because not every locale has datepicker 11657// configs, so make sure there are English fallbacks for these in the defaults file. 11658var dpComputableOptions = { 11659 11660 buttonText: function(dpOptions) { 11661 return { 11662 // the translations sometimes wrongly contain HTML entities 11663 prev: stripHtmlEntities(dpOptions.prevText), 11664 next: stripHtmlEntities(dpOptions.nextText), 11665 today: stripHtmlEntities(dpOptions.currentText) 11666 }; 11667 }, 11668 11669 // Produces format strings like "MMMM YYYY" -> "September 2014" 11670 monthYearFormat: function(dpOptions) { 11671 return dpOptions.showMonthAfterYear ? 11672 'YYYY[' + dpOptions.yearSuffix + '] MMMM' : 11673 'MMMM YYYY[' + dpOptions.yearSuffix + ']'; 11674 } 11675 11676}; 11677 11678var momComputableOptions = { 11679 11680 // Produces format strings like "ddd M/D" -> "Fri 9/15" 11681 dayOfMonthFormat: function(momOptions, fcOptions) { 11682 var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY" 11683 11684 // strip the year off the edge, as well as other misc non-whitespace chars
11685 format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, ''); 11686 11687 if (fcOptions.isRTL) { 11688 format += ' ddd'; // for RTL, add day-of-week to end 11689 } 11690 else { 11691 format = 'ddd ' + format; // for LTR, add day-of-week to beginning 11692 } 11693 return format; 11694 }, 11695 11696 // Produces format strings like "h:mma" -> "6:00pm" 11697 mediumTimeFormat: function(momOptions) { // can't be called `timeFormat` because collides with option 11698 return momOptions.longDateFormat('LT') 11699 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand 11700 }, 11701 11702 // Produces format strings like "h(:mm)a" -> "6pm" / "6:30pm" 11703 smallTimeFormat: function(momOptions) { 11704 return momOptions.longDateFormat('LT') 11705 .replace(':mm', '(:mm)') 11706 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales 11707 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand 11708 }, 11709 11710 // Produces format strings like "h(:mm)t" -> "6p" / "6:30p" 11711 extraSmallTimeFormat: function(momOptions) { 11712 return momOptions.longDateFormat('LT') 11713 .replace(':mm', '(:mm)') 11714 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales 11715 .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand 11716 }, 11717 11718 // Produces format strings like "ha" / "H" -> "6pm" / "18" 11719 hourFormat: function(momOptions) { 11720 return momOptions.longDateFormat('LT') 11721 .replace(':mm', '') 11722 .replace(/(\Wmm)$/, '') // like above, but for foreign locales 11723 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand 11724 }, 11725 11726 // Produces format strings like "h:mm" -> "6:30" (with no AM/PM) 11727 noMeridiemTimeFormat: function(momOptions) { 11728 return momOptions.longDateFormat('LT') 11729 .replace(/\s*a$/i, ''); // remove trailing AM/PM 11730 } 11731 11732}; 11733 11734 11735// options that should be computed off live calendar options (considers override options) 11736// TODO: best place for this? related to locale? 11737// TODO: flipping text based on isRTL is a bad idea because the CSS `direction` might want to handle it 11738var instanceComputableOptions = { 11739 11740 // Produces format strings for results like "Mo 16" 11741 smallDayDateFormat: function(options) { 11742 return options.isRTL ? 11743 'D dd' : 11744 'dd D'; 11745 }, 11746 11747 // Produces format strings for results like "Wk 5" 11748 weekFormat: function(options) { 11749 return options.isRTL ? 11750 'w[ ' + options.weekNumberTitle + ']' : 11751 '[' + options.weekNumberTitle + ' ]w'; 11752 }, 11753 11754 // Produces format strings for results like "Wk5" 11755 smallWeekFormat: function(options) { 11756 return options.isRTL ? 11757 'w[' + options.weekNumberTitle + ']' : 11758 '[' + options.weekNumberTitle + ']w'; 11759 } 11760 11761}; 11762 11763function populateInstanceComputableOptions(options) { 11764 $.each(instanceComputableOptions, function(name, func) { 11765 if (options[name] == null) { 11766 options[name] = func(options); 11767 } 11768 }); 11769} 11770 11771 11772// Returns moment's internal locale data. If doesn't exist, returns English. 11773function getMomentLocaleData(localeCode) { 11774 return moment.localeData(localeCode) || moment.localeData('en'); 11775} 11776 11777 11778// Initialize English by forcing computation of moment-derived options. 11779// Also, sets it as the default. 11780FC.locale('en', Calendar.englishDefaults); 11781 11782;; 11783 11784FC.sourceNormalizers = []; 11785FC.sourceFetchers = []; 11786 11787var ajaxDefaults = { 11788 dataType: 'json', 11789 cache: false 11790}; 11791 11792var eventGUID = 1; 11793 11794 11795function EventManager() { // assumed to be a calendar 11796 var t = this; 11797 11798 11799 // exports 11800 t.requestEvents = requestEvents; 11801 t.reportEventChange = reportEventChange; 11802 t.isFetchNeeded = isFetchNeeded; 11803 t.fetchEvents = fetchEvents; 11804 t.fetchEventSources = fetchEventSources; 11805 t.refetchEvents = refetchEvents; 11806 t.refetchEventSources = refetchEventSources; 11807 t.getEventSources = getEventSources; 11808 t.getEventSourceById = getEventSourceById; 11809 t.addEventSource = addEventSource; 11810 t.removeEventSource = removeEventSource; 11811 t.removeEventSources = removeEventSources; 11812 t.updateEvent = updateEvent; 11813 t.updateEvents = updateEvents; 11814 t.renderEvent = renderEvent; 11815 t.renderEvents = renderEvents; 11816 t.removeEvents = removeEvents; 11817 t.clientEvents = clientEvents; 11818 t.mutateEvent = mutateEvent; 11819 t.normalizeEventDates = normalizeEventDates; 11820 t.normalizeEventTimes = normalizeEventTimes; 11821 11822 11823 // locals 11824 var stickySource = { events: [] }; 11825 var sources = [ stickySource ]; 11826 var rangeStart, rangeEnd; 11827 var pendingSourceCnt = 0; // outstanding fetch requests, max one per source 11828 var cache = []; // holds events that have already been expanded 11829 var prunedCache; // like cache, but only events that intersect with rangeStart/rangeEnd 11830 11831 11832 $.each(
11833 (t.options.events ? [ t.options.events ] : []).concat(t.options.eventSources || []), 11834 function(i, sourceInput) { 11835 var source = buildEventSource(sourceInput); 11836 if (source) { 11837 sources.push(source); 11838 } 11839 } 11840 ); 11841 11842 11843 11844 function requestEvents(start, end) { 11845 if (!t.options.lazyFetching || isFetchNeeded(start, end)) { 11846 return fetchEvents(start, end); 11847 } 11848 else { 11849 return Promise.resolve(prunedCache); 11850 } 11851 } 11852 11853 11854 function reportEventChange() { 11855 prunedCache = filterEventsWithinRange(cache); 11856 t.trigger('eventsReset', prunedCache); 11857 } 11858 11859 11860 function filterEventsWithinRange(events) { 11861 var filteredEvents = []; 11862 var i, event; 11863 11864 for (i = 0; i < events.length; i++) { 11865 event = events[i]; 11866 11867 if ( 11868 event.start.clone().stripZone() < rangeEnd && 11869 t.getEventEnd(event).stripZone() > rangeStart 11870 ) { 11871 filteredEvents.push(event); 11872 } 11873 } 11874 11875 return filteredEvents; 11876 } 11877 11878 11879 t.getEventCache = function() { 11880 return cache; 11881 }; 11882 11883 11884 t.getPrunedEventCache = function() { 11885 return prunedCache; 11886 }; 11887 11888 11889 11890 /* Fetching 11891 -----------------------------------------------------------------------------*/ 11892 11893 11894 // start and end are assumed to be unzoned 11895 function isFetchNeeded(start, end) { 11896 return !rangeStart || // nothing has been fetched yet? 11897 start < rangeStart || end > rangeEnd; // is part of the new range outside of the old range? 11898 } 11899 11900 11901 function fetchEvents(start, end) { 11902 rangeStart = start; 11903 rangeEnd = end; 11904 return refetchEvents(); 11905 } 11906 11907 11908 // poorly named. fetches all sources with current `rangeStart` and `rangeEnd`. 11909 function refetchEvents() { 11910 return fetchEventSources(sources, 'reset'); 11911 } 11912 11913 11914 // poorly named. fetches a subset of event sources. 11915 function refetchEventSources(matchInputs) { 11916 return fetchEventSources(getEventSourcesByMatchArray(matchInputs)); 11917 } 11918 11919 11920 // expects an array of event source objects (the originals, not copies) 11921 // `specialFetchType` is an optimization parameter that affects purging of the event cache. 11922 function fetchEventSources(specificSources, specialFetchType) { 11923 var i, source; 11924 11925 if (specialFetchType === 'reset') { 11926 cache = []; 11927 } 11928 else if (specialFetchType !== 'add') { 11929 cache = excludeEventsBySources(cache, specificSources); 11930 } 11931 11932 for (i = 0; i < specificSources.length; i++) { 11933 source = specificSources[i]; 11934 11935 // already-pending sources have already been accounted for in pendingSourceCnt 11936 if (source._status !== 'pending') { 11937 pendingSourceCnt++; 11938 } 11939 11940 source._fetchId = (source._fetchId || 0) + 1; 11941 source._status = 'pending'; 11942 } 11943 11944 for (i = 0; i < specificSources.length; i++) { 11945 source = specificSources[i]; 11946 tryFetchEventSource(source, source._fetchId); 11947 } 11948 11949 if (pendingSourceCnt) { 11950 return new Promise(function(resolve) { 11951 t.one('eventsReceived', resolve); // will send prunedCache 11952 }); 11953 } 11954 else { // executed all synchronously, or no sources at all 11955 return Promise.resolve(prunedCache); 11956 } 11957 } 11958 11959 11960 // fetches an event source and processes its result ONLY if it is still the current fetch. 11961 // caller is responsible for incrementing pendingSourceCnt first. 11962 function tryFetchEventSource(source, fetchId) { 11963 _fetchEventSource(source, function(eventInputs) { 11964 var isArraySource = $.isArray(source.events); 11965 var i, eventInput; 11966 var abstractEvent; 11967 11968 if ( 11969 // is this the source's most recent fetch? 11970 // if not, rely on an upcoming fetch of this source to decrement pendingSourceCnt 11971 fetchId === source._fetchId && 11972 // event source no longer valid? 11973 source._status !== 'rejected' 11974 ) { 11975 source._status = 'resolved'; 11976 11977 if (eventInputs) { 11978 for (i = 0; i < eventInputs.length; i++) { 11979 eventInput = eventInputs[i]; 11980 11981 if (isArraySource) { // array sources have already been convert to Event Objects 11982 abstractEvent = eventInput; 11983 } 11984 else { 11985 abstractEvent = buildEventFromInput(eventInput, source); 11986 } 11987 11988 if (abstractEvent) { // not false (an invalid event) 11989 cache.push.apply( // append 11990 cache, 11991 expandEvent(abstractEvent) // add individual expanded events to the cache 11992 ); 11993 } 11994 } 11995 } 11996 11997 decrementPendingSourceCnt(); 11998 } 11999 }); 12000 } 12001 12002 12003 function rejectEventSource(source) {
12004 var wasPending = source._status === 'pending'; 12005 12006 source._status = 'rejected'; 12007 12008 if (wasPending) { 12009 decrementPendingSourceCnt(); 12010 } 12011 } 12012 12013 12014 function decrementPendingSourceCnt() { 12015 pendingSourceCnt--; 12016 if (!pendingSourceCnt) { 12017 reportEventChange(cache); // updates prunedCache 12018 t.trigger('eventsReceived', prunedCache); 12019 } 12020 } 12021 12022 12023 function _fetchEventSource(source, callback) { 12024 var i; 12025 var fetchers = FC.sourceFetchers; 12026 var res; 12027 12028 for (i=0; i<fetchers.length; i++) { 12029 res = fetchers[i].call( 12030 t, // this, the Calendar object 12031 source, 12032 rangeStart.clone(), 12033 rangeEnd.clone(), 12034 t.options.timezone, 12035 callback 12036 ); 12037 12038 if (res === true) { 12039 // the fetcher is in charge. made its own async request 12040 return; 12041 } 12042 else if (typeof res == 'object') { 12043 // the fetcher returned a new source. process it 12044 _fetchEventSource(res, callback); 12045 return; 12046 } 12047 } 12048 12049 var events = source.events; 12050 if (events) { 12051 if ($.isFunction(events)) { 12052 t.pushLoading(); 12053 events.call( 12054 t, // this, the Calendar object 12055 rangeStart.clone(), 12056 rangeEnd.clone(), 12057 t.options.timezone, 12058 function(events) { 12059 callback(events); 12060 t.popLoading(); 12061 } 12062 ); 12063 } 12064 else if ($.isArray(events)) { 12065 callback(events); 12066 } 12067 else { 12068 callback(); 12069 } 12070 }else{ 12071 var url = source.url; 12072 if (url) { 12073 var success = source.success; 12074 var error = source.error; 12075 var complete = source.complete; 12076 12077 // retrieve any outbound GET/POST $.ajax data from the options 12078 var customData; 12079 if ($.isFunction(source.data)) { 12080 // supplied as a function that returns a key/value object 12081 customData = source.data(); 12082 } 12083 else { 12084 // supplied as a straight key/value object 12085 customData = source.data; 12086 } 12087 12088 // use a copy of the custom data so we can modify the parameters 12089 // and not affect the passed-in object. 12090 var data = $.extend({}, customData || {}); 12091 12092 var startParam = firstDefined(source.startParam, t.options.startParam); 12093 var endParam = firstDefined(source.endParam, t.options.endParam); 12094 var timezoneParam = firstDefined(source.timezoneParam, t.options.timezoneParam); 12095 12096 if (startParam) { 12097 data[startParam] = rangeStart.format(); 12098 } 12099 if (endParam) { 12100 data[endParam] = rangeEnd.format(); 12101 } 12102 if (t.options.timezone && t.options.timezone != 'local') { 12103 data[timezoneParam] = t.options.timezone; 12104 } 12105 12106 t.pushLoading(); 12107 $.ajax($.extend({}, ajaxDefaults, source, { 12108 data: data, 12109 success: function(events) { 12110 events = events || []; 12111 var res = applyAll(success, this, arguments); 12112 if ($.isArray(res)) { 12113 events = res; 12114 } 12115 callback(events); 12116 }, 12117 error: function() { 12118 applyAll(error, this, arguments); 12119 callback(); 12120 }, 12121 complete: function() { 12122 applyAll(complete, this, arguments); 12123 t.popLoading(); 12124 } 12125 })); 12126 }else{ 12127 callback(); 12128 } 12129 } 12130 } 12131 12132 12133 12134 /* Sources 12135 -----------------------------------------------------------------------------*/ 12136 12137 12138 function addEventSource(sourceInput) { 12139 var source = buildEventSource(sourceInput); 12140 if (source) { 12141 sources.push(source); 12142 fetchEventSources([ source ], 'add'); // will eventually call reportEventChange 12143 } 12144 } 12145 12146 12147 function buildEventSource(sourceInput) { // will return undefined if invalid source 12148 var normalizers = FC.sourceNormalizers; 12149 var source; 12150 var i; 12151 12152 if ($.isFunction(sourceInput) || $.isArray(sourceInput)) { 12153 source = { events: sourceInput }; 12154 } 12155 else if (typeof sourceInput === 'string') { 12156 source = { url: sourceInput }; 12157 } 12158 else if (typeof sourceInput === 'object') { 12159 source = $.extend({}, sourceInput); // shallow copy 12160 } 12161 12162 if (source) { 12163 12164 // TODO: repeat code, same code for event classNames 12165 if (source.className) { 12166 if (typeof source.className === 'string') { 12167 source.className = source.className.split(/\s+/); 12168 } 12169 // otherwise, assumed to be an array 12170 } 12171 else { 12172 source.className = []; 12173 } 12174 12175 // for array sources, we convert to standard Event Objects up front 12176 if ($.isArray(source.events)) { 12177 source.origArray = source.events; // for removeEventSource 12178 source.events = $.map(source.events, function(eventInput) { 12179 return buildEventFromInput(eventInput, source); 12180 }); 12181 } 12182 12183 for (i=0; i<normalizers.length; i++) { 12184 normalizers[i].call(t, source); 12185 } 12186 12187 return source; 12188 } 12189 } 12190 12191 12192 function removeEventSource(matchInput) { 12193 removeSpecificEventSources( 12194 getEventSourcesByMatch(matchInput) 12195 ); 12196 } 12197 12198 12199 // if called with no arguments, removes all. 12200 function removeEventSources(matchInputs) { 12201 if (matchInputs == null) { 12202 removeSpecificEventSources(sources, true); // isAll=true 12203 } 12204 else { 12205 removeSpecificEventSources( 12206 getEventSourcesByMatchArray(matchInputs) 12207 ); 12208 } 12209 } 12210 12211 12212 function removeSpecificEventSources(targetSources, isAll) { 12213 var i; 12214 12215 // cancel pending requests 12216 for (i = 0; i < targetSources.length; i++) { 12217 rejectEventSource(targetSources[i]); 12218 } 12219 12220 if (isAll) { // an optimization 12221 sources = []; 12222 cache = []; 12223 } 12224 else { 12225 // remove from persisted source list 12226 sources = $.grep(sources, function(source) { 12227 for (i = 0; i < targetSources.length; i++) { 12228 if (source === targetSources[i]) { 12229 return false; // exclude 12230 } 12231 } 12232 return true; // include 12233 }); 12234 12235 cache = excludeEventsBySources(cache, targetSources); 12236 } 12237 12238 reportEventChange(); 12239 } 12240 12241 12242 function getEventSources() { 12243 return sources.slice(1); // returns a shallow copy of sources with stickySource removed 12244 } 12245 12246 12247 function getEventSourceById(id) { 12248 return $.grep(sources, function(source) { 12249 return source.id && source.id === id; 12250 })[0]; 12251 } 12252 12253 12254 // like getEventSourcesByMatch, but accepts multple match criteria (like multiple IDs) 12255 function getEventSourcesByMatchArray(matchInputs) { 12256 12257 // coerce into an array 12258 if (!matchInputs) { 12259 matchInputs = []; 12260 } 12261 else if (!$.isArray(matchInputs)) { 12262 matchInputs = [ matchInputs ]; 12263 } 12264 12265 var matchingSources = []; 12266 var i; 12267 12268 // resolve raw inputs to real event source objects 12269 for (i = 0; i < matchInputs.length; i++) { 12270 matchingSources.push.apply( // append 12271 matchingSources, 12272 getEventSourcesByMatch(matchInputs[i]) 12273 ); 12274 } 12275 12276 return matchingSources; 12277 } 12278 12279 12280 // matchInput can either by a real event source object, an ID, or the function/URL for the source. 12281 // returns an array of matching source objects. 12282 function getEventSourcesByMatch(matchInput) { 12283 var i, source; 12284 12285 // given an proper event source object 12286 for (i = 0; i < sources.length; i++) { 12287 source = sources[i]; 12288 if (source === matchInput) { 12289 return [ source ]; 12290 } 12291 } 12292 12293 // an ID match 12294 source = getEventSourceById(matchInput); 12295 if (source) { 12296 return [ source ]; 12297 } 12298 12299 return $.grep(sources, function(source) { 12300 return isSourcesEquivalent(matchInput, source); 12301 }); 12302 } 12303 12304 12305 function isSourcesEquivalent(source1, source2) { 12306 return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2); 12307 } 12308 12309 12310 function getSourcePrimitive(source) { 12311 return ( 12312 (typeof source === 'object') ? // a normalized event source? 12313 (source.origArray || source.googleCalendarId || source.url || source.events) : // get the primitive 12314 null 12315 ) || 12316 source; // the given argument *is* the primitive 12317 } 12318 12319 12320 // util 12321 // returns a filtered array without events that are part of any of the given sources 12322 function excludeEventsBySources(specificEvents, specificSources) { 12323 return $.grep(specificEvents, function(event) { 12324 for (var i = 0; i < specificSources.length; i++) { 12325 if (event.source === specificSources[i]) { 12326 return false; // exclude 12327 } 12328 } 12329 return true; // keep 12330 }); 12331 } 12332 12333 12334 12335 /* Manipulation 12336 -----------------------------------------------------------------------------*/ 12337 12338 12339 // Only ever called from the externally-facing API 12340 function updateEvent(event) { 12341 updateEvents([ event ]); 12342 } 12343 12344 12345 // Only ever called from the externally-facing API 12346 function updateEvents(events) { 12347 var i, event; 12348 12349 for (i = 0; i < events.length; i++) { 12350 event = events[i]; 12351 12352 // massage start/end values, even if date string values 12353 event.start = t.moment(event.start);
12354 if (event.end) { 12355 event.end = t.moment(event.end); 12356 } 12357 else { 12358 event.end = null; 12359 } 12360 12361 mutateEvent(event, getMiscEventProps(event)); // will handle start/end/allDay normalization 12362 } 12363 12364 reportEventChange(); // reports event modifications (so we can redraw) 12365 } 12366 12367 12368 // Returns a hash of misc event properties that should be copied over to related events. 12369 function getMiscEventProps(event) { 12370 var props = {}; 12371 12372 $.each(event, function(name, val) { 12373 if (isMiscEventPropName(name)) { 12374 if (val !== undefined && isAtomic(val)) { // a defined non-object 12375 props[name] = val; 12376 } 12377 } 12378 }); 12379 12380 return props; 12381 } 12382 12383 // non-date-related, non-id-related, non-secret 12384 function isMiscEventPropName(name) { 12385 return !/^_|^(id|allDay|start|end)$/.test(name); 12386 } 12387 12388 12389 // returns the expanded events that were created 12390 function renderEvent(eventInput, stick) { 12391 return renderEvents([ eventInput ], stick); 12392 } 12393 12394 12395 // returns the expanded events that were created 12396 function renderEvents(eventInputs, stick) { 12397 var renderedEvents = []; 12398 var renderableEvents; 12399 var abstractEvent; 12400 var i, j, event; 12401 12402 for (i = 0; i < eventInputs.length; i++) { 12403 abstractEvent = buildEventFromInput(eventInputs[i]); 12404 12405 if (abstractEvent) { // not false (a valid input) 12406 renderableEvents = expandEvent(abstractEvent); 12407 12408 for (j = 0; j < renderableEvents.length; j++) { 12409 event = renderableEvents[j]; 12410 12411 if (!event.source) { 12412 if (stick) { 12413 stickySource.events.push(event); 12414 event.source = stickySource; 12415 } 12416 cache.push(event); 12417 } 12418 } 12419 12420 renderedEvents = renderedEvents.concat(renderableEvents); 12421 } 12422 } 12423 12424 if (renderedEvents.length) { // any new events rendered? 12425 reportEventChange(); 12426 } 12427 12428 return renderedEvents; 12429 } 12430 12431 12432 function removeEvents(filter) { 12433 var eventID; 12434 var i; 12435 12436 if (filter == null) { // null or undefined. remove all events 12437 filter = function() { return true; }; // will always match 12438 } 12439 else if (!$.isFunction(filter)) { // an event ID 12440 eventID = filter + ''; 12441 filter = function(event) { 12442 return event._id == eventID; 12443 }; 12444 } 12445 12446 // Purge event(s) from our local cache 12447 cache = $.grep(cache, filter, true); // inverse=true 12448 12449 // Remove events from array sources. 12450 // This works because they have been converted to official Event Objects up front. 12451 // (and as a result, event._id has been calculated). 12452 for (i=0; i<sources.length; i++) { 12453 if ($.isArray(sources[i].events)) { 12454 sources[i].events = $.grep(sources[i].events, filter, true); 12455 } 12456 } 12457 12458 reportEventChange(); 12459 } 12460 12461 12462 function clientEvents(filter) { 12463 if ($.isFunction(filter)) { 12464 return $.grep(cache, filter); 12465 } 12466 else if (filter != null) { // not null, not undefined. an event ID 12467 filter += ''; 12468 return $.grep(cache, function(e) { 12469 return e._id == filter; 12470 }); 12471 } 12472 return cache; // else, return all 12473 } 12474 12475 12476 // Makes sure all array event sources have their internal event objects 12477 // converted over to the Calendar's current timezone. 12478 t.rezoneArrayEventSources = function() { 12479 var i; 12480 var events; 12481 var j; 12482 12483 for (i = 0; i < sources.length; i++) { 12484 events = sources[i].events; 12485 if ($.isArray(events)) { 12486 12487 for (j = 0; j < events.length; j++) { 12488 rezoneEventDates(events[j]); 12489 } 12490 } 12491 } 12492 }; 12493 12494 function rezoneEventDates(event) { 12495 event.start = t.moment(event.start); 12496 if (event.end) { 12497 event.end = t.moment(event.end); 12498 } 12499 backupEventDates(event); 12500 } 12501 12502 12503 /* Event Normalization 12504 -----------------------------------------------------------------------------*/ 12505 12506 12507 // Given a raw object with key/value properties, returns an "abstract" Event object. 12508 // An "abstract" event is an event that, if recurring, will not have been expanded yet. 12509 // Will return `false` when input is invalid. 12510 // `source` is optional 12511 function buildEventFromInput(input, source) { 12512 var out = {}; 12513 var start, end; 12514 var allDay; 12515 12516 if (t.options.eventDataTransform) { 12517 input = t.options.eventDataTransform(input); 12518 } 12519 if (source && source.eventDataTransform) { 12520 input = source.eventDataTransform(input); 12521 } 12522 12523 // Copy all properties over to the resulting object. 12524 // The special-case properties will be copied over afterwards. 12525 $.extend(out, input); 12526 12527 if (source) { 12528 out.source = source; 12529 } 12530 12531 out._id = input._id || (input.id === undefined ? '_fc' + eventGUID++ : input.id + ''); 12532 12533 if (input.className) { 12534 if (typeof input.className == 'string') { 12535 out.className = input.className.split(/\s+/); 12536 } 12537 else { // assumed to be an array 12538 out.className = input.className; 12539 } 12540 } 12541 else { 12542 out.className = []; 12543 } 12544 12545 start = input.start || input.date; // "date" is an alias for "start" 12546 end = input.end; 12547 12548 // parse as a time (Duration) if applicable
12549 if (isTimeString(start)) { 12550 start = moment.duration(start); 12551 } 12552 if (isTimeString(end)) { 12553 end = moment.duration(end); 12554 } 12555 12556 if (input.dow || moment.isDuration(start) || moment.isDuration(end)) { 12557 12558 // the event is "abstract" (recurring) so don't calculate exact start/end dates just yet 12559 out.start = start ? moment.duration(start) : null; // will be a Duration or null 12560 out.end = end ? moment.duration(end) : null; // will be a Duration or null 12561 out._recurring = true; // our internal marker 12562 } 12563 else { 12564 12565 if (start) { 12566 start = t.moment(start); 12567 if (!start.isValid()) { 12568 return false; 12569 } 12570 } 12571 12572 if (end) { 12573 end = t.moment(end); 12574 if (!end.isValid()) { 12575 end = null; // let defaults take over 12576 } 12577 } 12578 12579 allDay = input.allDay; 12580 if (allDay === undefined) { // still undefined? fallback to default 12581 allDay = firstDefined( 12582 source ? source.allDayDefault : undefined, 12583 t.options.allDayDefault 12584 ); 12585 // still undefined? normalizeEventDates will calculate it 12586 } 12587 12588 assignDatesToEvent(start, end, allDay, out); 12589 } 12590 12591 t.normalizeEvent(out); // hook for external use. a prototype method 12592 12593 return out; 12594 } 12595 t.buildEventFromInput = buildEventFromInput; 12596 12597 12598 // Normalizes and assigns the given dates to the given partially-formed event object. 12599 // NOTE: mutates the given start/end moments. does not make a copy. 12600 function assignDatesToEvent(start, end, allDay, event) { 12601 event.start = start; 12602 event.end = end; 12603 event.allDay = allDay; 12604 normalizeEventDates(event); 12605 backupEventDates(event); 12606 } 12607 12608 12609 // Ensures proper values for allDay/start/end. Accepts an Event object, or a plain object with event-ish properties. 12610 // NOTE: Will modify the given object. 12611 function normalizeEventDates(eventProps) { 12612 12613 normalizeEventTimes(eventProps); 12614 12615 if (eventProps.end && !eventProps.end.isAfter(eventProps.start)) { 12616 eventProps.end = null; 12617 } 12618 12619 if (!eventProps.end) { 12620 if (t.options.forceEventDuration) { 12621 eventProps.end = t.getDefaultEventEnd(eventProps.allDay, eventProps.start); 12622 } 12623 else { 12624 eventProps.end = null; 12625 } 12626 } 12627 } 12628 12629 12630 // Ensures the allDay property exists and the timeliness of the start/end dates are consistent 12631 function normalizeEventTimes(eventProps) { 12632 if (eventProps.allDay == null) { 12633 eventProps.allDay = !(eventProps.start.hasTime() || (eventProps.end && eventProps.end.hasTime())); 12634 } 12635 12636 if (eventProps.allDay) { 12637 eventProps.start.stripTime(); 12638 if (eventProps.end) { 12639 // TODO: consider nextDayThreshold here? If so, will require a lot of testing and adjustment 12640 eventProps.end.stripTime(); 12641 } 12642 } 12643 else { 12644 if (!eventProps.start.hasTime()) { 12645 eventProps.start = t.applyTimezone(eventProps.start.time(0)); // will assign a 00:00 time 12646 } 12647 if (eventProps.end && !eventProps.end.hasTime()) { 12648 eventProps.end = t.applyTimezone(eventProps.end.time(0)); // will assign a 00:00 time 12649 } 12650 } 12651 } 12652 12653 12654 // If the given event is a recurring event, break it down into an array of individual instances. 12655 // If not a recurring event, return an array with the single original event. 12656 // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array. 12657 // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours). 12658 function expandEvent(abstractEvent, _rangeStart, _rangeEnd) { 12659 var events = []; 12660 var dowHash; 12661 var dow; 12662 var i; 12663 var date; 12664 var startTime, endTime; 12665 var start, end; 12666 var event; 12667 12668 _rangeStart = _rangeStart || rangeStart; 12669 _rangeEnd = _rangeEnd || rangeEnd; 12670 12671 if (abstractEvent) { 12672 if (abstractEvent._recurring) { 12673 12674 // make a boolean hash as to whether the event occurs on each day-of-week 12675 if ((dow = abstractEvent.dow)) { 12676 dowHash = {}; 12677 for (i = 0; i < dow.length; i++) { 12678 dowHash[dow[i]] = true; 12679 } 12680 } 12681 12682 // iterate through every day in the current range 12683 date = _rangeStart.clone().stripTime(); // holds the date of the current day 12684 while (date.isBefore(_rangeEnd)) { 12685 12686 if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week 12687 12688 startTime = abstractEvent.start; // the stored start and end properties are times (Durations) 12689 endTime = abstractEvent.end; // " 12690 start = date.clone(); 12691 end = null; 12692 12693 if (startTime) { 12694 start = start.time(startTime); 12695 } 12696 if (endTime) { 12697 end = date.clone().time(endTime); 12698 } 12699 12700 event = $.extend({}, abstractEvent); // make a copy of the original 12701 assignDatesToEvent( 12702 start, end, 12703 !startTime && !endTime, // allDay? 12704 event 12705 ); 12706 events.push(event); 12707 } 12708 12709 date.add(1, 'days'); 12710 } 12711 } 12712 else { 12713 events.push(abstractEvent); // return the original event. will be a one-item array 12714 } 12715 } 12716 12717 return events; 12718 } 12719 t.expandEvent = expandEvent; 12720 12721 12722 12723 /* Event Modification Math 12724 -----------------------------------------------------------------------------------------*/ 12725 12726 12727 // Modifies an event and all related events by applying the given properties. 12728 // Special date-diffing logic is used for manipulation of dates. 12729 // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end. 12730 // All date comparisons are done against the event's pristine _start and _end dates. 12731 // Returns an object with delta information and a function to undo all operations. 12732 // For making computations in a granularity greater than day/time, specify largeUnit. 12733 // NOTE: The given `newProps` might be mutated for normalization purposes. 12734 function mutateEvent(event, newProps, largeUnit) { 12735 var miscProps = {}; 12736 var oldProps; 12737 var clearEnd; 12738 var startDelta; 12739 var endDelta; 12740 var durationDelta;
12741 var undoFunc; 12742 12743 // diffs the dates in the appropriate way, returning a duration 12744 function diffDates(date1, date0) { // date1 - date0 12745 if (largeUnit) { 12746 return diffByUnit(date1, date0, largeUnit); 12747 } 12748 else if (newProps.allDay) { 12749 return diffDay(date1, date0); 12750 } 12751 else { 12752 return diffDayTime(date1, date0); 12753 } 12754 } 12755 12756 newProps = newProps || {}; 12757 12758 // normalize new date-related properties 12759 if (!newProps.start) { 12760 newProps.start = event.start.clone(); 12761 } 12762 if (newProps.end === undefined) { 12763 newProps.end = event.end ? event.end.clone() : null; 12764 } 12765 if (newProps.allDay == null) { // is null or undefined? 12766 newProps.allDay = event.allDay; 12767 } 12768 normalizeEventDates(newProps); 12769 12770 // create normalized versions of the original props to compare against 12771 // need a real end value, for diffing 12772 oldProps = { 12773 start: event._start.clone(), 12774 end: event._end ? event._end.clone() : t.getDefaultEventEnd(event._allDay, event._start), 12775 allDay: newProps.allDay // normalize the dates in the same regard as the new properties 12776 }; 12777 normalizeEventDates(oldProps); 12778 12779 // need to clear the end date if explicitly changed to null 12780 clearEnd = event._end !== null && newProps.end === null; 12781 12782 // compute the delta for moving the start date 12783 startDelta = diffDates(newProps.start, oldProps.start); 12784 12785 // compute the delta for moving the end date 12786 if (newProps.end) { 12787 endDelta = diffDates(newProps.end, oldProps.end); 12788 durationDelta = endDelta.subtract(startDelta); 12789 } 12790 else { 12791 durationDelta = null; 12792 } 12793 12794 // gather all non-date-related properties 12795 $.each(newProps, function(name, val) { 12796 if (isMiscEventPropName(name)) { 12797 if (val !== undefined) { 12798 miscProps[name] = val; 12799 } 12800 } 12801 }); 12802 12803 // apply the operations to the event and all related events 12804 undoFunc = mutateEvents( 12805 clientEvents(event._id), // get events with this ID 12806 clearEnd, 12807 newProps.allDay, 12808 startDelta, 12809 durationDelta, 12810 miscProps 12811 ); 12812 12813 return { 12814 dateDelta: startDelta, 12815 durationDelta: durationDelta, 12816 undo: undoFunc 12817 }; 12818 } 12819 12820 12821 // Modifies an array of events in the following ways (operations are in order): 12822 // - clear the event's `end` 12823 // - convert the event to allDay 12824 // - add `dateDelta` to the start and end 12825 // - add `durationDelta` to the event's duration 12826 // - assign `miscProps` to the event 12827 // 12828 // Returns a function that can be called to undo all the operations. 12829 // 12830 // TODO: don't use so many closures. possible memory issues when lots of events with same ID. 12831 // 12832 function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) { 12833 var isAmbigTimezone = t.getIsAmbigTimezone(); 12834 var undoFunctions = []; 12835 12836 // normalize zero-length deltas to be null 12837 if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; } 12838 if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; } 12839 12840 $.each(events, function(i, event) { 12841 var oldProps; 12842 var newProps; 12843 12844 // build an object holding all the old values, both date-related and misc. 12845 // for the undo function. 12846 oldProps = { 12847 start: event.start.clone(), 12848 end: event.end ? event.end.clone() : null, 12849 allDay: event.allDay 12850 }; 12851 $.each(miscProps, function(name) { 12852 oldProps[name] = event[name]; 12853 }); 12854 12855 // new date-related properties. work off the original date snapshot. 12856 // ok to use references because they will be thrown away when backupEventDates is called. 12857 newProps = { 12858 start: event._start, 12859 end: event._end, 12860 allDay: allDay // normalize the dates in the same regard as the new properties 12861 }; 12862 normalizeEventDates(newProps); // massages start/end/allDay 12863 12864 // strip or ensure the end date 12865 if (clearEnd) { 12866 newProps.end = null; 12867 } 12868 else if (durationDelta && !newProps.end) { // the duration translation requires an end date 12869 newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start); 12870 } 12871 12872 if (dateDelta) { 12873 newProps.start.add(dateDelta); 12874 if (newProps.end) { 12875 newProps.end.add(dateDelta); 12876 } 12877 } 12878 12879 if (durationDelta) { 12880 newProps.end.add(durationDelta); // end already ensured above 12881 } 12882 12883 // if the dates have changed, and we know it is impossible to re
12883compute the 12884 // timezone offsets, strip the zone. 12885 if ( 12886 isAmbigTimezone && 12887 !newProps.allDay && 12888 (dateDelta || durationDelta) 12889 ) { 12890 newProps.start.stripZone(); 12891 if (newProps.end) { 12892 newProps.end.stripZone(); 12893 } 12894 } 12895 12896 $.extend(event, miscProps, newProps); // copy over misc props, then date-related props 12897 backupEventDates(event); // regenerate internal _start/_end/_allDay 12898 12899 undoFunctions.push(function() { 12900 $.extend(event, oldProps); 12901 backupEventDates(event); // regenerate internal _start/_end/_allDay 12902 }); 12903 }); 12904 12905 return function() { 12906 for (var i = 0; i < undoFunctions.length; i++) { 12907 undoFunctions[i](); 12908 } 12909 }; 12910 } 12911 12912} 12913 12914 12915// returns an undo function 12916Calendar.prototype.mutateSeg = function(seg, newProps) { 12917 return this.mutateEvent(seg.event, newProps); 12918}; 12919 12920 12921// hook for external libs to manipulate event properties upon creation. 12922// should manipulate the event in-place. 12923Calendar.prototype.normalizeEvent = function(event) { 12924}; 12925 12926 12927// Does the given span (start, end, and other location information) 12928// fully contain the other? 12929Calendar.prototype.spanContainsSpan = function(outerSpan, innerSpan) { 12930 var eventStart = outerSpan.start.clone().stripZone(); 12931 var eventEnd = this.getEventEnd(outerSpan).stripZone(); 12932 12933 return innerSpan.start >= eventStart && innerSpan.end <= eventEnd; 12934}; 12935 12936 12937// Returns a list of events that the given event should be compared against when being considered for a move to 12938// the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar. 12939Calendar.prototype.getPeerEvents = function(span, event) { 12940 var cache = this.getEventCache(); 12941 var peerEvents = []; 12942 var i, otherEvent; 12943 12944 for (i = 0; i < cache.length; i++) { 12945 otherEvent = cache[i]; 12946 if ( 12947 !event || 12948 event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events 12949 ) { 12950 peerEvents.push(otherEvent); 12951 } 12952 } 12953 12954 return peerEvents; 12955}; 12956 12957 12958// updates the "backup" properties, which are preserved in order to compute diffs later on. 12959function backupEventDates(event) { 12960 event._allDay = event.allDay; 12961 event._start = event.start.clone(); 12962 event._end = event.end ? event.end.clone() : null; 12963} 12964 12965 12966/* Overlapping / Constraining 12967-----------------------------------------------------------------------------------------*/ 12968 12969 12970// Determines if the given event can be relocated to the given span (unzoned start/end with other misc data) 12971Calendar.prototype.isEventSpanAllowed = function(span, event) { 12972 var source = event.source || {}; 12973 12974 var constraint = firstDefined( 12975 event.constraint, 12976 source.constraint, 12977 this.options.eventConstraint 12978 ); 12979 12980 var overlap = firstDefined( 12981 event.overlap, 12982 source.overlap, 12983 this.options.eventOverlap 12984 ); 12985 12986 return this.isSpanAllowed(span, constraint, overlap, event) && 12987 (!this.options.eventAllow || this.options.eventAllow(span, event) !== false); 12988}; 12989 12990 12991// Determines if an external event can be relocated to the given span (unzoned start/end with other misc data) 12992Calendar.prototype.isExternalSpanAllowed = function(eventSpan, eventLocation, eventProps) { 12993 var eventInput; 12994 var event; 12995 12996 // note: very similar logic is in View's reportExternalDrop 12997 if (eventProps) { 12998 eventInput = $.extend({}, eventProps, eventLocation); 12999 event = this.expandEvent( 13000 this.buildEventFromInput(eventInput) 13001 )[0]; 13002 } 13003 13004 if (event) { 13005 return this.isEventSpanAllowed(eventSpan, event); 13006 } 13007 else { // treat it as a selection 13008 13009 return this.isSelectionSpanAllowed(eventSpan); 13010 } 13011}; 13012 13013 13014// Determines the given span (unzoned start/end with other misc data) can be selected. 13015Calendar.prototype.isSelectionSpanAllowed = function(span) { 13016 return this.isSpanAllowed(span, this.options.selectConstraint, this.options.selectOverlap) && 13017 (!this.options.selectAllow || this.options.selectAllow(span) !== false); 13018}; 13019 13020 13021// Returns true if the given span (caused by an event drop/resize or a selection) is allowed to exist 13022// according to the constraint/overlap settings. 13023// `event` is not required if checking a selection. 13024Calendar.prototype.isSpanAllowed = function(span, constraint, overlap, event) { 13025 var constraintEvents; 13026 var anyContainment; 13027 var peerEvents; 13028 var i, peerEvent; 13029 var peerOverlap; 13030 13031 // the range must be fully contained by at least one of produced constraint events 13032 if (constraint != null) { 13033 13034 // not treated as an event! intermediate data structure 13035 // TODO: use ranges in the future 13036 constraintEvents = this.constraintToEvents(constraint); 13037 if (constraintEvents) { // not invalid 13038 13039 anyContainment = false;
13040 for (i = 0; i < constraintEvents.length; i++) { 13041 if (this.spanContainsSpan(constraintEvents[i], span)) { 13042 anyContainment = true; 13043 break; 13044 } 13045 } 13046 13047 if (!anyContainment) { 13048 return false; 13049 } 13050 } 13051 } 13052 13053 peerEvents = this.getPeerEvents(span, event); 13054 13055 for (i = 0; i < peerEvents.length; i++) { 13056 peerEvent = peerEvents[i]; 13057 13058 // there needs to be an actual intersection before disallowing anything 13059 if (this.eventIntersectsRange(peerEvent, span)) { 13060 13061 // evaluate overlap for the given range and short-circuit if necessary 13062 if (overlap === false) { 13063 return false; 13064 } 13065 // if the event's overlap is a test function, pass the peer event in question as the first param 13066 else if (typeof overlap === 'function' && !overlap(peerEvent, event)) { 13067 return false; 13068 } 13069 13070 // if we are computing if the given range is allowable for an event, consider the other event's 13071 // EventObject-specific or Source-specific `overlap` property 13072 if (event) { 13073 peerOverlap = firstDefined( 13074 peerEvent.overlap, 13075 (peerEvent.source || {}).overlap 13076 // we already considered the global `eventOverlap` 13077 ); 13078 if (peerOverlap === false) { 13079 return false; 13080 } 13081 // if the peer event's overlap is a test function, pass the subject event as the first param 13082 if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) { 13083 return false; 13084 } 13085 } 13086 } 13087 } 13088 13089 return true; 13090}; 13091 13092 13093// Given an event input from the API, produces an array of event objects. Possible event inputs: 13094// 'businessHours' 13095// An event ID (number or string) 13096// An object with specific start/end dates or a recurring event (like what businessHours accepts) 13097Calendar.prototype.constraintToEvents = function(constraintInput) { 13098 13099 if (constraintInput === 'businessHours') { 13100 return this.getCurrentBusinessHourEvents(); 13101 } 13102 13103 if (typeof constraintInput === 'object') { 13104 if (constraintInput.start != null) { // needs to be event-like input 13105 return this.expandEvent(this.buildEventFromInput(constraintInput)); 13106 } 13107 else { 13108 return null; // invalid 13109 } 13110 } 13111 13112 return this.clientEvents(constraintInput); // probably an ID 13113}; 13114 13115 13116// Does the event's date range intersect with the given range? 13117// start/end already assumed to have stripped zones :( 13118Calendar.prototype.eventIntersectsRange = function(event, range) { 13119 var eventStart = event.start.clone().stripZone(); 13120 var eventEnd = this.getEventEnd(event).stripZone(); 13121 13122 return range.start < eventEnd && range.end > eventStart; 13123}; 13124 13125 13126/* Business Hours 13127-----------------------------------------------------------------------------------------*/ 13128 13129var BUSINESS_HOUR_EVENT_DEFAULTS = { 13130 id: '_fcBusinessHours', // will relate events from different calls to expandEvent 13131 start: '09:00', 13132 end: '17:00', 13133 dow: [ 1, 2, 3, 4, 5 ], // monday - friday 13134 rendering: 'inverse-background' 13135 // classNames are defined in businessHoursSegClasses 13136}; 13137 13138// Return events objects for business hours within the current view. 13139// Abuse of our event system :( 13140Calendar.prototype.getCurrentBusinessHourEvents = function(wholeDay) { 13141 return this.computeBusinessHourEvents(wholeDay, this.options.businessHours); 13142}; 13143 13144// Given a raw input value from options, return events objects for business hours within the current view. 13145Calendar.prototype.computeBusinessHourEvents = function(wholeDay, input) { 13146 if (input === true) { 13147 return this.expandBusinessHourEvents(wholeDay, [ {} ]); 13148 } 13149 else if ($.isPlainObject(input)) { 13150 return this.expandBusinessHourEvents(wholeDay, [ input ]); 13151 } 13152 else if ($.isArray(input)) { 13153 return this.expandBusinessHourEvents(wholeDay, input, true); 13154 } 13155 else { 13156 return []; 13157 } 13158}; 13159
13160// inputs expected to be an array of objects. 13161// if ignoreNoDow is true, will ignore entries that don't specify a day-of-week (dow) key. 13162Calendar.prototype.expandBusinessHourEvents = function(wholeDay, inputs, ignoreNoDow) { 13163 var view = this.getView(); 13164 var events = []; 13165 var i, input; 13166 13167 for (i = 0; i < inputs.length; i++) { 13168 input = inputs[i]; 13169 13170 if (ignoreNoDow && !input.dow) { 13171 continue; 13172 } 13173 13174 // give defaults. will make a copy 13175 input = $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, input); 13176 13177 // if a whole-day series is requested, clear the start/end times 13178 if (wholeDay) { 13179 input.start = null; 13180 input.end = null; 13181 } 13182 13183 events.push.apply(events, // append 13184 this.expandEvent( 13185 this.buildEventFromInput(input), 13186 view.start, 13187 view.end 13188 ) 13189 ); 13190 } 13191 13192 return events; 13193}; 13194 13195;; 13196 13197/* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells. 13198----------------------------------------------------------------------------------------------------------------------*/ 13199// It is a manager for a DayGrid subcomponent, which does most of the heavy lifting. 13200// It is responsible for managing width/height. 13201 13202var BasicView = FC.BasicView = View.extend({ 13203 13204 scroller: null, 13205 13206 dayGridClass: DayGrid, // class the dayGrid will be instantiated from (overridable by subclasses) 13207 dayGrid: null, // the main subcomponent that does most of the heavy lifting 13208 13209 dayNumbersVisible: false, // display day numbers on each day cell? 13210 colWeekNumbersVisible: false, // display week numbers along the side? 13211 cellWeekNumbersVisible: false, // display week numbers in day cell? 13212 13213 weekNumberWidth: null, // width of all the week-number cells running down the side 13214 13215 headContainerEl: null, // div that hold's the dayGrid's rendered date header 13216 headRowEl: null, // the fake row element of the day-of-week header 13217 13218 13219 initialize: function() { 13220 this.dayGrid = this.instantiateDayGrid(); 13221 13222 this.scroller = new Scroller({ 13223 overflowX: 'hidden', 13224 overflowY: 'auto' 13225 }); 13226 }, 13227 13228 13229 // Generates the DayGrid object this view needs. Draws from this.dayGridClass 13230 instantiateDayGrid: function() { 13231 // generate a subclass on the fly with BasicView-specific behavior 13232 // TODO: cache this subclass 13233 var subclass = this.dayGridClass.extend(basicDayGridMethods); 13234 13235 return new subclass(this); 13236 }, 13237 13238 13239 // Sets the display range and computes all necessary dates 13240 setRange: function(range) { 13241 View.prototype.setRange.call(this, range); // call the super-method 13242 13243 this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange 13244 this.dayGrid.setRange(range); 13245 }, 13246 13247 13248 // Compute the value to feed into setRange. Overrides superclass. 13249 computeRange: function(date) { 13250 var range = View.prototype.computeRange.call(this, date); // get value from the super-method 13251 13252 // year and month views should be aligned with weeks. this is already done for week 13253 if (/year|month/.test(range.intervalUnit)) { 13254 range.start.startOf('week'); 13255 range.start = this.skipHiddenDays(range.start); 13256 13257 // make end-of-week if not already 13258 if (range.end.weekday()) { 13259 range.end.add(1, 'week').startOf('week'); 13260 range.end = this.skipHiddenDays(range.end, -1, true); // exclusively move backwards 13261 } 13262 } 13263 13264 return range; 13265 }, 13266 13267 13268 // Renders the view into `this.el`, which should already be assigned 13269 renderDates: function() { 13270 13271 this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible 13272 if (this.opt('weekNumbers')) { 13273 if (this.opt('weekNumbersWithinDays')) { 13274 this.cellWeekNumbersVisible = true; 13275 this.colWeekNumbersVisible = false; 13276 } 13277 else { 13278 this.cellWeekNumbersVisible = false; 13279 this.colWeekNumbersVisible = true; 13280 }; 13281 } 13282 this.dayGrid.numbersVisible = this.dayNumbersVisible || 13283 this.cellWeekNumbersVisible || this.colWeekNumbersVisible; 13284 13285 this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml()); 13286 this.renderHead(); 13287 13288 this.scroller.render(); 13289 var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container'); 13290 var dayGridEl = $('<div class="fc-day-grid" />').appendTo(dayGridContainerEl); 13291 this.el.find('.fc-body > tr > td').append(dayGridContainerEl); 13292 13293 this.dayGrid.setElement(dayGridEl); 13294 this.dayGrid.renderDates(this.hasRigidRows()); 13295 }, 13296 13297 13298 // render the day-of-week headers 13299 renderHead: function() { 13300 this.headContainerEl = 13301 this.el.find('.fc-head-container') 13302 .html(this.dayGrid.renderHeadHtml()); 13303 this.headRowEl = this.headContainerEl.find('.fc-row'); 13304 }, 13305 13306 13307 // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, 13308 // always completely kill the dayGrid's rendering. 13309 unrenderDates: function() { 13310 this.dayGrid.unrenderDates(); 13311 this.dayGrid.removeElement(); 13312 this.scroller.destroy(); 13313 }, 13314 13315 13316 renderBusinessHours: function() { 13317 this.dayGrid.renderBusinessHours(); 13318 }, 13319 13320 13321 unrenderBusinessHours: function() { 13322 this.dayGrid.unrenderBusinessHours(); 13323 }, 13324 13325 13326 // Builds the HTML skeleton for the view. 13327 // The day-grid component will render inside of a container defined by this HTML. 13328 renderSkeletonHtml: function() { 13329 return '' + 13330 '<table>' + 13331 '<thead class="fc-head">' + 13332 '<tr>' + 13333 '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' + 13334 '</tr>' + 13335 '</thead>' + 13336 '<tbody class="fc-body">' + 13337 '<tr>' + 13338 '<td class="' + this.widgetContentClass + '"></td>' + 13339 '</tr>' + 13340 '</tbody>' + 13341 '</table>'; 13342 }, 13343 13344 13345 // Generates an HTML attribute string for setting the width of the week number column, if it is known 13346 weekNumberStyleAttr: function() { 13347 if (this.weekNumberWidth !== null) { 13348 return 'style="width:' + this.weekNumberWidth + 'px"'; 13349 } 13350 return ''; 13351 }, 13352 13353 13354 // Determines whether each row should have a constant height 13355 hasRigidRows: function() { 13356 var eventLimit = this.opt('eventLimit'); 13357 return eventLimit && typeof eventLimit !== 'number'; 13358 }, 13359 13360 13361 /* Dimensions 13362 ------------------------------------------------------------------------------------------------------------------*/ 13363 13364 13365 // Refreshes the horizontal dimensions of the view 13366 updateWidth: function() { 13367 if (this.colWeekNumbersVisible) { 13368 // Make sure all week number cells running down the side have the same width. 13369 // Record the width for cells created later. 13370 this.weekNumberWidth = matchCellWidths( 13371 this.el.find('.fc-week-number') 13372 ); 13373 } 13374 }, 13375 13376 13377 // Adjusts the vertical dimensions of the view to the specified values 13378 setHeight: function(totalHeight, isAuto) { 13379 var eventLimit = this.opt('eventLimit'); 13380 var scrollerHeight; 13381 var scrollbarWidths; 13382 13383 // reset all heights to be natural 13384 this.scroller.clear(); 13385 uncompensateScroll(this.headRowEl); 13386 13387 this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed 13388 13389 // is the event limit a constant level number? 13390 if (eventLimit && typeof eventLimit === 'number') { 13391 this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after 13392 } 13393 13394 // distribute the height to the rows 13395 // (totalHeight is a "recommended" value if isAuto) 13396 scrollerHeight = this.computeScrollerHeight(totalHeight); 13397 this.setGridHeight(scrollerHeight, isAuto); 13398 13399 // is the event limit dynamically calculated? 13400 if (eventLimit && typeof eventLimit !== 'number') { 13401 this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set 13402 } 13403 13404 if (!isAuto) { // should we force dimensions of the scroll container? 13405 13406 this.scroller.setHeight(scrollerHeight);
13407 scrollbarWidths = this.scroller.getScrollbarWidths(); 13408 13409 if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars? 13410 13411 compensateScroll(this.headRowEl, scrollbarWidths); 13412 13413 // doing the scrollbar compensation might have created text overflow which created more height. redo 13414 scrollerHeight = this.computeScrollerHeight(totalHeight); 13415 this.scroller.setHeight(scrollerHeight); 13416 } 13417 13418 // guarantees the same scrollbar widths 13419 this.scroller.lockOverflow(scrollbarWidths); 13420 } 13421 }, 13422 13423 13424 // given a desired total height of the view, returns what the height of the scroller should be 13425 computeScrollerHeight: function(totalHeight) { 13426 return totalHeight - 13427 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller 13428 }, 13429 13430 13431 // Sets the height of just the DayGrid component in this view 13432 setGridHeight: function(height, isAuto) { 13433 if (isAuto) { 13434 undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding 13435 } 13436 else { 13437 distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows 13438 } 13439 }, 13440 13441 13442 /* Scroll 13443 ------------------------------------------------------------------------------------------------------------------*/ 13444 13445 13446 computeInitialScroll: function() { 13447 return { top: 0 }; 13448 }, 13449 13450 13451 queryScroll: function() { 13452 return { top: this.scroller.getScrollTop() }; 13453 }, 13454 13455 13456 setScroll: function(scroll) { 13457 this.scroller.setScrollTop(scroll.top); 13458 }, 13459 13460 13461 /* Hit Areas 13462 ------------------------------------------------------------------------------------------------------------------*/ 13463 // forward all hit-related method calls to dayGrid 13464 13465 13466 hitsNeeded: function() { 13467 this.dayGrid.hitsNeeded(); 13468 }, 13469 13470 13471 hitsNotNeeded: function() { 13472 this.dayGrid.hitsNotNeeded(); 13473 }, 13474 13475 13476 prepareHits: function() { 13477 this.dayGrid.prepareHits(); 13478 }, 13479 13480 13481 releaseHits: function() { 13482 this.dayGrid.releaseHits(); 13483 }, 13484 13485 13486 queryHit: function(left, top) { 13487 return this.dayGrid.queryHit(left, top); 13488 }, 13489 13490 13491 getHitSpan: function(hit) { 13492 return this.dayGrid.getHitSpan(hit); 13493 }, 13494 13495 13496 getHitEl: function(hit) { 13497 return this.dayGrid.getHitEl(hit); 13498 }, 13499 13500 13501 /* Events 13502 ------------------------------------------------------------------------------------------------------------------*/ 13503 13504 13505 // Renders the given events onto the view and populates the segments array 13506 renderEvents: function(events) { 13507 this.dayGrid.renderEvents(events); 13508 13509 this.updateHeight(); // must compensate for events that overflow the row 13510 }, 13511 13512 13513 // Retrieves all segment objects that are rendered in the view 13514 getEventSegs: function() { 13515 return this.dayGrid.getEventSegs(); 13516 }, 13517 13518 13519 // Unrenders all event elements and clears internal segment data 13520 unrenderEvents: function() { 13521 this.dayGrid.unrenderEvents(); 13522 13523 // we DON'T need to call updateHeight() because 13524 // a renderEvents() call always happens after this, which will eventually call updateHeight() 13525 }, 13526 13527 13528 /* Dragging (for both events and external elements) 13529 ------------------------------------------------------------------------------------------------------------------*/ 13530 13531 13532 // A returned value of `true` signals that a mock "helper" event has been rendered. 13533 renderDrag: function(dropLocation, seg) { 13534 return this.dayGrid.renderDrag(dropLocation, seg); 13535 }, 13536 13537 13538 unrenderDrag: function() { 13539 this.dayGrid.unrenderDrag(); 13540 }, 13541 13542 13543 /* Selection 13544 ------------------------------------------------------------------------------------------------------------------*/ 13545 13546 13547 // Renders a visual indication of a selection 13548 renderSelection: function(span) { 13549 this.dayGrid.renderSelection(span); 13550 }, 13551 13552 13553 // Unrenders a visual indications of a selection 13554 unrenderSelection: function() { 13555 this.dayGrid.unrenderSelection(); 13556 } 13557 13558}); 13559 13560 13561// Methods that will customize the rendering behavior of the BasicView's dayGrid 13562var basicDayGridMethods = { 13563 13564 13565 // Generates the HTML that will go before the day-of week header cells 13566 renderHeadIntroHtml: function() { 13567 var view = this.view; 13568 13569 if (view.colWeekNumbersVisible) { 13570 return '' + 13571 '<th class="fc-week-number ' + view.widgetHeaderClass + '" ' + view.weekNumberStyleAttr() + '>' + 13572 '<span>' + // needed for matchCellWidths 13573 htmlEscape(view.opt('weekNumberTitle')) + 13574 '</span>' + 13575 '</th>'; 13576 } 13577 13578 return ''; 13579 }, 13580 13581 13582 // Generates the HTML that will go before content-skeleton cells that display the day/week numbers 13583 renderNumberIntroHtml: function(row) { 13584 var view = this.view; 13585 var weekStart = this.getCellDate(row, 0); 13586 13587 if (view.colWeekNumbersVisible) { 13588 return '' + 13589 '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '>' + 13590 view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths 13591 { date: weekStart, type: 'week', forceOff: this.colCnt === 1 }, 13592 weekStart.format('w') // inner HTML 13593 ) + 13594 '</td>'; 13595 } 13596 13597 return ''; 13598 }, 13599 13600 13601 // Generates the HTML that goes before the day bg cells for each day-row 13602 renderBgIntroHtml: function() { 13603 var view = this.view; 13604 13605 if (view.colWeekNumbersVisible) { 13606 return '<td class="fc-week-number ' + view.widgetContentClass + '" ' + 13607 view.weekNumberStyleAttr() + '></td>'; 13608 } 13609 13610 return ''; 13611 }, 13612 13613 13614 // Generates the HTML that goes before every other type of row generated by DayGrid. 13615 // Affects helper-skeleton and highlight-skeleton rows. 13616 renderIntroHtml: function() { 13617 var view = this.view; 13618 13619 if (view.colWeekNumbersVisible) { 13620 return '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '></td>'; 13621 } 13622 13623 return ''; 13624 } 13625 13626}; 13627 13628;; 13629 13630/* A month view with day cells running in rows (one-per-week) and columns 13631----------------------------------------------------------------------------------------------------------------------*/ 13632 13633var MonthView = FC.MonthView = BasicView.extend({ 13634 13635 // Produces information about what range to display 13636 computeRange: function(date) { 13637 var range = BasicView.prototype.computeRange.call(this, date); // get value from super-method 13638 var rowCnt; 13639 13640 // ensure 6 weeks 13641 if (this.isFixedWeeks()) {
13642 rowCnt = Math.ceil(range.end.diff(range.start, 'weeks', true)); // could be partial weeks due to hiddenDays 13643 range.end.add(6 - rowCnt, 'weeks'); 13644 } 13645 13646 return range; 13647 }, 13648 13649 13650 // Overrides the default BasicView behavior to have special multi-week auto-height logic 13651 setGridHeight: function(height, isAuto) { 13652 13653 // if auto, make the height of each row the height that it would be if there were 6 weeks 13654 if (isAuto) { 13655 height *= this.rowCnt / 6; 13656 } 13657 13658 distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows 13659 }, 13660 13661 13662 isFixedWeeks: function() { 13663 return this.opt('fixedWeekCount'); 13664 } 13665 13666}); 13667 13668;; 13669 13670fcViews.basic = { 13671 'class': BasicView 13672}; 13673 13674fcViews.basicDay = { 13675 type: 'basic', 13676 duration: { days: 1 } 13677}; 13678 13679fcViews.basicWeek = { 13680 type: 'basic', 13681 duration: { weeks: 1 } 13682}; 13683 13684fcViews.month = { 13685 'class': MonthView, 13686 duration: { months: 1 }, // important for prev/next 13687 defaults: { 13688 fixedWeekCount: true 13689 } 13690}; 13691;; 13692 13693/* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically. 13694----------------------------------------------------------------------------------------------------------------------*/ 13695// Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on). 13696// Responsible for managing width/height. 13697 13698var AgendaView = FC.AgendaView = View.extend({ 13699 13700 scroller: null, 13701 13702 timeGridClass: TimeGrid, // class used to instantiate the timeGrid. subclasses can override 13703 timeGrid: null, // the main time-grid subcomponent of this view 13704 13705 dayGridClass: DayGrid, // class used to instantiate the dayGrid. subclasses can override 13706 dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null 13707 13708 axisWidth: null, // the width of the time axis running down the side 13709 13710 headContainerEl: null, // div that hold's the timeGrid's rendered date header 13711 noScrollRowEls: null, // set of fake row elements that must compensate when scroller has scrollbars 13712 13713 // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath 13714 bottomRuleEl: null, 13715 13716 13717 initialize: function() { 13718 this.timeGrid = this.instantiateTimeGrid(); 13719 13720 if (this.opt('allDaySlot')) { // should we display the "all-day" area? 13721 this.dayGrid = this.instantiateDayGrid(); // the all-day subcomponent of this view 13722 } 13723 13724 this.scroller = new Scroller({ 13725 overflowX: 'hidden', 13726 overflowY: 'auto' 13727 }); 13728 }, 13729 13730 13731 // Instantiates the TimeGrid object this view needs. Draws from this.timeGridClass 13732 instantiateTimeGrid: function() { 13733 var subclass = this.timeGridClass.extend(agendaTimeGridMethods); 13734 13735 return new subclass(this); 13736 }, 13737 13738 13739 // Instantiates the DayGrid object this view might need. Draws from this.dayGridClass 13740 instantiateDayGrid: function() { 13741 var subclass = this.dayGridClass.extend(agendaDayGridMethods); 13742 13743 return new subclass(this); 13744 }, 13745 13746 13747 /* Rendering 13748 ------------------------------------------------------------------------------------------------------------------*/ 13749 13750 13751 // Sets the display range and computes all necessary dates 13752 setRange: function(range) { 13753 View.prototype.setRange.call(this, range); // call the super-method 13754 13755 this.timeGrid.setRange(range); 13756 if (this.dayGrid) { 13757 this.dayGrid.setRange(range); 13758 } 13759 }, 13760 13761 13762 // Renders the view into `this.el`, which has already been assigned 13763 renderDates: function() { 13764 13765 this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml()); 13766 this.renderHead(); 13767 13768 this.scroller.render(); 13769 var timeGridWrapEl = this.scroller.el.addClass('fc-time-grid-container'); 13770 var timeGridEl = $('<div class="fc-time-grid" />').appendTo(timeGridWrapEl); 13771 this.el.find('.fc-body > tr > td').append(timeGridWrapEl); 13772 13773 this.timeGrid.setElement(timeGridEl); 13774 this.timeGrid.renderDates(); 13775 13776 // the <hr> that sometimes displays under the time-grid 13777 this.bottomRuleEl = $('<hr class="fc-divider ' + this.widgetHeaderClass + '"/>') 13778 .appendTo(this.timeGrid.el); // inject it into the time-grid 13779 13780 if (this.dayGrid) { 13781 this.dayGrid.setElement(this.el.find('.fc-day-grid')); 13782 this.dayGrid.renderDates(); 13783 13784 // have the day-grid extend it's coordinate area over the <hr> dividing the two grids 13785 this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight(); 13786 } 13787 13788 this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller 13789 }, 13790 13791 13792 // render the day-of-week headers 13793 renderHead: function() { 13794 this.headContainerEl = 13795 this.el.find('.fc-head-container') 13796 .html(this.timeGrid.renderHeadHtml()); 13797 }, 13798 13799 13800 // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, 13801 // always completely kill each grid's rendering. 13802 unrenderDates: function() { 13803 this.timeGrid.unrenderDates(); 13804 this.timeGrid.removeElement(); 13805 13806 if (this.dayGrid) { 13807 this.dayGrid.unrenderDates(); 13808 this.dayGrid.removeElement(); 13809 } 13810 13811 this.scroller.destroy(); 13812 }, 13813 13814 13815 // Builds the HTML skeleton for the view. 13816 // The day-grid and time-grid components will render inside containers defined by this HTML. 13817 renderSkeletonHtml: function() { 13818 return '' + 13819 '<table>' + 13820 '<thead class="fc-head">' + 13821 '<tr>' + 13822 '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' + 13823 '</tr>' + 13824 '</thead>' + 13825 '<tbody class="fc-body">' + 13826 '<tr>' + 13827 '<td class="' + this.widgetContentClass + '">' + 13828 (this.dayGrid ? 13829 '<div class="fc-day-grid"/>' + 13830 '<hr class="fc-divider ' + this.widgetHeaderClass + '"/>' : 13831 '' 13832 ) + 13833 '</td>' + 13834 '</tr>' + 13835 '</tbody>' + 13836 '</table>'; 13837 }, 13838 13839 13840 // Generates an HTML attribute string for setting the width of the axis, if it is known 13841 axisStyleAttr: function() { 13842 if (this.axisWidth !== null) { 13843 return 'style="width:' + this.axisWidth + 'px"'; 13844 } 13845 return ''; 13846 }, 13847 13848 13849 /* Business Hours 13850 ------------------------------------------------------------------------------------------------------------------*/ 13851 13852 13853 renderBusinessHours: function() { 13854 this.timeGrid.renderBusinessHours(); 13855 13856 if (this.dayGrid) { 13857 this.dayGrid.renderBusinessHours(); 13858 } 13859 }, 13860 13861 13862 unrenderBusinessHours: function() { 13863 this.timeGrid.unrenderBusinessHours(); 13864 13865 if (this.dayGrid) { 13866 this.dayGrid.unrenderBusinessHours(); 13867 } 13868 }, 13869 13870 13871 /* Now Indicator 13872 ------------------------------------------------------------------------------------------------------------------*/ 13873 13874 13875 getNowIndicatorUnit: function() { 13876 return this.timeGrid.getNowIndicatorUnit(); 13877 }, 13878 13879 13880 renderNowIndicator: function(date) { 13881 this.timeGrid.renderNowIndicator(date); 13882 }, 13883 13884 13885 unrenderNowIndicator: function() { 13886 this.timeGrid.unrenderNowIndicator(); 13887 }, 13888 13889 13890 /* Dimensions 13891 ------------------------------------------------------------------------------------------------------------------*/ 13892 13893 13894 updateSize: function(isResize) { 13895 this.timeGrid.updateSize(isResize); 13896 13897 View.prototype.updateSize.call(this, isResize); // call the super-method 13898 }, 13899 13900 13901 // Refreshes the horizontal dimensions of the view 13902 updateWidth: function() { 13903 // make all axis cells line up, and record the width so newly created axis cells will have it 13904 this.axisWidth = matchCellWidths(this.el.find('.fc-axis')); 13905 }, 13906 13907 13908 // Adjusts the vertical dimensions of the view to the specified values 13909 setHeight: function(totalHeight, isAuto) { 13910 var eventLimit; 13911 var scrollerHeight; 13912 var scrollbarWidths; 13913 13914 // reset all dimensions back to the original state 13915 this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary 13916 this.scroller.clear(); // sets height to 'auto' and clears overflow 13917 uncompensateScroll(this.noScrollRowEls); 13918 13919 // limit number of events in the all-day area 13920 if (this.dayGrid) { 13921 this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed 13922 13923 eventLimit = this.opt('eventLimit'); 13924 if (eventLimit && typeof eventLimit !== 'number') { 13925 eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number 13926 } 13927 if (eventLimit) { 13928 this.dayGrid.limitRows(eventLimit); 13929 } 13930 } 13931 13932 if (!isAuto) { // should we force dimensions of the scroll container? 13933 13934 scrollerHeight = this.computeScrollerHeight(totalHeight); 13935 this.scroller.setHeight(scrollerHeight);
13936 scrollbarWidths = this.scroller.getScrollbarWidths(); 13937 13938 if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars? 13939 13940 // make the all-day and header rows lines up 13941 compensateScroll(this.noScrollRowEls, scrollbarWidths); 13942 13943 // the scrollbar compensation might have changed text flow, which might affect height, so recalculate 13944 // and reapply the desired height to the scroller. 13945 scrollerHeight = this.computeScrollerHeight(totalHeight); 13946 this.scroller.setHeight(scrollerHeight); 13947 } 13948 13949 // guarantees the same scrollbar widths 13950 this.scroller.lockOverflow(scrollbarWidths); 13951 13952 // if there's any space below the slats, show the horizontal rule. 13953 // this won't cause any new overflow, because lockOverflow already called. 13954 if (this.timeGrid.getTotalSlatHeight() < scrollerHeight) { 13955 this.bottomRuleEl.show(); 13956 } 13957 } 13958 }, 13959 13960 13961 // given a desired total height of the view, returns what the height of the scroller should be 13962 computeScrollerHeight: function(totalHeight) { 13963 return totalHeight - 13964 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller 13965 }, 13966 13967 13968 /* Scroll 13969 ------------------------------------------------------------------------------------------------------------------*/ 13970 13971 13972 // Computes the initial pre-configured scroll state prior to allowing the user to change it 13973 computeInitialScroll: function() { 13974 var scrollTime = moment.duration(this.opt('scrollTime')); 13975 var top = this.timeGrid.computeTimeTop(scrollTime); 13976 13977 // zoom can give weird floating-point values. rather scroll a little bit further 13978 top = Math.ceil(top); 13979 13980 if (top) { 13981 top++; // to overcome top border that slots beyond the first have. looks better 13982 } 13983 13984 return { top: top }; 13985 }, 13986 13987 13988 queryScroll: function() { 13989 return { top: this.scroller.getScrollTop() }; 13990 }, 13991 13992 13993 setScroll: function(scroll) { 13994 this.scroller.setScrollTop(scroll.top); 13995 }, 13996 13997 13998 /* Hit Areas 13999 ------------------------------------------------------------------------------------------------------------------*/ 14000 // forward all hit-related method calls to the grids (dayGrid might not be defined) 14001 14002 14003 hitsNeeded: function() { 14004 this.timeGrid.hitsNeeded(); 14005 if (this.dayGrid) { 14006 this.dayGrid.hitsNeeded(); 14007 } 14008 }, 14009 14010 14011 hitsNotNeeded: function() { 14012 this.timeGrid.hitsNotNeeded(); 14013 if (this.dayGrid) { 14014 this.dayGrid.hitsNotNeeded(); 14015 } 14016 }, 14017 14018 14019 prepareHits: function() { 14020 this.timeGrid.prepareHits(); 14021 if (this.dayGrid) { 14022 this.dayGrid.prepareHits(); 14023 } 14024 }, 14025 14026 14027 releaseHits: function() { 14028 this.timeGrid.releaseHits(); 14029 if (this.dayGrid) { 14030 this.dayGrid.releaseHits(); 14031 } 14032 }, 14033 14034 14035 queryHit: function(left, top) { 14036 var hit = this.timeGrid.queryHit(left, top); 14037 14038 if (!hit && this.dayGrid) { 14039 hit = this.dayGrid.queryHit(left, top); 14040 } 14041 14042 return hit; 14043 }, 14044 14045 14046 getHitSpan: function(hit) { 14047 // TODO: hit.component is set as a hack to identify where the hit came from 14048 return hit.component.getHitSpan(hit); 14049 }, 14050 14051 14052 getHitEl: function(hit) { 14053 // TODO: hit.component is set as a hack to identify where the hit came from 14054 return hit.component.getHitEl(hit); 14055 }, 14056 14057 14058 /* Events 14059 ------------------------------------------------------------------------------------------------------------------*/ 14060 14061 14062 // Renders events onto the view and populates the View's segment array 14063 renderEvents: function(events) { 14064 var dayEvents = []; 14065 var timedEvents = []; 14066 var daySegs = []; 14067 var timedSegs; 14068 var i; 14069 14070 // separate the events into all-day and timed 14071 for (i = 0; i < events.length; i++) { 14072 if (events[i].allDay) { 14073 dayEvents.push(events[i]); 14074 } 14075 else { 14076 timedEvents.push(events[i]); 14077 } 14078 } 14079 14080 // render the events in the subcomponents 14081 timedSegs = this.timeGrid.renderEvents(timedEvents); 14082 if (this.dayGrid) { 14083 daySegs = this.dayGrid.renderEvents(dayEvents); 14084 } 14085 14086 // the all-day area is flexible and might have a lot of events, so shift the height 14087 this.updateHeight(); 14088 }, 14089 14090 14091 // Retrieves all segment objects that are rendered in the view 14092 getEventSegs: function() { 14093 return this.timeGrid.getEventSegs().concat( 14094 this.dayGrid ? this.dayGrid.getEventSegs() : [] 14095 ); 14096 }, 14097 14098 14099 // Unrenders all event elements and clears internal segment data 14100 unrenderEvents: function() { 14101 14102 // unrender the events in the subcomponents 14103 this.timeGrid.unrenderEvents(); 14104 if (this.dayGrid) { 14105 this.dayGrid.unrenderEvents(); 14106 } 14107 14108 // we DON'T need to call updateHeight() because 14109 // a renderEvents() call always happens after this, which will eventually call updateHeight() 14110 }, 14111 14112 14113 /* Dragging (for events and external elements) 14114 ------------------------------------------------------------------------------------------------------------------*/ 14115 14116 14117 // A returned value of `true` signals that a mock "helper" event has been rendered. 14118 renderDrag: function(dropLocation, seg) { 14119 if (dropLocation.start.hasTime()) { 14120 return this.timeGrid.renderDrag(dropLocation, seg); 14121 } 14122 else if (this.dayGrid) { 14123 return this.dayGrid.renderDrag(dropLocation, seg); 14124 } 14125 }, 14126 14127 14128 unrenderDrag: function() { 14129 this.timeGrid.unrenderDrag(); 14130 if (this.dayGrid) { 14131 this.dayGrid.unrenderDrag(); 14132 } 14133 }, 14134 14135 14136 /* Selection 14137 ------------------------------------------------------------------------------------------------------------------*/ 14138 14139 14140 // Renders a visual indication of a selection
14141 renderSelection: function(span) { 14142 if (span.start.hasTime() || span.end.hasTime()) { 14143 this.timeGrid.renderSelection(span); 14144 } 14145 else if (this.dayGrid) { 14146 this.dayGrid.renderSelection(span); 14147 } 14148 }, 14149 14150 14151 // Unrenders a visual indications of a selection 14152 unrenderSelection: function() { 14153 this.timeGrid.unrenderSelection(); 14154 if (this.dayGrid) { 14155 this.dayGrid.unrenderSelection(); 14156 } 14157 } 14158 14159}); 14160 14161 14162// Methods that will customize the rendering behavior of the AgendaView's timeGrid 14163// TODO: move into TimeGrid 14164var agendaTimeGridMethods = { 14165 14166 14167 // Generates the HTML that will go before the day-of week header cells 14168 renderHeadIntroHtml: function() { 14169 var view = this.view; 14170 var weekText; 14171 14172 if (view.opt('weekNumbers')) { 14173 weekText = this.start.format(view.opt('smallWeekFormat')); 14174 14175 return '' + 14176 '<th class="fc-axis fc-week-number ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '>' + 14177 view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths 14178 { date: this.start, type: 'week', forceOff: this.colCnt > 1 }, 14179 htmlEscape(weekText) // inner HTML 14180 ) + 14181 '</th>'; 14182 } 14183 else { 14184 return '<th class="fc-axis ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '></th>'; 14185 } 14186 }, 14187 14188 14189 // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column. 14190 renderBgIntroHtml: function() { 14191 var view = this.view; 14192 14193 return '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '></td>'; 14194 }, 14195 14196 14197 // Generates the HTML that goes before all other types of cells. 14198 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. 14199 renderIntroHtml: function() { 14200 var view = this.view; 14201 14202 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>'; 14203 } 14204 14205}; 14206 14207 14208// Methods that will customize the rendering behavior of the AgendaView's dayGrid 14209var agendaDayGridMethods = { 14210 14211 14212 // Generates the HTML that goes before the all-day cells 14213 renderBgIntroHtml: function() { 14214 var view = this.view; 14215 14216 return '' + 14217 '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' + 14218 '<span>' + // needed for matchCellWidths 14219 view.getAllDayHtml() + 14220 '</span>' + 14221 '</td>'; 14222 }, 14223 14224 14225 // Generates the HTML that goes before all other types of cells. 14226 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. 14227 renderIntroHtml: function() { 14228 var view = this.view; 14229 14230 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>'; 14231 } 14232 14233}; 14234 14235;; 14236 14237var AGENDA_ALL_DAY_EVENT_LIMIT = 5; 14238 14239// potential nice values for the slot-duration and interval-duration 14240// from largest to smallest 14241var AGENDA_STOCK_SUB_DURATIONS = [ 14242 { hours: 1 }, 14243 { minutes: 30 }, 14244 { minutes: 15 }, 14245 { seconds: 30 }, 14246 { seconds: 15 } 14247]; 14248 14249fcViews.agenda = { 14250 'class': AgendaView, 14251 defaults: { 14252 allDaySlot: true, 14253 slotDuration: '00:30:00', 14254 minTime: '00:00:00', 14255 maxTime: '24:00:00', 14256 slotEventOverlap: true // a bad name. confused with overlap/constraint system 14257 } 14258}; 14259 14260fcViews.agendaDay = { 14261 type: 'agenda', 14262 duration: { days: 1 } 14263}; 14264 14265fcViews.agendaWeek = { 14266 type: 'agenda', 14267 duration: { weeks: 1 } 14268}; 14269;; 14270 14271/* 14272Responsible for the scroller, and forwarding event-related actions into the "grid" 14273*/ 14274var ListView = View.extend({ 14275 14276 grid: null, 14277 scroller: null, 14278 14279 initialize: function() { 14280 this.grid = new ListViewGrid(this); 14281 this.scroller = new Scroller({ 14282 overflowX: 'hidden', 14283 overflowY: 'auto' 14284 }); 14285 }, 14286 14287 setRange: function(range) { 14288 View.prototype.setRange.call(this, range); // super 14289 14290 this.grid.setRange(range); // needs to process range-related options 14291 }, 14292 14293 renderSkeleton: function() { 14294 this.el.addClass( 14295 'fc-list-view ' + 14296 this.widgetContentClass 14297 ); 14298 14299 this.scroller.render(); 14300 this.scroller.el.appendTo(this.el); 14301 14302 this.grid.setElement(this.scroller.scrollEl); 14303 }, 14304 14305 unrenderSkeleton: function() { 14306 this.scroller.destroy(); // will remove the Grid too 14307 }, 14308 14309 setHeight: function(totalHeight, isAuto) { 14310 this.scroller.setHeight(this.computeScrollerHeight(totalHeight)); 14311 }, 14312 14313 computeScrollerHeight: function(totalHeight) { 14314 return totalHeight -
14315 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller 14316 }, 14317 14318 renderEvents: function(events) { 14319 this.grid.renderEvents(events); 14320 }, 14321 14322 unrenderEvents: function() { 14323 this.grid.unrenderEvents(); 14324 }, 14325 14326 isEventResizable: function(event) { 14327 return false; 14328 }, 14329 14330 isEventDraggable: function(event) { 14331 return false; 14332 } 14333 14334}); 14335 14336/* 14337Responsible for event rendering and user-interaction. 14338Its "el" is the inner-content of the above view's scroller. 14339*/ 14340var ListViewGrid = Grid.extend({ 14341 14342 segSelector: '.fc-list-item', // which elements accept event actions 14343 hasDayInteractions: false, // no day selection or day clicking 14344 14345 // slices by day 14346 spanToSegs: function(span) { 14347 var view = this.view; 14348 var dayStart = view.start.clone().time(0); // timed, so segs get times! 14349 var dayIndex = 0; 14350 var seg; 14351 var segs = []; 14352 14353 while (dayStart < view.end) { 14354 14355 seg = intersectRanges(span, { 14356 start: dayStart, 14357 end: dayStart.clone().add(1, 'day') 14358 }); 14359 14360 if (seg) { 14361 seg.dayIndex = dayIndex; 14362 segs.push(seg); 14363 } 14364 14365 dayStart.add(1, 'day'); 14366 dayIndex++; 14367 14368 // detect when span won't go fully into the next day, 14369 // and mutate the latest seg to the be the end. 14370 if ( 14371 seg && !seg.isEnd && span.end.hasTime() && 14372 span.end < dayStart.clone().add(this.view.nextDayThreshold) 14373 ) { 14374 seg.end = span.end.clone(); 14375 seg.isEnd = true; 14376 break; 14377 } 14378 } 14379 14380 return segs; 14381 }, 14382 14383 // like "4:00am" 14384 computeEventTimeFormat: function() { 14385 return this.view.opt('mediumTimeFormat'); 14386 }, 14387 14388 // for events with a url, the whole <tr> should be clickable, 14389 // but it's impossible to wrap with an <a> tag. simulate this. 14390 handleSegClick: function(seg, ev) { 14391 var url; 14392 14393 Grid.prototype.handleSegClick.apply(this, arguments); // super. might prevent the default action 14394 14395 // not clicking on or within an <a> with an href 14396 if (!$(ev.target).closest('a[href]').length) { 14397 url = seg.event.url; 14398 if (url && !ev.isDefaultPrevented()) { // jsEvent not cancelled in handler 14399 window.location.href = url; // simulate link click 14400 } 14401 } 14402 }, 14403 14404 // returns list of foreground segs that were actually rendered 14405 renderFgSegs: function(segs) { 14406 segs = this.renderFgSegEls(segs); // might filter away hidden events 14407 14408 if (!segs.length) { 14409 this.renderEmptyMessage(); 14410 } 14411 else { 14412 this.renderSegList(segs); 14413 } 14414 14415 return segs; 14416 }, 14417 14418 renderEmptyMessage: function() { 14419 this.el.html( 14420 '<div class="fc-list-empty-wrap2">' + // TODO: try less wraps 14421 '<div class="fc-list-empty-wrap1">' + 14422 '<div class="fc-list-empty">' + 14423 htmlEscape(this.view.opt('noEventsMessage')) + 14424 '</div>' + 14425 '</div>' + 14426 '</div>' 14427 ); 14428 }, 14429 14430 // render the event segments in the view 14431 renderSegList: function(allSegs) { 14432 var segsByDay = this.groupSegsByDay(allSegs); // sparse array 14433 var dayIndex; 14434 var daySegs; 14435 var i; 14436 var tableEl = $('<table class="fc-list-table"><tbody/></table>'); 14437 var tbodyEl = tableEl.find('tbody'); 14438 14439 for (dayIndex = 0; dayIndex < segsByDay.length; dayIndex++) { 14440 daySegs = segsByDay[dayIndex]; 14441 if (daySegs) { // sparse array, so might be undefined 14442 14443 // append a day header 14444 tbodyEl.append(this.dayHeaderHtml( 14445 this.view.start.clone().add(dayIndex, 'days') 14446 )); 14447 14448 this.sortEventSegs(daySegs); 14449 14450 for (i = 0; i < daySegs.length; i++) { 14451 tbodyEl.append(daySegs[i].el); // append event row 14452 } 14453 } 14454 } 14455 14456 this.el.empty().append(tableEl); 14457 }, 14458 14459 // Returns a sparse array of arrays, segs grouped by their dayIndex 14460 groupSegsByDay: function(segs) { 14461 var segsByDay = []; // sparse array 14462 var i, seg; 14463 14464 for (i = 0; i < segs.length; i++) { 14465 seg = segs[i]; 14466 (segsByDay[seg.dayIndex] || (segsByDay[seg.dayIndex] = [])) 14467 .push(seg); 14468 } 14469 14470 return segsByDay; 14471 }, 14472 14473 // generates the HTML for the day headers that live amongst the event rows 14474 dayHeaderHtml: function(dayDate) { 14475 var view = this.view; 14476 var mainFormat = view.opt('listDayFormat'); 14477 var altFormat = view.opt('listDayAltFormat'); 14478 14479 return '<tr class="fc-list-heading" data-date="' + dayDate.format('YYYY-MM-DD') + '">' + 14480 '<td class="' + view.widgetHeaderClass + '" colspan="3">' + 14481 (mainFormat ? 14482 view.buildGotoAnchorHtml( 14483 dayDate, 14484 { 'class': 'fc-list-heading-main' }, 14485 htmlEscape(dayDate.format(mainFormat)) // inner HTML 14486 ) : 14487 '') + 14488 (altFormat ? 14489 view.buildGotoAnchorHtml( 14490 dayDate, 14491 { 'class': 'fc-list-heading-alt' }, 14492 htmlEscape(dayDate.format(altFormat)) // inner HTML 14493 ) : 14494 '') + 14495 '</td>' + 14496 '</tr>'; 14497 }, 14498 14499 // generates the HTML for a single event row 14500 fgSegHtml: function(seg) { 14501 var view = this.view; 14502 var classes = [ 'fc-list-item' ].concat(this.getSegCustomClasses(seg));
14503 var bgColor = this.getSegBackgroundColor(seg); 14504 var event = seg.event; 14505 var url = event.url; 14506 var timeHtml; 14507 14508 if (event.allDay) { 14509 timeHtml = view.getAllDayHtml(); 14510 } 14511 else if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day 14512 if (seg.isStart || seg.isEnd) { // outer segment that probably lasts part of the day 14513 timeHtml = htmlEscape(this.getEventTimeText(seg)); 14514 } 14515 else { // inner segment that lasts the whole day 14516 timeHtml = view.getAllDayHtml(); 14517 } 14518 } 14519 else { 14520 // Display the normal time text for the *event's* times 14521 timeHtml = htmlEscape(this.getEventTimeText(event)); 14522 } 14523 14524 if (url) { 14525 classes.push('fc-has-url'); 14526 } 14527 14528 return '<tr class="' + classes.join(' ') + '">' + 14529 (this.displayEventTime ? 14530 '<td class="fc-list-item-time ' + view.widgetContentClass + '">' + 14531 (timeHtml || '') + 14532 '</td>' : 14533 '') + 14534 '<td class="fc-list-item-marker ' + view.widgetContentClass + '">' + 14535 '<span class="fc-event-dot"' + 14536 (bgColor ? 14537 ' style="background-color:' + bgColor + '"' : 14538 '') + 14539 '></span>' + 14540 '</td>' + 14541 '<td class="fc-list-item-title ' + view.widgetContentClass + '">' + 14542 '<a' + (url ? ' href="' + htmlEscape(url) + '"' : '') + '>' + 14543 htmlEscape(seg.event.title || '') +
14544 '</a>' + 14545 '</td>' + 14546 '</tr>'; 14547 } 14548 14549}); 14550 14551;; 14552 14553fcViews.list = { 14554 'class': ListView, 14555 buttonTextKey: 'list', // what to lookup in locale files 14556 defaults: { 14557 buttonText: 'list', // text to display for English 14558 listDayFormat: 'LL', // like "January 1, 2016" 14559 noEventsMessage: 'No events to display' 14560 } 14561}; 14562 14563fcViews.listDay = { 14564 type: 'list', 14565 duration: { days: 1 }, 14566 defaults: { 14567 listDayFormat: 'dddd' // day-of-week is all we need. full date is probably in header 14568 } 14569}; 14570 14571fcViews.listWeek = { 14572 type: 'list', 14573 duration: { weeks: 1 }, 14574 defaults: { 14575 listDayFormat: 'dddd', // day-of-week is more important 14576 listDayAltFormat: 'LL' 14577 } 14578}; 14579 14580fcViews.listMonth = { 14581 type: 'list', 14582 duration: { month: 1 }, 14583 defaults: { 14584 listDayAltFormat: 'dddd' // day-of-week is nice-to-have 14585 } 14586}; 14587 14588fcViews.listYear = { 14589 type: 'list', 14590 duration: { year: 1 }, 14591 defaults: { 14592 listDayAltFormat: 'dddd' // day-of-week is nice-to-have 14593 } 14594}; 14595 14596;; 14597 14598return FC; // export for Node/CommonJS 14599});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.