1/*! 2 * <%= meta.title %> v<%= meta.version %> 3 * Docs & License: <%= meta.homepage %> 4 * (c) <%= meta.copyright %> 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: "<%= meta.version %>", 23 internalApiVersion: 5 24}; 25var fcViews = FC.views = {}; 26 27 28$.fn.fullCalendar = function(options) { 29 var args = Array.prototype.slice.call(arguments, 1); // for a possible method call 30 var res = this; // what this function will return (this jQuery object by default) 31 32 this.each(function(i, _element) { // loop each DOM element involved 33 var element = $(_element); 34 var calendar = element.data('fullCalendar'); // get the existing calendar object (if any) 35 var singleRes; // the returned value of this single method call 36 37 // a method call 38 if (typeof options === 'string') { 39 if (calendar && $.isFunction(calendar[options])) { 40 singleRes = calendar[options].apply(calendar, args); 41 if (!i) { 42 res = singleRes; // record the first method call result 43 } 44 if (options === 'destroy') { // for the destroy method, must remove Calendar object data 45 element.removeData('fullCalendar'); 46 } 47 } 48 } 49 // a new calendar initialization 50 else if (!calendar) { // don't initialize twice 51 calendar = new Calendar(element, options); 52 element.data('fullCalendar', calendar); 53 calendar.render(); 54 } 55 }); 56 57 return res; 58}; 59 60 61var complexOptions = [ // names of options that are objects whose properties should be combined 62 'header', 63 'buttonText', 64 'buttonIcons', 65 'themeButtonIcons' 66]; 67 68 69// Merges an array of option objects into a single object 70function mergeOptions(optionObjs) { 71 return mergeProps(optionObjs, complexOptions); 72} 73 74 75// Given options specified for the calendar's constructor, massages any legacy options into a non-legacy form. 76// Converts View-Option-Hashes into the View-Specific-Options format. 77function massageOverrides(input) { 78 var overrides = { views: input.views || {} }; // the output. ensure a `views` hash 79 var subObj; 80 81 // iterate through all option override properties (except `views`) 82 $.each(input, function(name, val) { 83 if (name != 'views') { 84 85 // could the value be a legacy View-Option-Hash? 86 if ( 87 $.isPlainObject(val) && 88 !/(time|duration|interval)$/i.test(name) && // exclude duration options. might be given as objects 89 $.inArray(name, complexOptions) == -1 // complex options aren't allowed to be View-Option-Hashes 90 ) { 91 subObj = null; 92 93 // iterate through the properties of this possible View-Option-Hash value 94 $.each(val, function(subName, subVal) { 95 96 // is the property targeting a view? 97 if (/^(month|week|day|default|basic(Week|Day)?|agenda(Week|Day)?)$/.test(subName)) { 98 if (!overrides.views[subName]) { // ensure the view-target entry exists 99 overrides.views[subName] = {}; 100 } 101 overrides.views[subName][name] = subVal; // record the value in the `views` object 102 } 103 else { // a non-View-Option-Hash property 104 if (!subObj) { 105 subObj = {}; 106 } 107 subObj[subName] = subVal; // accumulate these unrelated values for later 108 } 109 }); 110 111 if (subObj) { // non-View-Option-Hash properties? transfer them as-is 112 overrides[name] = subObj; 113 } 114 } 115 else { 116 overrides[name] = val; // transfer normal options as-is 117 } 118 } 119 }); 120 121 return overrides; 122} 123 124;; 125 126// exports 127FC.intersectRanges = intersectRanges; 128FC.applyAll = applyAll; 129FC.debounce = debounce; 130FC.isInt = isInt; 131FC.htmlEscape = htmlEscape; 132FC.cssToStr = cssToStr; 133FC.proxy = proxy; 134FC.capitaliseFirstLetter = capitaliseFirstLetter; 135 136 137/* FullCalendar-specific DOM Utilities 138----------------------------------------------------------------------------------------------------------------------*/ 139 140 141// Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left 142// and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that. 143function compensateScroll(rowEls, scrollbarWidths) { 144 if (scrollbarWidths.left) { 145 rowEls.css({ 146 'border-left-width': 1, 147 'margin-left': scrollbarWidths.left - 1 148 }); 149 } 150 if (scrollbarWidths.right) { 151 rowEls.css({ 152 'border-right-width': 1, 153 'margin-right': scrollbarWidths.right - 1 154 }); 155 } 156} 157 158 159// Undoes compensateScroll and restores all borders/margins 160function uncompensateScroll(rowEls) { 161 rowEls.css({ 162 'margin-left': '', 163 'margin-right': '', 164 'border-left-width': '', 165 'border-right-width': '' 166 }); 167} 168 169
170// Make the mouse cursor express that an event is not allowed in the current area 171function disableCursor() { 172 $('body').addClass('fc-not-allowed'); 173} 174 175 176// Returns the mouse cursor to its original look 177function enableCursor() { 178 $('body').removeClass('fc-not-allowed'); 179} 180 181 182// Given a total available height to fill, have `els` (essentially child rows) expand to accomodate. 183// By default, all elements that are shorter than the recommended height are expanded uniformly, not considering 184// any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and 185// reduces the available height. 186function distributeHeight(els, availableHeight, shouldRedistribute) { 187 188 // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions, 189 // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars. 190 191 var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element 192 var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE* 193 var flexEls = []; // elements that are allowed to expand. array of DOM nodes 194 var flexOffsets = []; // amount of vertical space it takes up 195 var flexHeights = []; // actual css height 196 var usedHeight = 0; 197 198 undistributeHeight(els); // give all elements their natural height 199 200 // find elements that are below the recommended height (expandable). 201 // important to query for heights in a single first pass (to avoid reflow oscillation). 202 els.each(function(i, el) { 203 var minOffset = i === els.length - 1 ? minOffset2 : minOffset1; 204 var naturalOffset = $(el).outerHeight(true); 205 206 if (naturalOffset < minOffset) { 207 flexEls.push(el); 208 flexOffsets.push(naturalOffset); 209 flexHeights.push($(el).height()); 210 } 211 else { 212 // this element stretches past recommended height (non-expandable). mark the space as occupied. 213 usedHeight += naturalOffset; 214 } 215 }); 216 217 // readjust the recommended height to only consider the height available to non-maxed-out rows. 218 if (shouldRedistribute) { 219 availableHeight -= usedHeight; 220 minOffset1 = Math.floor(availableHeight / flexEls.length); 221 minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE* 222 } 223 224 // assign heights to all expandable elements 225 $(flexEls).each(function(i, el) { 226 var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1; 227 var naturalOffset = flexOffsets[i]; 228 var naturalHeight = flexHeights[i]; 229 var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding 230 231 if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things 232 $(el).height(newHeight); 233 } 234 }); 235} 236 237 238// Undoes distrubuteHeight, restoring all els to their natural height 239function undistributeHeight(els) { 240 els.height(''); 241} 242 243 244// Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the 245// cells to be that width. 246// PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline 247function matchCellWidths(els) { 248 var maxInnerWidth = 0; 249 250 els.find('> span').each(function(i, innerEl) { 251 var innerWidth = $(innerEl).outerWidth(); 252 if (innerWidth > maxInnerWidth) { 253 maxInnerWidth = innerWidth; 254 } 255 }); 256 257 maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance 258 259 els.width(maxInnerWidth); 260 261 return maxInnerWidth; 262} 263 264 265// Given one element that resides inside another, 266// Subtracts the height of the inner element from the outer element. 267function subtractInnerElHeight(outerEl, innerEl) { 268 var both = outerEl.add(innerEl); 269 var diff; 270 271 // effin' IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked 272 both.css({ 273 position: 'relative', // cause a reflow, which will force fresh dimension recalculation 274 left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll 275 }); 276 diff = outerEl.outerHeight() - innerEl.outerHeight(); // grab the dimensions 277 both.css({ position: '', left: '' }); // undo hack 278 279 return diff; 280} 281 282 283/* Element Geom Utilities 284----------------------------------------------------------------------------------------------------------------------*/ 285 286FC.getOuterRect = getOuterRect; 287FC.getClientRect = getClientRect; 288FC.getContentRect = getContentRect;
289FC.getScrollbarWidths = getScrollbarWidths; 290 291 292// borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51 293function getScrollParent(el) { 294 var position = el.css('position'), 295 scrollParent = el.parents().filter(function() { 296 var parent = $(this); 297 return (/(auto|scroll)/).test( 298 parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x') 299 ); 300 }).eq(0); 301 302 return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent; 303} 304 305 306// Queries the outer bounding area of a jQuery element. 307// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). 308// Origin is optional. 309function getOuterRect(el, origin) { 310 var offset = el.offset(); 311 var left = offset.left - (origin ? origin.left : 0); 312 var top = offset.top - (origin ? origin.top : 0); 313 314 return { 315 left: left, 316 right: left + el.outerWidth(), 317 top: top, 318 bottom: top + el.outerHeight() 319 }; 320} 321 322 323// Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding. 324// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). 325// Origin is optional. 326// NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. 327function getClientRect(el, origin) { 328 var offset = el.offset(); 329 var scrollbarWidths = getScrollbarWidths(el); 330 var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left - (origin ? origin.left : 0); 331 var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top - (origin ? origin.top : 0); 332 333 return { 334 left: left, 335 right: left + el[0].clientWidth, // clientWidth includes padding but NOT scrollbars 336 top: top, 337 bottom: top + el[0].clientHeight // clientHeight includes padding but NOT scrollbars 338 }; 339} 340 341 342// Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars. 343// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). 344// Origin is optional. 345function getContentRect(el, origin) { 346 var offset = el.offset(); // just outside of border, margin not included 347 var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left') - 348 (origin ? origin.left : 0); 349 var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top') - 350 (origin ? origin.top : 0); 351 352 return { 353 left: left, 354 right: left + el.width(), 355 top: top, 356 bottom: top + el.height() 357 }; 358} 359 360 361// Returns the computed left/right/top/bottom scrollbar widths for the given jQuery element. 362// NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. 363function getScrollbarWidths(el) { 364 var leftRightWidth = el.innerWidth() - el[0].clientWidth; // the paddings cancel out, leaving the scrollbars 365 var widths = { 366 left: 0, 367 right: 0, 368 top: 0,
369 bottom: el.innerHeight() - el[0].clientHeight // the paddings cancel out, leaving the bottom scrollbar 370 }; 371 372 if (getIsLeftRtlScrollbars() && el.css('direction') == 'rtl') { // is the scrollbar on the left side? 373 widths.left = leftRightWidth; 374 } 375 else { 376 widths.right = leftRightWidth; 377 } 378 379 return widths; 380} 381 382 383// Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side 384 385var _isLeftRtlScrollbars = null; 386 387function getIsLeftRtlScrollbars() { // responsible for caching the computation 388 if (_isLeftRtlScrollbars === null) { 389 _isLeftRtlScrollbars = computeIsLeftRtlScrollbars(); 390 } 391 return _isLeftRtlScrollbars; 392} 393 394function computeIsLeftRtlScrollbars() { // creates an offscreen test element, then removes it 395 var el = $('<div><div/></div>') 396 .css({ 397 position: 'absolute', 398 top: -1000, 399 left: 0, 400 border: 0, 401 padding: 0, 402 overflow: 'scroll', 403 direction: 'rtl' 404 }) 405 .appendTo('body'); 406 var innerEl = el.children(); 407 var res = innerEl.offset().left > el.offset().left; // is the inner div shifted to accommodate a left scrollbar? 408 el.remove(); 409 return res; 410} 411 412 413// Retrieves a jQuery element's computed CSS value as a floating-point number. 414// If the queried value is non-numeric (ex: IE can return "medium" for border width), will just return zero. 415function getCssFloat(el, prop) { 416 return parseFloat(el.css(prop)) || 0; 417} 418 419 420/* Mouse / Touch Utilities 421----------------------------------------------------------------------------------------------------------------------*/ 422 423FC.preventDefault = preventDefault; 424 425 426// Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac) 427function isPrimaryMouseButton(ev) { 428 return ev.which == 1 && !ev.ctrlKey; 429} 430 431 432function getEvX(ev) { 433 if (ev.pageX !== undefined) { 434 return ev.pageX; 435 } 436 var touches = ev.originalEvent.touches; 437 if (touches) { 438 return touches[0].pageX; 439 } 440} 441 442 443function getEvY(ev) { 444 if (ev.pageY !== undefined) { 445 return ev.pageY; 446 } 447 var touches = ev.originalEvent.touches; 448 if (touches) { 449 return touches[0].pageY; 450 } 451} 452 453 454function getEvIsTouch(ev) { 455 return /^touch/.test(ev.type); 456} 457 458 459function preventSelection(el) { 460 el.addClass('fc-unselectable') 461 .on('selectstart', preventDefault); 462} 463 464 465// Stops a mouse/touch event from doing it's native browser action 466function preventDefault(ev) { 467 ev.preventDefault(); 468} 469 470 471// attach a handler to get called when ANY scroll action happens on the page. 472// this was impossible to do with normal on/off because 'scroll' doesn't bubble. 473// http://stackoverflow.com/a/32954565/96342 474// returns `true` on success. 475function bindAnyScroll(handler) { 476 if (window.addEventListener) { 477 window.addEventListener('scroll', handler, true); // useCapture=true 478 return true; 479 } 480 return false; 481} 482 483 484// undoes bindAnyScroll. must pass in the original function. 485// returns `true` on success. 486function unbindAnyScroll(handler) { 487 if (window.removeEventListener) { 488 window.removeEventListener('scroll', handler, true); // useCapture=true 489 return true; 490 } 491 return false; 492} 493 494 495/* General Geometry Utils 496----------------------------------------------------------------------------------------------------------------------*/ 497 498FC.intersectRects = intersectRects; 499 500// Returns a new rectangle that is the intersection of the two rectangles. If they don't intersect, returns false 501function intersectRects(rect1, rect2) { 502 var res = { 503 left: Math.max(rect1.left, rect2.left), 504 right: Math.min(rect1.right, rect2.right), 505 top: Math.max(rect1.top, rect2.top), 506 bottom: Math.min(rect1.bottom, rect2.bottom) 507 }; 508 509 if (res.left < res.right && res.top < res.bottom) { 510 return res; 511 } 512 return false; 513} 514 515 516// Returns a new point that will have been moved to reside within the given rectangle 517function constrainPoint(point, rect) { 518 return { 519 left: Math.min(Math.max(point.left, rect.left), rect.right), 520 top: Math.min(Math.max(point.top, rect.top), rect.bottom) 521 }; 522} 523 524 525// Returns a point that is the center of the given rectangle 526function getRectCenter(rect) { 527 return { 528 left: (rect.left + rect.right) / 2, 529 top: (rect.top + rect.bottom) / 2 530 }; 531} 532 533 534// Subtracts point2's coordinates from point1's coordinates, returning a delta 535function diffPoints(point1, point2) { 536 return { 537 left: point1.left - point2.left, 538 top: point1.top - point2.top 539 }; 540} 541 542 543/* Object Ordering by Field 544----------------------------------------------------------------------------------------------------------------------*/ 545 546FC.parseFieldSpecs = parseFieldSpecs; 547FC.compareByFieldSpecs = compareByFieldSpecs; 548FC.compareByFieldSpec = compareByFieldSpec; 549FC.flexibleCompare = flexibleCompare; 550 551 552function parseFieldSpecs(input) { 553 var specs = []; 554 var tokens = []; 555 var i, token; 556 557 if (typeof input === 'string') { 558 tokens = input.split(/\s*,\s*/); 559 } 560 else if (typeof input === 'function') { 561 tokens = [ input ]; 562 } 563 else if ($.isArray(input)) { 564 tokens = input; 565 } 566 567 for (i = 0; i < tokens.length; i++) { 568 token = tokens[i]; 569 570 if (typeof token === 'string') { 571 specs.push( 572 token.charAt(0) == '-' ? 573 { field: token.substring(1), order: -1 } : 574 { field: token, order: 1 } 575 ); 576 } 577 else if (typeof token === 'function') { 578 specs.push({ func: token }); 579 } 580 } 581 582 return specs; 583} 584 585 586function compareByFieldSpecs(obj1, obj2, fieldSpecs) { 587 var i; 588 var cmp; 589 590 for (i = 0; i < fieldSpecs.length; i++) { 591 cmp = compareByFieldSpec(obj1, obj2, fieldSpecs[i]); 592 if (cmp) { 593 return cmp; 594 } 595 } 596 597 return 0; 598} 599 600 601function compareByFieldSpec(obj1, obj2, fieldSpec) { 602 if (fieldSpec.func) { 603 return fieldSpec.func(obj1, obj2); 604 } 605 return flexibleCompare(obj1[fieldSpec.field], obj2[fieldSpec.field]) * 606 (fieldSpec.order || 1); 607} 608 609 610function flexibleCompare(a, b) { 611 if (!a && !b) { 612 return 0; 613 } 614 if (b == null) { 615 return -1; 616 } 617 if (a == null) { 618 return 1; 619 } 620 if ($.type(a) === 'string' || $.type(b) === 'string') { 621 return String(a).localeCompare(String(b)); 622 } 623 return a - b; 624} 625 626 627/* FullCalendar-specific Misc Utilities 628----------------------------------------------------------------------------------------------------------------------*/ 629 630 631// Computes the intersection of the two ranges. Returns undefined if no intersection. 632// Expects all dates to be normalized to the same timezone beforehand. 633// TODO: move to date section? 634function intersectRanges(subjectRange, constraintRange) { 635 var subjectStart = subjectRange.start; 636 var subjectEnd = subjectRange.end; 637 var constraintStart = constraintRange.start; 638 var constraintEnd = constraintRange.end; 639 var segStart, segEnd; 640 var isStart, isEnd; 641 642 if (subjectEnd > constraintStart && subjectStart < constraintEnd) { // in bounds at all? 643 644 if (subjectStart >= constraintStart) { 645 segStart = subjectStart.clone(); 646 isStart = true; 647 } 648 else { 649 segStart = constraintStart.clone(); 650 isStart = false; 651 } 652 653 if (subjectEnd <= constraintEnd) { 654 segEnd = subjectEnd.clone(); 655 isEnd = true; 656 } 657 else { 658 segEnd = constraintEnd.clone(); 659 isEnd = false;
660 } 661 662 return { 663 start: segStart, 664 end: segEnd, 665 isStart: isStart, 666 isEnd: isEnd 667 }; 668 } 669} 670 671 672/* Date Utilities 673----------------------------------------------------------------------------------------------------------------------*/ 674 675FC.computeIntervalUnit = computeIntervalUnit; 676FC.divideRangeByDuration = divideRangeByDuration; 677FC.divideDurationByDuration = divideDurationByDuration; 678FC.multiplyDuration = multiplyDuration; 679FC.durationHasTime = durationHasTime; 680 681var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ]; 682var intervalUnits = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ]; 683 684 685// Diffs the two moments into a Duration where full-days are recorded first, then the remaining time. 686// Moments will have their timezones normalized. 687function diffDayTime(a, b) { 688 return moment.duration({ 689 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'), 690 ms: a.time() - b.time() // time-of-day from day start. disregards timezone 691 }); 692} 693 694 695// Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations. 696function diffDay(a, b) { 697 return moment.duration({ 698 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days') 699 }); 700} 701 702 703// Diffs two moments, producing a duration, made of a whole-unit-increment of the given unit. Uses rounding. 704function diffByUnit(a, b, unit) { 705 return moment.duration( 706 Math.round(a.diff(b, unit, true)), // returnFloat=true 707 unit 708 ); 709} 710 711 712// Computes the unit name of the largest whole-unit period of time. 713// For example, 48 hours will be "days" whereas 49 hours will be "hours". 714// Accepts start/end, a range object, or an original duration object. 715function computeIntervalUnit(start, end) { 716 var i, unit; 717 var val; 718 719 for (i = 0; i < intervalUnits.length; i++) { 720 unit = intervalUnits[i]; 721 val = computeRangeAs(unit, start, end); 722 723 if (val >= 1 && isInt(val)) { 724 break; 725 } 726 } 727 728 return unit; // will be "milliseconds" if nothing else matches 729} 730 731 732// Computes the number of units (like "hours") in the given range. 733// Range can be a {start,end} object, separate start/end args, or a Duration. 734// Results are based on Moment's .as() and .diff() methods, so results can depend on internal handling 735// of month-diffing logic (which tends to vary from version to version). 736function computeRangeAs(unit, start, end) { 737 738 if (end != null) { // given start, end 739 return end.diff(start, unit, true); 740 } 741 else if (moment.isDuration(start)) { // given duration 742 return start.as(unit); 743 } 744 else { // given { start, end } range object 745 return start.end.diff(start.start, unit, true); 746 } 747} 748 749 750// Intelligently divides a range (specified by a start/end params) by a duration 751function divideRangeByDuration(start, end, dur) { 752 var months; 753 754 if (durationHasTime(dur)) { 755 return (end - start) / dur; 756 } 757 months = dur.asMonths(); 758 if (Math.abs(months) >= 1 && isInt(months)) { 759 return end.diff(start, 'months', true) / months; 760 } 761 return end.diff(start, 'days', true) / dur.asDays(); 762} 763 764 765// Intelligently divides one duration by another 766function divideDurationByDuration(dur1, dur2) { 767 var months1, months2; 768 769 if (durationHasTime(dur1) || durationHasTime(dur2)) { 770 return dur1 / dur2; 771 } 772 months1 = dur1.asMonths(); 773 months2 = dur2.asMonths(); 774 if ( 775 Math.abs(months1) >= 1 && isInt(months1) && 776 Math.abs(months2) >= 1 && isInt(months2) 777 ) { 778 return months1 / months2; 779 } 780 return dur1.asDays() / dur2.asDays(); 781} 782 783 784// Intelligently multiplies a duration by a number 785function multiplyDuration(dur, n) { 786 var months; 787 788 if (durationHasTime(dur)) { 789 return moment.duration(dur * n); 790 } 791 months = dur.asMonths(); 792 if (Math.abs(months) >= 1 && isInt(months)) { 793 return moment.duration({ months: months * n }); 794 } 795 return moment.duration({ days: dur.asDays() * n }); 796} 797 798 799// Returns a boolean about whether the given duration has any time parts (hours/minutes/seconds/ms) 800function durationHasTime(dur) { 801 return Boolean(dur.hours() || dur.minutes() || dur.seconds() || dur.milliseconds()); 802} 803 804 805function isNativeDate(input) { 806 return Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date; 807} 808 809 810// Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00"
811function isTimeString(str) { 812 return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str); 813} 814 815 816/* Logging and Debug 817----------------------------------------------------------------------------------------------------------------------*/ 818 819FC.log = function() { 820 var console = window.console; 821 822 if (console && console.log) { 823 return console.log.apply(console, arguments); 824 } 825}; 826 827FC.warn = function() { 828 var console = window.console; 829 830 if (console && console.warn) { 831 return console.warn.apply(console, arguments); 832 } 833 else { 834 return FC.log.apply(FC, arguments); 835 } 836}; 837 838 839/* General Utilities 840----------------------------------------------------------------------------------------------------------------------*/ 841 842var hasOwnPropMethod = {}.hasOwnProperty; 843 844 845// Merges an array of objects into a single object. 846// The second argument allows for an array of property names who's object values will be merged together. 847function mergeProps(propObjs, complexProps) { 848 var dest = {}; 849 var i, name; 850 var complexObjs; 851 var j, val; 852 var props; 853 854 if (complexProps) { 855 for (i = 0; i < complexProps.length; i++) { 856 name = complexProps[i]; 857 complexObjs = []; 858 859 // collect the trailing object values, stopping when a non-object is discovered 860 for (j = propObjs.length - 1; j >= 0; j--) { 861 val = propObjs[j][name]; 862 863 if (typeof val === 'object') { 864 complexObjs.unshift(val); 865 } 866 else if (val !== undefined) { 867 dest[name] = val; // if there were no objects, this value will be used 868 break; 869 } 870 } 871 872 // if the trailing values were objects, use the merged value 873 if (complexObjs.length) { 874 dest[name] = mergeProps(complexObjs); 875 } 876 } 877 } 878 879 // copy values into the destination, going from last to first 880 for (i = propObjs.length - 1; i >= 0; i--) { 881 props = propObjs[i]; 882 883 for (name in props) { 884 if (!(name in dest)) { // if already assigned by previous props or complex props, don't reassign 885 dest[name] = props[name]; 886 } 887 } 888 } 889 890 return dest; 891} 892 893 894// Create an object that has the given prototype. Just like Object.create 895function createObject(proto) { 896 var f = function() {}; 897 f.prototype = proto; 898 return new f(); 899} 900 901 902function copyOwnProps(src, dest) { 903 for (var name in src) { 904 if (hasOwnProp(src, name)) { 905 dest[name] = src[name]; 906 } 907 } 908} 909 910 911// Copies over certain methods with the same names as Object.prototype methods. Overcomes an IE<=8 bug: 912// https://developer.mozilla.org/en-US/docs/ECMAScript_DontEnum_attribute#JScript_DontEnum_Bug 913function copyNativeMethods(src, dest) { 914 var names = [ 'constructor', 'toString', 'valueOf' ]; 915 var i, name; 916 917 for (i = 0; i < names.length; i++) { 918 name = names[i]; 919 920 if (src[name] !== Object.prototype[name]) { 921 dest[name] = src[name]; 922 } 923 } 924} 925 926 927function hasOwnProp(obj, name) { 928 return hasOwnPropMethod.call(obj, name); 929} 930 931 932// Is the given value a non-object non-function value? 933function isAtomic(val) { 934 return /undefined|null|boolean|number|string/.test($.type(val)); 935} 936 937 938function applyAll(functions, thisObj, args) { 939 if ($.isFunction(functions)) { 940 functions = [ functions ]; 941 } 942 if (functions) { 943 var i; 944 var ret; 945 for (i=0; i<functions.length; i++) { 946 ret = functions[i].apply(thisObj, args) || ret; 947 } 948 return ret; 949 } 950} 951 952 953function firstDefined() { 954 for (var i=0; i<arguments.length; i++) { 955 if (arguments[i] !== undefined) { 956 return arguments[i]; 957 } 958 } 959} 960 961 962function htmlEscape(s) { 963 return (s + '').replace(/&/g, '&') 964 .replace(/</g, '<') 965 .replace(/>/g, '>') 966 .replace(/'/g, ''') 967 .replace(/"/g, '"') 968 .replace(/\n/g, '<br />'); 969} 970 971 972function stripHtmlEntities(text) { 973 return text.replace(/&.*?;/g, ''); 974} 975 976 977// Given a hash of CSS properties, returns a string of CSS. 978// Uses property names as-is (no camel-case conversion). Will not make statements for null/undefined values. 979function cssToStr(cssProps) { 980 var statements = []; 981 982 $.each(cssProps, function(name, val) { 983 if (val != null) { 984 statements.push(name + ':' + val); 985 } 986 }); 987 988 return statements.join(';'); 989} 990 991 992function capitaliseFirstLetter(str) { 993 return str.charAt(0).toUpperCase() + str.slice(1); 994} 995 996 997function compareNumbers(a, b) { // for .sort() 998 return a - b; 999} 1000 1001 1002function isInt(n) { 1003 return n % 1 === 0; 1004} 1005 1006
1007// Returns a method bound to the given object context. 1008// Just like one of the jQuery.proxy signatures, but without the undesired behavior of treating the same method with 1009// different contexts as identical when binding/unbinding events. 1010function proxy(obj, methodName) { 1011 var method = obj[methodName]; 1012 1013 return function() { 1014 return method.apply(obj, arguments); 1015 }; 1016} 1017 1018 1019// Returns a function, that, as long as it continues to be invoked, will not 1020// be triggered. The function will be called after it stops being called for 1021// N milliseconds. If `immediate` is passed, trigger the function on the 1022// leading edge, instead of the trailing. 1023// https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714 1024function debounce(func, wait, immediate) { 1025 var timeout, args, context, timestamp, result; 1026 1027 var later = function() { 1028 var last = +new Date() - timestamp; 1029 if (last < wait) { 1030 timeout = setTimeout(later, wait - last); 1031 } 1032 else { 1033 timeout = null; 1034 if (!immediate) { 1035 result = func.apply(context, args); 1036 context = args = null; 1037 } 1038 } 1039 }; 1040 1041 return function() { 1042 context = this; 1043 args = arguments; 1044 timestamp = +new Date(); 1045 var callNow = immediate && !timeout; 1046 if (!timeout) { 1047 timeout = setTimeout(later, wait); 1048 } 1049 if (callNow) { 1050 result = func.apply(context, args); 1051 context = args = null; 1052 } 1053 return result; 1054 }; 1055} 1056 1057 1058// HACK around jQuery's now A+ promises: execute callback synchronously if already resolved. 1059// thenFunc shouldn't accept args. 1060// similar to whenResources in Scheduler plugin. 1061function syncThen(promise, thenFunc) { 1062 // not a promise, or an already-resolved promise? 1063 if (!promise || !promise.then || promise.state() === 'resolved') { 1064 return $.when(thenFunc()); // resolve immediately 1065 } 1066 else if (thenFunc) { 1067 return promise.then(thenFunc); 1068 } 1069} 1070 1071;; 1072 1073var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/; 1074var ambigTimeOrZoneRegex = 1075 /^\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+)?)?)?)?)?$/; 1076var newMomentProto = moment.fn; // where we will attach our new methods 1077var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods 1078var allowValueOptimization; 1079var setUTCValues; // function defined below 1080var setLocalValues; // function defined below 1081 1082 1083// Creating 1084// ------------------------------------------------------------------------------------------------- 1085 1086// Creates a new moment, similar to the vanilla moment(...) constructor, but with 1087// extra features (ambiguous time, enhanced formatting). When given an existing moment, 1088// it will function as a clone (and retain the zone of the moment). Anything else will 1089// result in a moment in the local zone. 1090FC.moment = function() { 1091 return makeMoment(arguments); 1092}; 1093 1094// Sames as FC.moment, but forces the resulting moment to be in the UTC timezone. 1095FC.moment.utc = function() { 1096 var mom = makeMoment(arguments, true); 1097 1098 // Force it into UTC because makeMoment doesn't guarantee it 1099 // (if given a pre-existing moment for example) 1100 if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone 1101 mom.utc(); 1102 } 1103 1104 return mom; 1105}; 1106 1107// Same as FC.moment, but when given an ISO8601 string, the timezone offset is preserved. 1108// ISO8601 strings with no timezone offset will become ambiguously zoned. 1109FC.moment.parseZone = function() { 1110 return makeMoment(arguments, true, true); 1111}; 1112 1113// Builds an enhanced moment from args. When given an existing moment, it clones. When given a 1114// native Date, or called with no arguments (the current time), the resulting moment will be local. 1115// Anything else needs to be "parsed" (a string or an array), and will be affected by: 1116// parseAsUTC - if there is no zone information, should we parse the input in UTC? 1117// parseZone - if there is zone information, should we force the zone of the moment? 1118function makeMoment(args, parseAsUTC, parseZone) { 1119 var input = args[0]; 1120 var isSingleString = args.length == 1 && typeof input === 'string'; 1121 var isAmbigTime; 1122 var isAmbigZone; 1123 var ambigMatch; 1124 var mom; 1125 1126 if (moment.isMoment(input)) { 1127 mom = moment.apply(null, args); // clone it 1128 transferAmbigs(input, mom); // the ambig flags weren't transfered with the clone 1129 } 1130 else if (isNativeDate(input) || input === undefined) {
1131 mom = moment.apply(null, args); // will be local 1132 } 1133 else { // "parsing" is required 1134 isAmbigTime = false; 1135 isAmbigZone = false; 1136 1137 if (isSingleString) { 1138 if (ambigDateOfMonthRegex.test(input)) { 1139 // accept strings like '2014-05', but convert to the first of the month 1140 input += '-01'; 1141 args = [ input ]; // for when we pass it on to moment's constructor 1142 isAmbigTime = true; 1143 isAmbigZone = true; 1144 } 1145 else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) { 1146 isAmbigTime = !ambigMatch[5]; // no time part? 1147 isAmbigZone = true; 1148 } 1149 } 1150 else if ($.isArray(input)) { 1151 // arrays have no timezone information, so assume ambiguous zone 1152 isAmbigZone = true; 1153 } 1154 // otherwise, probably a string with a format 1155 1156 if (parseAsUTC || isAmbigTime) { 1157 mom = moment.utc.apply(moment, args); 1158 } 1159 else { 1160 mom = moment.apply(null, args); 1161 } 1162 1163 if (isAmbigTime) { 1164 mom._ambigTime = true; 1165 mom._ambigZone = true; // ambiguous time always means ambiguous zone 1166 } 1167 else if (parseZone) { // let's record the inputted zone somehow 1168 if (isAmbigZone) { 1169 mom._ambigZone = true; 1170 } 1171 else if (isSingleString) { 1172 if (mom.utcOffset) { 1173 mom.utcOffset(input); // if not a valid zone, will assign UTC 1174 } 1175 else { 1176 mom.zone(input); // for moment-pre-2.9 1177 } 1178 } 1179 } 1180 } 1181 1182 mom._fullCalendar = true; // flag for extended functionality 1183 1184 return mom; 1185} 1186 1187 1188// A clone method that works with the flags related to our enhanced functionality. 1189// In the future, use moment.momentProperties 1190newMomentProto.clone = function() { 1191 var mom = oldMomentProto.clone.apply(this, arguments); 1192 1193 // these flags weren't transfered with the clone 1194 transferAmbigs(this, mom); 1195 if (this._fullCalendar) { 1196 mom._fullCalendar = true; 1197 } 1198 1199 return mom; 1200}; 1201 1202 1203// Week Number 1204// ------------------------------------------------------------------------------------------------- 1205 1206 1207// Returns the week number, considering the locale's custom week number calcuation 1208// `weeks` is an alias for `week` 1209newMomentProto.week = newMomentProto.weeks = function(input) { 1210 var weekCalc = (this._locale || this._lang) // works pre-moment-2.8 1211 ._fullCalendar_weekCalc; 1212 1213 if (input == null && typeof weekCalc === 'function') { // custom function only works for getter 1214 return weekCalc(this); 1215 } 1216 else if (weekCalc === 'ISO') { 1217 return oldMomentProto.isoWeek.apply(this, arguments); // ISO getter/setter 1218 } 1219 1220 return oldMomentProto.week.apply(this, arguments); // local getter/setter 1221}; 1222 1223 1224// Time-of-day 1225// ------------------------------------------------------------------------------------------------- 1226 1227// GETTER 1228// Returns a Duration with the hours/minutes/seconds/ms values of the moment. 1229// If the moment has an ambiguous time, a duration of 00:00 will be returned. 1230// 1231// SETTER 1232// You can supply a Duration, a Moment, or a Duration-like argument. 1233// When setting the time, and the moment has an ambiguous time, it then becomes unambiguous. 1234newMomentProto.time = function(time) { 1235 1236 // Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar. 1237 // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins. 1238 if (!this._fullCalendar) { 1239 return oldMomentProto.time.apply(this, arguments); 1240 } 1241 1242 if (time == null) { // getter 1243 return moment.duration({ 1244 hours: this.hours(), 1245 minutes: this.minutes(), 1246 seconds: this.seconds(), 1247 milliseconds: this.milliseconds() 1248 }); 1249 } 1250 else { // setter 1251 1252 this._ambigTime = false; // mark that the moment now has a time 1253 1254 if (!moment.isDuration(time) && !moment.isMoment(time)) { 1255 time = moment.duration(time); 1256 } 1257 1258 // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day). 1259 // Only for Duration times, not Moment times. 1260 var dayHours = 0; 1261 if (moment.isDuration(time)) { 1262 dayHours = Math.floor(time.asDays()) * 24; 1263 } 1264 1265 // We need to set the individual fields. 1266 // Can't use startOf('day') then add duration. In case of DST at start of day. 1267 return this.hours(dayHours + time.hours()) 1268 .minutes(time.minutes()) 1269 .seconds(time.seconds()) 1270 .milliseconds(time.milliseconds()); 1271 } 1272}; 1273 1274// Converts the moment to UTC, stripping out its time-of-day and timezone offset, 1275// but preserving its YMD. A moment with a stripped time will display no time 1276// nor timezone offset when .format() is called. 1277newMomentProto.stripTime = function() { 1278 var a; 1279 1280 if (!this._ambigTime) { 1281 1282 // get the values before any conversion happens 1283 a = this.toArray(); // array of y/m/d/h/m/s/ms 1284 1285 // TODO: use keepLocalTime in the future 1286 this.utc(); // set the internal UTC flag (will clear the ambig flags) 1287 setUTCValues(this, a.slice(0, 3)); // set the year/month/date. time will be zero 1288 1289 // Mark the time as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), 1290 // which clears all ambig flags. Same with setUTCValues with moment-timezone. 1291 this._ambigTime = true; 1292 this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset 1293 } 1294 1295 return this; // for chaining 1296}; 1297
1298// Returns if the moment has a non-ambiguous time (boolean) 1299newMomentProto.hasTime = function() { 1300 return !this._ambigTime; 1301}; 1302 1303 1304// Timezone 1305// ------------------------------------------------------------------------------------------------- 1306 1307// Converts the moment to UTC, stripping out its timezone offset, but preserving its 1308// YMD and time-of-day. A moment with a stripped timezone offset will display no 1309// timezone offset when .format() is called. 1310// TODO: look into Moment's keepLocalTime functionality 1311newMomentProto.stripZone = function() { 1312 var a, wasAmbigTime; 1313 1314 if (!this._ambigZone) { 1315 1316 // get the values before any conversion happens 1317 a = this.toArray(); // array of y/m/d/h/m/s/ms 1318 wasAmbigTime = this._ambigTime; 1319 1320 this.utc(); // set the internal UTC flag (might clear the ambig flags, depending on Moment internals) 1321 setUTCValues(this, a); // will set the year/month/date/hours/minutes/seconds/ms 1322 1323 // the above call to .utc()/.utcOffset() unfortunately might clear the ambig flags, so restore 1324 this._ambigTime = wasAmbigTime || false; 1325 1326 // Mark the zone as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), 1327 // which clears the ambig flags. Same with setUTCValues with moment-timezone. 1328 this._ambigZone = true; 1329 } 1330 1331 return this; // for chaining 1332}; 1333 1334// Returns of the moment has a non-ambiguous timezone offset (boolean) 1335newMomentProto.hasZone = function() { 1336 return !this._ambigZone; 1337}; 1338 1339 1340// this method implicitly marks a zone 1341newMomentProto.local = function() { 1342 var a = this.toArray(); // year,month,date,hours,minutes,seconds,ms as an array 1343 var wasAmbigZone = this._ambigZone; 1344 1345 oldMomentProto.local.apply(this, arguments); 1346 1347 // ensure non-ambiguous 1348 // this probably already happened via local() -> utcOffset(), but don't rely on Moment's internals 1349 this._ambigTime = false; 1350 this._ambigZone = false; 1351 1352 if (wasAmbigZone) { 1353 // If the moment was ambiguously zoned, the date fields were stored as UTC. 1354 // We want to preserve these, but in local time. 1355 // TODO: look into Moment's keepLocalTime functionality 1356 setLocalValues(this, a); 1357 } 1358 1359 return this; // for chaining 1360}; 1361 1362 1363// implicitly marks a zone 1364newMomentProto.utc = function() { 1365 oldMomentProto.utc.apply(this, arguments); 1366 1367 // ensure non-ambiguous 1368 // this probably already happened via utc() -> utcOffset(), but don't rely on Moment's internals 1369 this._ambigTime = false; 1370 this._ambigZone = false; 1371 1372 return this; 1373}; 1374 1375 1376// methods for arbitrarily manipulating timezone offset. 1377// should clear time/zone ambiguity when called. 1378$.each([ 1379 'zone', // only in moment-pre-2.9. deprecated afterwards 1380 'utcOffset' 1381], function(i, name) { 1382 if (oldMomentProto[name]) { // original method exists? 1383 1384 // this method implicitly marks a zone (will probably get called upon .utc() and .local()) 1385 newMomentProto[name] = function(tzo) { 1386 1387 if (tzo != null) { // setter 1388 // these assignments needs to happen before the original zone method is called. 1389 // I forget why, something to do with a browser crash. 1390 this._ambigTime = false; 1391 this._ambigZone = false; 1392 } 1393 1394 return oldMomentProto[name].apply(this, arguments); 1395 }; 1396 } 1397}); 1398 1399 1400// Formatting 1401// ------------------------------------------------------------------------------------------------- 1402 1403newMomentProto.format = function() { 1404 if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided? 1405 return formatDate(this, arguments[0]); // our extended formatting 1406 } 1407 if (this._ambigTime) { 1408 return oldMomentFormat(this, 'YYYY-MM-DD'); 1409 } 1410 if (this._ambigZone) { 1411 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); 1412 } 1413 return oldMomentProto.format.apply(this, arguments); 1414}; 1415 1416newMomentProto.toISOString = function() { 1417 if (this._ambigTime) { 1418 return oldMomentFormat(this, 'YYYY-MM-DD'); 1419 } 1420 if (this._ambigZone) { 1421 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); 1422 } 1423 return oldMomentProto.toISOString.apply(this, arguments); 1424}; 1425 1426 1427// Querying 1428// ------------------------------------------------------------------------------------------------- 1429 1430// Is the moment within the specified range? `end` is exclusive. 1431// FYI, this method is not a standard Moment method, so always do our enhanced logic. 1432newMomentProto.isWithin = function(start, end) { 1433 var a = commonlyAmbiguate([ this, start, end ]); 1434 return a[0] >= a[1] && a[0] < a[2]; 1435}; 1436 1437// When isSame is called with units, timezone ambiguity is normalized before the comparison happens. 1438// If no units specified, the two moments must be identically the same, with matching ambig flags. 1439newMomentProto.isSame = function(input, units) { 1440 var a; 1441 1442 // only do custom logic if this is an enhanced moment 1443 if (!this._fullCalendar) { 1444 return oldMomentProto.isSame.apply(this, arguments); 1445 } 1446 1447 if (units) { 1448 a = commonlyAmbiguate([ this, input ], true); // normalize timezones but don't erase times 1449 return oldMomentProto.isSame.call(a[0], a[1], units); 1450 } 1451 else { 1452 input = FC.moment.parseZone(input); // normalize input 1453 return oldMomentProto.isSame.call(this, input) && 1454 Boolean(this._ambigTime) === Boolean(input._ambigTime) && 1455 Boolean(this._ambigZone) === Boolean(input._ambigZone); 1456 } 1457}; 1458 1459// Make these query methods work with ambiguous moments 1460$.each([ 1461 'isBefore', 1462 'isAfter' 1463], function(i, methodName) { 1464 newMomentProto[methodName] = function(input, units) { 1465 var a; 1466 1467 // only do custom logic if this is an enhanced moment 1468 if (!this._fullCalendar) { 1469 return oldMomentProto[methodName].apply(this, arguments); 1470 } 1471 1472 a = commonlyAmbiguate([ this, input ]); 1473 return oldMomentProto[methodName].call(a[0], a[1], units); 1474 }; 1475}); 1476 1477 1478// Misc Internals 1479// ------------------------------------------------------------------------------------------------- 1480 1481// given an array of moment-like inputs, return a parallel array w/ moments similarly ambiguated. 1482// for example, of one moment has ambig time, but not others, all moments will have their time stripped. 1483// set `preserveTime` to `true` to keep times, but only normalize zone ambiguity. 1484// returns the original moments if no modifications are necessary. 1485function commonlyAmbiguate(inputs, preserveTime) { 1486 var anyAmbigTime = false;
1487 var anyAmbigZone = false; 1488 var len = inputs.length; 1489 var moms = []; 1490 var i, mom; 1491 1492 // parse inputs into real moments and query their ambig flags 1493 for (i = 0; i < len; i++) { 1494 mom = inputs[i]; 1495 if (!moment.isMoment(mom)) { 1496 mom = FC.moment.parseZone(mom); 1497 } 1498 anyAmbigTime = anyAmbigTime || mom._ambigTime; 1499 anyAmbigZone = anyAmbigZone || mom._ambigZone; 1500 moms.push(mom); 1501 } 1502 1503 // strip each moment down to lowest common ambiguity 1504 // use clones to avoid modifying the original moments 1505 for (i = 0; i < len; i++) { 1506 mom = moms[i]; 1507 if (!preserveTime && anyAmbigTime && !mom._ambigTime) { 1508 moms[i] = mom.clone().stripTime(); 1509 } 1510 else if (anyAmbigZone && !mom._ambigZone) { 1511 moms[i] = mom.clone().stripZone(); 1512 } 1513 } 1514 1515 return moms; 1516} 1517 1518// Transfers all the flags related to ambiguous time/zone from the `src` moment to the `dest` moment 1519// TODO: look into moment.momentProperties for this. 1520function transferAmbigs(src, dest) { 1521 if (src._ambigTime) { 1522 dest._ambigTime = true; 1523 } 1524 else if (dest._ambigTime) { 1525 dest._ambigTime = false; 1526 } 1527 1528 if (src._ambigZone) { 1529 dest._ambigZone = true; 1530 } 1531 else if (dest._ambigZone) { 1532 dest._ambigZone = false; 1533 } 1534} 1535 1536 1537// Sets the year/month/date/etc values of the moment from the given array. 1538// Inefficient because it calls each individual setter. 1539function setMomentValues(mom, a) { 1540 mom.year(a[0] || 0) 1541 .month(a[1] || 0) 1542 .date(a[2] || 0) 1543 .hours(a[3] || 0) 1544 .minutes(a[4] || 0) 1545 .seconds(a[5] || 0) 1546 .milliseconds(a[6] || 0); 1547} 1548 1549// Can we set the moment's internal date directly? 1550allowValueOptimization = '_d' in moment() && 'updateOffset' in moment; 1551 1552// Utility function. Accepts a moment and an array of the UTC year/month/date/etc values to set. 1553// Assumes the given moment is already in UTC mode. 1554setUTCValues = allowValueOptimization ? function(mom, a) { 1555 // simlate what moment's accessors do 1556 mom._d.setTime(Date.UTC.apply(Date, a)); 1557 moment.updateOffset(mom, false); // keepTime=false 1558} : setMomentValues; 1559 1560// Utility function. Accepts a moment and an array of the local year/month/date/etc values to set. 1561// Assumes the given moment is already in local mode. 1562setLocalValues = allowValueOptimization ? function(mom, a) { 1563 // simlate what moment's accessors do 1564 mom._d.setTime(+new Date( // FYI, there is now way to apply an array of args to a constructor 1565 a[0] || 0, 1566 a[1] || 0, 1567 a[2] || 0, 1568 a[3] || 0, 1569 a[4] || 0, 1570 a[5] || 0, 1571 a[6] || 0 1572 )); 1573 moment.updateOffset(mom, false); // keepTime=false 1574} : setMomentValues; 1575 1576;; 1577 1578// Single Date Formatting 1579// ------------------------------------------------------------------------------------------------- 1580 1581 1582// call this if you want Moment's original format method to be used 1583function oldMomentFormat(mom, formatStr) { 1584 return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js 1585} 1586 1587 1588// Formats `date` with a Moment formatting string, but allow our non-zero areas and 1589// additional token. 1590function formatDate(date, formatStr) { 1591 return formatDateWithChunks(date, getFormatStringChunks(formatStr)); 1592} 1593 1594 1595function formatDateWithChunks(date, chunks) { 1596 var s = ''; 1597 var i; 1598 1599 for (i=0; i<chunks.length; i++) { 1600 s += formatDateWithChunk(date, chunks[i]); 1601 } 1602 1603 return s; 1604} 1605 1606 1607// addition formatting tokens we want recognized 1608var tokenOverrides = { 1609 t: function(date) { // "a" or "p" 1610 return oldMomentFormat(date, 'a').charAt(0); 1611 }, 1612 T: function(date) { // "A" or "P" 1613 return oldMomentFormat(date, 'A').charAt(0); 1614 } 1615}; 1616 1617 1618function formatDateWithChunk(date, chunk) { 1619 var token; 1620 var maybeStr; 1621 1622 if (typeof chunk === 'string') { // a literal string 1623 return chunk; 1624 } 1625 else if ((token = chunk.token)) { // a token, like "YYYY" 1626 if (tokenOverrides[token]) { 1627 return tokenOverrides[token](date); // use our custom token 1628 } 1629 return oldMomentFormat(date, token); 1630 } 1631 else if (chunk.maybe) { // a grouping of other chunks that must be non-zero 1632 maybeStr = formatDateWithChunks(date, chunk.maybe); 1633 if (maybeStr.match(/[1-9]/)) { 1634 return maybeStr; 1635 } 1636 } 1637 1638 return ''; 1639} 1640 1641 1642// Date Range Formatting 1643// ------------------------------------------------------------------------------------------------- 1644// TODO: make it work with timezone offset 1645 1646// Using a formatting string meant for a single date, generate a range str
1646ing, like 1647// "Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ. 1648// If the dates are the same as far as the format string is concerned, just return a single 1649// rendering of one date, without any separator. 1650function formatRange(date1, date2, formatStr, separator, isRTL) { 1651 var localeData; 1652 1653 date1 = FC.moment.parseZone(date1); 1654 date2 = FC.moment.parseZone(date2); 1655 1656 localeData = (date1.localeData || date1.lang).call(date1); // works with moment-pre-2.8 1657 1658 // Expand localized format strings, like "LL" -> "MMMM D YYYY" 1659 formatStr = localeData.longDateFormat(formatStr) || formatStr; 1660 // BTW, this is not important for `formatDate` because it is impossible to put custom tokens 1661 // or non-zero areas in Moment's localized format strings. 1662 1663 separator = separator || ' - '; 1664 1665 return formatRangeWithChunks( 1666 date1, 1667 date2, 1668 getFormatStringChunks(formatStr), 1669 separator, 1670 isRTL 1671 ); 1672} 1673FC.formatRange = formatRange; // expose 1674 1675 1676function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { 1677 var unzonedDate1 = date1.clone().stripZone(); // for formatSimilarChunk 1678 var unzonedDate2 = date2.clone().stripZone(); // " 1679 var chunkStr; // the rendering of the chunk 1680 var leftI; 1681 var leftStr = ''; 1682 var rightI; 1683 var rightStr = ''; 1684 var middleI; 1685 var middleStr1 = ''; 1686 var middleStr2 = ''; 1687 var middleStr = ''; 1688 1689 // Start at the leftmost side of the formatting string and continue until you hit a token 1690 // that is not the same between dates. 1691 for (leftI=0; leftI<chunks.length; leftI++) { 1692 chunkStr = formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunks[leftI]); 1693 if (chunkStr === false) { 1694 break; 1695 } 1696 leftStr += chunkStr; 1697 } 1698 1699 // Similarly, start at the rightmost side of the formatting string and move left 1700 for (rightI=chunks.length-1; rightI>leftI; rightI--) { 1701 chunkStr = formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunks[rightI]); 1702 if (chunkStr === false) { 1703 break; 1704 } 1705 rightStr = chunkStr + rightStr; 1706 } 1707 1708 // The area in the middle is different for both of the dates. 1709 // Collect them distinctly so we can jam them together later. 1710 for (middleI=leftI; middleI<=rightI; middleI++) { 1711 middleStr1 += formatDateWithChunk(date1, chunks[middleI]); 1712 middleStr2 += formatDateWithChunk(date2, chunks[middleI]); 1713 } 1714 1715 if (middleStr1 || middleStr2) { 1716 if (isRTL) { 1717 middleStr = middleStr2 + separator + middleStr1; 1718 } 1719 else { 1720 middleStr = middleStr1 + separator + middleStr2; 1721 } 1722 } 1723 1724 return leftStr + middleStr + rightStr; 1725} 1726 1727 1728var similarUnitMap = { 1729 Y: 'year', 1730 M: 'month', 1731 D: 'day', // day of month 1732 d: 'day', // day of week 1733 // prevents a separator between anything time-related... 1734 A: 'second', // AM/PM 1735 a: 'second', // am/pm 1736 T: 'second', // A/P 1737 t: 'second', // a/p 1738 H: 'second', // hour (24) 1739 h: 'second', // hour (12) 1740 m: 'second', // minute 1741 s: 'second' // second 1742}; 1743// TODO: week maybe? 1744 1745 1746// Given a formatting chunk, and given that both dates are similar in the regard the 1747// formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`. 1748function formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunk) { 1749 var token; 1750 var unit; 1751 1752 if (typeof chunk === 'string') { // a literal string 1753 return chunk; 1754 } 1755 else if ((token = chunk.token)) { 1756 unit = similarUnitMap[token.charAt(0)]; 1757 1758 // are the dates the same for this unit of measurement? 1759 // use the unzoned dates for this calculation because unreliable when near DST (bug #2396) 1760 if (unit && unzonedDate1.isSame(unzonedDate2, unit)) { 1761 return oldMomentFormat(date1, token); // would be the same if we used `date2` 1762 // BTW, don't support custom tokens 1763 } 1764 } 1765 1766 return false; // the chunk is NOT the same for the two dates 1767 // BTW, don't support splitting on non-zero areas 1768} 1769 1770 1771// Chunking Utils 1772// ------------------------------------------------------------------------------------------------- 1773 1774 1775var formatStringChunkCache = {}; 1776 1777 1778function getFormatStringChunks(formatStr) { 1779 if (formatStr in formatStringChunkCache) { 1780 return formatStringChunkCache[formatStr]; 1781 } 1782 return (formatStringChunkCache[formatStr] = chunkFormatString(formatStr)); 1783} 1784 1785 1786// Break the formatting string into an array of chunks 1787function chunkFormatString(formatStr) { 1788 var chunks = []; 1789 var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g; // TODO: more descrimination 1790 var match; 1791 1792 while ((match = chunker.exec(formatStr))) { 1793 if (match[1]) { // a literal string inside [ ... ] 1794 chunks.push(match[1]); 1795 } 1796 else if (match[2]) { // non-zero formatting inside ( ... ) 1797 chunks.push({ maybe: chunkFormatString(match[2]) }); 1798 } 1799 else if (match[3]) { // a formatting token 1800 chunks.push({ token: match[3] }); 1801 } 1802 else if (match[5]) { // an unenclosed literal string 1803 chunks.push(match[5]); 1804 } 1805 } 1806 1807 return chunks; 1808} 1809 1810;; 1811 1812FC.Class = Class; // export 1813 1814// Class that all other classes will inherit from 1815function Class() { } 1816 1817 1818// Called on a class to create a subclass. 1819// Last argument contains instance methods. Any argument before the last are considered mixins. 1820Class.extend = function() { 1821 var len = arguments.length; 1822 var i; 1823 var members; 1824 1825 for (i = 0; i < len; i++) { 1826 members = arguments[i]; 1827 if (i < len - 1) { // not the last argument? 1828 mixIntoClass(this, members); 1829 } 1830 } 1831 1832 return extendClass(this, members || {}); // members will be undefined if no arguments 1833}; 1834 1835 1836// Adds new member variables/methods to the class's prototype. 1837// Can be called with another class, or a plain object hash containing new members. 1838Class.mixin = function(members) { 1839 mixIntoClass(this, members); 1840}; 1841 1842 1843function extendClass(superClass, members) { 1844 var subClass; 1845 1846 // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist 1847 if (hasOwnProp(members, 'constructor')) { 1848 subClass = members.constructor; 1849 } 1850 if (typeof subClass !== 'function') { 1851 subClass = members.constructor = function() { 1852 superClass.apply(this, arguments); 1853 }; 1854 } 1855 1856 // build the base prototype for the subclass, which is an new object chained to the superclass's prototype 1857 subClass.prototype = createObject(superClass.prototype); 1858 1859 // copy each member variable/method onto the the subclass's prototype 1860 copyOwnProps(members, subClass.prototype); 1861 copyNativeMethods(members, subClass.prototype); // hack for IE8 1862 1863 // copy over all class variables/methods to the subclass, such as `extend` and `mixin` 1864 copyOwnProps(superClass, subClass); 1865 1866 return subClass; 1867} 1868 1869 1870function mixIntoClass(theClass, members) { 1871 copyOwnProps(members, theClass.prototype); // TODO: copyNativeMethods? 1872} 1873;; 1874 1875var EmitterMixin = FC.EmitterMixin = { 1876 1877 // jQuery-ification via $(this) allows a non-DOM object to have 1878 // the same event handling capabilities (including namespaces). 1879 1880 1881 on: function(types, handler) { 1882 1883 // handlers are always called with an "event" object as their first param. 1884 // sneak the `this` context and arguments into the extra parameter object 1885 // and forward them on to the original handler. 1886 var intercept = function(ev, extra) { 1887 return handler.apply( 1888 extra.context || this, 1889 extra.args || [] 1890 ); 1891 }; 1892 1893 // mimick jQuery's internal "proxy" system (risky, I know) 1894 // causing all functions with the same .guid to appear to be the same. 1895 // https://github.com/jquery/jquery/blob/2.2.4/src/core.js#L448 1896 // this is needed for calling .off with the original non-intercept handler. 1897 if (!handler.guid) { 1898 handler.guid = $.guid++; 1899 } 1900 intercept.guid = handler.guid; 1901 1902 $(this).on(types, intercept); 1903 1904 return this; // for chaining 1905 }, 1906 1907 1908 off: function(types, handler) { 1909 $(this).off(types, handler); 1910 1911 return this; // for chaining 1912 }, 1913 1914 1915 trigger: function(types) { 1916 var args = Array.prototype.slice.call(arguments, 1); // arguments after the first 1917 1918 // pass in "extra" info to the intercept 1919 $(this).triggerHandler(types, { args: args }); 1920 1921 return this; // for chaining 1922 }, 1923 1924
1925 triggerWith: function(types, context, args) { 1926 1927 // `triggerHandler` is less reliant on the DOM compared to `trigger`. 1928 // pass in "extra" info to the intercept. 1929 $(this).triggerHandler(types, { context: context, args: args }); 1930 1931 return this; // for chaining 1932 } 1933 1934}; 1935 1936;; 1937 1938/* 1939Utility methods for easily listening to events on another object, 1940and more importantly, easily unlistening from them. 1941*/ 1942var ListenerMixin = FC.ListenerMixin = (function() { 1943 var guid = 0; 1944 var ListenerMixin = { 1945 1946 listenerId: null, 1947 1948 /* 1949 Given an `other` object that has on/off methods, bind the given `callback` to an event by the given name. 1950 The `callback` will be called with the `this` context of the object that .listenTo is being called on. 1951 Can be called: 1952 .listenTo(other, eventName, callback) 1953 OR 1954 .listenTo(other, { 1955 eventName1: callback1, 1956 eventName2: callback2 1957 }) 1958 */ 1959 listenTo: function(other, arg, callback) { 1960 if (typeof arg === 'object') { // given dictionary of callbacks 1961 for (var eventName in arg) { 1962 if (arg.hasOwnProperty(eventName)) { 1963 this.listenTo(other, eventName, arg[eventName]); 1964 } 1965 } 1966 } 1967 else if (typeof arg === 'string') { 1968 other.on( 1969 arg + '.' + this.getListenerNamespace(), // use event namespacing to identify this object 1970 $.proxy(callback, this) // always use `this` context 1971 // the usually-undesired jQuery guid behavior doesn't matter, 1972 // because we always unbind via namespace 1973 ); 1974 } 1975 }, 1976 1977 /* 1978 Causes the current object to stop listening to events on the `other` object. 1979 `eventName` is optional. If omitted, will stop listening to ALL events on `other`. 1980 */ 1981 stopListeningTo: function(other, eventName) { 1982 other.off((eventName || '') + '.' + this.getListenerNamespace()); 1983 }, 1984 1985 /* 1986 Returns a string, unique to this object, to be used for event namespacing 1987 */ 1988 getListenerNamespace: function() { 1989 if (this.listenerId == null) { 1990 this.listenerId = guid++; 1991 } 1992 return '_listener' + this.listenerId; 1993 } 1994 1995 }; 1996 return ListenerMixin; 1997})(); 1998;; 1999 2000// simple class for toggle a `isIgnoringMouse` flag on delay 2001// initMouseIgnoring must first be called, with a millisecond delay setting. 2002var MouseIgnorerMixin = { 2003 2004 isIgnoringMouse: false, // bool 2005 delayUnignoreMouse: null, // method 2006 2007 2008 initMouseIgnoring: function(delay) { 2009 this.delayUnignoreMouse = debounce(proxy(this, 'unignoreMouse'), delay || 1000); 2010 }, 2011 2012 2013 // temporarily ignore mouse actions on segments 2014 tempIgnoreMouse: function() { 2015 this.isIgnoringMouse = true; 2016 this.delayUnignoreMouse(); 2017 }, 2018 2019 2020 // delayUnignoreMouse eventually calls this 2021 unignoreMouse: function() { 2022 this.isIgnoringMouse = false; 2023 } 2024 2025}; 2026 2027;; 2028 2029/* A rectangular panel that is absolutely positioned over other content 2030------------------------------------------------------------------------------------------------------------------------ 2031Options: 2032 - className (string) 2033 - content (HTML string or jQuery element set) 2034 - parentEl 2035 - top 2036 - left 2037 - right (the x coord of where the right edge should be. not a "CSS" right) 2038 - autoHide (boolean) 2039 - show (callback) 2040 - hide (callback) 2041*/ 2042 2043var Popover = Class.extend(ListenerMixin, { 2044 2045 isHidden: true, 2046 options: null, 2047 el: null, // the container element for the popover. generated by this object 2048 margin: 10, // the space required between the popover and the edges of the scroll container 2049 2050 2051 constructor: function(options) { 2052 this.options = options || {}; 2053 }, 2054 2055 2056 // Shows the popover on the specified position. Renders it if not already 2057 show: function() { 2058 if (this.isHidden) { 2059 if (!this.el) { 2060 this.render(); 2061 } 2062 this.el.show(); 2063 this.position(); 2064 this.isHidden = false; 2065 this.trigger('show'); 2066 } 2067 }, 2068 2069 2070 // Hides the popover, through CSS, but does not remove it from the DOM 2071 hide: function() { 2072 if (!this.isHidden) { 2073 this.el.hide(); 2074 this.isHidden = true; 2075 this.trigger('hide'); 2076 } 2077 }, 2078 2079 2080 // Creates `this.el` and renders content inside of it 2081 render: function() { 2082 var _this = this; 2083 var options = this.options; 2084 2085 this.el = $('<div class="fc-popover"/>') 2086 .addClass(options.className || '') 2087 .css({ 2088 // position initially to the top left to avoid creating scrollbars 2089 top: 0, 2090 left: 0 2091 })
2092 .append(options.content) 2093 .appendTo(options.parentEl); 2094 2095 // when a click happens on anything inside with a 'fc-close' className, hide the popover 2096 this.el.on('click', '.fc-close', function() { 2097 _this.hide(); 2098 }); 2099 2100 if (options.autoHide) { 2101 this.listenTo($(document), 'mousedown', this.documentMousedown); 2102 } 2103 }, 2104 2105 2106 // Triggered when the user clicks *anywhere* in the document, for the autoHide feature 2107 documentMousedown: function(ev) { 2108 // only hide the popover if the click happened outside the popover 2109 if (this.el && !$(ev.target).closest(this.el).length) { 2110 this.hide(); 2111 } 2112 }, 2113 2114 2115 // Hides and unregisters any handlers 2116 removeElement: function() { 2117 this.hide(); 2118 2119 if (this.el) { 2120 this.el.remove(); 2121 this.el = null; 2122 } 2123 2124 this.stopListeningTo($(document), 'mousedown'); 2125 }, 2126 2127 2128 // Positions the popover optimally, using the top/left/right options 2129 position: function() { 2130 var options = this.options; 2131 var origin = this.el.offsetParent().offset(); 2132 var width = this.el.outerWidth(); 2133 var height = this.el.outerHeight(); 2134 var windowEl = $(window); 2135 var viewportEl = getScrollParent(this.el); 2136 var viewportTop; 2137 var viewportLeft; 2138 var viewportOffset; 2139 var top; // the "position" (not "offset") values for the popover 2140 var left; // 2141 2142 // compute top and left 2143 top = options.top || 0; 2144 if (options.left !== undefined) { 2145 left = options.left; 2146 } 2147 else if (options.right !== undefined) { 2148 left = options.right - width; // derive the left value from the right value 2149 } 2150 else { 2151 left = 0; 2152 } 2153 2154 if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result 2155 viewportEl = windowEl; 2156 viewportTop = 0; // the window is always at the top left 2157 viewportLeft = 0; // (and .offset() won't work if called here) 2158 } 2159 else { 2160 viewportOffset = viewportEl.offset(); 2161 viewportTop = viewportOffset.top; 2162 viewportLeft = viewportOffset.left; 2163 } 2164 2165 // if the window is scrolled, it causes the visible area to be further down 2166 viewportTop += windowEl.scrollTop(); 2167 viewportLeft += windowEl.scrollLeft(); 2168 2169 // constrain to the view port. if constrained by two edges, give precedence to top/left 2170 if (options.viewportConstrain !== false) { 2171 top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin); 2172 top = Math.max(top, viewportTop + this.margin); 2173 left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin); 2174 left = Math.max(left, viewportLeft + this.margin); 2175 } 2176 2177 this.el.css({ 2178 top: top - origin.top, 2179 left: left - origin.left 2180 }); 2181 }, 2182 2183 2184 // Triggers a callback. Calls a function in the option hash of the same name. 2185 // Arguments beyond the first `name` are forwarded on. 2186 // TODO: better code reuse for this. Repeat code 2187 trigger: function(name) { 2188 if (this.options[name]) { 2189 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); 2190 } 2191 } 2192 2193}); 2194 2195;; 2196 2197/* 2198A cache for the left/right/top/bottom/width/height values for one or more elements. 2199Works with both offset (from topleft document) and position (from offsetParent). 2200 2201options: 2202- els 2203- isHorizontal 2204- isVertical 2205*/ 2206var CoordCache = FC.CoordCache = Class.extend({ 2207 2208 els: null, // jQuery set (assumed to be siblings) 2209 forcedOffsetParentEl: null, // options can override the natural offsetParent 2210 origin: null, // {left,top} position of offsetParent of els 2211 boundingRect: null, // constrain cordinates to this rectangle. {left,right,top,bottom} or null 2212 isHorizontal: false, // whether to query for left/right/width 2213 isVertical: false, // whether to query for top/bottom/height 2214 2215 // arrays of coordinates (offsets from topleft of document) 2216 lefts: null, 2217 rights: null, 2218 tops: null, 2219 bottoms: null, 2220 2221 2222 constructor: function(options) { 2223 this.els = $(options.els); 2224 this.isHorizontal = options.isHorizontal; 2225 this.isVertical = options.isVertical; 2226 this.forcedOffsetParentEl = options.offsetParent ? $(options.offsetParent) : null; 2227 }, 2228 2229 2230 // Queries the els for coordinates and stores them. 2231 // Call this method before using and of the get* methods below. 2232 build: function() { 2233 var offsetParentEl = this.forcedOffsetParentEl || this.els.eq(0).offsetParent(); 2234 2235 this.origin = offsetParentEl.offset();
2236 this.boundingRect = this.queryBoundingRect(); 2237 2238 if (this.isHorizontal) { 2239 this.buildElHorizontals(); 2240 } 2241 if (this.isVertical) { 2242 this.buildElVerticals(); 2243 } 2244 }, 2245 2246 2247 // Destroys all internal data about coordinates, freeing memory 2248 clear: function() { 2249 this.origin = null; 2250 this.boundingRect = null; 2251 this.lefts = null; 2252 this.rights = null; 2253 this.tops = null; 2254 this.bottoms = null; 2255 }, 2256 2257 2258 // When called, if coord caches aren't built, builds them 2259 ensureBuilt: function() { 2260 if (!this.origin) { 2261 this.build(); 2262 } 2263 }, 2264 2265 2266 // Populates the left/right internal coordinate arrays 2267 buildElHorizontals: function() { 2268 var lefts = []; 2269 var rights = []; 2270 2271 this.els.each(function(i, node) { 2272 var el = $(node); 2273 var left = el.offset().left; 2274 var width = el.outerWidth(); 2275 2276 lefts.push(left); 2277 rights.push(left + width); 2278 }); 2279 2280 this.lefts = lefts; 2281 this.rights = rights; 2282 }, 2283 2284 2285 // Populates the top/bottom internal coordinate arrays 2286 buildElVerticals: function() { 2287 var tops = []; 2288 var bottoms = []; 2289 2290 this.els.each(function(i, node) { 2291 var el = $(node); 2292 var top = el.offset().top; 2293 var height = el.outerHeight(); 2294 2295 tops.push(top); 2296 bottoms.push(top + height); 2297 }); 2298 2299 this.tops = tops; 2300 this.bottoms = bottoms; 2301 }, 2302 2303 2304 // Given a left offset (from document left), returns the index of the el that it horizontally intersects. 2305 // If no intersection is made, returns undefined. 2306 getHorizontalIndex: function(leftOffset) { 2307 this.ensureBuilt(); 2308 2309 var lefts = this.lefts; 2310 var rights = this.rights; 2311 var len = lefts.length; 2312 var i; 2313 2314 for (i = 0; i < len; i++) { 2315 if (leftOffset >= lefts[i] && leftOffset < rights[i]) { 2316 return i; 2317 } 2318 } 2319 }, 2320 2321 2322 // Given a top offset (from document top), returns the index of the el that it vertically intersects. 2323 // If no intersection is made, returns undefined. 2324 getVerticalIndex: function(topOffset) { 2325 this.ensureBuilt(); 2326 2327 var tops = this.tops; 2328 var bottoms = this.bottoms; 2329 var len = tops.length; 2330 var i; 2331 2332 for (i = 0; i < len; i++) { 2333 if (topOffset >= tops[i] && topOffset < bottoms[i]) { 2334 return i; 2335 } 2336 } 2337 }, 2338 2339 2340 // Gets the left offset (from document left) of the element at the given index 2341 getLeftOffset: function(leftIndex) { 2342 this.ensureBuilt(); 2343 return this.lefts[leftIndex]; 2344 }, 2345 2346 2347 // Gets the left position (from offsetParent left) of the element at the given index 2348 getLeftPosition: function(leftIndex) { 2349 this.ensureBuilt(); 2350 return this.lefts[leftIndex] - this.origin.left; 2351 }, 2352 2353 2354 // Gets the right offset (from document left) of the element at the given index. 2355 // This value is NOT relative to the document's right edge, like the CSS concept of "right" would be. 2356 getRightOffset: function(leftIndex) { 2357 this.ensureBuilt(); 2358 return this.rights[leftIndex]; 2359 }, 2360 2361 2362 // Gets the right position (from offsetParent left) of the element at the given index. 2363 // This value is NOT relative to the offsetParent's right edge, like the CSS concept of "right" would be. 2364 getRightPosition: function(leftIndex) { 2365 this.ensureBuilt(); 2366 return this.rights[leftIndex] - this.origin.left; 2367 }, 2368 2369 2370 // Gets the width of the element at the given index 2371 getWidth: function(leftIndex) { 2372 this.ensureBuilt(); 2373 return this.rights[leftIndex] - this.lefts[leftIndex]; 2374 }, 2375 2376 2377 // Gets the top offset (from document top) of the element at the given index 2378 getTopOffset: function(topIndex) { 2379 this.ensureBuilt(); 2380 return this.tops[topIndex]; 2381 }, 2382 2383 2384 // Gets the top position (from offsetParent top) of the element at the given position 2385 getTopPosition: function(topIndex) { 2386 this.ensureBuilt(); 2387 return this.tops[topIndex] - this.origin.top; 2388 }, 2389 2390 // Gets the bottom offset (from the document top) of the element at the given index. 2391 // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be. 2392 getBottomOffset: function(topIndex) { 2393 this.ensureBuilt(); 2394 return this.bottoms[topIndex]; 2395 }, 2396 2397 2398 // Gets the bottom position (from the offsetParent top) of the element at the given index. 2399 // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be. 2400 getBottomPosition: function(topIndex) { 2401 this.ensureBuilt(); 2402 return this.bottoms[topIndex] - this.origin.top; 2403 }, 2404 2405 2406 // Gets the height of the element at the given index 2407 getHeight: function(topIndex) { 2408 this.ensureBuilt(); 2409 return this.bottoms[topIndex] - this.tops[topIndex]; 2410 }, 2411 2412 2413 // Bounding Rect 2414 // TODO: decouple this from CoordCache 2415 2416 // Compute and return what the elements' bounding rectangle is, from the user's perspective. 2417 // Right now, only returns a rectangle if constrained by an overflow:scroll element. 2418 queryBoundingRect: function() { 2419 var scrollParentEl = getScrollParent(this.els.eq(0)); 2420 2421 if (!scrollParentEl.is(document)) { 2422 return getClientRect(scrollParentEl); 2423 } 2424 }, 2425 2426 isPointInBounds: function(leftOffset, topOffset) { 2427 return this.isLeftInBounds(leftOffset) && this.isTopInBounds(topOffset); 2428 }, 2429 2430 isLeftInBounds: function(leftOffset) { 2431 return !this.boundingRect || (leftOffset >= this.boundingRect.left && leftOffset < this.boundingRect.right); 2432 }, 2433 2434 isTopInBounds: function(topOffset) { 2435 return !this.boundingRect || (topOffset >= this.boundingRect.top && topOffset < this.boundingRect.bottom); 2436 } 2437 2438}); 2439 2440;; 2441 2442/* Tracks a drag's mouse movement, firing various handlers 2443----------------------------------------------------------------------------------------------------------------------*/ 2444// TODO: use Emitter 2445 2446var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMixin, { 2447 2448 options: null, 2449 2450 // for IE8 bug-fighting behavior 2451 subjectEl: null, 2452 subjectHref: null, 2453
2454 // coordinates of the initial mousedown 2455 originX: null, 2456 originY: null, 2457 2458 // the wrapping element that scrolls, or MIGHT scroll if there's overflow. 2459 // TODO: do this for wrappers that have overflow:hidden as well. 2460 scrollEl: null, 2461 2462 isInteracting: false, 2463 isDistanceSurpassed: false, 2464 isDelayEnded: false, 2465 isDragging: false, 2466 isTouch: false, 2467 2468 delay: null, 2469 delayTimeoutId: null, 2470 minDistance: null, 2471 2472 handleTouchScrollProxy: null, // calls handleTouchScroll, always bound to `this` 2473 2474 2475 constructor: function(options) { 2476 this.options = options || {}; 2477 this.handleTouchScrollProxy = proxy(this, 'handleTouchScroll'); 2478 this.initMouseIgnoring(500); 2479 }, 2480 2481 2482 // Interaction (high-level) 2483 // ----------------------------------------------------------------------------------------------------------------- 2484 2485 2486 startInteraction: function(ev, extraOptions) { 2487 var isTouch = getEvIsTouch(ev); 2488 2489 if (ev.type === 'mousedown') { 2490 if (this.isIgnoringMouse) { 2491 return; 2492 } 2493 else if (!isPrimaryMouseButton(ev)) { 2494 return; 2495 } 2496 else { 2497 ev.preventDefault(); // prevents native selection in most browsers 2498 } 2499 } 2500 2501 if (!this.isInteracting) { 2502 2503 // process options 2504 extraOptions = extraOptions || {}; 2505 this.delay = firstDefined(extraOptions.delay, this.options.delay, 0); 2506 this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0); 2507 this.subjectEl = this.options.subjectEl; 2508 2509 this.isInteracting = true; 2510 this.isTouch = isTouch; 2511 this.isDelayEnded = false; 2512 this.isDistanceSurpassed = false; 2513 2514 this.originX = getEvX(ev); 2515 this.originY = getEvY(ev); 2516 this.scrollEl = getScrollParent($(ev.target)); 2517 2518 this.bindHandlers(); 2519 this.initAutoScroll(); 2520 this.handleInteractionStart(ev); 2521 this.startDelay(ev); 2522 2523 if (!this.minDistance) { 2524 this.handleDistanceSurpassed(ev); 2525 } 2526 } 2527 }, 2528 2529 2530 handleInteractionStart: function(ev) { 2531 this.trigger('interactionStart', ev); 2532 }, 2533 2534 2535 endInteraction: function(ev, isCancelled) { 2536 if (this.isInteracting) { 2537 this.endDrag(ev); 2538 2539 if (this.delayTimeoutId) { 2540 clearTimeout(this.delayTimeoutId); 2541 this.delayTimeoutId = null; 2542 } 2543 2544 this.destroyAutoScroll(); 2545 this.unbindHandlers(); 2546 2547 this.isInteracting = false; 2548 this.handleInteractionEnd(ev, isCancelled); 2549 2550 // a touchstart+touchend on the same element will result in the following addition simulated events: 2551 // mouseover + mouseout + click 2552 // let's ignore these bogus events 2553 if (this.isTouch) { 2554 this.tempIgnoreMouse(); 2555 } 2556 } 2557 }, 2558 2559 2560 handleInteractionEnd: function(ev, isCancelled) { 2561 this.trigger('interactionEnd', ev, isCancelled || false); 2562 }, 2563 2564 2565 // Binding To DOM 2566 // ----------------------------------------------------------------------------------------------------------------- 2567 2568 2569 bindHandlers: function() { 2570 var _this = this; 2571 var touchStartIgnores = 1; 2572 2573 if (this.isTouch) { 2574 this.listenTo($(document), { 2575 touchmove: this.handleTouchMove, 2576 touchend: this.endInteraction, 2577 touchcancel: this.endInteraction, 2578 2579 // Sometimes touchend doesn't fire 2580 // (can't figure out why. touchcancel doesn't fire either. has to do with scrolling?) 2581 // If another touchstart happens, we know it's bogus, so cancel the drag. 2582 // touchend will continue to be broken until user does a shorttap/scroll, but this is best we can do. 2583 touchstart: function(ev) { 2584 if (touchStartIgnores) { // bindHandlers is called from within a touchstart, 2585 touchStartIgnores--; // and we don't want this to fire immediately, so ignore. 2586 } 2587 else { 2588 _this.endInteraction(ev, true); // isCancelled=true 2589 } 2590 } 2591 }); 2592 2593 // listen to ALL scroll actions on the page 2594 if ( 2595 !bindAnyScroll(this.handleTouchScrollProxy) && // hopefully this works and short-circuits the rest 2596 this.scrollEl // otherwise, attach a single handler to this 2597 ) { 2598 this.listenTo(this.scrollEl, 'scroll', this.handleTouchScroll); 2599 } 2600 } 2601 else { 2602 this.listenTo($(document), { 2603 mousemove: this.handleMouseMove, 2604 mouseup: this.endInteraction 2605 }); 2606 } 2607 2608 this.listenTo($(document), { 2609 selectstart: preventDefault, // don't allow selection while dragging 2610 contextmenu: preventDefault // long taps would open menu on Chrome dev tools 2611 }); 2612 }, 2613 2614 2615 unbindHandlers: function() { 2616 this.stopListeningTo($(document)); 2617 2618 // unbind scroll listening 2619 unbindAnyScroll(this.handleTouchScrollProxy); 2620 if (this.scrollEl) { 2621 this.stopListeningTo(this.scrollEl, 'scroll'); 2622 } 2623 }, 2624 2625 2626 // Drag (high-level) 2627 // ----------------------------------------------------------------------------------------------------------------- 2628 2629 2630 // extraOptions ignored if drag already started 2631 startDrag: function(ev, extraOptions) { 2632 this.startInteraction(ev, extraOptions); // ensure interaction began 2633 2634 if (!this.isDragging) { 2635 this.isDragging = true; 2636 this.handleDragStart(ev); 2637 } 2638 }, 2639 2640 2641 handleDragStart: function(ev) { 2642 this.trigger('dragStart', ev); 2643 this.initHrefHack(); 2644 }, 2645 2646 2647 handleMove: function(ev) { 2648 var dx = getEvX(ev) - this.originX; 2649 var dy = getEvY(ev) - this.originY; 2650 var minDistance = this.minDistance; 2651 var distanceSq; // current distance from the origin, squared 2652 2653 if (!this.isDistanceSurpassed) { 2654 distanceSq = dx * dx + dy * dy; 2655 if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem 2656 this.handleDistanceSurpassed(ev); 2657 } 2658 } 2659 2660 if (this.isDragging) { 2661 this.handleDrag(dx, dy, ev); 2662 } 2663 }, 2664 2665 2666 // Called while the mouse is being moved and when we know a legitimate drag is taking place 2667 handleDrag: function(dx, dy, ev) { 2668 this.trigger('drag', dx, dy, ev); 2669 this.updateAutoScroll(ev); // will possibly cause scrolling 2670 }, 2671 2672 2673 endDrag: function(ev) { 2674 if (this.isDragging) { 2675 this.isDragging = false;
2676 this.handleDragEnd(ev); 2677 } 2678 }, 2679 2680 2681 handleDragEnd: function(ev) { 2682 this.trigger('dragEnd', ev); 2683 this.destroyHrefHack(); 2684 }, 2685 2686 2687 // Delay 2688 // ----------------------------------------------------------------------------------------------------------------- 2689 2690 2691 startDelay: function(initialEv) { 2692 var _this = this; 2693 2694 if (this.delay) { 2695 this.delayTimeoutId = setTimeout(function() { 2696 _this.handleDelayEnd(initialEv); 2697 }, this.delay); 2698 } 2699 else { 2700 this.handleDelayEnd(initialEv); 2701 } 2702 }, 2703 2704 2705 handleDelayEnd: function(initialEv) { 2706 this.isDelayEnded = true; 2707 2708 if (this.isDistanceSurpassed) { 2709 this.startDrag(initialEv); 2710 } 2711 }, 2712 2713 2714 // Distance 2715 // ----------------------------------------------------------------------------------------------------------------- 2716 2717 2718 handleDistanceSurpassed: function(ev) { 2719 this.isDistanceSurpassed = true; 2720 2721 if (this.isDelayEnded) { 2722 this.startDrag(ev); 2723 } 2724 }, 2725 2726 2727 // Mouse / Touch 2728 // ----------------------------------------------------------------------------------------------------------------- 2729 2730 2731 handleTouchMove: function(ev) { 2732 // prevent inertia and touchmove-scrolling while dragging 2733 if (this.isDragging) { 2734 ev.preventDefault(); 2735 } 2736 2737 this.handleMove(ev); 2738 }, 2739 2740 2741 handleMouseMove: function(ev) { 2742 this.handleMove(ev); 2743 }, 2744 2745 2746 // Scrolling (unrelated to auto-scroll) 2747 // ----------------------------------------------------------------------------------------------------------------- 2748 2749 2750 handleTouchScroll: function(ev) { 2751 // if the drag is being initiated by touch, but a scroll happens before 2752 // the drag-initiating delay is over, cancel the drag 2753 if (!this.isDragging) { 2754 this.endInteraction(ev, true); // isCancelled=true 2755 } 2756 }, 2757 2758 2759 // <A> HREF Hack 2760 // ----------------------------------------------------------------------------------------------------------------- 2761 2762 2763 initHrefHack: function() { 2764 var subjectEl = this.subjectEl; 2765 2766 // remove a mousedown'd <a>'s href so it is not visited (IE8 bug) 2767 if ((this.subjectHref = subjectEl ? subjectEl.attr('href') : null)) { 2768 subjectEl.removeAttr('href'); 2769 } 2770 }, 2771 2772 2773 destroyHrefHack: function() { 2774 var subjectEl = this.subjectEl; 2775 var subjectHref = this.subjectHref; 2776 2777 // restore a mousedown'd <a>'s href (for IE8 bug) 2778 setTimeout(function() { // must be outside of the click's execution 2779 if (subjectHref) { 2780 subjectEl.attr('href', subjectHref); 2781 } 2782 }, 0); 2783 }, 2784 2785 2786 // Utils 2787 // ----------------------------------------------------------------------------------------------------------------- 2788 2789 2790 // Triggers a callback. Calls a function in the option hash of the same name. 2791 // Arguments beyond the first `name` are forwarded on. 2792 trigger: function(name) { 2793 if (this.options[name]) { 2794 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); 2795 } 2796 // makes _methods callable by event name. TODO: kill this 2797 if (this['_' + name]) { 2798 this['_' + name].apply(this, Array.prototype.slice.call(arguments, 1)); 2799 } 2800 } 2801 2802 2803}); 2804 2805;; 2806/* 2807this.scrollEl is set in DragListener 2808*/ 2809DragListener.mixin({ 2810 2811 isAutoScroll: false, 2812 2813 scrollBounds: null, // { top, bottom, left, right } 2814 scrollTopVel: null, // pixels per second 2815 scrollLeftVel: null, // pixels per second 2816 scrollIntervalId: null, // ID of setTimeout for scrolling animation loop 2817 2818 // defaults 2819 scrollSensitivity: 30, // pixels from edge for scrolling to start 2820 scrollSpeed: 200, // pixels per second, at maximum speed 2821 scrollIntervalMs: 50, // millisecond wait between scroll increment 2822 2823 2824 initAutoScroll: function() { 2825 var scrollEl = this.scrollEl; 2826 2827 this.isAutoScroll = 2828 this.options.scroll && 2829 scrollEl && 2830 !scrollEl.is(window) && 2831 !scrollEl.is(document); 2832 2833 if (this.isAutoScroll) { 2834 // debounce makes sure rapid calls don't happen 2835 this.listenTo(scrollEl, 'scroll', debounce(this.handleDebouncedScroll, 100)); 2836 } 2837 }, 2838 2839 2840 destroyAutoScroll: function() { 2841 this.endAutoScroll(); // kill any animation loop 2842 2843 // remove the scroll handler if there is a scrollEl 2844 if (this.isAutoScroll) { 2845 this.stopListeningTo(this.scrollEl, 'scroll'); // will probably get removed by unbindHandlers too :( 2846 } 2847 }, 2848 2849 2850 // Computes and stores the bounding rectangle of scrollEl 2851 computeScrollBounds: function() { 2852 if (this.isAutoScroll) { 2853 this.scrollBounds = getOuterRect(this.scrollEl); 2854 // TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars 2855 } 2856 }, 2857 2858 2859 // Called when the dragging is in progress and scrolling should be updated 2860 updateAutoScroll: function(ev) { 2861 var sensitivity = this.scrollSensitivity; 2862 var bounds = this.scrollBounds; 2863 var topCloseness, bottomCloseness; 2864 var leftCloseness, rightCloseness; 2865 var topVel = 0; 2866 var leftVel = 0; 2867 2868 if (bounds) { // only scroll if scrollEl exists 2869
2870 // compute closeness to edges. valid range is from 0.0 - 1.0 2871 topCloseness = (sensitivity - (getEvY(ev) - bounds.top)) / sensitivity; 2872 bottomCloseness = (sensitivity - (bounds.bottom - getEvY(ev))) / sensitivity; 2873 leftCloseness = (sensitivity - (getEvX(ev) - bounds.left)) / sensitivity; 2874 rightCloseness = (sensitivity - (bounds.right - getEvX(ev))) / sensitivity; 2875 2876 // translate vertical closeness into velocity. 2877 // mouse must be completely in bounds for velocity to happen. 2878 if (topCloseness >= 0 && topCloseness <= 1) { 2879 topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up 2880 } 2881 else if (bottomCloseness >= 0 && bottomCloseness <= 1) { 2882 topVel = bottomCloseness * this.scrollSpeed; 2883 } 2884 2885 // translate horizontal closeness into velocity 2886 if (leftCloseness >= 0 && leftCloseness <= 1) { 2887 leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left 2888 } 2889 else if (rightCloseness >= 0 && rightCloseness <= 1) { 2890 leftVel = rightCloseness * this.scrollSpeed; 2891 } 2892 } 2893 2894 this.setScrollVel(topVel, leftVel); 2895 }, 2896 2897 2898 // Sets the speed-of-scrolling for the scrollEl 2899 setScrollVel: function(topVel, leftVel) { 2900 2901 this.scrollTopVel = topVel; 2902 this.scrollLeftVel = leftVel; 2903 2904 this.constrainScrollVel(); // massages into realistic values 2905 2906 // if there is non-zero velocity, and an animation loop hasn't already started, then START 2907 if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) { 2908 this.scrollIntervalId = setInterval( 2909 proxy(this, 'scrollIntervalFunc'), // scope to `this` 2910 this.scrollIntervalMs 2911 ); 2912 } 2913 }, 2914 2915 2916 // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way 2917 constrainScrollVel: function() { 2918 var el = this.scrollEl; 2919 2920 if (this.scrollTopVel < 0) { // scrolling up? 2921 if (el.scrollTop() <= 0) { // already scrolled all the way up? 2922 this.scrollTopVel = 0; 2923 } 2924 } 2925 else if (this.scrollTopVel > 0) { // scrolling down? 2926 if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down? 2927 this.scrollTopVel = 0; 2928 } 2929 } 2930 2931 if (this.scrollLeftVel < 0) { // scrolling left? 2932 if (el.scrollLeft() <= 0) { // already scrolled all the left? 2933 this.scrollLeftVel = 0; 2934 } 2935 } 2936 else if (this.scrollLeftVel > 0) { // scrolling right? 2937 if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right? 2938 this.scrollLeftVel = 0; 2939 } 2940 } 2941 }, 2942 2943 2944 // This function gets called during every iteration of the scrolling animation loop 2945 scrollIntervalFunc: function() { 2946 var el = this.scrollEl; 2947 var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by 2948 2949 // change the value of scrollEl's scroll 2950 if (this.scrollTopVel) { 2951 el.scrollTop(el.scrollTop() + this.scrollTopVel * frac); 2952 } 2953 if (this.scrollLeftVel) { 2954 el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac); 2955 } 2956 2957 this.constrainScrollVel(); // since the scroll values changed, recompute the velocities 2958 2959 // if scrolled all the way, which causes the vels to be zero, stop the animation loop 2960 if (!this.scrollTopVel && !this.scrollLeftVel) { 2961 this.endAutoScroll(); 2962 } 2963 }, 2964 2965 2966 // Kills any existing scrolling animation loop 2967 endAutoScroll: function() { 2968 if (this.scrollIntervalId) { 2969 clearInterval(this.scrollIntervalId); 2970 this.scrollIntervalId = null; 2971 2972 this.handleScrollEnd(); 2973 } 2974 }, 2975 2976 2977 // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce) 2978 handleDebouncedScroll: function() { 2979 // recompute all coordinates, but *only* if this is *not* part of our scrolling animation 2980 if (!this.scrollIntervalId) { 2981 this.handleScrollEnd(); 2982 } 2983 }, 2984 2985 2986 // Called when scrolling has stopped, whether through auto scroll, or the user scrolling 2987 handleScrollEnd: function() { 2988 } 2989 2990}); 2991;; 2992 2993/* Tracks mouse movements over a component and raises events about which hit the mouse is over. 2994------------------------------------------------------------------------------------------------------------------------ 2995options: 2996- subjectEl 2997- subjectCenter 2998*/ 2999 3000var HitDragListener = DragListener.extend({ 3001 3002 component: null, // converts coordinates to hits 3003 // methods: prepareHits, releaseHits, queryHit 3004 3005 origHit: null, // the hit the mouse was over when listening started 3006 hit: null, // the hit the mouse is over
3007 coordAdjust: null, // delta that will be added to the mouse coordinates when computing collisions 3008 3009 3010 constructor: function(component, options) { 3011 DragListener.call(this, options); // call the super-constructor 3012 3013 this.component = component; 3014 }, 3015 3016 3017 // Called when drag listening starts (but a real drag has not necessarily began). 3018 // ev might be undefined if dragging was started manually. 3019 handleInteractionStart: function(ev) { 3020 var subjectEl = this.subjectEl; 3021 var subjectRect; 3022 var origPoint; 3023 var point; 3024 3025 this.computeCoords(); 3026 3027 if (ev) { 3028 origPoint = { left: getEvX(ev), top: getEvY(ev) }; 3029 point = origPoint; 3030 3031 // constrain the point to bounds of the element being dragged 3032 if (subjectEl) { 3033 subjectRect = getOuterRect(subjectEl); // used for centering as well 3034 point = constrainPoint(point, subjectRect); 3035 } 3036 3037 this.origHit = this.queryHit(point.left, point.top); 3038 3039 // treat the center of the subject as the collision point? 3040 if (subjectEl && this.options.subjectCenter) { 3041 3042 // only consider the area the subject overlaps the hit. best for large subjects. 3043 // TODO: skip this if hit didn't supply left/right/top/bottom 3044 if (this.origHit) { 3045 subjectRect = intersectRects(this.origHit, subjectRect) || 3046 subjectRect; // in case there is no intersection 3047 } 3048 3049 point = getRectCenter(subjectRect); 3050 } 3051 3052 this.coordAdjust = diffPoints(point, origPoint); // point - origPoint 3053 } 3054 else { 3055 this.origHit = null; 3056 this.coordAdjust = null; 3057 } 3058 3059 // call the super-method. do it after origHit has been computed 3060 DragListener.prototype.handleInteractionStart.apply(this, arguments); 3061 }, 3062 3063 3064 // Recomputes the drag-critical positions of elements 3065 computeCoords: function() { 3066 this.component.prepareHits(); 3067 this.computeScrollBounds(); // why is this here?????? 3068 }, 3069 3070 3071 // Called when the actual drag has started 3072 handleDragStart: function(ev) { 3073 var hit; 3074 3075 DragListener.prototype.handleDragStart.apply(this, arguments); // call the super-method 3076 3077 // might be different from this.origHit if the min-distance is large 3078 hit = this.queryHit(getEvX(ev), getEvY(ev)); 3079 3080 // report the initial hit the mouse is over 3081 // especially important if no min-distance and drag starts immediately 3082 if (hit) { 3083 this.handleHitOver(hit); 3084 } 3085 }, 3086 3087 3088 // Called when the drag moves 3089 handleDrag: function(dx, dy, ev) { 3090 var hit; 3091 3092 DragListener.prototype.handleDrag.apply(this, arguments); // call the super-method 3093 3094 hit = this.queryHit(getEvX(ev), getEvY(ev)); 3095 3096 if (!isHitsEqual(hit, this.hit)) { // a different hit than before? 3097 if (this.hit) { 3098 this.handleHitOut(); 3099 } 3100 if (hit) { 3101 this.handleHitOver(hit); 3102 } 3103 } 3104 }, 3105 3106 3107 // Called when dragging has been stopped 3108 handleDragEnd: function() { 3109 this.handleHitDone(); 3110 DragListener.prototype.handleDragEnd.apply(this, arguments); // call the super-method 3111 }, 3112 3113 3114 // Called when a the mouse has just moved over a new hit 3115 handleHitOver: function(hit) { 3116 var isOrig = isHitsEqual(hit, this.origHit); 3117 3118 this.hit = hit; 3119 3120 this.trigger('hitOver', this.hit, isOrig, this.origHit); 3121 }, 3122 3123 3124 // Called when the mouse has just moved out of a hit 3125 handleHitOut: function() { 3126 if (this.hit) { 3127 this.trigger('hitOut', this.hit); 3128 this.handleHitDone(); 3129 this.hit = null; 3130 } 3131 }, 3132 3133 3134 // Called after a hitOut. Also called before a dragStop 3135 handleHitDone: function() { 3136 if (this.hit) { 3137 this.trigger('hitDone', this.hit); 3138 } 3139 }, 3140 3141 3142 // Called when the interaction ends, whether there was a real drag or not 3143 handleInteractionEnd: function() { 3144 DragListener.prototype.handleInteractionEnd.apply(this, arguments); // call the super-method 3145 3146 this.origHit = null; 3147 this.hit = null; 3148 3149 this.component.releaseHits(); 3150 }, 3151 3152 3153 // Called when scrolling has stopped, whether through auto scroll, or the user scrolling 3154 handleScrollEnd: function() { 3155 DragListener.prototype.handleScrollEnd.apply(this, arguments); // call the super-method 3156 3157 this.computeCoords(); // hits' absolute positions will be in new places. recompute 3158 }, 3159 3160 3161 // Gets the hit underneath the coordinates for the given mouse event 3162 queryHit: function(left, top) { 3163 3164 if (this.coordAdjust) { 3165 left += this.coordAdjust.left; 3166 top += this.coordAdjust.top; 3167 } 3168 3169 return this.component.queryHit(left, top); 3170 } 3171 3172}); 3173 3174 3175// Returns `true` if the hits are identically equal. `false` otherwise. Must be from the same component. 3176// Two null values will be considered equal, as two "out of the component" states are the same. 3177function isHitsEqual(hit0, hit1) { 3178 3179 if (!hit0 && !hit1) { 3180 return true; 3181 } 3182 3183 if (hit0 && hit1) { 3184 return hit0.component === hit1.component && 3185 isHitPropsWithin(hit0, hit1) && 3186 isHitPropsWithin(hit1, hit0); // ensures all props are identical 3187 } 3188 3189 return false;
3190} 3191 3192 3193// Returns true if all of subHit's non-standard properties are within superHit 3194function isHitPropsWithin(subHit, superHit) { 3195 for (var propName in subHit) { 3196 if (!/^(component|left|right|top|bottom)$/.test(propName)) { 3197 if (subHit[propName] !== superHit[propName]) { 3198 return false; 3199 } 3200 } 3201 } 3202 return true; 3203} 3204 3205;; 3206 3207/* Creates a clone of an element and lets it track the mouse as it moves 3208----------------------------------------------------------------------------------------------------------------------*/ 3209 3210var MouseFollower = Class.extend(ListenerMixin, { 3211 3212 options: null, 3213 3214 sourceEl: null, // the element that will be cloned and made to look like it is dragging 3215 el: null, // the clone of `sourceEl` that will track the mouse 3216 parentEl: null, // the element that `el` (the clone) will be attached to 3217 3218 // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl 3219 top0: null, 3220 left0: null, 3221 3222 // the absolute coordinates of the initiating touch/mouse action 3223 y0: null, 3224 x0: null, 3225 3226 // the number of pixels the mouse has moved from its initial position 3227 topDelta: null, 3228 leftDelta: null, 3229 3230 isFollowing: false, 3231 isHidden: false, 3232 isAnimating: false, // doing the revert animation? 3233 3234 constructor: function(sourceEl, options) { 3235 this.options = options = options || {}; 3236 this.sourceEl = sourceEl; 3237 this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent 3238 }, 3239 3240 3241 // Causes the element to start following the mouse 3242 start: function(ev) { 3243 if (!this.isFollowing) { 3244 this.isFollowing = true; 3245 3246 this.y0 = getEvY(ev); 3247 this.x0 = getEvX(ev); 3248 this.topDelta = 0; 3249 this.leftDelta = 0; 3250 3251 if (!this.isHidden) { 3252 this.updatePosition(); 3253 } 3254 3255 if (getEvIsTouch(ev)) { 3256 this.listenTo($(document), 'touchmove', this.handleMove); 3257 } 3258 else { 3259 this.listenTo($(document), 'mousemove', this.handleMove); 3260 } 3261 } 3262 }, 3263 3264 3265 // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position. 3266 // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately. 3267 stop: function(shouldRevert, callback) { 3268 var _this = this; 3269 var revertDuration = this.options.revertDuration; 3270 3271 function complete() { // might be called by .animate(), which might change `this` context 3272 _this.isAnimating = false; 3273 _this.removeElement(); 3274 3275 _this.top0 = _this.left0 = null; // reset state for future updatePosition calls 3276 3277 if (callback) { 3278 callback(); 3279 } 3280 } 3281 3282 if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time 3283 this.isFollowing = false; 3284 3285 this.stopListeningTo($(document)); 3286 3287 if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation? 3288 this.isAnimating = true; 3289 this.el.animate({ 3290 top: this.top0, 3291 left: this.left0 3292 }, { 3293 duration: revertDuration, 3294 complete: complete 3295 }); 3296 } 3297 else { 3298 complete(); 3299 } 3300 } 3301 }, 3302 3303 3304 // Gets the tracking element. Create it if necessary 3305 getEl: function() { 3306 var el = this.el; 3307 3308 if (!el) { 3309 this.sourceEl.width(); // hack to force IE8 to compute correct bounding box 3310 el = this.el = this.sourceEl.clone() 3311 .addClass(this.options.additionalClass || '') 3312 .css({ 3313 position: 'absolute', 3314 visibility: '', // in case original element was hidden (commonly through hideEvents()) 3315 display: this.isHidden ? 'none' : '', // for when initially hidden 3316 margin: 0, 3317 right: 'auto', // erase and set width instead 3318 bottom: 'auto', // erase and set height instead 3319 width: this.sourceEl.width(), // explicit height in case there was a 'right' value 3320 height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value 3321 opacity: this.options.opacity || '', 3322 zIndex: this.options.zIndex 3323 }); 3324 3325 // we don't want long taps or any mouse interaction causing selection/menus. 3326 // would use preventSelection(), but that prevents selectstart, causing problems. 3327 el.addClass('fc-unselectable'); 3328 3329 el.appendTo(this.parentEl); 3330 } 3331 3332 return el; 3333 }, 3334 3335 3336 // Removes the tracking element if it has already been created 3337 removeElement: function() { 3338 if (this.el) { 3339 this.el.remove(); 3340 this.el = null; 3341 } 3342 }, 3343 3344 3345 // Update the CSS position of the tracking element 3346 updatePosition: function() { 3347 var sourceOffset; 3348 var origin; 3349 3350 this.getEl(); // ensure this.el 3351
3352 // make sure origin info was computed 3353 if (this.top0 === null) { 3354 this.sourceEl.width(); // hack to force IE8 to compute correct bounding box 3355 sourceOffset = this.sourceEl.offset(); 3356 origin = this.el.offsetParent().offset(); 3357 this.top0 = sourceOffset.top - origin.top; 3358 this.left0 = sourceOffset.left - origin.left; 3359 } 3360 3361 this.el.css({ 3362 top: this.top0 + this.topDelta, 3363 left: this.left0 + this.leftDelta 3364 }); 3365 }, 3366 3367 3368 // Gets called when the user moves the mouse 3369 handleMove: function(ev) { 3370 this.topDelta = getEvY(ev) - this.y0; 3371 this.leftDelta = getEvX(ev) - this.x0; 3372 3373 if (!this.isHidden) { 3374 this.updatePosition(); 3375 } 3376 }, 3377 3378 3379 // Temporarily makes the tracking element invisible. Can be called before following starts 3380 hide: function() { 3381 if (!this.isHidden) { 3382 this.isHidden = true; 3383 if (this.el) { 3384 this.el.hide(); 3385 } 3386 } 3387 }, 3388 3389 3390 // Show the tracking element after it has been temporarily hidden 3391 show: function() { 3392 if (this.isHidden) { 3393 this.isHidden = false; 3394 this.updatePosition(); 3395 this.getEl().show(); 3396 } 3397 } 3398 3399}); 3400 3401;; 3402 3403/* An abstract class comprised of a "grid" of areas that each represent a specific datetime 3404----------------------------------------------------------------------------------------------------------------------*/ 3405 3406var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { 3407 3408 view: null, // a View object 3409 isRTL: null, // shortcut to the view's isRTL option 3410 3411 start: null, 3412 end: null, 3413 3414 el: null, // the containing element 3415 elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name. 3416 3417 // derived from options 3418 eventTimeFormat: null, 3419 displayEventTime: null, 3420 displayEventEnd: null, 3421 3422 minResizeDuration: null, // TODO: hack. set by subclasses. minumum event resize duration 3423 3424 // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity 3425 // of the date areas. if not defined, assumes to be day and time granularity. 3426 // TODO: port isTimeScale into same system? 3427 largeUnit: null, 3428 3429 dayDragListener: null, 3430 segDragListener: null, 3431 segResizeListener: null, 3432 externalDragListener: null, 3433 3434 3435 constructor: function(view) { 3436 this.view = view; 3437 this.isRTL = view.opt('isRTL'); 3438 this.elsByFill = {}; 3439 3440 this.dayDragListener = this.buildDayDragListener(); 3441 this.initMouseIgnoring(); 3442 }, 3443 3444 3445 /* Options 3446 ------------------------------------------------------------------------------------------------------------------*/ 3447 3448 3449 // Generates the format string used for event time text, if not explicitly defined by 'timeFormat' 3450 computeEventTimeFormat: function() { 3451 return this.view.opt('smallTimeFormat'); 3452 }, 3453 3454 3455 // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventTime'. 3456 // Only applies to non-all-day events. 3457 computeDisplayEventTime: function() { 3458 return true; 3459 }, 3460 3461 3462 // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd' 3463 computeDisplayEventEnd: function() { 3464 return true; 3465 }, 3466 3467 3468 /* Dates 3469 ------------------------------------------------------------------------------------------------------------------*/ 3470 3471 3472 // Tells the grid about what period of time to display. 3473 // Any date-related internal data should be generated. 3474 setRange: function(range) { 3475 this.start = range.start.clone(); 3476 this.end = range.end.clone(); 3477 3478 this.rangeUpdated(); 3479 this.processRangeOptions(); 3480 }, 3481 3482 3483 // Called when internal variables that rely on the range should be updated 3484 rangeUpdated: function() { 3485 }, 3486 3487 3488 // Updates values that rely on options and also relate to range 3489 processRangeOptions: function() { 3490 var view = this.view; 3491 var displayEventTime; 3492 var displayEventEnd; 3493 3494 this.eventTimeFormat = 3495 view.opt('eventTimeFormat') || 3496 view.opt('timeFormat') || // deprecated 3497 this.computeEventTimeFormat(); 3498 3499 displayEventTime = view.opt('displayEventTime'); 3500 if (displayEventTime == null) { 3501 displayEventTime = this.computeDisplayEventTime(); // might be based off of range 3502 } 3503 3504 displayEventEnd = view.opt('displayEventEnd'); 3505 if (displayEventEnd == null) { 3506 displayEventEnd = this.computeDisplayEventEnd(); // might be based off of range 3507 } 3508 3509 this.displayEventTime = displayEventTime; 3510 this.displayEventEnd = displayEventEnd; 3511 }, 3512 3513 3514 // Converts a span (has unzoned start/end and any other grid-specific location information) 3515 // into an array of segments (pieces of events whose format is decided by the grid). 3516 spanToSegs: function(span) { 3517 // subclasses must implement 3518 }, 3519 3520 3521 // Diffs the two dates, returning a duration, based on granularity of the grid 3522 // TODO: port isTimeScale into this system? 3523 diffDates: function(a, b) { 3524 if (this.largeUnit) { 3525 return diffByUnit(a, b, this.largeUnit); 3526 } 3527 else { 3528 return diffDayTime(a, b); 3529 } 3530 }, 3531 3532 3533 /* Hit Area 3534 ------------------------------------------------------------------------------------------------------------------*/ 3535 3536 3537 // Called before one or more queryHit calls might happen. Should prepare any cached coordinates for queryHit 3538 prepareHits: function() { 3539 }, 3540 3541 3542 // Called when queryHit calls have subsided. Good place to clear any coordinate caches. 3543 releaseHits: function() { 3544 }, 3545 3546 3547 // Given coordinates from the topleft of the document, return data about the date-related area underneath.
3548 // Can return an object with arbitrary properties (although top/right/left/bottom are encouraged). 3549 // Must have a `grid` property, a reference to this current grid. TODO: avoid this 3550 // The returned object will be processed by getHitSpan and getHitEl. 3551 queryHit: function(leftOffset, topOffset) { 3552 }, 3553 3554 3555 // Given position-level information about a date-related area within the grid, 3556 // should return an object with at least a start/end date. Can provide other information as well. 3557 getHitSpan: function(hit) { 3558 }, 3559 3560 3561 // Given position-level information about a date-related area within the grid, 3562 // should return a jQuery element that best represents it. passed to dayClick callback. 3563 getHitEl: function(hit) { 3564 }, 3565 3566 3567 /* Rendering 3568 ------------------------------------------------------------------------------------------------------------------*/ 3569 3570 3571 // Sets the container element that the grid should render inside of. 3572 // Does other DOM-related initializations. 3573 setElement: function(el) { 3574 this.el = el; 3575 preventSelection(el); 3576 3577 this.bindDayHandler('touchstart', this.dayTouchStart); 3578 this.bindDayHandler('mousedown', this.dayMousedown); 3579 3580 // attach event-element-related handlers. in Grid.events 3581 // same garbage collection note as above. 3582 this.bindSegHandlers(); 3583 3584 this.bindGlobalHandlers(); 3585 }, 3586 3587 3588 bindDayHandler: function(name, handler) { 3589 var _this = this; 3590 3591 // attach a handler to the grid's root element. 3592 // jQuery will take care of unregistering them when removeElement gets called. 3593 this.el.on(name, function(ev) { 3594 if ( 3595 !$(ev.target).is('.fc-event-container *, .fc-more') // not an an event element, or "more.." link 3596 ) { 3597 return handler.call(_this, ev); 3598 } 3599 }); 3600 }, 3601 3602 3603 // Removes the grid's container element from the DOM. Undoes any other DOM-related attachments. 3604 // DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View 3605 removeElement: function() { 3606 this.unbindGlobalHandlers(); 3607 this.clearDragListeners(); 3608 3609 this.el.remove(); 3610 3611 // NOTE: we don't null-out this.el for the same reasons we don't do it within View::removeElement 3612 }, 3613 3614 3615 // Renders the basic structure of grid view before any content is rendered 3616 renderSkeleton: function() { 3617 // subclasses should implement 3618 }, 3619 3620 3621 // Renders the grid's date-related content (like areas that represent days/times). 3622 // Assumes setRange has already been called and the skeleton has already been rendered. 3623 renderDates: function() { 3624 // subclasses should implement 3625 }, 3626 3627 3628 // Unrenders the grid's date-related content 3629 unrenderDates: function() { 3630 // subclasses should implement 3631 }, 3632 3633 3634 /* Handlers 3635 ------------------------------------------------------------------------------------------------------------------*/ 3636 3637 3638 // Binds DOM handlers to elements that reside outside the grid, such as the document 3639 bindGlobalHandlers: function() { 3640 this.listenTo($(document), { 3641 dragstart: this.externalDragStart, // jqui 3642 sortstart: this.externalDragStart // jqui 3643 }); 3644 }, 3645 3646 3647 // Unbinds DOM handlers from elements that reside outside the grid 3648 unbindGlobalHandlers: function() { 3649 this.stopListeningTo($(document)); 3650 }, 3651 3652 3653 // Process a mousedown on an element that represents a day. For day clicking and selecting. 3654 dayMousedown: function(ev) { 3655 if (!this.isIgnoringMouse) { 3656 this.dayDragListener.startInteraction(ev, { 3657 //distance: 5, // needs more work if we want dayClick to fire correctly 3658 }); 3659 } 3660 }, 3661 3662 3663 dayTouchStart: function(ev) { 3664 var view = this.view; 3665 3666 // HACK to prevent a user's clickaway for unselecting a range or an event 3667 // from causing a dayClick. 3668 if (view.isSelected || view.selectedEvent) { 3669 this.tempIgnoreMouse(); 3670 } 3671 3672 this.dayDragListener.startInteraction(ev, { 3673 delay: this.view.opt('longPressDelay') 3674 }); 3675 }, 3676 3677 3678 // Creates a listener that tracks the user's drag across day elements. 3679 // For day clicking and selecting. 3680 buildDayDragListener: function() { 3681 var _this = this; 3682 var view = this.view; 3683 var isSelectable = view.opt('selectable'); 3684 var dayClickHit; // null if invalid dayClick 3685 var selectionSpan; // null if invalid selection 3686 3687 // this listener tracks a mousedown on a day element, and a subsequent drag. 3688 // if the drag ends on the same day, it is a 'dayClick'. 3689 // if 'selectable' is enabled, this listener also detects selections. 3690 var dragListener = new HitDragListener(this, { 3691 scroll: view.opt('dragScroll'), 3692 interactionStart: function() { 3693 dayClickHit = dragListener.origHit; // for dayClick, where no dragging happens 3694 selectionSpan = null; 3695 }, 3696 dragStart: function() { 3697 view.unselect(); // since we could be rendering a new selection, we want to clear any old one 3698 }, 3699 hitOver: function(hit, isOrig, origHit) { 3700 if (origHit) { // click needs to have started on a hit 3701 3702 // if user dragged to another cell at any point, it can no longer be a dayClick
3703 if (!isOrig) { 3704 dayClickHit = null; 3705 } 3706 3707 if (isSelectable) { 3708 selectionSpan = _this.computeSelection( 3709 _this.getHitSpan(origHit), 3710 _this.getHitSpan(hit) 3711 ); 3712 if (selectionSpan) { 3713 _this.renderSelection(selectionSpan); 3714 } 3715 else if (selectionSpan === false) { 3716 disableCursor(); 3717 } 3718 } 3719 } 3720 }, 3721 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits 3722 dayClickHit = null; 3723 selectionSpan = null; 3724 _this.unrenderSelection(); 3725 }, 3726 hitDone: function() { // called after a hitOut OR before a dragEnd 3727 enableCursor(); 3728 }, 3729 interactionEnd: function(ev, isCancelled) { 3730 if (!isCancelled) { 3731 if ( 3732 dayClickHit && 3733 !_this.isIgnoringMouse // see hack in dayTouchStart 3734 ) { 3735 view.triggerDayClick( 3736 _this.getHitSpan(dayClickHit), 3737 _this.getHitEl(dayClickHit), 3738 ev 3739 ); 3740 } 3741 if (selectionSpan) { 3742 // the selection will already have been rendered. just report it 3743 view.reportSelection(selectionSpan, ev); 3744 } 3745 } 3746 } 3747 }); 3748 3749 return dragListener; 3750 }, 3751 3752 3753 // Kills all in-progress dragging. 3754 // Useful for when public API methods that result in re-rendering are invoked during a drag. 3755 // Also useful for when touch devices misbehave and don't fire their touchend. 3756 clearDragListeners: function() { 3757 this.dayDragListener.endInteraction(); 3758 3759 if (this.segDragListener) { 3760 this.segDragListener.endInteraction(); // will clear this.segDragListener 3761 } 3762 if (this.segResizeListener) { 3763 this.segResizeListener.endInteraction(); // will clear this.segResizeListener 3764 } 3765 if (this.externalDragListener) { 3766 this.externalDragListener.endInteraction(); // will clear this.externalDragListener 3767 } 3768 }, 3769 3770 3771 /* Event Helper 3772 ------------------------------------------------------------------------------------------------------------------*/ 3773 // TODO: should probably move this to Grid.events, like we did event dragging / resizing 3774 3775 3776 // Renders a mock event at the given event location, which contains zoned start/end properties. 3777 // Returns all mock event elements. 3778 renderEventLocationHelper: function(eventLocation, sourceSeg) { 3779 var fakeEvent = this.fabricateHelperEvent(eventLocation, sourceSeg); 3780 3781 return this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering 3782 }, 3783 3784 3785 // Builds a fake event given zoned event date properties and a segment is should be inspired from. 3786 // The range's end can be null, in which case the mock event that is rendered will have a null end time. 3787 // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging. 3788 fabricateHelperEvent: function(eventLocation, sourceSeg) { 3789 var fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible 3790 3791 fakeEvent.start = eventLocation.start.clone(); 3792 fakeEvent.end = eventLocation.end ? eventLocation.end.clone() : null; 3793 fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventDates 3794 this.view.calendar.normalizeEventDates(fakeEvent); 3795 3796 // this extra className will be useful for differentiating real events from mock events in CSS 3797 fakeEvent.className = (fakeEvent.className || []).concat('fc-helper'); 3798 3799 // if something external is being dragged in, don't render a resizer 3800 if (!sourceSeg) { 3801 fakeEvent.editable = false; 3802 } 3803 3804 return fakeEvent; 3805 }, 3806 3807 3808 // Renders a mock event. Given zoned event date properties. 3809 // Must return all mock event elements. 3810 renderHelper: function(eventLocation, sourceSeg) { 3811 // subclasses must implement 3812 }, 3813 3814 3815 // Unrenders a mock event 3816 unrenderHelper: function() { 3817 // subclasses must implement 3818 }, 3819 3820 3821 /* Selection 3822 ------------------------------------------------------------------------------------------------------------------*/ 3823 3824 3825 // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses. 3826 // Given a span (unzoned start/end and other misc data) 3827 renderSelection: function(span) { 3828 this.renderHighlight(span); 3829 }, 3830 3831 3832 // Unrenders any visual indications of a selection. Will unrender a highlight by default. 3833 unrenderSelection: function() { 3834 this.unrenderHighlight(); 3835 }, 3836 3837 3838 // Given the first and last date-spans of a selection, returns another date-span object. 3839 // Subclasses can override and provide additional data in the span object. Will be passed to render
3839Selection(). 3840 // Will return false if the selection is invalid and this should be indicated to the user. 3841 // Will return null/undefined if a selection invalid but no error should be reported. 3842 computeSelection: function(span0, span1) { 3843 var span = this.computeSelectionSpan(span0, span1); 3844 3845 if (span && !this.view.calendar.isSelectionSpanAllowed(span)) { 3846 return false; 3847 } 3848 3849 return span; 3850 }, 3851 3852 3853 // Given two spans, must return the combination of the two. 3854 // TODO: do this separation of concerns (combining VS validation) for event dnd/resize too. 3855 computeSelectionSpan: function(span0, span1) { 3856 var dates = [ span0.start, span0.end, span1.start, span1.end ]; 3857 3858 dates.sort(compareNumbers); // sorts chronologically. works with Moments 3859 3860 return { start: dates[0].clone(), end: dates[3].clone() }; 3861 }, 3862 3863 3864 /* Highlight 3865 ------------------------------------------------------------------------------------------------------------------*/ 3866 3867 3868 // Renders an emphasis on the given date range. Given a span (unzoned start/end and other misc data) 3869 renderHighlight: function(span) { 3870 this.renderFill('highlight', this.spanToSegs(span)); 3871 }, 3872 3873 3874 // Unrenders the emphasis on a date range 3875 unrenderHighlight: function() { 3876 this.unrenderFill('highlight'); 3877 }, 3878 3879 3880 // Generates an array of classNames for rendering the highlight. Used by the fill system. 3881 highlightSegClasses: function() { 3882 return [ 'fc-highlight' ]; 3883 }, 3884 3885 3886 /* Business Hours 3887 ------------------------------------------------------------------------------------------------------------------*/ 3888 3889 3890 renderBusinessHours: function() { 3891 }, 3892 3893 3894 unrenderBusinessHours: function() { 3895 }, 3896 3897 3898 /* Now Indicator 3899 ------------------------------------------------------------------------------------------------------------------*/ 3900 3901 3902 getNowIndicatorUnit: function() { 3903 }, 3904 3905 3906 renderNowIndicator: function(date) { 3907 }, 3908 3909 3910 unrenderNowIndicator: function() { 3911 }, 3912 3913 3914 /* Fill System (highlight, background events, business hours) 3915 -------------------------------------------------------------------------------------------------------------------- 3916 TODO: remove this system. like we did in TimeGrid 3917 */ 3918 3919 3920 // Renders a set of rectangles over the given segments of time. 3921 // MUST RETURN a subset of segs, the segs that were actually rendered. 3922 // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement 3923 renderFill: function(type, segs) { 3924 // subclasses must implement 3925 }, 3926 3927 3928 // Unrenders a specific type of fill that is currently rendered on the grid 3929 unrenderFill: function(type) { 3930 var el = this.elsByFill[type]; 3931 3932 if (el) { 3933 el.remove(); 3934 delete this.elsByFill[type]; 3935 } 3936 }, 3937 3938 3939 // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types. 3940 // Only returns segments that successfully rendered. 3941 // To be harnessed by renderFill (implemented by subclasses). 3942 // Analagous to renderFgSegEls. 3943 renderFillSegEls: function(type, segs) { 3944 var _this = this; 3945 var segElMethod = this[type + 'SegEl']; 3946 var html = ''; 3947 var renderedSegs = []; 3948 var i; 3949 3950 if (segs.length) { 3951 3952 // build a large concatenation of segment HTML 3953 for (i = 0; i < segs.length; i++) { 3954 html += this.fillSegHtml(type, segs[i]); 3955 } 3956 3957 // Grab individual elements from the combined HTML string. Use each as the default rendering. 3958 // Then, compute the 'el' for each segment. 3959 $(html).each(function(i, node) { 3960 var seg = segs[i]; 3961 var el = $(node); 3962 3963 // allow custom filter methods per-type 3964 if (segElMethod) { 3965 el = segElMethod.call(_this, seg, el); 3966 } 3967 3968 if (el) { // custom filters did not cancel the render 3969 el = $(el); // allow custom filter to return raw DOM node 3970 3971 // correct element type? (would be bad if a non-TD were inserted into a table for example) 3972 if (el.is(_this.fillSegTag)) { 3973 seg.el = el; 3974 renderedSegs.push(seg); 3975 } 3976 } 3977 }); 3978 } 3979 3980 return renderedSegs; 3981 }, 3982 3983 3984 fillSegTag: 'div', // subclasses can override 3985 3986 3987 // Builds the HTML needed for one fill segment. Generic enough to work with different types. 3988 fillSegHtml: function(type, seg) { 3989 3990 // custom hooks per-type 3991 var classesMethod = this[type + 'SegClasses']; 3992 var cssMethod = this[type + 'SegCss']; 3993 3994 var classes = classesMethod ? classesMethod.call(this, seg) : []; 3995 var css = cssToStr(cssMethod ? cssMethod.call(this, seg) : {}); 3996 3997 return '<' + this.fillSegTag + 3998 (classes.length ? ' class="' + classes.join(' ') + '"' : '') + 3999 (css ? ' style="' + css + '"' : '') + 4000 ' />'; 4001 }, 4002 4003 4004 4005 /* Generic rendering utilities for subclasses 4006 ------------------------------------------------------------------------------------------------------------------*/ 4007 4008 4009 // Computes HTML classNames for a single-day element 4010 getDayClasses: function(date) { 4011 var view = this.view; 4012 var today = view.calendar.getNow(); 4013 var classes = [ 'fc-' + dayIDs[date.day()] ]; 4014 4015 if ( 4016 view.intervalDuration.as('months') == 1 && 4017 date.month() != view.intervalStart.month() 4018 ) { 4019 classes.push('fc-other-month'); 4020 } 4021 4022 if (date.isSame(today, 'day')) { 4023 classes.push( 4024 'fc-today', 4025 view.highlightStateClass 4026 ); 4027 } 4028 else if (date < today) { 4029 classes.push('fc-past'); 4030 } 4031 else { 4032 classes.push('fc-future'); 4033 } 4034 4035 return classes; 4036 } 4037 4038}); 4039 4040;; 4041 4042/* Event-rendering and event-interaction methods for the abstract Grid class 4043----------------------------------------------------------------------------------------------------------------------*/ 4044 4045Grid.mixin({ 4046 4047 mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing 4048 isDraggingSeg: false, // is a segment being dragged? boolean 4049 isResizingSeg: false, // is a segment being resized? boolean
4050 isDraggingExternal: false, // jqui-dragging an external element? boolean 4051 segs: null, // the *event* segments currently rendered in the grid. TODO: rename to `eventSegs` 4052 4053 4054 // Renders the given events onto the grid 4055 renderEvents: function(events) { 4056 var bgEvents = []; 4057 var fgEvents = []; 4058 var i; 4059 4060 for (i = 0; i < events.length; i++) { 4061 (isBgEvent(events[i]) ? bgEvents : fgEvents).push(events[i]); 4062 } 4063 4064 this.segs = [].concat( // record all segs 4065 this.renderBgEvents(bgEvents), 4066 this.renderFgEvents(fgEvents) 4067 ); 4068 }, 4069 4070 4071 renderBgEvents: function(events) { 4072 var segs = this.eventsToSegs(events); 4073 4074 // renderBgSegs might return a subset of segs, segs that were actually rendered 4075 return this.renderBgSegs(segs) || segs; 4076 }, 4077 4078 4079 renderFgEvents: function(events) { 4080 var segs = this.eventsToSegs(events); 4081 4082 // renderFgSegs might return a subset of segs, segs that were actually rendered 4083 return this.renderFgSegs(segs) || segs; 4084 }, 4085 4086 4087 // Unrenders all events currently rendered on the grid 4088 unrenderEvents: function() { 4089 this.handleSegMouseout(); // trigger an eventMouseout if user's mouse is over an event 4090 this.clearDragListeners(); 4091 4092 this.unrenderFgSegs(); 4093 this.unrenderBgSegs(); 4094 4095 this.segs = null; 4096 }, 4097 4098 4099 // Retrieves all rendered segment objects currently rendered on the grid 4100 getEventSegs: function() { 4101 return this.segs || []; 4102 }, 4103 4104 4105 /* Foreground Segment Rendering 4106 ------------------------------------------------------------------------------------------------------------------*/ 4107 4108 4109 // Renders foreground event segments onto the grid. May return a subset of segs that were rendered. 4110 renderFgSegs: function(segs) { 4111 // subclasses must implement 4112 }, 4113 4114 4115 // Unrenders all currently rendered foreground segments 4116 unrenderFgSegs: function() { 4117 // subclasses must implement 4118 }, 4119 4120 4121 // Renders and assigns an `el` property for each foreground event segment. 4122 // Only returns segments that successfully rendered. 4123 // A utility that subclasses may use. 4124 renderFgSegEls: function(segs, disableResizing) { 4125 var view = this.view; 4126 var html = ''; 4127 var renderedSegs = []; 4128 var i; 4129 4130 if (segs.length) { // don't build an empty html string 4131 4132 // build a large concatenation of event segment HTML 4133 for (i = 0; i < segs.length; i++) { 4134 html += this.fgSegHtml(segs[i], disableResizing); 4135 } 4136 4137 // Grab individual elements from the combined HTML string. Use each as the default rendering. 4138 // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false. 4139 $(html).each(function(i, node) { 4140 var seg = segs[i]; 4141 var el = view.resolveEventEl(seg.event, $(node)); 4142 4143 if (el) { 4144 el.data('fc-seg', seg); // used by handlers 4145 seg.el = el; 4146 renderedSegs.push(seg); 4147 } 4148 }); 4149 } 4150 4151 return renderedSegs; 4152 }, 4153 4154 4155 // Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls() 4156 fgSegHtml: function(seg, disableResizing) { 4157 // subclasses should implement 4158 }, 4159 4160 4161 /* Background Segment Rendering 4162 ------------------------------------------------------------------------------------------------------------------*/ 4163 4164 4165 // Renders the given background event segments onto the grid. 4166 // Returns a subset of the segs that were actually rendered. 4167 renderBgSegs: function(segs) { 4168 return this.renderFill('bgEvent', segs); 4169 }, 4170 4171 4172 // Unrenders all the currently rendered background event segments 4173 unrenderBgSegs: function() { 4174 this.unrenderFill('bgEvent'); 4175 }, 4176 4177 4178 // Renders a background event element, given the default rendering. Called by the fill system. 4179 bgEventSegEl: function(seg, el) { 4180 return this.view.resolveEventEl(seg.event, el); // will filter through eventRender 4181 }, 4182 4183 4184 // Generates an array of classNames to be used for the default rendering of a background event. 4185 // Called by fillSegHtml. 4186 bgEventSegClasses: function(seg) { 4187 var event = seg.event; 4188 var source = event.source || {}; 4189 4190 return [ 'fc-bgevent' ].concat( 4191 event.className, 4192 source.className || [] 4193 ); 4194 }, 4195 4196 4197 // Generates a semicolon-separated CSS string to be used for the default rendering of a background event. 4198 // Called by fillSegHtml. 4199 bgEventSegCss: function(seg) { 4200 return {
4201 'background-color': this.getSegSkinCss(seg)['background-color'] 4202 }; 4203 }, 4204 4205 4206 // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system. 4207 // Called by fillSegHtml. 4208 businessHoursSegClasses: function(seg) { 4209 return [ 'fc-nonbusiness', 'fc-bgevent' ]; 4210 }, 4211 4212 4213 /* Business Hours 4214 ------------------------------------------------------------------------------------------------------------------*/ 4215 4216 4217 // Compute business hour segs for the grid's current date range. 4218 // Caller must ask if whole-day business hours are needed. 4219 buildBusinessHourSegs: function(wholeDay) { 4220 var events = this.view.calendar.getCurrentBusinessHourEvents(wholeDay); 4221 4222 // HACK. Eventually refactor business hours "events" system. 4223 // If no events are given, but businessHours is activated, this means the entire visible range should be 4224 // marked as *not* business-hours, via inverse-background rendering. 4225 if ( 4226 !events.length && 4227 this.view.calendar.options.businessHours // don't access view option. doesn't update with dynamic options 4228 ) { 4229 events = [ 4230 $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, { 4231 start: this.view.end, // guaranteed out-of-range 4232 end: this.view.end, // " 4233 dow: null 4234 }) 4235 ]; 4236 } 4237 4238 return this.eventsToSegs(events); 4239 }, 4240 4241 4242 /* Handlers 4243 ------------------------------------------------------------------------------------------------------------------*/ 4244 4245 4246 // Attaches event-element-related handlers for *all* rendered event segments of the view. 4247 bindSegHandlers: function() { 4248 this.bindSegHandlersToEl(this.el); 4249 }, 4250 4251 4252 // Attaches event-element-related handlers to an arbitrary container element. leverages bubbling. 4253 bindSegHandlersToEl: function(el) { 4254 this.bindSegHandlerToEl(el, 'touchstart', this.handleSegTouchStart); 4255 this.bindSegHandlerToEl(el, 'touchend', this.handleSegTouchEnd); 4256 this.bindSegHandlerToEl(el, 'mouseenter', this.handleSegMouseover); 4257 this.bindSegHandlerToEl(el, 'mouseleave', this.handleSegMouseout); 4258 this.bindSegHandlerToEl(el, 'mousedown', this.handleSegMousedown); 4259 this.bindSegHandlerToEl(el, 'click', this.handleSegClick); 4260 }, 4261 4262 4263 // Executes a handler for any a user-interaction on a segment. 4264 // Handler gets called with (seg, ev), and with the `this` context of the Grid 4265 bindSegHandlerToEl: function(el, name, handler) { 4266 var _this = this; 4267 4268 el.on(name, '.fc-event-container > *', function(ev) { 4269 var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents 4270 4271 // only call the handlers if there is not a drag/resize in progress 4272 if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) { 4273 return handler.call(_this, seg, ev); // context will be the Grid 4274 } 4275 }); 4276 }, 4277 4278 4279 handleSegClick: function(seg, ev) { 4280 return this.view.trigger('eventClick', seg.el[0], seg.event, ev); // can return `false` to cancel 4281 }, 4282 4283 4284 // Updates internal state and triggers handlers for when an event element is moused over 4285 handleSegMouseover: function(seg, ev) { 4286 if ( 4287 !this.isIgnoringMouse && 4288 !this.mousedOverSeg 4289 ) { 4290 this.mousedOverSeg = seg; 4291 seg.el.addClass('fc-allow-mouse-resize'); 4292 this.view.trigger('eventMouseover', seg.el[0], seg.event, ev); 4293 } 4294 }, 4295 4296 4297 // Updates internal state and triggers handlers for when an event element is moused out. 4298 // Can be given no arguments, in which case it will mouseout the segment that was previously moused over. 4299 handleSegMouseout: function(seg, ev) { 4300 ev = ev || {}; // if given no args, make a mock mouse event 4301 4302 if (this.mousedOverSeg) { 4303 seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment 4304 this.mousedOverSeg = null; 4305 seg.el.removeClass('fc-allow-mouse-resize'); 4306 this.view.trigger('eventMouseout', seg.el[0], seg.event, ev); 4307 } 4308 }, 4309 4310 4311 handleSegMousedown: function(seg, ev) { 4312 var isResizing = this.startSegResize(seg, ev, { distance: 5 }); 4313 4314 if (!isResizing && this.view.isEventDraggable(seg.event)) { 4315 this.buildSegDragListener(seg) 4316 .startInteraction(ev, { 4317 distance: 5 4318 }); 4319 } 4320 }, 4321 4322 4323 handleSegTouchStart: function(seg, ev) { 4324 var view = this.view; 4325 var event = seg.event; 4326 var isSelected = view.isEventSelected(event); 4327 var isDraggable = view.isEventDraggable(event); 4328 var isResizable = view.isEventResizable(event); 4329 var isResizing = false;
4330 var dragListener; 4331 4332 if (isSelected && isResizable) { 4333 // only allow resizing of the event is selected 4334 isResizing = this.startSegResize(seg, ev); 4335 } 4336 4337 if (!isResizing && (isDraggable || isResizable)) { // allowed to be selected? 4338 4339 dragListener = isDraggable ? 4340 this.buildSegDragListener(seg) : 4341 this.buildSegSelectListener(seg); // seg isn't draggable, but still needs to be selected 4342 4343 dragListener.startInteraction(ev, { // won't start if already started 4344 delay: isSelected ? 0 : this.view.opt('longPressDelay') // do delay if not already selected 4345 }); 4346 } 4347 4348 // a long tap simulates a mouseover. ignore this bogus mouseover. 4349 this.tempIgnoreMouse(); 4350 }, 4351 4352 4353 handleSegTouchEnd: function(seg, ev) { 4354 // touchstart+touchend = click, which simulates a mouseover. 4355 // ignore this bogus mouseover. 4356 this.tempIgnoreMouse(); 4357 }, 4358 4359 4360 // returns boolean whether resizing actually started or not. 4361 // assumes the seg allows resizing. 4362 // `dragOptions` are optional. 4363 startSegResize: function(seg, ev, dragOptions) { 4364 if ($(ev.target).is('.fc-resizer')) { 4365 this.buildSegResizeListener(seg, $(ev.target).is('.fc-start-resizer')) 4366 .startInteraction(ev, dragOptions); 4367 return true; 4368 } 4369 return false; 4370 }, 4371 4372 4373 4374 /* Event Dragging 4375 ------------------------------------------------------------------------------------------------------------------*/ 4376 4377 4378 // Builds a listener that will track user-dragging on an event segment. 4379 // Generic enough to work with any type of Grid. 4380 // Has side effect of setting/unsetting `segDragListener` 4381 buildSegDragListener: function(seg) { 4382 var _this = this; 4383 var view = this.view; 4384 var calendar = view.calendar; 4385 var el = seg.el; 4386 var event = seg.event; 4387 var isDragging; 4388 var mouseFollower; // A clone of the original element that will move with the mouse 4389 var dropLocation; // zoned event date properties 4390 4391 if (this.segDragListener) { 4392 return this.segDragListener; 4393 } 4394 4395 // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents 4396 // of the view. 4397 var dragListener = this.segDragListener = new HitDragListener(view, { 4398 scroll: view.opt('dragScroll'), 4399 subjectEl: el, 4400 subjectCenter: true, 4401 interactionStart: function(ev) { 4402 seg.component = _this; // for renderDrag 4403 isDragging = false; 4404 mouseFollower = new MouseFollower(seg.el, { 4405 additionalClass: 'fc-dragging', 4406 parentEl: view.el, 4407 opacity: dragListener.isTouch ? null : view.opt('dragOpacity'), 4408 revertDuration: view.opt('dragRevertDuration'), 4409 zIndex: 2 // one above the .fc-view 4410 }); 4411 mouseFollower.hide(); // don't show until we know this is a real drag 4412 mouseFollower.start(ev); 4413 }, 4414 dragStart: function(ev) { 4415 if (dragListener.isTouch && !view.isEventSelected(event)) { 4416 // if not previously selected, will fire after a delay. then, select the event 4417 view.selectEvent(event); 4418 } 4419 isDragging = true; 4420 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported 4421 _this.segDragStart(seg, ev); 4422 view.hideEvent(event); // hide all event segments. our mouseFollower will take over 4423 }, 4424 hitOver: function(hit, isOrig, origHit) { 4425 var dragHelperEls; 4426 4427 // starting hit could be forced (DayGrid.limit) 4428 if (seg.hit) { 4429 origHit = seg.hit; 4430 } 4431 4432 // since we are querying the parent view, might not belong to this grid 4433 dropLocation = _this.computeEventDrop( 4434 origHit.component.getHitSpan(origHit), 4435 hit.component.getHitSpan(hit), 4436 event 4437 ); 4438 4439 if (dropLocation && !calendar.isEventSpanAllowed(_this.eventToSpan(dropLocation), event)) { 4440 disableCursor(); 4441 dropLocation = null; 4442 } 4443 4444 // if a valid drop location, have the subclass render a visual indication 4445 if (dropLocation && (dragHelperEls = view.renderDrag(dropLocation, seg))) { 4446 4447 dragHelperEls.addClass('fc-dragging'); 4448 if (!dragListener.isTouch) { 4449 _this.applyDragOpacity(dragHelperEls); 4450 } 4451 4452 mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own 4453 } 4454 else { 4455 mouseFollower.show(); // otherwise, have the helper follow the mouse (no snapping) 4456 } 4457 4458 if (isOrig) { 4459 dropLocation = null; // needs to have moved hits to be a valid drop 4460 } 4461 }, 4462 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits 4463 view.unrenderDrag(); // unrender whatever was done in renderDrag 4464 mouseFollower.show(); // show in case we are moving out of all hits 4465 dropLocation = null; 4466 }, 4467 hitDone: function() { // Called after a hitOut OR before a dragEnd
4468 enableCursor(); 4469 }, 4470 interactionEnd: function(ev) { 4471 delete seg.component; // prevent side effects 4472 4473 // do revert animation if hasn't changed. calls a callback when finished (whether animation or not) 4474 mouseFollower.stop(!dropLocation, function() { 4475 if (isDragging) { 4476 view.unrenderDrag(); 4477 view.showEvent(event); 4478 _this.segDragStop(seg, ev); 4479 } 4480 if (dropLocation) { 4481 view.reportEventDrop(event, dropLocation, this.largeUnit, el, ev); 4482 } 4483 }); 4484 _this.segDragListener = null; 4485 } 4486 }); 4487 4488 return dragListener; 4489 }, 4490 4491 4492 // seg isn't draggable, but let's use a generic DragListener 4493 // simply for the delay, so it can be selected. 4494 // Has side effect of setting/unsetting `segDragListener` 4495 buildSegSelectListener: function(seg) { 4496 var _this = this; 4497 var view = this.view; 4498 var event = seg.event; 4499 4500 if (this.segDragListener) { 4501 return this.segDragListener; 4502 } 4503 4504 var dragListener = this.segDragListener = new DragListener({ 4505 dragStart: function(ev) { 4506 if (dragListener.isTouch && !view.isEventSelected(event)) { 4507 // if not previously selected, will fire after a delay. then, select the event 4508 view.selectEvent(event); 4509 } 4510 }, 4511 interactionEnd: function(ev) { 4512 _this.segDragListener = null; 4513 } 4514 }); 4515 4516 return dragListener; 4517 }, 4518 4519 4520 // Called before event segment dragging starts 4521 segDragStart: function(seg, ev) { 4522 this.isDraggingSeg = true; 4523 this.view.trigger('eventDragStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy 4524 }, 4525 4526 4527 // Called after event segment dragging stops 4528 segDragStop: function(seg, ev) { 4529 this.isDraggingSeg = false; 4530 this.view.trigger('eventDragStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy 4531 }, 4532 4533 4534 // Given the spans an event drag began, and the span event was dropped, calculates the new zoned start/end/allDay 4535 // values for the event. Subclasses may override and set additional properties to be used by renderDrag. 4536 // A falsy returned value indicates an invalid drop. 4537 // DOES NOT consider overlap/constraint. 4538 computeEventDrop: function(startSpan, endSpan, event) { 4539 var calendar = this.view.calendar; 4540 var dragStart = startSpan.start; 4541 var dragEnd = endSpan.start; 4542 var delta; 4543 var dropLocation; // zoned event date properties 4544 4545 if (dragStart.hasTime() === dragEnd.hasTime()) { 4546 delta = this.diffDates(dragEnd, dragStart); 4547 4548 // if an all-day event was in a timed area and it was dragged to a different time, 4549 // guarantee an end and adjust start/end to have times 4550 if (event.allDay && durationHasTime(delta)) { 4551 dropLocation = { 4552 start: event.start.clone(), 4553 end: calendar.getEventEnd(event), // will be an ambig day 4554 allDay: false // for normalizeEventTimes 4555 }; 4556 calendar.normalizeEventTimes(dropLocation); 4557 } 4558 // othewise, work off existing values 4559 else { 4560 dropLocation = { 4561 start: event.start.clone(), 4562 end: event.end ? event.end.clone() : null, 4563 allDay: event.allDay // keep it the same 4564 }; 4565 } 4566 4567 dropLocation.start.add(delta); 4568 if (dropLocation.end) { 4569 dropLocation.end.add(delta); 4570 } 4571 } 4572 else { 4573 // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared 4574 dropLocation = { 4575 start: dragEnd.clone(), 4576 end: null, // end should be cleared 4577 allDay: !dragEnd.hasTime() 4578 }; 4579 } 4580 4581 return dropLocation; 4582 }, 4583 4584 4585 // Utility for apply dragOpacity to a jQuery set 4586 applyDragOpacity: function(els) { 4587 var opacity = this.view.opt('dragOpacity'); 4588 4589 if (opacity != null) { 4590 els.each(function(i, node) { 4591 // Don't use jQuery (will set an IE filter), do it the old fashioned way. 4592 // In IE8, a helper element will disappears if there's a filter. 4593 node.style.opacity = opacity; 4594 }); 4595 } 4596 }, 4597 4598 4599 /* External Element Dragging 4600 ------------------------------------------------------------------------------------------------------------------*/ 4601 4602 4603 // Called when a jQuery UI drag is initiated anywhere in the DOM 4604 externalDragStart: function(ev, ui) { 4605 var view = this.view; 4606 var el; 4607 var accept; 4608 4609 if (view.opt('droppable')) { // only listen if this setting is on 4610 el = $((ui ? ui.item : null) || ev.target); 4611 4612 // Test that the dragged element passes the dropAccept selector or filter function. 4613 // FYI, the default is "*" (matches all) 4614 accept = view.opt('dropAccept'); 4615 if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) {
4616 if (!this.isDraggingExternal) { // prevent double-listening if fired twice 4617 this.listenToExternalDrag(el, ev, ui); 4618 } 4619 } 4620 } 4621 }, 4622 4623 4624 // Called when a jQuery UI drag starts and it needs to be monitored for dropping 4625 listenToExternalDrag: function(el, ev, ui) { 4626 var _this = this; 4627 var calendar = this.view.calendar; 4628 var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create 4629 var dropLocation; // a null value signals an unsuccessful drag 4630 4631 // listener that tracks mouse movement over date-associated pixel regions 4632 var dragListener = _this.externalDragListener = new HitDragListener(this, { 4633 interactionStart: function() { 4634 _this.isDraggingExternal = true; 4635 }, 4636 hitOver: function(hit) { 4637 dropLocation = _this.computeExternalDrop( 4638 hit.component.getHitSpan(hit), // since we are querying the parent view, might not belong to this grid 4639 meta 4640 ); 4641 4642 if ( // invalid hit? 4643 dropLocation && 4644 !calendar.isExternalSpanAllowed(_this.eventToSpan(dropLocation), dropLocation, meta.eventProps) 4645 ) { 4646 disableCursor(); 4647 dropLocation = null; 4648 } 4649 4650 if (dropLocation) { 4651 _this.renderDrag(dropLocation); // called without a seg parameter 4652 } 4653 }, 4654 hitOut: function() { 4655 dropLocation = null; // signal unsuccessful 4656 }, 4657 hitDone: function() { // Called after a hitOut OR before a dragEnd 4658 enableCursor(); 4659 _this.unrenderDrag(); 4660 }, 4661 interactionEnd: function(ev) { 4662 if (dropLocation) { // element was dropped on a valid hit 4663 _this.view.reportExternalDrop(meta, dropLocation, el, ev, ui); 4664 }
4665 _this.isDraggingExternal = false; 4666 _this.externalDragListener = null; 4667 } 4668 }); 4669 4670 dragListener.startDrag(ev); // start listening immediately 4671 }, 4672 4673 4674 // Given a hit to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object), 4675 // returns the zoned start/end dates for the event that would result from the hypothetical drop. end might be null. 4676 // Returning a null value signals an invalid drop hit. 4677 // DOES NOT consider overlap/constraint. 4678 computeExternalDrop: function(span, meta) { 4679 var calendar = this.view.calendar; 4680 var dropLocation = { 4681 start: calendar.applyTimezone(span.start), // simulate a zoned event start date 4682 end: null 4683 }; 4684 4685 // if dropped on an all-day span, and element's metadata specified a time, set it 4686 if (meta.startTime && !dropLocation.start.hasTime()) { 4687 dropLocation.start.time(meta.startTime); 4688 } 4689 4690 if (meta.duration) { 4691 dropLocation.end = dropLocation.start.clone().add(meta.duration); 4692 } 4693 4694 return dropLocation; 4695 }, 4696 4697 4698 4699 /* Drag Rendering (for both events and an external elements) 4700 ------------------------------------------------------------------------------------------------------------------*/ 4701 4702 4703 // Renders a visual indication of an event or external element being dragged. 4704 // `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null. 4705 // `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null. 4706 // A truthy returned value indicates this method has rendered a helper element. 4707 // Must return elements used for any mock events. 4708 renderDrag: function(dropLocation, seg) { 4709 // subclasses must implement 4710 }, 4711 4712 4713 // Unrenders a visual indication of an event or external element being dragged 4714 unrenderDrag: function() { 4715 // subclasses must implement 4716 }, 4717 4718 4719 /* Resizing 4720 ------------------------------------------------------------------------------------------------------------------*/ 4721 4722 4723 // Creates a listener that tracks the user as they resize an event segment. 4724 // Generic enough to work with any type of Grid. 4725 buildSegResizeListener: function(seg, isStart) { 4726 var _this = this; 4727 var view = this.view; 4728 var calendar = view.calendar; 4729 var el = seg.el; 4730 var event = seg.event; 4731 var eventEnd = calendar.getEventEnd(event); 4732 var isDragging; 4733 var resizeLocation; // zoned event date properties. falsy if invalid resize 4734 4735 // Tracks mouse movement over the *grid's* coordinate map 4736 var dragListener = this.segResizeListener = new HitDragListener(this, { 4737 scroll: view.opt('dragScroll'), 4738 subjectEl: el, 4739 interactionStart: function() { 4740 isDragging = false; 4741 }, 4742 dragStart: function(ev) { 4743 isDragging = true; 4744 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported 4745 _this.segResizeStart(seg, ev); 4746 }, 4747 hitOver: function(hit, isOrig, origHit) { 4748 var origHitSpan = _this.getHitSpan(origHit); 4749 var hitSpan = _this.getHitSpan(hit); 4750 4751 resizeLocation = isStart ? 4752 _this.computeEventStartResize(origHitSpan, hitSpan, event) : 4753 _this.computeEventEndResize(origHitSpan, hitSpan, event); 4754 4755 if (resizeLocation) { 4756 if (!calendar.isEventSpanAllowed(_this.eventToSpan(resizeLocation), event)) { 4757 disableCursor(); 4758 resizeLocation = null; 4759 } 4760 // no change? (TODO: how does this work with timezones?) 4761 else if (resizeLocation.start.isSame(event.start) && resizeLocation.end.isSame(eventEnd)) { 4762 resizeLocation = null; 4763 } 4764 } 4765 4766 if (resizeLocation) { 4767 view.hideEvent(event); 4768 _this.renderEventResize(resizeLocation, seg); 4769 } 4770 }, 4771 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits 4772 resizeLocation = null; 4773 }, 4774 hitDone: function() { // resets the rendering to show the original event 4775 _this.unrenderEventResize(); 4776 view.showEvent(event); 4777 enableCursor(); 4778 }, 4779 interactionEnd: function(ev) { 4780 if (isDragging) { 4781 _this.segResizeStop(seg, ev); 4782 } 4783 if (resizeLocation) { // valid date to resize to? 4784 view.reportEventResize(event, resizeLocation, this.largeUnit, el, ev); 4785 } 4786 _this.segResizeListener = null; 4787 } 4788 }); 4789 4790 return dragListener; 4791 }, 4792 4793 4794 // Called before event segment resizing starts 4795 segResizeStart: function(seg, ev) { 4796 this.isResizingSeg = true; 4797 this.view.trigger('eventResizeStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy 4798 }, 4799 4800 4801 // Called after event segment resizing stops 4802 segResizeStop: function(seg, ev) { 4803 this.isResizingSeg = false;
4804 this.view.trigger('eventResizeStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy 4805 }, 4806 4807 4808 // Returns new date-information for an event segment being resized from its start 4809 computeEventStartResize: function(startSpan, endSpan, event) { 4810 return this.computeEventResize('start', startSpan, endSpan, event); 4811 }, 4812 4813 4814 // Returns new date-information for an event segment being resized from its end 4815 computeEventEndResize: function(startSpan, endSpan, event) { 4816 return this.computeEventResize('end', startSpan, endSpan, event); 4817 }, 4818 4819 4820 // Returns new zoned date information for an event segment being resized from its start OR end 4821 // `type` is either 'start' or 'end'. 4822 // DOES NOT consider overlap/constraint. 4823 computeEventResize: function(type, startSpan, endSpan, event) { 4824 var calendar = this.view.calendar; 4825 var delta = this.diffDates(endSpan[type], startSpan[type]); 4826 var resizeLocation; // zoned event date properties 4827 var defaultDuration; 4828 4829 // build original values to work from, guaranteeing a start and end 4830 resizeLocation = { 4831 start: event.start.clone(), 4832 end: calendar.getEventEnd(event), 4833 allDay: event.allDay 4834 }; 4835 4836 // if an all-day event was in a timed area and was resized to a time, adjust start/end to have times 4837 if (resizeLocation.allDay && durationHasTime(delta)) { 4838 resizeLocation.allDay = false; 4839 calendar.normalizeEventTimes(resizeLocation); 4840 } 4841 4842 resizeLocation[type].add(delta); // apply delta to start or end 4843 4844 // if the event was compressed too small, find a new reasonable duration for it 4845 if (!resizeLocation.start.isBefore(resizeLocation.end)) { 4846 4847 defaultDuration = 4848 this.minResizeDuration || // TODO: hack 4849 (event.allDay ? 4850 calendar.defaultAllDayEventDuration : 4851 calendar.defaultTimedEventDuration); 4852 4853 if (type == 'start') { // resizing the start? 4854 resizeLocation.start = resizeLocation.end.clone().subtract(defaultDuration); 4855 } 4856 else { // resizing the end? 4857 resizeLocation.end = resizeLocation.start.clone().add(defaultDuration); 4858 } 4859 } 4860 4861 return resizeLocation; 4862 }, 4863 4864 4865 // Renders a visual indication of an event being resized. 4866 // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag. 4867 // Must return elements used for any mock events. 4868 renderEventResize: function(range, seg) { 4869 // subclasses must implement 4870 }, 4871 4872 4873 // Unrenders a visual indication of an event being resized. 4874 unrenderEventResize: function() { 4875 // subclasses must implement 4876 }, 4877 4878 4879 /* Rendering Utils 4880 ------------------------------------------------------------------------------------------------------------------*/ 4881 4882 4883 // Compute the text that should be displayed on an event's element. 4884 // `range` can be the Event object itself, or something range-like, with at least a `start`. 4885 // If event times are disabled, or the event has no time, will return a blank string. 4886 // If not specified, formatStr will default to the eventTimeFormat setting, 4887 // and displayEnd will default to the displayEventEnd setting. 4888 getEventTimeText: function(range, formatStr, displayEnd) { 4889 4890 if (formatStr == null) { 4891 formatStr = this.eventTimeFormat; 4892 } 4893 4894 if (displayEnd == null) { 4895 displayEnd = this.displayEventEnd; 4896 } 4897 4898 if (this.displayEventTime && range.start.hasTime()) { 4899 if (displayEnd && range.end) { 4900 return this.view.formatRange(range, formatStr); 4901 } 4902 else { 4903 return range.start.format(formatStr); 4904 } 4905 } 4906 4907 return ''; 4908 }, 4909 4910 4911 // Generic utility for generating the HTML classNames for an event segment's element 4912 getSegClasses: function(seg, isDraggable, isResizable) { 4913 var view = this.view; 4914 var event = seg.event; 4915 var classes = [ 4916 'fc-event', 4917 seg.isStart ? 'fc-start' : 'fc-not-start', 4918 seg.isEnd ? 'fc-end' : 'fc-not-end' 4919 ].concat( 4920 event.className, 4921 event.source ? event.source.className : [] 4922 ); 4923 4924 if (isDraggable) { 4925 classes.push('fc-draggable'); 4926 } 4927 if (isResizable) { 4928 classes.push('fc-resizable'); 4929 } 4930 4931 // event is currently selected? attach a className. 4932 if (view.isEventSelected(event)) { 4933 classes.push('fc-selected'); 4934 } 4935 4936 return classes; 4937 }, 4938 4939 4940 // Utility for generating event skin-related CSS properties 4941 getSegSkinCss: function(seg) { 4942 var event = seg.event; 4943 var view = this.view; 4944 var source = event.source || {}; 4945 var eventColor = event.color; 4946 var sourceColor = source.color; 4947 var optionColor = view.opt('eventColor'); 4948 4949 return { 4950 'background-color': 4951 event.backgroundColor || 4952 eventColor || 4953 source.backgroundColor || 4954 sourceColor ||
4955 view.opt('eventBackgroundColor') || 4956 optionColor, 4957 'border-color': 4958 event.borderColor || 4959 eventColor || 4960 source.borderColor || 4961 sourceColor || 4962 view.opt('eventBorderColor') || 4963 optionColor, 4964 color: 4965 event.textColor || 4966 source.textColor || 4967 view.opt('eventTextColor') 4968 }; 4969 }, 4970 4971 4972 /* Converting events -> eventRange -> eventSpan -> eventSegs 4973 ------------------------------------------------------------------------------------------------------------------*/ 4974 4975 4976 // Generates an array of segments for the given single event 4977 // Can accept an event "location" as well (which only has start/end and no allDay) 4978 eventToSegs: function(event) { 4979 return this.eventsToSegs([ event ]); 4980 }, 4981 4982 4983 eventToSpan: function(event) { 4984 return this.eventToSpans(event)[0]; 4985 }, 4986 4987 4988 // Generates spans (always unzoned) for the given event. 4989 // Does not do any inverting for inverse-background events. 4990 // Can accept an event "location" as well (which only has start/end and no allDay) 4991 eventToSpans: function(event) { 4992 var range = this.eventToRange(event); 4993 return this.eventRangeToSpans(range, event); 4994 }, 4995 4996 4997 4998 // Converts an array of event objects into an array of event segment objects. 4999 // A custom `segSliceFunc` may be given for arbitrarily slicing up events. 5000 // Doesn't guarantee an order for the resulting array. 5001 eventsToSegs: function(allEvents, segSliceFunc) { 5002 var _this = this; 5003 var eventsById = groupEventsById(allEvents); 5004 var segs = []; 5005 5006 $.each(eventsById, function(id, events) { 5007 var ranges = []; 5008 var i; 5009 5010 for (i = 0; i < events.length; i++) { 5011 ranges.push(_this.eventToRange(events[i])); 5012 } 5013 5014 // inverse-background events (utilize only the first event in calculations) 5015 if (isInverseBgEvent(events[0])) { 5016 ranges = _this.invertRanges(ranges); 5017 5018 for (i = 0; i < ranges.length; i++) { 5019 segs.push.apply(segs, // append to 5020 _this.eventRangeToSegs(ranges[i], events[0], segSliceFunc)); 5021 } 5022 } 5023 // normal event ranges 5024 else { 5025 for (i = 0; i < ranges.length; i++) { 5026 segs.push.apply(segs, // append to 5027 _this.eventRangeToSegs(ranges[i], events[i], segSliceFunc)); 5028 } 5029 } 5030 }); 5031 5032 return segs; 5033 }, 5034 5035 5036 // Generates the unzoned start/end dates an event appears to occupy 5037 // Can accept an event "location" as well (which only has start/end and no allDay) 5038 eventToRange: function(event) { 5039 return { 5040 start: event.start.clone().stripZone(), 5041 end: ( 5042 event.end ? 5043 event.end.clone() : 5044 // derive the end from the start and allDay. compute allDay if necessary 5045 this.view.calendar.getDefaultEventEnd( 5046 event.allDay != null ? 5047 event.allDay : 5048 !event.start.hasTime(), 5049 event.start 5050 ) 5051 ).stripZone() 5052 }; 5053 }, 5054 5055 5056 // Given an event's range (unzoned start/end), and the event itself, 5057 // slice into segments (using the segSliceFunc function if specified) 5058 eventRangeToSegs: function(range, event, segSliceFunc) { 5059 var spans = this.eventRangeToSpans(range, event); 5060 var segs = []; 5061 var i; 5062 5063 for (i = 0; i < spans.length; i++) { 5064 segs.push.apply(segs, // append to 5065 this.eventSpanToSegs(spans[i], event, segSliceFunc)); 5066 } 5067 5068 return segs; 5069 }, 5070 5071 5072 // Given an event's unzoned date range, return an array of "span" objects. 5073 // Subclasses can override. 5074 eventRangeToSpans: function(range, event) { 5075 return [ $.extend({}, range) ]; // copy into a single-item array 5076 }, 5077 5078 5079 // Given an event's span (unzoned start/end and other misc data), and the event itself, 5080 // slices into segments and attaches event-derived properties to them. 5081 eventSpanToSegs: function(span, event, segSliceFunc) { 5082 var segs = segSliceFunc ? segSliceFunc(span) : this.spanToSegs(span); 5083 var i, seg; 5084 5085 for (i = 0; i < segs.length; i++) { 5086 seg = segs[i]; 5087 seg.event = event; 5088 seg.eventStartMS = +span.start; // TODO: not the best name after making spans unzoned 5089 seg.eventDurationMS = span.end - span.start; 5090 } 5091 5092 return segs; 5093 }, 5094 5095 5096 // Produces a new array of range objects that will cover all the time NOT covered by the given ranges. 5097 // SIDE EFFECT: will mutate the given array and will use its date references. 5098 invertRanges: function(ranges) { 5099 var view = this.view; 5100 var viewStart = view.start.clone(); // need a copy 5101 var viewEnd = view.end.clone(); // need a copy 5102 var inverseRanges = []; 5103 var start = viewStart; // the end of the previous range. the start of the new range 5104 var i, range; 5105 5106 // ranges need to be in order. required for our date-walking algorithm 5107 ranges.sort(compareRanges); 5108 5109 for (i = 0; i < ranges.length; i++) { 5110 range = ranges[i]; 5111 5112 // add the span of time before the event (if there is any) 5113 if (range.start > start) { // compare millisecond time (skip any ambig logic) 5114 inverseRanges.push({ 5115 start: start, 5116 end: range.start 5117 }); 5118 } 5119 5120 start = range.end; 5121 } 5122 5123 // add the span of time after the last event (if there is any) 5124 if (start < viewEnd) { // compare millisecond time (skip any ambig logic) 5125 inverseRanges.push({ 5126 start: start, 5127 end: viewEnd 5128 }); 5129 } 5130 5131 return inverseRanges; 5132 }, 5133 5134 5135 sortEventSegs: function(segs) { 5136 segs.sort(proxy(this, 'compareEventSegs')); 5137 }, 5138 5139 5140 // A cmp function for determining which segments should take visual priority 5141 compareEventSegs: function(seg1, seg2) { 5142 return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first 5143 seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first 5144 seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1) 5145 compareByFieldSpecs(seg1.event, seg2.event, this.view.eventOrderSpecs); 5146 } 5147 5148}); 5149 5150 5151/* Utilities 5152----------------------------------------------------------------------------------------------------------------------*/ 5153 5154 5155function isBgEvent(event) { // returns true if background OR inverse-background 5156 var rendering = getEventRendering(event); 5157 return rendering === 'background' || rendering === 'inverse-background'; 5158} 5159FC.isBgEvent = isBgEvent; // export 5160 5161 5162function isInverseBgEvent(event) {
5163 return getEventRendering(event) === 'inverse-background'; 5164} 5165 5166 5167function getEventRendering(event) { 5168 return firstDefined((event.source || {}).rendering, event.rendering); 5169} 5170 5171 5172function groupEventsById(events) { 5173 var eventsById = {}; 5174 var i, event; 5175 5176 for (i = 0; i < events.length; i++) { 5177 event = events[i]; 5178 (eventsById[event._id] || (eventsById[event._id] = [])).push(event); 5179 } 5180 5181 return eventsById; 5182} 5183 5184 5185// A cmp function for determining which non-inverted "ranges" (see above) happen earlier 5186function compareRanges(range1, range2) { 5187 return range1.start - range2.start; // earlier ranges go first 5188} 5189 5190 5191/* External-Dragging-Element Data 5192----------------------------------------------------------------------------------------------------------------------*/ 5193 5194// Require all HTML5 data-* attributes used by FullCalendar to have this prefix. 5195// A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event. 5196FC.dataAttrPrefix = ''; 5197 5198// Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure 5199// to be used for Event Object creation. 5200// A defined `.eventProps`, even when empty, indicates that an event should be created. 5201function getDraggedElMeta(el) { 5202 var prefix = FC.dataAttrPrefix; 5203 var eventProps; // properties for creating the event, not related to date/time 5204 var startTime; // a Duration 5205 var duration; 5206 var stick; 5207 5208 if (prefix) { prefix += '-'; } 5209 eventProps = el.data(prefix + 'event') || null; 5210 5211 if (eventProps) { 5212 if (typeof eventProps === 'object') { 5213 eventProps = $.extend({}, eventProps); // make a copy 5214 } 5215 else { // something like 1 or true. still signal event creation 5216 eventProps = {}; 5217 } 5218 5219 // pluck special-cased date/time properties 5220 startTime = eventProps.start; 5221 if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well 5222 duration = eventProps.duration; 5223 stick = eventProps.stick; 5224 delete eventProps.start; 5225 delete eventProps.time; 5226 delete eventProps.duration; 5227 delete eventProps.stick; 5228 } 5229 5230 // fallback to standalone attribute values for each of the date/time properties 5231 if (startTime == null) { startTime = el.data(prefix + 'start'); } 5232 if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well 5233 if (duration == null) { duration = el.data(prefix + 'duration'); } 5234 if (stick == null) { stick = el.data(prefix + 'stick'); } 5235 5236 // massage into correct data types 5237 startTime = startTime != null ? moment.duration(startTime) : null; 5238 duration = duration != null ? moment.duration(duration) : null; 5239 stick = Boolean(stick); 5240 5241 return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick }; 5242} 5243 5244 5245;; 5246 5247/* 5248A set of rendering and date-related methods for a visual component comprised of one or more rows of day columns. 5249Prerequisite: the object being mixed into needs to be a *Grid* 5250*/ 5251var DayTableMixin = FC.DayTableMixin = { 5252 5253 breakOnWeeks: false, // should create a new row for each week? 5254 dayDates: null, // whole-day dates for each column. left to right 5255 dayIndices: null, // for each day from start, the offset 5256 daysPerRow: null, 5257 rowCnt: null, 5258 colCnt: null, 5259 colHeadFormat: null, 5260 5261 5262 // Populates internal variables used for date calculation and rendering 5263 updateDayTable: function() { 5264 var view = this.view; 5265 var date = this.start.clone(); 5266 var dayIndex = -1; 5267 var dayIndices = []; 5268 var dayDates = []; 5269 var daysPerRow; 5270 var firstDay; 5271 var rowCnt; 5272 5273 while (date.isBefore(this.end)) { // loop each day from start to end 5274 if (view.isHiddenDay(date)) { 5275 dayIndices.push(dayIndex + 0.5); // mark that it's between indices 5276 } 5277 else { 5278 dayIndex++; 5279 dayIndices.push(dayIndex); 5280 dayDates.push(date.clone()); 5281 } 5282 date.add(1, 'days'); 5283 } 5284 5285 if (this.breakOnWeeks) { 5286 // count columns until the day-of-week repeats 5287 firstDay = dayDates[0].day(); 5288 for (daysPerRow = 1; daysPerRow < dayDates.length; daysPerRow++) { 5289 if (dayDates[daysPerRow].day() == firstDay) { 5290 break; 5291 } 5292 } 5293 rowCnt = Math.ceil(dayDates.length / daysPerRow); 5294 } 5295 else { 5296 rowCnt = 1; 5297 daysPerRow = dayDates.length; 5298 } 5299 5300 this.dayDates = dayDates; 5301 this.dayIndices = dayIndices; 5302 this.daysPerRow = daysPerRow; 5303 this.rowCnt = rowCnt; 5304 5305 this.updateDayTableCols(); 5306 }, 5307 5308 5309 // Computes and assigned the colCnt property and updates any options that may be computed from it 5310 updateDayTableCols: function() { 5311 this.colCnt = this.computeColCnt(); 5312 this.colHeadFormat = this.view.opt('columnFormat') || this.computeColHeadFormat(); 5313 }, 5314 5315 5316 // Determines how many columns there should be in the table 5317 computeColCnt: function() { 5318 return this.daysPerRow; 5319 }, 5320 5321 5322 // Computes the ambiguously-timed moment for the given cell 5323 getCellDate: function(row, col) { 5324 return this.dayDates[ 5325 this.getCellDayIndex(row, col) 5326 ].clone(); 5327 }, 5328 5329 5330 // Computes the ambiguously-timed date range for the given cell 5331 getCellRange: function(row, col) { 5332 var start = this.getCellDate(row, col); 5333 var end = start.clone().add(1, 'days'); 5334 5335 return { start: start, end: end }; 5336 }, 5337 5338 5339 // Returns the number of day cells, chronologically, from the first of the grid (0-based) 5340 getCellDayIndex: function(row, col) { 5341 return row * this.daysPerRow + this.getColDayIndex(col); 5342 }, 5343 5344 5345 // Returns the numner of day cells, chronologically, from the first cell in *any given row* 5346 getColDayIndex: function(col) { 5347 if (this.isRTL) { 5348 return this.colCnt - 1 - col; 5349 } 5350 else { 5351 return col; 5352 } 5353 }, 5354 5355 5356 // Given a date, returns its chronolocial cell-index from the first cell of the grid. 5357 // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets. 5358 // If before the first offset, returns a negative number. 5359 // If after the last offset, returns an offset past the last cell offset. 5360 // Only works for *start* dates of cells. Will not work for exclusive end dates for cells. 5361 getDateDayIndex: function(date) { 5362 var dayIndices = this.dayIndices; 5363 var dayOffset = date.diff(this.start, 'days'); 5364 5365 if (dayOffset < 0) {
5366 return dayIndices[0] - 1; 5367 } 5368 else if (dayOffset >= dayIndices.length) { 5369 return dayIndices[dayIndices.length - 1] + 1; 5370 } 5371 else { 5372 return dayIndices[dayOffset]; 5373 } 5374 }, 5375 5376 5377 /* Options 5378 ------------------------------------------------------------------------------------------------------------------*/ 5379 5380 5381 // Computes a default column header formatting string if `colFormat` is not explicitly defined 5382 computeColHeadFormat: function() { 5383 // if more than one week row, or if there are a lot of columns with not much space, 5384 // put just the day numbers will be in each cell 5385 if (this.rowCnt > 1 || this.colCnt > 10) { 5386 return 'ddd'; // "Sat" 5387 } 5388 // multiple days, so full single date string WON'T be in title text 5389 else if (this.colCnt > 1) { 5390 return this.view.opt('dayOfMonthFormat'); // "Sat 12/10" 5391 } 5392 // single day, so full single date string will probably be in title text 5393 else { 5394 return 'dddd'; // "Saturday" 5395 } 5396 }, 5397 5398 5399 /* Slicing 5400 ------------------------------------------------------------------------------------------------------------------*/ 5401 5402 5403 // Slices up a date range into a segment for every week-row it intersects with 5404 sliceRangeByRow: function(range) { 5405 var daysPerRow = this.daysPerRow; 5406 var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold 5407 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index 5408 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index 5409 var segs = []; 5410 var row; 5411 var rowFirst, rowLast; // inclusive day-index range for current row 5412 var segFirst, segLast; // inclusive day-index range for segment 5413 5414 for (row = 0; row < this.rowCnt; row++) { 5415 rowFirst = row * daysPerRow; 5416 rowLast = rowFirst + daysPerRow - 1; 5417 5418 // intersect segment's offset range with the row's 5419 segFirst = Math.max(rangeFirst, rowFirst); 5420 segLast = Math.min(rangeLast, rowLast); 5421 5422 // deal with in-between indices 5423 segFirst = Math.ceil(segFirst); // in-between starts round to next cell 5424 segLast = Math.floor(segLast); // in-between ends round to prev cell 5425 5426 if (segFirst <= segLast) { // was there any intersection with the current row? 5427 segs.push({ 5428 row: row, 5429 5430 // normalize to start of row 5431 firstRowDayIndex: segFirst - rowFirst, 5432 lastRowDayIndex: segLast - rowFirst, 5433 5434 // must be matching integers to be the segment's start/end 5435 isStart: segFirst === rangeFirst, 5436 isEnd: segLast === rangeLast 5437 }); 5438 } 5439 } 5440 5441 return segs; 5442 }, 5443 5444 5445 // Slices up a date range into a segment for every day-cell it intersects with. 5446 // TODO: make more DRY with sliceRangeByRow somehow. 5447 sliceRangeByDay: function(range) { 5448 var daysPerRow = this.daysPerRow; 5449 var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold 5450 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index 5451 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index 5452 var segs = []; 5453 var row; 5454 var rowFirst, rowLast; // inclusive day-index range for current row 5455 var i; 5456 var segFirst, segLast; // inclusive day-index range for segment 5457 5458 for (row = 0; row < this.rowCnt; row++) { 5459 rowFirst = row * daysPerRow; 5460 rowLast = rowFirst + daysPerRow - 1; 5461 5462 for (i = rowFirst; i <= rowLast; i++) { 5463 5464 // intersect segment's offset range with the row's 5465 segFirst = Math.max(rangeFirst, i); 5466 segLast = Math.min(rangeLast, i); 5467 5468 // deal with in-between indices 5469 segFirst = Math.ceil(segFirst); // in-between starts round to next cell 5470 segLast = Math.floor(segLast); // in-between ends round to prev cell 5471 5472 if (segFirst <= segLast) { // was there any intersection with the current row? 5473 segs.push({ 5474 row: row, 5475 5476 // normalize to start of row 5477 firstRowDayIndex: segFirst - rowFirst, 5478 lastRowDayIndex: segLast - rowFirst, 5479 5480 // must be matching integers to be the segment's start/end 5481 isStart: segFirst === rangeFirst, 5482 isEnd: segLast === rangeLast 5483 }); 5484 } 5485 } 5486 } 5487 5488 return segs; 5489 }, 5490 5491 5492 /* Header Rendering 5493 ------------------------------------------------------------------------------------------------------------------*/ 5494 5495 5496 renderHeadHtml: function() { 5497 var view = this.view; 5498 5499 return '' + 5500 '<div class="fc-row ' + view.widgetHeaderClass + '">' + 5501 '<table>' + 5502 '<thead>' + 5503 this.renderHeadTrHtml() + 5504 '</thead>' + 5505 '</table>' + 5506 '</div>'; 5507 }, 5508 5509 5510 renderHeadIntroHtml: function() { 5511 return this.renderIntroHtml(); // fall back to generic 5512 }, 5513 5514 5515 renderHeadTrHtml: function() { 5516 return '' + 5517 '<tr>' + 5518 (this.isRTL ? '' : this.renderHeadIntroHtml()) + 5519 this.renderHeadDateCellsHtml() + 5520 (this.isRTL ? this.renderHeadIntroHtml() : '') + 5521 '</tr>'; 5522 }, 5523 5524 5525 renderHeadDateCellsHtml: function() { 5526 var htmls = []; 5527 var col, date; 5528 5529 for (col = 0; col < this.colCnt; col++) { 5530 date = this.getCellDate(0, col); 5531 htmls.push(this.renderHeadDateCellHtml(date)); 5532 } 5533 5534 return htmls.join(''); 5535 }, 5536 5537 5538 // TODO: when internalApiVersion, accept an object for HTML attributes 5539 // (colspan should be no different) 5540 renderHeadDateCellHtml: function(date, colspan, otherAttrs) { 5541 var view = this.view; 5542 5543 return '' + 5544 '<th class="fc-day-header ' + view.widgetHeaderClass + ' fc-' + dayIDs[date.day()] + '"' + 5545 (this.rowCnt == 1 ? 5546 ' data-date="' + date.format('YYYY-MM-DD') + '"' : 5547 '') + 5548 (colspan > 1 ? 5549 ' colspan="' + colspan + '"' : 5550 '') + 5551 (otherAttrs ?
5552 ' ' + otherAttrs : 5553 '') + 5554 '>' + 5555 htmlEscape(date.format(this.colHeadFormat)) + 5556 '</th>'; 5557 }, 5558 5559 5560 /* Background Rendering 5561 ------------------------------------------------------------------------------------------------------------------*/ 5562 5563 5564 renderBgTrHtml: function(row) { 5565 return '' + 5566 '<tr>' + 5567 (this.isRTL ? '' : this.renderBgIntroHtml(row)) + 5568 this.renderBgCellsHtml(row) + 5569 (this.isRTL ? this.renderBgIntroHtml(row) : '') + 5570 '</tr>'; 5571 }, 5572 5573 5574 renderBgIntroHtml: function(row) { 5575 return this.renderIntroHtml(); // fall back to generic 5576 }, 5577 5578 5579 renderBgCellsHtml: function(row) { 5580 var htmls = []; 5581 var col, date; 5582 5583 for (col = 0; col < this.colCnt; col++) { 5584 date = this.getCellDate(row, col); 5585 htmls.push(this.renderBgCellHtml(date)); 5586 } 5587 5588 return htmls.join(''); 5589 }, 5590 5591 5592 renderBgCellHtml: function(date, otherAttrs) { 5593 var view = this.view; 5594 var classes = this.getDayClasses(date); 5595 5596 classes.unshift('fc-day', view.widgetContentClass); 5597 5598 return '<td class="' + classes.join(' ') + '"' + 5599 ' data-date="' + date.format('YYYY-MM-DD') + '"' + // if date has a time, won't format it 5600 (otherAttrs ? 5601 ' ' + otherAttrs : 5602 '') + 5603 '></td>'; 5604 }, 5605 5606 5607 /* Generic 5608 ------------------------------------------------------------------------------------------------------------------*/ 5609 5610 5611 // Generates the default HTML intro for any row. User classes should override 5612 renderIntroHtml: function() { 5613 }, 5614 5615 5616 // TODO: a generic method for dealing with <tr>, RTL, intro 5617 // when increment internalApiVersion 5618 // wrapTr (scheduler) 5619 5620 5621 /* Utils 5622 ------------------------------------------------------------------------------------------------------------------*/ 5623 5624 5625 // Applies the generic "intro" and "outro" HTML to the given cells. 5626 // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro. 5627 bookendCells: function(trEl) { 5628 var introHtml = this.renderIntroHtml(); 5629 5630 if (introHtml) { 5631 if (this.isRTL) { 5632 trEl.append(introHtml); 5633 } 5634 else { 5635 trEl.prepend(introHtml); 5636 } 5637 } 5638 } 5639 5640}; 5641 5642;; 5643 5644/* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week. 5645----------------------------------------------------------------------------------------------------------------------*/ 5646 5647var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { 5648 5649 numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal 5650 bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid 5651
5652 rowEls: null, // set of fake row elements 5653 cellEls: null, // set of whole-day elements comprising the row's background 5654 helperEls: null, // set of cell skeleton elements for rendering the mock event "helper" 5655 5656 rowCoordCache: null, 5657 colCoordCache: null, 5658 5659 5660 // Renders the rows and columns into the component's `this.el`, which should already be assigned. 5661 // isRigid determins whether the individual rows should ignore the contents and be a constant height. 5662 // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient. 5663 renderDates: function(isRigid) { 5664 var view = this.view; 5665 var rowCnt = this.rowCnt; 5666 var colCnt = this.colCnt; 5667 var html = ''; 5668 var row; 5669 var col; 5670 5671 for (row = 0; row < rowCnt; row++) { 5672 html += this.renderDayRowHtml(row, isRigid); 5673 } 5674 this.el.html(html); 5675 5676 this.rowEls = this.el.find('.fc-row'); 5677 this.cellEls = this.el.find('.fc-day'); 5678 5679 this.rowCoordCache = new CoordCache({ 5680 els: this.rowEls, 5681 isVertical: true 5682 }); 5683 this.colCoordCache = new CoordCache({ 5684 els: this.cellEls.slice(0, this.colCnt), // only the first row 5685 isHorizontal: true 5686 }); 5687 5688 // trigger dayRender with each cell's element 5689 for (row = 0; row < rowCnt; row++) { 5690 for (col = 0; col < colCnt; col++) { 5691 view.trigger( 5692 'dayRender', 5693 null, 5694 this.getCellDate(row, col), 5695 this.getCellEl(row, col) 5696 ); 5697 } 5698 } 5699 }, 5700 5701 5702 unrenderDates: function() { 5703 this.removeSegPopover(); 5704 }, 5705 5706 5707 renderBusinessHours: function() { 5708 var segs = this.buildBusinessHourSegs(true); // wholeDay=true 5709 this.renderFill('businessHours', segs, 'bgevent'); 5710 }, 5711 5712 5713 unrenderBusinessHours: function() { 5714 this.unrenderFill('businessHours'); 5715 }, 5716 5717 5718 // Generates the HTML for a single row, which is a div that wraps a table. 5719 // `row` is the row number. 5720 renderDayRowHtml: function(row, isRigid) { 5721 var view = this.view; 5722 var classes = [ 'fc-row', 'fc-week', view.widgetContentClass ]; 5723 5724 if (isRigid) { 5725 classes.push('fc-rigid'); 5726 } 5727 5728 return '' + 5729 '<div class="' + classes.join(' ') + '">' + 5730 '<div class="fc-bg">' + 5731 '<table>' + 5732 this.renderBgTrHtml(row) + 5733 '</table>' + 5734 '</div>' + 5735 '<div class="fc-content-skeleton">' + 5736 '<table>' + 5737 (this.numbersVisible ? 5738 '<thead>' + 5739 this.renderNumberTrHtml(row) + 5740 '</thead>' : 5741 '' 5742 ) + 5743 '</table>' + 5744 '</div>' + 5745 '</div>'; 5746 }, 5747 5748 5749 /* Grid Number Rendering 5750 ------------------------------------------------------------------------------------------------------------------*/ 5751 5752 5753 renderNumberTrHtml: function(row) { 5754 return '' + 5755 '<tr>' + 5756 (this.isRTL ? '' : this.renderNumberIntroHtml(row)) + 5757 this.renderNumberCellsHtml(row) + 5758 (this.isRTL ? this.renderNumberIntroHtml(row) : '') + 5759 '</tr>'; 5760 }, 5761 5762 5763 renderNumberIntroHtml: function(row) { 5764 return this.renderIntroHtml(); 5765 }, 5766 5767 5768 renderNumberCellsHtml: function(row) { 5769 var htmls = []; 5770 var col, date; 5771 5772 for (col = 0; col < this.colCnt; col++) { 5773 date = this.getCellDate(row, col); 5774 htmls.push(this.renderNumberCellHtml(date)); 5775 } 5776 5777 return htmls.join(''); 5778 }, 5779 5780 5781 // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton. 5782 // The number row will only exist if either day numbers or week numbers are turned on. 5783 renderNumberCellHtml: function(date) { 5784 var classes; 5785 5786 if (!this.view.dayNumbersVisible) { // if there are week numbers but not day numbers 5787 return '<td/>'; // will create an empty space above events :( 5788 } 5789 5790 classes = this.getDayClasses(date); 5791 classes.unshift('fc-day-number'); 5792 5793 return '' + 5794 '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '">' + 5795 date.date() + 5796 '</td>'; 5797 }, 5798 5799 5800 /* Options 5801 ------------------------------------------------------------------------------------------------------------------*/ 5802 5803 5804 // Computes a default event time formatting string if `timeFormat` is not explicitly defined 5805 computeEventTimeFormat: function() { 5806 return this.view.opt('extraSmallTimeFormat'); // like "6p" or "6:30p" 5807 }, 5808 5809 5810 // Computes a default `displayEventEnd` value if one is not expliclty defined 5811 computeDisplayEventEnd: function() { 5812 return this.colCnt == 1; // we'll likely have space if there's only one day 5813 }, 5814 5815 5816 /* Dates 5817 ------------------------------------------------------------------------------------------------------------------*/ 5818 5819 5820 rangeUpdated: function() { 5821 this.updateDayTable(); 5822 }, 5823 5824
5825 // Slices up the given span (unzoned start/end with other misc data) into an array of segments 5826 spanToSegs: function(span) { 5827 var segs = this.sliceRangeByRow(span); 5828 var i, seg; 5829 5830 for (i = 0; i < segs.length; i++) { 5831 seg = segs[i]; 5832 if (this.isRTL) { 5833 seg.leftCol = this.daysPerRow - 1 - seg.lastRowDayIndex; 5834 seg.rightCol = this.daysPerRow - 1 - seg.firstRowDayIndex; 5835 } 5836 else { 5837 seg.leftCol = seg.firstRowDayIndex; 5838 seg.rightCol = seg.lastRowDayIndex; 5839 } 5840 } 5841 5842 return segs; 5843 }, 5844 5845 5846 /* Hit System 5847 ------------------------------------------------------------------------------------------------------------------*/ 5848 5849 5850 prepareHits: function() { 5851 this.colCoordCache.build(); 5852 this.rowCoordCache.build(); 5853 this.rowCoordCache.bottoms[this.rowCnt - 1] += this.bottomCoordPadding; // hack 5854 }, 5855 5856 5857 releaseHits: function() { 5858 this.colCoordCache.clear(); 5859 this.rowCoordCache.clear(); 5860 }, 5861 5862 5863 queryHit: function(leftOffset, topOffset) { 5864 if (this.colCoordCache.isLeftInBounds(leftOffset) && this.rowCoordCache.isTopInBounds(topOffset)) { 5865 var col = this.colCoordCache.getHorizontalIndex(leftOffset); 5866 var row = this.rowCoordCache.getVerticalIndex(topOffset); 5867 5868 if (row != null && col != null) { 5869 return this.getCellHit(row, col); 5870 } 5871 } 5872 }, 5873 5874 5875 getHitSpan: function(hit) { 5876 return this.getCellRange(hit.row, hit.col); 5877 }, 5878 5879 5880 getHitEl: function(hit) { 5881 return this.getCellEl(hit.row, hit.col); 5882 }, 5883 5884 5885 /* Cell System 5886 ------------------------------------------------------------------------------------------------------------------*/ 5887 // FYI: the first column is the leftmost column, regardless of date 5888 5889 5890 getCellHit: function(row, col) { 5891 return { 5892 row: row, 5893 col: col, 5894 component: this, // needed unfortunately :( 5895 left: this.colCoordCache.getLeftOffset(col), 5896 right: this.colCoordCache.getRightOffset(col), 5897 top: this.rowCoordCache.getTopOffset(row), 5898 bottom: this.rowCoordCache.getBottomOffset(row) 5899 }; 5900 }, 5901 5902 5903 getCellEl: function(row, col) { 5904 return this.cellEls.eq(row * this.colCnt + col); 5905 }, 5906 5907 5908 /* Event Drag Visualization 5909 ------------------------------------------------------------------------------------------------------------------*/ 5910 // TODO: move to DayGrid.event, similar to what we did with Grid's drag methods 5911 5912 5913 // Renders a visual indication of an event or external element being dragged. 5914 // `eventLocation` has zoned start and end (optional) 5915 renderDrag: function(eventLocation, seg) { 5916 5917 // always render a highlight underneath 5918 this.renderHighlight(this.eventToSpan(eventLocation)); 5919 5920 // if a segment from the same calendar but another component is being dragged, render a helper event 5921 if (seg && seg.component !== this) { 5922 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements 5923 } 5924 }, 5925 5926 5927 // Unrenders any visual indication of a hovering event 5928 unrenderDrag: function() { 5929 this.unrenderHighlight(); 5930 this.unrenderHelper(); 5931 }, 5932 5933 5934 /* Event Resize Visualization 5935 ------------------------------------------------------------------------------------------------------------------*/ 5936 5937 5938 // Renders a visual indication of an event being resized 5939 renderEventResize: function(eventLocation, seg) { 5940 this.renderHighlight(this.eventToSpan(eventLocation)); 5941 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements 5942 }, 5943 5944 5945 // Unrenders a visual indication of an event being resized 5946 unrenderEventResize: function() { 5947 this.unrenderHighlight(); 5948 this.unrenderHelper(); 5949 }, 5950 5951 5952 /* Event Helper 5953 ------------------------------------------------------------------------------------------------------------------*/ 5954 5955 5956 // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null. 5957 renderHelper: function(event, sourceSeg) { 5958 var helperNodes = []; 5959 var segs = this.eventToSegs(event); 5960 var rowStructs; 5961 5962 segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered 5963 rowStructs = this.renderSegRows(segs); 5964 5965 // inject each new event skeleton into each associated row 5966 this.rowEls.each(function(row, rowNode) { 5967 var rowEl = $(rowNode); // the .fc-row
5968 var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned 5969 var skeletonTop; 5970 5971 // If there is an original segment, match the top position. Otherwise, put it at the row's top level 5972 if (sourceSeg && sourceSeg.row === row) { 5973 skeletonTop = sourceSeg.el.position().top; 5974 } 5975 else { 5976 skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top; 5977 } 5978 5979 skeletonEl.css('top', skeletonTop) 5980 .find('table') 5981 .append(rowStructs[row].tbodyEl); 5982 5983 rowEl.append(skeletonEl); 5984 helperNodes.push(skeletonEl[0]); 5985 }); 5986 5987 return ( // must return the elements rendered 5988 this.helperEls = $(helperNodes) // array -> jQuery set 5989 ); 5990 }, 5991 5992 5993 // Unrenders any visual indication of a mock helper event 5994 unrenderHelper: function() { 5995 if (this.helperEls) { 5996 this.helperEls.remove(); 5997 this.helperEls = null; 5998 } 5999 }, 6000 6001 6002 /* Fill System (highlight, background events, business hours) 6003 ------------------------------------------------------------------------------------------------------------------*/ 6004 6005 6006 fillSegTag: 'td', // override the default tag name 6007 6008 6009 // Renders a set of rectangles over the given segments of days. 6010 // Only returns segments that successfully rendered. 6011 renderFill: function(type, segs, className) { 6012 var nodes = []; 6013 var i, seg; 6014 var skeletonEl; 6015 6016 segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs 6017 6018 for (i = 0; i < segs.length; i++) { 6019 seg = segs[i]; 6020 skeletonEl = this.renderFillRow(type, seg, className); 6021 this.rowEls.eq(seg.row).append(skeletonEl); 6022 nodes.push(skeletonEl[0]); 6023 } 6024 6025 this.elsByFill[type] = $(nodes); 6026 6027 return segs; 6028 }, 6029 6030 6031 // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered. 6032 renderFillRow: function(type, seg, className) { 6033 var colCnt = this.colCnt; 6034 var startCol = seg.leftCol; 6035 var endCol = seg.rightCol + 1; 6036 var skeletonEl; 6037 var trEl; 6038 6039 className = className || type.toLowerCase(); 6040 6041 skeletonEl = $( 6042 '<div class="fc-' + className + '-skeleton">' + 6043 '<table><tr/></table>' + 6044 '</div>' 6045 ); 6046 trEl = skeletonEl.find('tr'); 6047 6048 if (startCol > 0) { 6049 trEl.append('<td colspan="' + startCol + '"/>'); 6050 } 6051 6052 trEl.append( 6053 seg.el.attr('colspan', endCol - startCol) 6054 ); 6055 6056 if (endCol < colCnt) { 6057 trEl.append('<td colspan="' + (colCnt - endCol) + '"/>'); 6058 } 6059 6060 this.bookendCells(trEl); 6061 6062 return skeletonEl; 6063 } 6064 6065}); 6066 6067;; 6068 6069/* Event-rendering methods for the DayGrid class 6070----------------------------------------------------------------------------------------------------------------------*/ 6071 6072DayGrid.mixin({ 6073 6074 rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering 6075 6076 6077 // Unrenders all events currently rendered on the grid 6078 unrenderEvents: function() { 6079 this.removeSegPopover(); // removes the "more.." events popover 6080 Grid.prototype.unrenderEvents.apply(this, arguments); // calls the super-method 6081 }, 6082 6083 6084 // Retrieves all rendered segment objects currently rendered on the grid 6085 getEventSegs: function() { 6086 return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method 6087 .concat(this.popoverSegs || []); // append the segments from the "more..." popover 6088 }, 6089 6090 6091 // Renders the given background event segments onto the grid 6092 renderBgSegs: function(segs) { 6093 6094 // don't render timed background events 6095 var allDaySegs = $.grep(segs, function(seg) { 6096 return seg.event.allDay; 6097 }); 6098 6099 return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method 6100 }, 6101 6102 6103 // Renders the given foreground event segments onto the grid 6104 renderFgSegs: function(segs) { 6105 var rowStructs; 6106 6107 // render an `.el` on each seg 6108 // returns a subset of the segs. segs that were actually rendered 6109 segs = this.renderFgSegEls(segs); 6110 6111 rowStructs = this.rowStructs = this.renderSegRows(segs); 6112 6113 // append to each row's content skeleton 6114 this.rowEls.each(function(i, rowNode) { 6115 $(rowNode).find('.fc-content-skeleton > table').append( 6116 rowStructs[i].tbodyEl 6117 ); 6118 }); 6119 6120 return segs; // return only the segs that were actually rendered 6121 }, 6122 6123 6124 // Unrenders all currently rendered foreground event segments 6125 unrenderFgSegs: function() { 6126 var rowStructs = this.rowStructs || []; 6127 var rowStruct; 6128 6129 while ((rowStruct = rowStructs.pop())) { 6130 rowStruct.tbodyEl.remove(); 6131 } 6132 6133 this.rowStructs = null; 6134 }, 6135 6136 6137 // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton. 6138 // Returns an array of rowStruct objects (see the bottom of `renderSegRow`). 6139 // PRECONDITION: each segment shoud already have a rendered and assigned `.el` 6140 renderSegRows: function(segs) { 6141 var rowStructs = []; 6142 var segRows; 6143 var row; 6144 6145 segRows = this.groupSegRows(segs); // group into nested arrays 6146 6147 // iterate each row of segment groupings 6148 for (row = 0; row < segRows.length; row++) { 6149 rowStructs.push( 6150 this.renderSegRow(row, segRows[row]) 6151 ); 6152 } 6153 6154 return rowStructs; 6155 }, 6156 6157 6158 // Builds the HTML to be used for the default element for an individual segment 6159 fgSegHtml: function(seg, disableResizing) { 6160 var view = this.view; 6161 var event = seg.event; 6162 var isDraggable = view.isEventDraggable(event); 6163 var isResizableFromStart = !disableResizing && event.allDay && 6164 seg.isStart && view.isEventResizableFromStart(event); 6165 var isResizableFromEnd = !disableResizing && event.allDay && 6166 seg.isEnd && view.isEventResizableFromEnd(event);
6167 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); 6168 var skinCss = cssToStr(this.getSegSkinCss(seg)); 6169 var timeHtml = ''; 6170 var timeText; 6171 var titleHtml; 6172 6173 classes.unshift('fc-day-grid-event', 'fc-h-event'); 6174 6175 // Only display a timed events time if it is the starting segment 6176 if (seg.isStart) { 6177 timeText = this.getEventTimeText(event); 6178 if (timeText) { 6179 timeHtml = '<span class="fc-time">' + htmlEscape(timeText) + '</span>'; 6180 } 6181 } 6182 6183 titleHtml = 6184 '<span class="fc-title">' + 6185 (htmlEscape(event.title || '') || ' ') + // we always want one line of height 6186 '</span>'; 6187 6188 return '<a class="' + classes.join(' ') + '"' + 6189 (event.url ? 6190 ' href="' + htmlEscape(event.url) + '"' : 6191 '' 6192 ) + 6193 (skinCss ? 6194 ' style="' + skinCss + '"' : 6195 '' 6196 ) + 6197 '>' + 6198 '<div class="fc-content">' + 6199 (this.isRTL ? 6200 titleHtml + ' ' + timeHtml : // put a natural space in between 6201 timeHtml + ' ' + titleHtml // 6202 ) + 6203 '</div>' + 6204 (isResizableFromStart ? 6205 '<div class="fc-resizer fc-start-resizer" />' : 6206 '' 6207 ) + 6208 (isResizableFromEnd ? 6209 '<div class="fc-resizer fc-end-resizer" />' : 6210 '' 6211 ) + 6212 '</a>'; 6213 }, 6214 6215 6216 // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains 6217 // the segments. Returns object with a bunch of internal data about how the render was calculated. 6218 // NOTE: modifies rowSegs 6219 renderSegRow: function(row, rowSegs) { 6220 var colCnt = this.colCnt; 6221 var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels 6222 var levelCnt = Math.max(1, segLevels.length); // ensure at least one level 6223 var tbody = $('<tbody/>'); 6224 var segMatrix = []; // lookup for which segments are rendered into which level+col cells 6225 var cellMatrix = []; // lookup for all <td> elements of the level+col matrix 6226 var loneCellMatrix = []; // lookup for <td> elements that only take up a single column 6227 var i, levelSegs; 6228 var col; 6229 var tr; 6230 var j, seg; 6231 var td; 6232 6233 // populates empty cells from the current column (`col`) to `endCol` 6234 function emptyCellsUntil(endCol) { 6235 while (col < endCol) { 6236 // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell 6237 td = (loneCellMatrix[i - 1] || [])[col]; 6238 if (td) { 6239 td.attr( 6240 'rowspan', 6241 parseInt(td.attr('rowspan') || 1, 10) + 1 6242 ); 6243 } 6244 else { 6245 td = $('<td/>'); 6246 tr.append(td); 6247 } 6248 cellMatrix[i][col] = td; 6249 loneCellMatrix[i][col] = td; 6250 col++; 6251 } 6252 } 6253 6254 for (i = 0; i < levelCnt; i++) { // iterate through all levels 6255 levelSegs = segLevels[i]; 6256 col = 0; 6257 tr = $('<tr/>'); 6258 6259 segMatrix.push([]); 6260 cellMatrix.push([]); 6261 loneCellMatrix.push([]); 6262 6263 // levelCnt might be 1 even though there are no actual levels. protect against this. 6264 // this single empty row is useful for styling. 6265 if (levelSegs) { 6266 for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level 6267 seg = levelSegs[j]; 6268 6269 emptyCellsUntil(seg.leftCol); 6270 6271 // create a container that occupies or more columns. append the event element. 6272 td = $('<td class="fc-event-container"/>').append(seg.el); 6273 if (seg.leftCol != seg.rightCol) { 6274 td.attr('colspan', seg.rightCol - seg.leftCol + 1); 6275 } 6276 else { // a single-column segment 6277 loneCellMatrix[i][col] = td; 6278 } 6279 6280 while (col <= seg.rightCol) { 6281 cellMatrix[i][col] = td; 6282 segMatrix[i][col] = seg; 6283 col++; 6284 } 6285 6286 tr.append(td); 6287 } 6288 } 6289 6290 emptyCellsUntil(colCnt); // finish off the row 6291 this.bookendCells(tr); 6292 tbody.append(tr); 6293 } 6294 6295 return { // a "rowStruct" 6296 row: row, // the row number 6297 tbodyEl: tbody, 6298 cellMatrix: cellMatrix, 6299 segMatrix: segMatrix, 6300 segLevels: segLevels, 6301 segs: rowSegs 6302 }; 6303 }, 6304 6305 6306 // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels. 6307 // NOTE: modifies segs 6308 buildSegLevels: function(segs) { 6309 var levels = []; 6310 var i, seg; 6311 var j; 6312 6313 // Give preference to elements with certain criteria, so they have 6314 // a chance to be closer to the top. 6315 this.sortEventSegs(segs); 6316 6317 for (i = 0; i < segs.length; i++) { 6318 seg = segs[i]; 6319 6320 // loop through levels, starting with the topmost, until the segment doesn't collide with other segments 6321 for (j = 0; j < levels.length; j++) {
6322 if (!isDaySegCollision(seg, levels[j])) { 6323 break; 6324 } 6325 } 6326 // `j` now holds the desired subrow index 6327 seg.level = j; 6328 6329 // create new level array if needed and append segment 6330 (levels[j] || (levels[j] = [])).push(seg); 6331 } 6332 6333 // order segments left-to-right. very important if calendar is RTL 6334 for (j = 0; j < levels.length; j++) { 6335 levels[j].sort(compareDaySegCols); 6336 } 6337 6338 return levels; 6339 }, 6340 6341 6342 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row 6343 groupSegRows: function(segs) { 6344 var segRows = []; 6345 var i; 6346 6347 for (i = 0; i < this.rowCnt; i++) { 6348 segRows.push([]); 6349 } 6350 6351 for (i = 0; i < segs.length; i++) { 6352 segRows[segs[i].row].push(segs[i]); 6353 } 6354 6355 return segRows; 6356 } 6357 6358}); 6359 6360 6361// Computes whether two segments' columns collide. They are assumed to be in the same row. 6362function isDaySegCollision(seg, otherSegs) { 6363 var i, otherSeg; 6364 6365 for (i = 0; i < otherSegs.length; i++) { 6366 otherSeg = otherSegs[i]; 6367 6368 if ( 6369 otherSeg.leftCol <= seg.rightCol && 6370 otherSeg.rightCol >= seg.leftCol 6371 ) { 6372 return true; 6373 } 6374 } 6375 6376 return false; 6377} 6378 6379 6380// A cmp function for determining the leftmost event 6381function compareDaySegCols(a, b) { 6382 return a.leftCol - b.leftCol; 6383} 6384 6385;; 6386 6387/* Methods relate to limiting the number events for a given day on a DayGrid 6388----------------------------------------------------------------------------------------------------------------------*/ 6389// NOTE: all the segs being passed around in here are foreground segs 6390 6391DayGrid.mixin({ 6392 6393 segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible 6394 popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible 6395 6396 6397 removeSegPopover: function() { 6398 if (this.segPopover) { 6399 this.segPopover.hide(); // in handler, will call segPopover's removeElement 6400 } 6401 }, 6402 6403 6404 // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid. 6405 // `levelLimit` can be false (don't limit), a number, or true (should be computed). 6406 limitRows: function(levelLimit) { 6407 var rowStructs = this.rowStructs || []; 6408 var row; // row # 6409 var rowLevelLimit; 6410 6411 for (row = 0; row < rowStructs.length; row++) { 6412 this.unlimitRow(row); 6413 6414 if (!levelLimit) { 6415 rowLevelLimit = false; 6416 } 6417 else if (typeof levelLimit === 'number') { 6418 rowLevelLimit = levelLimit; 6419 } 6420 else { 6421 rowLevelLimit = this.computeRowLevelLimit(row); 6422 } 6423 6424 if (rowLevelLimit !== false) { 6425 this.limitRow(row, rowLevelLimit); 6426 } 6427 } 6428 }, 6429 6430 6431 // Computes the number of levels a row will accomodate without going outside its bounds. 6432 // Assumes the row is "rigid" (maintains a constant height regardless of what is inside). 6433 // `row` is the row number. 6434 computeRowLevelLimit: function(row) { 6435 var rowEl = this.rowEls.eq(row); // the containing "fake" row div 6436 var rowHeight = rowEl.height(); // TODO: cache somehow? 6437 var trEls = this.rowStructs[row].tbodyEl.children(); 6438 var i, trEl; 6439 var trHeight; 6440 6441 function iterInnerHeights(i, childNode) { 6442 trHeight = Math.max(trHeight, $(childNode).outerHeight()); 6443 } 6444 6445 // Reveal one level <tr> at a time and stop when we find one out of bounds 6446 for (i = 0; i < trEls.length; i++) { 6447 trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal) 6448 6449 // with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell, 6450 // so instead, find the tallest inner content element. 6451 trHeight = 0; 6452 trEl.find('> td > :first-child').each(iterInnerHeights); 6453 6454 if (trEl.position().top + trHeight > rowHeight) { 6455 return i; 6456 } 6457 } 6458 6459 return false; // should not limit at all 6460 }, 6461 6462 6463 // Limits the given grid row to the maximum number of levels and injects "more" links if necessary. 6464 // `row` is the row number. 6465 // `levelLimit` is a number for the maximum (inclusive) number of levels allowed. 6466 limitRow: function(row, levelLimit) { 6467 var _this = this; 6468 var rowStruct = this.rowStructs[row]; 6469 var moreNodes = []; // array of "more" <a> links and <td> DOM nodes 6470 var col = 0; // col #, left-to-right (not chronologically) 6471 var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right 6472 var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row 6473 var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes 6474 var i, seg; 6475 var segsBelow; // array of segment objects below `seg` in the current `col` 6476 var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies 6477 var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column) 6478 var td, rowspan; 6479 var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell 6480 var j; 6481 var moreTd, moreWrap, moreLink; 6482 6483 // Iterates through empty level cells and places "more" links inside if need be 6484 function emptyCellsUntil(endCol) { // goes from current `col` to `endCol` 6485 while (col < endCol) { 6486 segsBelow = _this.getCellSegs(row, col, levelLimit); 6487 if (segsBelow.length) { 6488 td = cellMatrix[levelLimit - 1][col]; 6489 moreLink = _this.renderMoreLink(row, col, segsBelow); 6490 moreWrap = $('<div/>').append(moreLink); 6491 td.append(moreWrap); 6492 moreNodes.push(moreWrap[0]); 6493 } 6494 col++; 6495 } 6496 } 6497 6498 if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit? 6499 levelSegs = rowStruct.segLevels[levelLimit - 1]; 6500 cellMatrix = rowStruct.cellMatrix; 6501 6502 limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit 6503 .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array 6504 6505 // iterate though segments in the last allowable level 6506 for (i = 0; i < levelSegs.length; i++) { 6507 seg = levelSegs[i]; 6508 emptyCellsUntil(seg.leftCol); // process empty cells before the segment 6509 6510 // determine *all* segments below `seg` that occupy the same columns 6511 colSegsBelow = []; 6512 totalSegsBelow = 0; 6513 while (col <= seg.rightCol) { 6514 segsBelow = this.getCellSegs(row, col, levelLimit); 6515 colSegsBelow.push(segsBelow); 6516 totalSegsBelow += segsBelow.length; 6517 col++; 6518 } 6519 6520 if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links? 6521 td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell 6522 rowspan = td.attr('rowspan') || 1; 6523 segMoreNodes = []; 6524 6525 // make a replacement <td> for each column the segment occupies. will be one for each colspan 6526 for (j = 0; j < colSegsBelow.length; j++) { 6527 moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan); 6528 segsBelow = colSegsBelow[j]; 6529 moreLink = this.renderMoreLink( 6530 row, 6531 seg.leftCol + j, 6532 [ seg ].concat(segsBelow) // count seg as hidden too 6533 ); 6534 moreWrap = $('<div/>').append(moreLink); 6535 moreTd.append(moreWrap); 6536 segMoreNodes.push(moreTd[0]); 6537 moreNodes.push(moreTd[0]); 6538 } 6539 6540 td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements 6541 limitedNodes.push(td[0]); 6542 } 6543 } 6544 6545 emptyCellsUntil(this.colCnt); // finish off the level 6546 rowStruct.moreEls = $(moreNodes); // for easy undoing later 6547 rowStruct.limitedEls = $(limitedNodes); // for easy undoing later 6548 } 6549 }, 6550 6551 6552 // Reveals all levels and removes all "more"-related elements for a grid's row. 6553 // `row` is a row number. 6554 unlimitRow: function(row) { 6555 var rowStruct = this.rowStructs[row]; 6556 6557 if (rowStruct.moreEls) { 6558 rowStruct.moreEls.remove(); 6559 rowStruct.moreEls = null; 6560 } 6561 6562 if (rowStruct.limitedEls) { 6563 rowStruct.limitedEls.removeClass('fc-limited'); 6564 rowStruct.limitedEls = null; 6565 } 6566 }, 6567 6568 6569 // Renders an <a> element that represents hidden event element for a cell. 6570 // Responsible for attaching click handler as well. 6571 renderMoreLink: function(row, col, hiddenSegs) { 6572 var _this = this; 6573 var view = this.view; 6574 6575 return $('<a class="fc-more"/>') 6576 .text( 6577 this.getMoreLinkText(hiddenSegs.length) 6578 ) 6579 .on('click', function(ev) { 6580 var clickOption = view.opt('eventLimitClick'); 6581 var date = _this.getCellDate(row, col); 6582 var moreEl = $(this); 6583 var dayEl = _this.getCellEl(row, col); 6584 var allSegs = _this.getCellSegs(row, col); 6585 6586 // rescope the segments to be within the cell's date
6587 var reslicedAllSegs = _this.resliceDaySegs(allSegs, date); 6588 var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date); 6589 6590 if (typeof clickOption === 'function') { 6591 // the returned value can be an atomic option 6592 clickOption = view.trigger('eventLimitClick', null, { 6593 date: date, 6594 dayEl: dayEl, 6595 moreEl: moreEl, 6596 segs: reslicedAllSegs, 6597 hiddenSegs: reslicedHiddenSegs 6598 }, ev); 6599 } 6600 6601 if (clickOption === 'popover') { 6602 _this.showSegPopover(row, col, moreEl, reslicedAllSegs); 6603 } 6604 else if (typeof clickOption === 'string') { // a view name 6605 view.calendar.zoomTo(date, clickOption); 6606 } 6607 }); 6608 }, 6609 6610 6611 // Reveals the popover that displays all events within a cell 6612 showSegPopover: function(row, col, moreLink, segs) { 6613 var _this = this; 6614 var view = this.view; 6615 var moreWrap = moreLink.parent(); // the <div> wrapper around the <a> 6616 var topEl; // the element we want to match the top coordinate of 6617 var options; 6618 6619 if (this.rowCnt == 1) { 6620 topEl = view.el; // will cause the popover to cover any sort of header 6621 } 6622 else { 6623 topEl = this.rowEls.eq(row); // will align with top of row 6624 } 6625 6626 options = { 6627 className: 'fc-more-popover', 6628 content: this.renderSegPopoverContent(row, col, segs), 6629 parentEl: this.view.el, // attach to root of view. guarantees outside of scrollbars. 6630 top: topEl.offset().top, 6631 autoHide: true, // when the user clicks elsewhere, hide the popover 6632 viewportConstrain: view.opt('popoverViewportConstrain'), 6633 hide: function() { 6634 // kill everything when the popover is hidden 6635 _this.segPopover.removeElement(); 6636 _this.segPopover = null; 6637 _this.popoverSegs = null; 6638 } 6639 }; 6640 6641 // Determine horizontal coordinate. 6642 // We use the moreWrap instead of the <td> to avoid border confusion. 6643 if (this.isRTL) { 6644 options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border 6645 } 6646 else { 6647 options.left = moreWrap.offset().left - 1; // -1 to be over cell border 6648 } 6649 6650 this.segPopover = new Popover(options); 6651 this.segPopover.show(); 6652 6653 // the popover doesn't live within the grid's container element, and thus won't get the event 6654 // delegated-handlers for free. attach event-related handlers to the popover. 6655 this.bindSegHandlersToEl(this.segPopover.el); 6656 }, 6657 6658 6659 // Builds the inner DOM contents of the segment popover 6660 renderSegPopoverContent: function(row, col, segs) { 6661 var view = this.view; 6662 var isTheme = view.opt('theme'); 6663 var title = this.getCellDate(row, col).format(view.opt('dayPopoverFormat')); 6664 var content = $( 6665 '<div class="fc-header ' + view.widgetHeaderClass + '">' + 6666 '<span class="fc-close ' + 6667 (isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') + 6668 '"></span>' + 6669 '<span class="fc-title">' + 6670 htmlEscape(title) + 6671 '</span>' + 6672 '<div class="fc-clear"/>' + 6673 '</div>' + 6674 '<div class="fc-body ' + view.widgetContentClass + '">' + 6675 '<div class="fc-event-container"></div>' + 6676 '</div>' 6677 ); 6678 var segContainer = content.find('.fc-event-container'); 6679 var i; 6680 6681 // render each seg's `el` and only return the visible segs 6682 segs = this.renderFgSegEls(segs, true); // disableResizing=true 6683 this.popoverSegs = segs; 6684 6685 for (i = 0; i < segs.length; i++) { 6686 6687 // because segments in the popover are not part of a grid coordinate system, provide a hint to any 6688 // grids that want to do drag-n-drop about which cell it came from 6689 this.prepareHits(); 6690 segs[i].hit = this.getCellHit(row, col); 6691 this.releaseHits(); 6692 6693 segContainer.append(segs[i].el); 6694 } 6695 6696 return content; 6697 }, 6698 6699 6700 // Given the events within an array of segment objects, reslice them to be in a single day 6701 resliceDaySegs: function(segs, dayDate) { 6702 6703 // build an array of the original events 6704 var events = $.map(segs, function(seg) { 6705 return seg.event; 6706 }); 6707 6708 var dayStart = dayDate.clone(); 6709 var dayEnd = dayStart.clone().add(1, 'days'); 6710 var dayRange = { start: dayStart, end: dayEnd }; 6711 6712 // slice the events with a custom slicing function 6713 segs = this.eventsToSegs( 6714 events, 6715 function(range) { 6716 var seg = intersectRanges(range, dayRange); // undefind if no intersection 6717 return seg ? [ seg ] : []; // must return an array of segments 6718 } 6719 ); 6720 6721 // force an order because eventsToSegs doesn't guarantee one 6722 this.sortEventSegs(segs); 6723 6724 return segs; 6725 }, 6726 6727 6728 // Generates the text that should be inside a "more" link, given the number of events it represents 6729 getMoreLinkText: function(num) { 6730 var opt = this.view.opt('eventLimitText'); 6731 6732 if (typeof opt === 'function') { 6733 return opt(num); 6734 } 6735 else { 6736 return '+' + num + ' ' + opt; 6737 } 6738 }, 6739 6740 6741 // Returns segments within a given cell. 6742 // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs. 6743 getCellSegs: function(row, col, startLevel) { 6744 var segMatrix = this.rowStructs[row].segMatrix; 6745 var level = startLevel || 0; 6746 var segs = []; 6747 var seg; 6748 6749 while (level < segMatrix.length) { 6750 seg = segMatrix[level][col]; 6751 if (seg) { 6752 segs.push(seg); 6753 } 6754 level++; 6755 } 6756 6757 return segs; 6758 } 6759 6760}); 6761 6762;; 6763 6764/* A component that renders one or more columns of vertical time slots 6765----------------------------------------------------------------------------------------------------------------------*/ 6766// We mixin DayTable, even though there is only a single row of days 6767 6768var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { 6769 6770 slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines 6771 snapDuration: null, // granularity of time for dragging and selecting 6772 snapsPerSlot: null, 6773 minTime: null, // Duration object that denotes the first visible time of any given day 6774 maxTime: null, // Duration object that denotes the exclusive visible end time of any given day 6775 labelFormat: null, // formatting string for times running along vertical axis 6776 labelInterval: null, // duration of how often a label should be displayed for a slot 6777 6778 colEls: null, // cells elements in the day-row background 6779 slatContainerEl: null, // div that wraps all the slat rows 6780 slatEls: null, // elements running horizontally across all columns 6781 nowIndicatorEls: null, 6782 6783 colCoordCache: null, 6784 slatCoordCache: null, 6785 6786 6787 constructor: function() { 6788 Grid.apply(this, arguments); // call the super-constructor 6789 6790 this.processOptions(); 6791 }, 6792 6793 6794 // Renders the time grid into `this.el`, which should already be assigned. 6795 // Relies on the view's colCnt. In the future, this component should probably be self-sufficient. 6796 renderDates: function() { 6797 this.el.html(this.renderHtml()); 6798 this.colEls = this.el.find('.fc-day');
6799 this.slatContainerEl = this.el.find('.fc-slats'); 6800 this.slatEls = this.slatContainerEl.find('tr'); 6801 6802 this.colCoordCache = new CoordCache({ 6803 els: this.colEls, 6804 isHorizontal: true 6805 }); 6806 this.slatCoordCache = new CoordCache({ 6807 els: this.slatEls, 6808 isVertical: true 6809 }); 6810 6811 this.renderContentSkeleton(); 6812 }, 6813 6814 6815 // Renders the basic HTML skeleton for the grid 6816 renderHtml: function() { 6817 return '' + 6818 '<div class="fc-bg">' + 6819 '<table>' + 6820 this.renderBgTrHtml(0) + // row=0 6821 '</table>' + 6822 '</div>' + 6823 '<div class="fc-slats">' + 6824 '<table>' + 6825 this.renderSlatRowHtml() + 6826 '</table>' + 6827 '</div>'; 6828 }, 6829 6830 6831 // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL. 6832 renderSlatRowHtml: function() { 6833 var view = this.view; 6834 var isRTL = this.isRTL; 6835 var html = ''; 6836 var slotTime = moment.duration(+this.minTime); // wish there was .clone() for durations 6837 var slotDate; // will be on the view's first day, but we only care about its time 6838 var isLabeled; 6839 var axisHtml; 6840 6841 // Calculate the time for each slot 6842 while (slotTime < this.maxTime) { 6843 slotDate = this.start.clone().time(slotTime); 6844 isLabeled = isInt(divideDurationByDuration(slotTime, this.labelInterval)); 6845 6846 axisHtml = 6847 '<td class="fc-axis fc-time ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' + 6848 (isLabeled ? 6849 '<span>' + // for matchCellWidths 6850 htmlEscape(slotDate.format(this.labelFormat)) + 6851 '</span>' : 6852 '' 6853 ) + 6854 '</td>'; 6855 6856 html += 6857 '<tr data-time="' + slotDate.format('HH:mm:ss') + '"' + 6858 (isLabeled ? '' : ' class="fc-minor"') + 6859 '>' + 6860 (!isRTL ? axisHtml : '') + 6861 '<td class="' + view.widgetContentClass + '"/>' + 6862 (isRTL ? axisHtml : '') + 6863 "</tr>"; 6864 6865 slotTime.add(this.slotDuration); 6866 } 6867 6868 return html; 6869 }, 6870 6871 6872 /* Options 6873 ------------------------------------------------------------------------------------------------------------------*/ 6874 6875 6876 // Parses various options into properties of this object 6877 processOptions: function() { 6878 var view = this.view; 6879 var slotDuration = view.opt('slotDuration'); 6880 var snapDuration = view.opt('snapDuration'); 6881 var input; 6882 6883 slotDuration = moment.duration(slotDuration); 6884 snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration; 6885 6886 this.slotDuration = slotDuration; 6887 this.snapDuration = snapDuration; 6888 this.snapsPerSlot = slotDuration / snapDuration; // TODO: ensure an integer multiple? 6889 6890 this.minResizeDuration = snapDuration; // hack 6891 6892 this.minTime = moment.duration(view.opt('minTime')); 6893 this.maxTime = moment.duration(view.opt('maxTime')); 6894 6895 // might be an array value (for TimelineView). 6896 // if so, getting the most granular entry (the last one probably). 6897 input = view.opt('slotLabelFormat'); 6898 if ($.isArray(input)) { 6899 input = input[input.length - 1]; 6900 } 6901 6902 this.labelFormat = 6903 input || 6904 view.opt('axisFormat') || // deprecated 6905 view.opt('smallTimeFormat'); // the computed default 6906 6907 input = view.opt('slotLabelInterval'); 6908 this.labelInterval = input ? 6909 moment.duration(input) : 6910 this.computeLabelInterval(slotDuration); 6911 }, 6912 6913 6914 // Computes an automatic value for slotLabelInterval 6915 computeLabelInterval: function(slotDuration) { 6916 var i; 6917 var labelInterval; 6918 var slotsPerLabel; 6919 6920 // find the smallest stock label interval that results in more than one slots-per-label 6921 for (i = AGENDA_STOCK_SUB_DURATIONS.length - 1; i >= 0; i--) { 6922 labelInterval = moment.duration(AGENDA_STOCK_SUB_DURATIONS[i]); 6923 slotsPerLabel = divideDurationByDuration(labelInterval, slotDuration); 6924 if (isInt(slotsPerLabel) && slotsPerLabel > 1) { 6925 return labelInterval; 6926 } 6927 } 6928 6929 return moment.duration(slotDuration); // fall back. clone 6930 }, 6931 6932 6933 // Computes a default event time formatting string if `timeFormat` is not explicitly defined 6934 computeEventTimeFormat: function() { 6935 return this.view.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM) 6936 }, 6937 6938 6939 // Computes a default `displayEventEnd` value if one is not expliclty defined 6940 computeDisplayEventEnd: function() { 6941 return true; 6942 }, 6943 6944 6945 /* Hit System 6946 ------------------------------------------------------------------------------------------------------------------*/ 6947 6948 6949 prepareHits: function() { 6950 this.colCoordCache.build();
6951 this.slatCoordCache.build(); 6952 }, 6953 6954 6955 releaseHits: function() { 6956 this.colCoordCache.clear(); 6957 // NOTE: don't clear slatCoordCache because we rely on it for computeTimeTop 6958 }, 6959 6960 6961 queryHit: function(leftOffset, topOffset) { 6962 var snapsPerSlot = this.snapsPerSlot; 6963 var colCoordCache = this.colCoordCache; 6964 var slatCoordCache = this.slatCoordCache; 6965 6966 if (colCoordCache.isLeftInBounds(leftOffset) && slatCoordCache.isTopInBounds(topOffset)) { 6967 var colIndex = colCoordCache.getHorizontalIndex(leftOffset); 6968 var slatIndex = slatCoordCache.getVerticalIndex(topOffset); 6969 6970 if (colIndex != null && slatIndex != null) { 6971 var slatTop = slatCoordCache.getTopOffset(slatIndex); 6972 var slatHeight = slatCoordCache.getHeight(slatIndex); 6973 var partial = (topOffset - slatTop) / slatHeight; // floating point number between 0 and 1 6974 var localSnapIndex = Math.floor(partial * snapsPerSlot); // the snap # relative to start of slat 6975 var snapIndex = slatIndex * snapsPerSlot + localSnapIndex; 6976 var snapTop = slatTop + (localSnapIndex / snapsPerSlot) * slatHeight; 6977 var snapBottom = slatTop + ((localSnapIndex + 1) / snapsPerSlot) * slatHeight; 6978 6979 return { 6980 col: colIndex, 6981 snap: snapIndex, 6982 component: this, // needed unfortunately :( 6983 left: colCoordCache.getLeftOffset(colIndex), 6984 right: colCoordCache.getRightOffset(colIndex), 6985 top: snapTop, 6986 bottom: snapBottom 6987 }; 6988 } 6989 } 6990 }, 6991 6992 6993 getHitSpan: function(hit) { 6994 var start = this.getCellDate(0, hit.col); // row=0 6995 var time = this.computeSnapTime(hit.snap); // pass in the snap-index 6996 var end; 6997 6998 start.time(time); 6999 end = start.clone().add(this.snapDuration); 7000 7001 return { start: start, end: end }; 7002 }, 7003 7004 7005 getHitEl: function(hit) { 7006 return this.colEls.eq(hit.col); 7007 }, 7008 7009 7010 /* Dates 7011 ------------------------------------------------------------------------------------------------------------------*/ 7012 7013 7014 rangeUpdated: function() { 7015 this.updateDayTable(); 7016 }, 7017 7018 7019 // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day 7020 computeSnapTime: function(snapIndex) { 7021 return moment.duration(this.minTime + this.snapDuration * snapIndex); 7022 }, 7023 7024 7025 // Slices up the given span (unzoned start/end with other misc data) into an array of segments 7026 spanToSegs: function(span) { 7027 var segs = this.sliceRangeByTimes(span); 7028 var i; 7029 7030 for (i = 0; i < segs.length; i++) { 7031 if (this.isRTL) { 7032 segs[i].col = this.daysPerRow - 1 - segs[i].dayIndex; 7033 } 7034 else { 7035 segs[i].col = segs[i].dayIndex; 7036 } 7037 } 7038 7039 return segs; 7040 }, 7041 7042 7043 sliceRangeByTimes: function(range) { 7044 var segs = []; 7045 var seg; 7046 var dayIndex; 7047 var dayDate; 7048 var dayRange; 7049 7050 for (dayIndex = 0; dayIndex < this.daysPerRow; dayIndex++) { 7051 dayDate = this.dayDates[dayIndex].clone(); // TODO: better API for this? 7052 dayRange = { 7053 start: dayDate.clone().time(this.minTime), 7054 end: dayDate.clone().time(this.maxTime) 7055 }; 7056 seg = intersectRanges(range, dayRange); // both will be ambig timezone 7057 if (seg) { 7058 seg.dayIndex = dayIndex; 7059 segs.push(seg); 7060 } 7061 } 7062 7063 return segs; 7064 }, 7065 7066 7067 /* Coordinates 7068 ------------------------------------------------------------------------------------------------------------------*/ 7069 7070 7071 updateSize: function(isResize) { // NOT a standard Grid method 7072 this.slatCoordCache.build(); 7073 7074 if (isResize) { 7075 this.updateSegVerticals( 7076 [].concat(this.fgSegs || [], this.bgSegs || [], this.businessSegs || []) 7077 ); 7078 } 7079 }, 7080 7081 7082 getTotalSlatHeight: function() {
7083 return this.slatContainerEl.outerHeight(); 7084 }, 7085 7086 7087 // Computes the top coordinate, relative to the bounds of the grid, of the given date. 7088 // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight. 7089 computeDateTop: function(date, startOfDayDate) { 7090 return this.computeTimeTop( 7091 moment.duration( 7092 date - startOfDayDate.clone().stripTime() 7093 ) 7094 ); 7095 }, 7096 7097 7098 // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration). 7099 computeTimeTop: function(time) { 7100 var len = this.slatEls.length; 7101 var slatCoverage = (time - this.minTime) / this.slotDuration; // floating-point value of # of slots covered 7102 var slatIndex; 7103 var slatRemainder; 7104 7105 // compute a floating-point number for how many slats should be progressed through. 7106 // from 0 to number of slats (inclusive) 7107 // constrained because minTime/maxTime might be customized. 7108 slatCoverage = Math.max(0, slatCoverage); 7109 slatCoverage = Math.min(len, slatCoverage); 7110 7111 // an integer index of the furthest whole slat 7112 // from 0 to number slats (*exclusive*, so len-1) 7113 slatIndex = Math.floor(slatCoverage); 7114 slatIndex = Math.min(slatIndex, len - 1); 7115 7116 // how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition. 7117 // could be 1.0 if slatCoverage is covering *all* the slots 7118 slatRemainder = slatCoverage - slatIndex; 7119
7120 return this.slatCoordCache.getTopPosition(slatIndex) + 7121 this.slatCoordCache.getHeight(slatIndex) * slatRemainder; 7122 }, 7123 7124 7125 7126 /* Event Drag Visualization 7127 ------------------------------------------------------------------------------------------------------------------*/ 7128 7129 7130 // Renders a visual indication of an event being dragged over the specified date(s). 7131 // A returned value of `true` signals that a mock "helper" event has been rendered. 7132 renderDrag: function(eventLocation, seg) { 7133 7134 if (seg) { // if there is event information for this drag, render a helper event 7135 7136 // returns mock event elements 7137 // signal that a helper has been rendered 7138 return this.renderEventLocationHelper(eventLocation, seg); 7139 } 7140 else { 7141 // otherwise, just render a highlight 7142 this.renderHighlight(this.eventToSpan(eventLocation)); 7143 } 7144 }, 7145 7146 7147 // Unrenders any visual indication of an event being dragged 7148 unrenderDrag: function() { 7149 this.unrenderHelper(); 7150 this.unrenderHighlight(); 7151 }, 7152 7153 7154 /* Event Resize Visualization 7155 ------------------------------------------------------------------------------------------------------------------*/ 7156 7157 7158 // Renders a visual indication of an event being resized 7159 renderEventResize: function(eventLocation, seg) { 7160 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements 7161 }, 7162 7163 7164 // Unrenders any visual indication of an event being resized 7165 unrenderEventResize: function() { 7166 this.unrenderHelper(); 7167 }, 7168 7169 7170 /* Event Helper 7171 ------------------------------------------------------------------------------------------------------------------*/ 7172 7173 7174 // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag) 7175 renderHelper: function(event, sourceSeg) { 7176 return this.renderHelperSegs(this.eventToSegs(event), sourceSeg); // returns mock event elements 7177 }, 7178 7179 7180 // Unrenders any mock helper event 7181 unrenderHelper: function() { 7182 this.unrenderHelperSegs(); 7183 }, 7184 7185 7186 /* Business Hours 7187 ------------------------------------------------------------------------------------------------------------------*/ 7188 7189 7190 renderBusinessHours: function() { 7191 this.renderBusinessSegs( 7192 this.buildBusinessHourSegs() 7193 ); 7194 }, 7195 7196 7197 unrenderBusinessHours: function() { 7198 this.unrenderBusinessSegs(); 7199 }, 7200 7201 7202 /* Now Indicator 7203 ------------------------------------------------------------------------------------------------------------------*/ 7204 7205 7206 getNowIndicatorUnit: function() { 7207 return 'minute'; // will refresh on the minute 7208 }, 7209 7210 7211 renderNowIndicator: function(date) { 7212 // seg system might be overkill, but it handles scenario where line needs to be rendered 7213 // more than once because of columns with the same date (resources columns for example) 7214 var segs = this.spanToSegs({ start: date, end: date }); 7215 var top = this.computeDateTop(date, date); 7216 var nodes = []; 7217 var i; 7218 7219 // render lines within the columns 7220 for (i = 0; i < segs.length; i++) { 7221 nodes.push($('<div class="fc-now-indicator fc-now-indicator-line"></div>') 7222 .css('top', top) 7223 .appendTo(this.colContainerEls.eq(segs[i].col))[0]); 7224 } 7225 7226 // render an arrow over the axis 7227 if (segs.length > 0) { // is the current time in view? 7228 nodes.push($('<div class="fc-now-indicator fc-now-indicator-arrow"></div>') 7229 .css('top', top) 7230 .appendTo(this.el.find('.fc-content-skeleton'))[0]); 7231 } 7232 7233 this.nowIndicatorEls = $(nodes); 7234 }, 7235 7236 7237 unrenderNowIndicator: function() { 7238 if (this.nowIndicatorEls) { 7239 this.nowIndicatorEls.remove(); 7240 this.nowIndicatorEls = null; 7241 } 7242 }, 7243 7244 7245 /* Selection 7246 ------------------------------------------------------------------------------------------------------------------*/ 7247 7248 7249 // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight. 7250 renderSelection: function(span) { 7251 if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered 7252 7253 // normally acceps an eventLocation, span has a start/end, which is good enough 7254 this.renderEventLocationHelper(span); 7255 } 7256 else { 7257 this.renderHighlight(span); 7258 } 7259 }, 7260 7261 7262 // Unrenders any visual indication of a selection 7263 unrenderSelection: function() { 7264 this.unrenderHelper(); 7265 this.unrenderHighlight(); 7266 }, 7267 7268 7269 /* Highlight 7270 ------------------------------------------------------------------------------------------------------------------*/ 7271 7272 7273 renderHighlight: function(span) { 7274 this.renderHighlightSegs(this.spanToSegs(span)); 7275 }, 7276 7277 7278 unrenderHighlight: function() { 7279 this.unrenderHighlightSegs(); 7280 } 7281 7282}); 7283 7284;; 7285 7286/* Methods for rendering SEGMENTS, pieces of content that live on the view 7287 ( this file is no longer just for events ) 7288----------------------------------------------------------------------------------------------------------------------*/ 7289 7290TimeGrid.mixin({ 7291 7292 colContainerEls: null, // containers for each column 7293 7294 // inner-containers for each column where different types of segs live 7295 fgContainerEls: null, 7296 bgContainerEls: null, 7297 helperContainerEls: null, 7298 highlightContainerEls: null, 7299 businessContainerEls: null, 7300 7301 // arrays of different types of displayed segments 7302 fgSegs: null, 7303 bgSegs: null, 7304 helperSegs: null, 7305 highlightSegs: null,
7306 businessSegs: null, 7307 7308 7309 // Renders the DOM that the view's content will live in 7310 renderContentSkeleton: function() { 7311 var cellHtml = ''; 7312 var i; 7313 var skeletonEl; 7314 7315 for (i = 0; i < this.colCnt; i++) { 7316 cellHtml += 7317 '<td>' + 7318 '<div class="fc-content-col">' + 7319 '<div class="fc-event-container fc-helper-container"></div>' + 7320 '<div class="fc-event-container"></div>' + 7321 '<div class="fc-highlight-container"></div>' + 7322 '<div class="fc-bgevent-container"></div>' + 7323 '<div class="fc-business-container"></div>' + 7324 '</div>' + 7325 '</td>'; 7326 } 7327 7328 skeletonEl = $( 7329 '<div class="fc-content-skeleton">' + 7330 '<table>' + 7331 '<tr>' + cellHtml + '</tr>' + 7332 '</table>' + 7333 '</div>' 7334 ); 7335 7336 this.colContainerEls = skeletonEl.find('.fc-content-col'); 7337 this.helperContainerEls = skeletonEl.find('.fc-helper-container'); 7338 this.fgContainerEls = skeletonEl.find('.fc-event-container:not(.fc-helper-container)'); 7339 this.bgContainerEls = skeletonEl.find('.fc-bgevent-container'); 7340 this.highlightContainerEls = skeletonEl.find('.fc-highlight-container'); 7341 this.businessContainerEls = skeletonEl.find('.fc-business-container'); 7342 7343 this.bookendCells(skeletonEl.find('tr')); // TODO: do this on string level 7344 this.el.append(skeletonEl); 7345 }, 7346 7347 7348 /* Foreground Events 7349 ------------------------------------------------------------------------------------------------------------------*/ 7350 7351 7352 renderFgSegs: function(segs) { 7353 segs = this.renderFgSegsIntoContainers(segs, this.fgContainerEls); 7354 this.fgSegs = segs; 7355 return segs; // needed for Grid::renderEvents 7356 }, 7357 7358 7359 unrenderFgSegs: function() { 7360 this.unrenderNamedSegs('fgSegs'); 7361 }, 7362 7363 7364 /* Foreground Helper Events 7365 ------------------------------------------------------------------------------------------------------------------*/ 7366 7367 7368 renderHelperSegs: function(segs, sourceSeg) { 7369 var helperEls = []; 7370 var i, seg; 7371 var sourceEl; 7372 7373 segs = this.renderFgSegsIntoContainers(segs, this.helperContainerEls); 7374 7375 // Try to make the segment that is in the same row as sourceSeg look the same 7376 for (i = 0; i < segs.length; i++) { 7377 seg = segs[i]; 7378 if (sourceSeg && sourceSeg.col === seg.col) { 7379 sourceEl = sourceSeg.el; 7380 seg.el.css({ 7381 left: sourceEl.css('left'), 7382 right: sourceEl.css('right'), 7383 'margin-left': sourceEl.css('margin-left'), 7384 'margin-right': sourceEl.css('margin-right') 7385 }); 7386 } 7387 helperEls.push(seg.el[0]); 7388 } 7389 7390 this.helperSegs = segs; 7391 7392 return $(helperEls); // must return rendered helpers 7393 }, 7394 7395 7396 unrenderHelperSegs: function() { 7397 this.unrenderNamedSegs('helperSegs'); 7398 }, 7399 7400 7401 /* Background Events 7402 ------------------------------------------------------------------------------------------------------------------*/ 7403 7404 7405 renderBgSegs: function(segs) { 7406 segs = this.renderFillSegEls('bgEvent', segs); // TODO: old fill system 7407 this.updateSegVerticals(segs); 7408 this.attachSegsByCol(this.groupSegsByCol(segs), this.bgContainerEls); 7409 this.bgSegs = segs; 7410 return segs; // needed for Grid::renderEvents 7411 }, 7412 7413 7414 unrenderBgSegs: function() { 7415 this.unrenderNamedSegs('bgSegs'); 7416 }, 7417 7418 7419 /* Highlight 7420 ------------------------------------------------------------------------------------------------------------------*/ 7421 7422 7423 renderHighlightSegs: function(segs) { 7424 segs = this.renderFillSegEls('highlight', segs); // TODO: old fill system 7425 this.updateSegVerticals(segs); 7426 this.attachSegsByCol(this.groupSegsByCol(segs), this.highlightContainerEls); 7427 this.highlightSegs = segs; 7428 }, 7429 7430 7431 unrenderHighlightSegs: function() { 7432 this.unrenderNamedSegs('highlightSegs'); 7433 }, 7434 7435 7436 /* Business Hours 7437 ------------------------------------------------------------------------------------------------------------------*/ 7438 7439 7440 renderBusinessSegs: function(segs) { 7441 segs = this.renderFillSegEls('businessHours', segs); // TODO: old fill system 7442 this.updateSegVerticals(segs); 7443 this.attachSegsByCol(this.groupSegsByCol(segs), this.businessContainerEls); 7444 this.businessSegs = segs; 7445 }, 7446 7447 7448 unrenderBusinessSegs: function() { 7449 this.unrenderNamedSegs('businessSegs'); 7450 }, 7451 7452 7453 /* Seg Rendering Utils 7454 ------------------------------------------------------------------------------------------------------------------*/ 7455 7456 7457 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col 7458 groupSegsByCol: function(segs) { 7459 var segsByCol = []; 7460 var i; 7461 7462 for (i = 0; i < this.colCnt; i++) { 7463 segsByCol.push([]); 7464 } 7465 7466 for (i = 0; i < segs.length; i++) { 7467 segsByCol[segs[i].col].push(segs[i]); 7468 } 7469 7470 return segsByCol; 7471 }, 7472 7473 7474 // Given segments grouped by column, insert the segments' elements into a parallel array of container 7475 // elements, each living within a column. 7476 attachSegsByCol: function(segsByCol, containerEls) { 7477 var col; 7478 var segs; 7479 var i; 7480 7481 for (col = 0; col < this.colCnt; col++) { // iterate each column grouping 7482 segs = segsByCol[col]; 7483 7484 for (i = 0; i < segs.length; i++) { 7485 containerEls.eq(col).append(segs[i].el); 7486 } 7487 } 7488 }, 7489 7490 7491 // Given the name of a property of `this` object, assumed to be an array of segments, 7492 // loops through each segment and removes from DOM. Will null-out the property afterwards. 7493 unrenderNamedSegs: function(propName) { 7494 var segs = this[propName]; 7495 var i; 7496 7497 if (segs) { 7498 for (i = 0; i < segs.length; i++) { 7499 segs[i].el.remove(); 7500 } 7501 this[propName] = null; 7502 } 7503 }, 7504 7505 7506 7507 /* Foreground Event Rendering Utils 7508 ------------------------------------------------------------------------------------------------------------------*/ 7509 7510 7511 // Given an array of foreground segments, render a DOM element for each, computes position, 7512 // and attaches to the column inner-container elements. 7513 renderFgSegsIntoContainers: function(segs, containerEls) { 7514 var segsByCol; 7515 var col; 7516 7517 segs = this.renderFgSegEls(segs); // will call fgSegHtml 7518 segsByCol = this.groupSegsByCol(segs); 7519 7520 for (col = 0; col < this.colCnt; col++) { 7521 this.updateFgSegCoords(segsByCol[col]); 7522 } 7523 7524 this.attachSegsByCol(segsByCol, containerEls); 7525 7526 return segs; 7527 }, 7528 7529 7530 // Renders the HTML for a single event segment's default rendering 7531 fgSegHtml: function(seg, disableResizing) { 7532 var view = this.view; 7533 var event = seg.event; 7534 var isDraggable = view.isEventDraggable(event); 7535 var isResizableFromStart = !disableResizing && seg.isStart && view.isEventResizableFromStart(event); 7536 var isResizableFromEnd = !disableResizing && seg.isEnd && view.isEventResizableFromEnd(event);
7537 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); 7538 var skinCss = cssToStr(this.getSegSkinCss(seg)); 7539 var timeText; 7540 var fullTimeText; // more verbose time text. for the print stylesheet 7541 var startTimeText; // just the start time text 7542 7543 classes.unshift('fc-time-grid-event', 'fc-v-event'); 7544 7545 if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day... 7546 // Don't display time text on segments that run entirely through a day. 7547 // That would appear as midnight-midnight and would look dumb. 7548 // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am) 7549 if (seg.isStart || seg.isEnd) { 7550 timeText = this.getEventTimeText(seg); 7551 fullTimeText = this.getEventTimeText(seg, 'LT'); 7552 startTimeText = this.getEventTimeText(seg, null, false); // displayEnd=false 7553 } 7554 } else { 7555 // Display the normal time text for the *event's* times 7556 timeText = this.getEventTimeText(event); 7557 fullTimeText = this.getEventTimeText(event, 'LT'); 7558 startTimeText = this.getEventTimeText(event, null, false); // displayEnd=false 7559 } 7560 7561 return '<a class="' + classes.join(' ') + '"' + 7562 (event.url ? 7563 ' href="' + htmlEscape(event.url) + '"' : 7564 '' 7565 ) + 7566 (skinCss ? 7567 ' style="' + skinCss + '"' : 7568 '' 7569 ) + 7570 '>' + 7571 '<div class="fc-content">' + 7572 (timeText ? 7573 '<div class="fc-time"' + 7574 ' data-start="' + htmlEscape(startTimeText) + '"' + 7575 ' data-full="' + htmlEscape(fullTimeText) + '"' + 7576 '>' + 7577 '<span>' + htmlEscape(timeText) + '</span>' + 7578 '</div>' : 7579 '' 7580 ) + 7581 (event.title ? 7582 '<div class="fc-title">' + 7583 htmlEscape(event.title) + 7584 '</div>' : 7585 '' 7586 ) + 7587 '</div>' + 7588 '<div class="fc-bg"/>' + 7589 /* TODO: write CSS for this 7590 (isResizableFromStart ? 7591 '<div class="fc-resizer fc-start-resizer" />' : 7592 '' 7593 ) + 7594 */ 7595 (isResizableFromEnd ? 7596 '<div class="fc-resizer fc-end-resizer" />' : 7597 '' 7598 ) + 7599 '</a>'; 7600 }, 7601 7602 7603 /* Seg Position Utils 7604 ------------------------------------------------------------------------------------------------------------------*/ 7605 7606 7607 // Refreshes the CSS top/bottom coordinates for each segment element. 7608 // Works when called after initial render, after a window resize/zoom for example. 7609 updateSegVerticals: function(segs) { 7610 this.computeSegVerticals(segs); 7611 this.assignSegVerticals(segs); 7612 }, 7613 7614 7615 // For each segment in an array, computes and assigns its top and bottom properties 7616 computeSegVerticals: function(segs) { 7617 var i, seg; 7618 7619 for (i = 0; i < segs.length; i++) { 7620 seg = segs[i]; 7621 seg.top = this.computeDateTop(seg.start, seg.start); 7622 seg.bottom = this.computeDateTop(seg.end, seg.start); 7623 } 7624 }, 7625 7626 7627 // Given segments that already have their top/bottom properties computed, applies those values to 7628 // the segments' elements. 7629 assignSegVerticals: function(segs) { 7630 var i, seg; 7631 7632 for (i = 0; i < segs.length; i++) { 7633 seg = segs[i]; 7634 seg.el.css(this.generateSegVerticalCss(seg)); 7635 } 7636 }, 7637 7638 7639 // Generates an object with CSS properties for the top/bottom coordinates of a segment element 7640 generateSegVerticalCss: function(seg) { 7641 return { 7642 top: seg.top, 7643 bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container 7644 }; 7645 }, 7646 7647 7648 /* Foreground Event Positioning Utils 7649 ------------------------------------------------------------------------------------------------------------------*/ 7650 7651 7652 // Given segments that are assumed to all live in the *same column*, 7653 // compute their verical/horizontal coordinates and assign to their elements. 7654 updateFgSegCoords: function(segs) { 7655 this.computeSegVerticals(segs); // horizontals relies on this 7656 this.computeFgSegHorizontals(segs); // compute horizontal coordinates, z-index's, and reorder the array 7657 this.assignSegVerticals(segs); 7658 this.assignFgSegHorizontals(segs); 7659 }, 7660 7661 7662 // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each. 7663 // NOTE: Also reorders the given array by date! 7664 computeFgSegHorizontals: function(segs) { 7665 var levels; 7666 var level0; 7667 var i; 7668 7669 this.sortEventSegs(segs); // order by certain criteria 7670 levels = buildSlotSegLevels(segs); 7671 computeForwardSlotSegs(levels); 7672 7673 if ((level0 = levels[0])) { 7674 7675 for (i = 0; i < level0.length; i++) { 7676 computeSlotSegPressures(level0[i]); 7677 } 7678 7679 for (i = 0; i < level0.length; i++) { 7680 this.computeFgSegForwardBack(level0[i], 0, 0); 7681 } 7682 } 7683 }, 7684 7685 7686 // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range 7687 // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and 7688 // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left. 7689 // 7690 // The segment might be part of a "series", which means consecutive segments with the same pressure 7691 // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of 7692 // segments behind this one in the current series, and `seriesBackwardCoord` is the starting 7693 // coordinate of the first segment in the series. 7694 computeFgSegForwardBack: function(seg, seriesBackwardPressure, seriesBackwardCoord) { 7695 var forwardSegs = seg.forwardSegs; 7696 var i; 7697 7698 if (seg.forwardCoord === undefined) { // not already computed 7699 7700 if (!forwardSegs.length) { 7701 7702 // if there are no forward segments, this segment should butt up against the edge 7703 seg.forwardCoord = 1; 7704 } 7705 else { 7706 7707 // sort highest pressure first 7708 this.sortForwardSegs(forwardSegs); 7709 7710 // this segment's forwardCoord will be calculated from the backwardCoord of the 7711 // highest-pressure forward segment. 7712 this.computeFgSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord); 7713 seg.forwardCoord = forwardSegs[0].backwardCoord; 7714 } 7715 7716 // calculate the backwardCoord from the forwardCoord. consider the series 7717 seg.backwardCoord = seg.forwardCoord - 7718 (seg.forwardCoord - seriesBackwardCoord) / // available width for series 7719 (seriesBackwardPressure + 1); // # of segments in the series 7720
7721 // use this segment's coordinates to computed the coordinates of the less-pressurized 7722 // forward segments 7723 for (i=0; i<forwardSegs.length; i++) { 7724 this.computeFgSegForwardBack(forwardSegs[i], 0, seg.forwardCoord); 7725 } 7726 } 7727 }, 7728 7729 7730 sortForwardSegs: function(forwardSegs) { 7731 forwardSegs.sort(proxy(this, 'compareForwardSegs')); 7732 }, 7733 7734 7735 // A cmp function for determining which forward segment to rely on more when computing coordinates. 7736 compareForwardSegs: function(seg1, seg2) { 7737 // put higher-pressure first 7738 return seg2.forwardPressure - seg1.forwardPressure || 7739 // put segments that are closer to initial edge first (and favor ones with no coords yet) 7740 (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) || 7741 // do normal sorting... 7742 this.compareEventSegs(seg1, seg2); 7743 }, 7744 7745 7746 // Given foreground event segments that have already had their position coordinates computed, 7747 // assigns position-related CSS values to their elements. 7748 assignFgSegHorizontals: function(segs) { 7749 var i, seg; 7750 7751 for (i = 0; i < segs.length; i++) { 7752 seg = segs[i]; 7753 seg.el.css(this.generateFgSegHorizontalCss(seg)); 7754 7755 // if the height is short, add a className for alternate styling 7756 if (seg.bottom - seg.top < 30) { 7757 seg.el.addClass('fc-short'); 7758 } 7759 } 7760 }, 7761 7762 7763 // Generates an object with CSS properties/values that should be applied to an event segment element. 7764 // Contains important positioning-related properties that should be applied to any event element, customized or not. 7765 generateFgSegHorizontalCss: function(seg) { 7766 var shouldOverlap = this.view.opt('slotEventOverlap'); 7767 var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point 7768 var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point 7769 var props = this.generateSegVerticalCss(seg); // get top/bottom first 7770 var left; // amount of space from left edge, a fraction of the total width 7771 var right; // amount of space from right edge, a fraction of the total width 7772 7773 if (shouldOverlap) { 7774 // double the width, but don't go beyond the maximum forward coordinate (1.0) 7775 forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2); 7776 } 7777 7778 if (this.isRTL) { 7779 left = 1 - forwardCoord; 7780 right = backwardCoord; 7781 } 7782 else { 7783 left = backwardCoord; 7784 right = 1 - forwardCoord; 7785 } 7786 7787 props.zIndex = seg.level + 1; // convert from 0-base to 1-based 7788 props.left = left * 100 + '%'; 7789 props.right = right * 100 + '%'; 7790 7791 if (shouldOverlap && seg.forwardPressure) { 7792 // add padding to the edge so that forward stacked events don't cover the resizer's icon 7793 props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width 7794 } 7795 7796 return props; 7797 } 7798 7799}); 7800 7801 7802// Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is 7803// left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date. 7804function buildSlotSegLevels(segs) { 7805 var levels = []; 7806 var i, seg; 7807 var j; 7808 7809 for (i=0; i<segs.length; i++) { 7810 seg = segs[i]; 7811 7812 // go through all the levels and stop on the first level where there are no collisions 7813 for (j=0; j<levels.length; j++) { 7814 if (!computeSlotSegCollisions(seg, levels[j]).length) { 7815 break; 7816 } 7817 } 7818 7819 seg.level = j; 7820 7821 (levels[j] || (levels[j] = [])).push(seg); 7822 } 7823 7824 return levels; 7825} 7826 7827 7828// For every segment, figure out the other segments that are in subsequent 7829// levels that also occupy the same vertical space. Accumulate in seg.forwardSegs 7830function computeForwardSlotSegs(levels) { 7831 var i, level; 7832 var j, seg; 7833 var k; 7834 7835 for (i=0; i<levels.length; i++) { 7836 level = levels[i]; 7837 7838 for (j=0; j<level.length; j++) { 7839 seg = level[j]; 7840 7841 seg.forwardSegs = []; 7842 for (k=i+1; k<levels.length; k++) { 7843 computeSlotSegCollisions(seg, levels[k], seg.forwardSegs); 7844 } 7845 } 7846 } 7847} 7848 7849 7850// Figure out which path forward (via seg.forwardSegs) results in the longest path until 7851// the furthest edge is reached. The number of segments in this path will be seg.forwardPressure 7852function computeSlotSegPressures(seg) { 7853 var forwardSegs = seg.forwardSegs; 7854 var forwardPressure = 0; 7855 var i, forwardSeg; 7856 7857 if (seg.forwardPressure === undefined) { // not already computed 7858 7859 for (i=0; i<forwardSegs.length; i++) { 7860 forwardSeg = forwardSegs[i]; 7861 7862 // figure out the child's maximum forward path 7863 computeSlotSegPressures(forwardSeg); 7864 7865 // either use the existing maximum, or use the child's forward pressure 7866 // plus one (for the forwardSeg itself) 7867 forwardPressure = Math.max( 7868 forwardPressure, 7869 1 + forwardSeg.forwardPressure 7870 ); 7871 } 7872 7873 seg.forwardPressure = forwardPressure; 7874 } 7875} 7876 7877 7878// Find all the segments in `otherSegs` that vertically collide with `seg`. 7879// Append into an optionally-supplied `results` array and return. 7880function computeSlotSegCollisions(seg, otherSegs, results) { 7881 results = results || []; 7882 7883 for (var i=0; i<otherSegs.length; i++) { 7884 if (isSlotSegCollision(seg, otherSegs[i])) { 7885 results.push(otherSegs[i]); 7886 } 7887 } 7888 7889 return results; 7890} 7891 7892 7893// Do these segments occupy the same vertical space? 7894function isSlotSegCollision(seg1, seg2) { 7895 return seg1.bottom > seg2.top && seg1.top < seg2.bottom; 7896} 7897 7898;; 7899 7900/* An abstract class from which other views inherit from 7901----------------------------------------------------------------------------------------------------------------------*/ 7902 7903var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, { 7904 7905 type: null, // subclass' view name (string) 7906 name: null, // deprecated. use `type` instead 7907 title: null, // the text that will be displayed in the header's title 7908 7909 calendar: null, // owner Calendar object 7910 options: null, // hash containing all options. already merged with view-specific-options 7911 el: null, // the view's containing element. set by Calendar 7912 7913 displaying: null, // a promise representing the state of rendering. null if no render requested 7914 isSkeletonRendered: false, 7915 isEventsRendered: false, 7916 7917 // range the view is actually displaying (moments) 7918 start: null, 7919 end: null, // exclusive 7920 7921 // range the view is formally responsible for (moments) 7922 // may be different from start/end. for example, a month view might have 1st-31st, excluding padded dates 7923 intervalStart: null, 7924 intervalEnd: null, // exclusive 7925 intervalDuration: null, 7926 intervalUnit: null, // name of largest unit being displayed, like "month" or "week" 7927 7928 isRTL: false, 7929 isSelected: false, // boolean whether a range of time is user-selected or not 7930 selectedEvent: null, 7931 7932 eventOrderSpecs: null, // criteria for ordering events when they have same date/time 7933 7934 // classNames styled by jqui themes 7935 widgetHeaderClass: null, 7936 widgetContentClass: null, 7937 highlightStateClass: null, 7938 7939 // for date utils, computed from options 7940 nextDayThreshold: null,
7941 isHiddenDayHash: null, 7942 7943 // now indicator 7944 isNowIndicatorRendered: null, 7945 initialNowDate: null, // result first getNow call 7946 initialNowQueriedMs: null, // ms time the getNow was called 7947 nowIndicatorTimeoutID: null, // for refresh timing of now indicator 7948 nowIndicatorIntervalID: null, // " 7949 7950 7951 constructor: function(calendar, type, options, intervalDuration) { 7952 7953 this.calendar = calendar; 7954 this.type = this.name = type; // .name is deprecated 7955 this.options = options; 7956 this.intervalDuration = intervalDuration || moment.duration(1, 'day'); 7957 7958 this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold')); 7959 this.initThemingProps(); 7960 this.initHiddenDays(); 7961 this.isRTL = this.opt('isRTL'); 7962 7963 this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder')); 7964 7965 this.initialize(); 7966 }, 7967 7968 7969 // A good place for subclasses to initialize member variables 7970 initialize: function() { 7971 // subclasses can implement 7972 }, 7973 7974 7975 // Retrieves an option with the given name 7976 opt: function(name) { 7977 return this.options[name]; 7978 }, 7979 7980 7981 // Triggers handlers that are view-related. Modifies args before passing to calendar. 7982 trigger: function(name, thisObj) { // arguments beyond thisObj are passed along 7983 var calendar = this.calendar; 7984 7985 return calendar.trigger.apply( 7986 calendar, 7987 [name, thisObj || this].concat( 7988 Array.prototype.slice.call(arguments, 2), // arguments beyond thisObj 7989 [ this ] // always make the last argument a reference to the view. TODO: deprecate 7990 ) 7991 ); 7992 }, 7993 7994 7995 /* Dates 7996 ------------------------------------------------------------------------------------------------------------------*/ 7997 7998 7999 // Updates all internal dates to center around the given current unzoned date. 8000 setDate: function(date) { 8001 this.setRange(this.computeRange(date)); 8002 }, 8003 8004 8005 // Updates all internal dates for displaying the given unzoned range. 8006 setRange: function(range) { 8007 $.extend(this, range); // assigns every property to this object's member variables 8008 this.updateTitle(); 8009 }, 8010 8011 8012 // Given a single current unzoned date, produce information about what range to display. 8013 // Subclasses can override. Must return all properties. 8014 computeRange: function(date) { 8015 var intervalUnit = computeIntervalUnit(this.intervalDuration); 8016 var intervalStart = date.clone().startOf(intervalUnit); 8017 var intervalEnd = intervalStart.clone().add(this.intervalDuration); 8018 var start, end; 8019 8020 // normalize the range's time-ambiguity 8021 if (/year|month|week|day/.test(intervalUnit)) { // whole-days? 8022 intervalStart.stripTime(); 8023 intervalEnd.stripTime(); 8024 } 8025 else { // needs to have a time? 8026 if (!intervalStart.hasTime()) { 8027 intervalStart = this.calendar.time(0); // give 00:00 time 8028 } 8029 if (!intervalEnd.hasTime()) { 8030 intervalEnd = this.calendar.time(0); // give 00:00 time 8031 } 8032 } 8033 8034 start = intervalStart.clone(); 8035 start = this.skipHiddenDays(start); 8036 end = intervalEnd.clone(); 8037 end = this.skipHiddenDays(end, -1, true); // exclusively move backwards 8038 8039 return { 8040 intervalUnit: intervalUnit, 8041 intervalStart: intervalStart, 8042 intervalEnd: intervalEnd, 8043 start: start, 8044 end: end 8045 }; 8046 }, 8047 8048 8049 // Computes the new date when the user hits the prev button, given the current date 8050 computePrevDate: function(date) { 8051 return this.massageCurrentDate( 8052 date.clone().startOf(this.intervalUnit).subtract(this.intervalDuration), -1 8053 ); 8054 }, 8055 8056 8057 // Computes the new date when the user hits the next button, given the current date 8058 computeNextDate: function(date) { 8059 return this.massageCurrentDate( 8060 date.clone().startOf(this.intervalUnit).add(this.intervalDuration) 8061 ); 8062 }, 8063 8064 8065 // Given an arbitrarily calculated current date of the calendar, returns a date that is ensured to be completely 8066 // visible. `direction` is optional and indicates which direction the current date was being 8067 // incremented or decremented (1 or -1). 8068 massageCurrentDate: function(date, direction) { 8069 if (this.intervalDuration.as('days') <= 1) { // if the view displays a single day or smaller 8070 if (this.isHiddenDay(date)) { 8071 date = this.skipHiddenDays(date, direction); 8072 date.startOf('day'); 8073 } 8074 } 8075 8076 return date; 8077 }, 8078 8079 8080 /* Title and Date Formatting 8081 ------------------------------------------------------------------------------------------------------------------*/ 8082 8083 8084 // Sets the view's title property to the most updated computed value 8085 updateTitle: function() { 8086 this.title = this.computeTitle(); 8087 }, 8088 8089 8090 // Computes what the title at the top of the calendar should be for this view 8091 computeTitle: function() { 8092 return this.formatRange( 8093 { 8094 // in case intervalStart/End has a time, make sure timezone is correct 8095 start: this.calendar.applyTimezone(this.intervalStart), 8096 end: this.calendar.applyTimezone(this.intervalEnd) 8097 }, 8098 this.opt('titleFormat') || this.computeTitleFormat(), 8099 this.opt('titleRangeSeparator') 8100 ); 8101 }, 8102 8103
8104 // Generates the format string that should be used to generate the title for the current date range. 8105 // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`. 8106 computeTitleFormat: function() { 8107 if (this.intervalUnit == 'year') { 8108 return 'YYYY'; 8109 } 8110 else if (this.intervalUnit == 'month') { 8111 return this.opt('monthYearFormat'); // like "September 2014" 8112 } 8113 else if (this.intervalDuration.as('days') > 1) { 8114 return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014" 8115 } 8116 else { 8117 return 'LL'; // one day. longer, like "September 9 2014" 8118 } 8119 }, 8120 8121 8122 // Utility for formatting a range. Accepts a range object, formatting string, and optional separator. 8123 // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account. 8124 // The timezones of the dates within `range` will be respected. 8125 formatRange: function(range, formatStr, separator) { 8126 var end = range.end; 8127 8128 if (!end.hasTime()) { // all-day? 8129 end = end.clone().subtract(1); // convert to inclusive. last ms of previous day 8130 } 8131 8132 return formatRange(range.start, end, formatStr, separator, this.opt('isRTL')); 8133 }, 8134 8135 8136 /* Rendering 8137 ------------------------------------------------------------------------------------------------------------------*/ 8138 8139 8140 // Sets the container element that the view should render inside of. 8141 // Does other DOM-related initializations. 8142 setElement: function(el) { 8143 this.el = el; 8144 this.bindGlobalHandlers(); 8145 }, 8146 8147 8148 // Removes the view's container element from the DOM, clearing any content beforehand. 8149 // Undoes any other DOM-related attachments. 8150 removeElement: function() { 8151 this.clear(); // clears all content 8152 8153 // clean up the skeleton 8154 if (this.isSkeletonRendered) { 8155 this.unrenderSkeleton(); 8156 this.isSkeletonRendered = false; 8157 } 8158 8159 this.unbindGlobalHandlers(); 8160 8161 this.el.remove(); 8162 8163 // NOTE: don't null-out this.el in case the View was destroyed within an API callback. 8164 // We don't null-out the View's other jQuery element references upon destroy, 8165 // so we shouldn't kill this.el either. 8166 }, 8167 8168 8169 // Does everything necessary to display the view centered around the given unzoned date. 8170 // Does every type of rendering EXCEPT rendering events. 8171 // Is asychronous and returns a promise. 8172 display: function(date, explicitScrollState) { 8173 var _this = this; 8174 var prevScrollState = null; 8175 8176 if (explicitScrollState != null && this.displaying) { // don't need prevScrollState if explicitScrollState 8177 prevScrollState = this.queryScroll(); 8178 } 8179 8180 this.calendar.freezeContentHeight(); 8181 8182 return syncThen(this.clear(), function() { // clear the content first 8183 return ( 8184 _this.displaying = 8185 syncThen(_this.displayView(date), function() { // displayView might return a promise 8186 8187 // caller of display() wants a specific scroll state? 8188 if (explicitScrollState != null) { 8189 // we make an assumption that this is NOT the initial render, 8190 // and thus don't need forceScroll (is inconveniently asynchronous) 8191 _this.setScroll(explicitScrollState); 8192 } 8193 else { 8194 _this.forceScroll(_this.computeInitialScroll(prevScrollState)); 8195 } 8196 8197 _this.calendar.unfreezeContentHeight(); 8198 _this.triggerRender(); 8199 }) 8200 ); 8201 }); 8202 }, 8203 8204 8205 // Does everything necessary to clear the content of the view. 8206 // Clears dates and events. Does not clear the skeleton. 8207 // Is asychronous and returns a promise. 8208 clear: function() { 8209 var _this = this; 8210 var displaying = this.displaying; 8211
8212 if (displaying) { // previously displayed, or in the process of being displayed? 8213 return syncThen(displaying, function() { // wait for the display to finish 8214 _this.displaying = null; 8215 _this.clearEvents(); 8216 return _this.clearView(); // might return a promise. chain it 8217 }); 8218 } 8219 else { 8220 return $.when(); // an immediately-resolved promise 8221 } 8222 }, 8223 8224 8225 // Displays the view's non-event content, such as date-related content or anything required by events. 8226 // Renders the view's non-content skeleton if necessary. 8227 // Can be asynchronous and return a promise. 8228 displayView: function(date) { 8229 if (!this.isSkeletonRendered) { 8230 this.renderSkeleton(); 8231 this.isSkeletonRendered = true; 8232 } 8233 if (date) { 8234 this.setDate(date); 8235 } 8236 if (this.render) { 8237 this.render(); // TODO: deprecate 8238 } 8239 this.renderDates(); 8240 this.updateSize(); 8241 this.renderBusinessHours(); // might need coordinates, so should go after updateSize() 8242 this.startNowIndicator(); 8243 }, 8244 8245 8246 // Unrenders the view content that was rendered in displayView. 8247 // Can be asynchronous and return a promise. 8248 clearView: function() { 8249 this.unselect(); 8250 this.stopNowIndicator(); 8251 this.triggerUnrender(); 8252 this.unrenderBusinessHours(); 8253 this.unrenderDates(); 8254 if (this.destroy) { 8255 this.destroy(); // TODO: deprecate 8256 } 8257 }, 8258 8259 8260 // Renders the basic structure of the view before any content is rendered 8261 renderSkeleton: function() { 8262 // subclasses should implement 8263 }, 8264 8265 8266 // Unrenders the basic structure of the view 8267 unrenderSkeleton: function() { 8268 // subclasses should implement 8269 }, 8270 8271 8272 // Renders the view's date-related content. 8273 // Assumes setRange has already been called and the skeleton has already been rendered. 8274 renderDates: function() { 8275 // subclasses should implement 8276 }, 8277 8278 8279 // Unrenders the view's date-related content 8280 unrenderDates: function() { 8281 // subclasses should override 8282 }, 8283 8284 8285 // Signals that the view's content has been rendered 8286 triggerRender: function() { 8287 this.trigger('viewRender', this, this, this.el); 8288 }, 8289 8290 8291 // Signals that the view's content is about to be unrendered 8292 triggerUnrender: function() { 8293 this.trigger('viewDestroy', this, this, this.el); 8294 }, 8295 8296 8297 // Binds DOM handlers to elements that reside outside the view container, such as the document 8298 bindGlobalHandlers: function() { 8299 this.listenTo($(document), 'mousedown', this.handleDocumentMousedown); 8300 this.listenTo($(document), 'touchstart', this.processUnselect); 8301 }, 8302 8303 8304 // Unbinds DOM handlers from elements that reside outside the view container 8305 unbindGlobalHandlers: function() { 8306 this.stopListeningTo($(document)); 8307 }, 8308 8309 8310 // Initializes internal variables related to theming 8311 initThemingProps: function() { 8312 var tm = this.opt('theme') ? 'ui' : 'fc'; 8313 8314 this.widgetHeaderClass = tm + '-widget-header'; 8315 this.widgetContentClass = tm + '-widget-content'; 8316 this.highlightStateClass = tm + '-state-highlight'; 8317 }, 8318 8319 8320 /* Business Hours 8321 ------------------------------------------------------------------------------------------------------------------*/ 8322 8323 8324 // Renders business-hours onto the view. Assumes updateSize has already been called. 8325 renderBusinessHours: function() { 8326 // subclasses should implement 8327 }, 8328 8329 8330 // Unrenders previously-rendered business-hours 8331 unrenderBusinessHours: function() { 8332 // subclasses should implement 8333 }, 8334 8335 8336 /* Now Indicator 8337 ------------------------------------------------------------------------------------------------------------------*/ 8338 8339 8340 // Immediately render the current time indicator and begins re-rendering it at an interval, 8341 // which is defined by this.getNowIndicatorUnit(). 8342 // TODO: somehow do this for the current whole day's background too 8343 startNowIndicator: function() { 8344 var _this = this; 8345 var unit; 8346 var update; 8347 var delay; // ms wait value 8348 8349 if (this.opt('nowIndicator')) { 8350 unit = this.getNowIndicatorUnit(); 8351 if (unit) { 8352 update = proxy(this, 'updateNowIndicator'); // bind to `this` 8353 8354 this.initialNowDate = this.calendar.getNow(); 8355 this.initialNowQueriedMs = +new Date(); 8356 this.renderNowIndicator(this.initialNowDate); 8357 this.isNowIndicatorRendered = true; 8358 8359 // wait until the beginning of the next interval
8360 delay = this.initialNowDate.clone().startOf(unit).add(1, unit) - this.initialNowDate; 8361 this.nowIndicatorTimeoutID = setTimeout(function() { 8362 _this.nowIndicatorTimeoutID = null; 8363 update(); 8364 delay = +moment.duration(1, unit); 8365 delay = Math.max(100, delay); // prevent too frequent 8366 _this.nowIndicatorIntervalID = setInterval(update, delay); // update every interval 8367 }, delay); 8368 } 8369 } 8370 }, 8371 8372 8373 // rerenders the now indicator, computing the new current time from the amount of time that has passed 8374 // since the initial getNow call. 8375 updateNowIndicator: function() { 8376 if (this.isNowIndicatorRendered) { 8377 this.unrenderNowIndicator(); 8378 this.renderNowIndicator( 8379 this.initialNowDate.clone().add(new Date() - this.initialNowQueriedMs) // add ms 8380 ); 8381 } 8382 }, 8383 8384 8385 // Immediately unrenders the view's current time indicator and stops any re-rendering timers. 8386 // Won't cause side effects if indicator isn't rendered. 8387 stopNowIndicator: function() { 8388 if (this.isNowIndicatorRendered) { 8389 8390 if (this.nowIndicatorTimeoutID) { 8391 clearTimeout(this.nowIndicatorTimeoutID); 8392 this.nowIndicatorTimeoutID = null; 8393 } 8394 if (this.nowIndicatorIntervalID) { 8395 clearTimeout(this.nowIndicatorIntervalID); 8396 this.nowIndicatorIntervalID = null; 8397 } 8398 8399 this.unrenderNowIndicator(); 8400 this.isNowIndicatorRendered = false; 8401 } 8402 }, 8403 8404 8405 // Returns a string unit, like 'second' or 'minute' that defined how often the current time indicator 8406 // should be refreshed. If something falsy is returned, no time indicator is rendered at all. 8407 getNowIndicatorUnit: function() { 8408 // subclasses should implement 8409 }, 8410 8411 8412 // Renders a current time indicator at the given datetime 8413 renderNowIndicator: function(date) { 8414 // subclasses should implement 8415 }, 8416 8417 8418 // Undoes the rendering actions from renderNowIndicator 8419 unrenderNowIndicator: function() { 8420 // subclasses should implement 8421 }, 8422 8423 8424 /* Dimensions 8425 ------------------------------------------------------------------------------------------------------------------*/ 8426 8427 8428 // Refreshes anything dependant upon sizing of the container element of the grid 8429 updateSize: function(isResize) { 8430 var scrollState; 8431 8432 if (isResize) { 8433 scrollState = this.queryScroll(); 8434 } 8435 8436 this.updateHeight(isResize); 8437 this.updateWidth(isResize); 8438 this.updateNowIndicator(); 8439 8440 if (isResize) { 8441 this.setScroll(scrollState); 8442 } 8443 }, 8444 8445 8446 // Refreshes the horizontal dimensions of the calendar 8447 updateWidth: function(isResize) { 8448 // subclasses should implement 8449 }, 8450 8451 8452 // Refreshes the vertical dimensions of the calendar 8453 updateHeight: function(isResize) { 8454 var calendar = this.calendar; // we poll the calendar for height information 8455 8456 this.setHeight( 8457 calendar.getSuggestedViewHeight(), 8458 calendar.isHeightAuto() 8459 ); 8460 }, 8461 8462 8463 // Updates the vertical dimensions of the calendar to the specified height. 8464 // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height. 8465 setHeight: function(height, isAuto) { 8466 // subclasses should implement 8467 }, 8468 8469 8470 /* Scroller 8471 ------------------------------------------------------------------------------------------------------------------*/ 8472 8473 8474 // Computes the initial pre-configured scroll state prior to allowing the user to change it. 8475 // Given the scroll state from the previous rendering. If first time rendering, given null. 8476 computeInitialScroll: function(previousScrollState) { 8477 return 0; 8478 }, 8479 8480 8481 // Retrieves the view's current natural scroll state. Can return an arbitrary format. 8482 queryScroll: function() { 8483 // subclasses must implement 8484 }, 8485 8486 8487 // Sets the view's scroll state. Will accept the same format computeInitialScroll and queryScroll produce. 8488 setScroll: function(scrollState) { 8489 // subclasses must implement 8490 }, 8491 8492 8493 // Sets the scroll state, making sure to overcome any predefined scroll value the browser has in mind 8494 forceScroll: function(scrollState) { 8495 var _this = this; 8496 8497 this.setScroll(scrollState); 8498 setTimeout(function() { 8499 _this.setScroll(scrollState); 8500 }, 0); 8501 }, 8502 8503 8504 /* Event Elements / Segments 8505 ------------------------------------------------------------------------------------------------------------------*/ 8506 8507 8508 // Does everything necessary to display the given events onto the current view 8509 displayEvents: function(events) { 8510 var scrollState = this.queryScroll(); 8511 8512 this.clearEvents(); 8513 this.renderEvents(events); 8514 this.isEventsRendered = true; 8515 this.setScroll(scrollState); 8516 this.triggerEventRender(); 8517 }, 8518 8519 8520 // Does everything necessary to clear the view's currently-rendered events 8521 clearEvents: function() { 8522 var scrollState; 8523 8524 if (this.isEventsRendered) { 8525 8526 // TODO: optimize: if we know this is part of a displayEvents call, don't queryScroll/setScroll 8527 scrollState = this.queryScroll(); 8528 8529 this.triggerEventUnrender(); 8530 if (this.destroyEvents) { 8531 this.destroyEvents(); // TODO: deprecate 8532 } 8533 this.unrenderEvents(); 8534 this.setScroll(scrollState); 8535 this.isEventsRendered = false;
8536 } 8537 }, 8538 8539 8540 // Renders the events onto the view. 8541 renderEvents: function(events) { 8542 // subclasses should implement 8543 }, 8544 8545 8546 // Removes event elements from the view. 8547 unrenderEvents: function() { 8548 // subclasses should implement 8549 }, 8550 8551 8552 // Signals that all events have been rendered 8553 triggerEventRender: function() { 8554 this.renderedEventSegEach(function(seg) { 8555 this.trigger('eventAfterRender', seg.event, seg.event, seg.el); 8556 }); 8557 this.trigger('eventAfterAllRender'); 8558 }, 8559 8560 8561 // Signals that all event elements are about to be removed 8562 triggerEventUnrender: function() { 8563 this.renderedEventSegEach(function(seg) { 8564 this.trigger('eventDestroy', seg.event, seg.event, seg.el); 8565 }); 8566 }, 8567 8568 8569 // Given an event and the default element used for rendering, returns the element that should actually be used. 8570 // Basically runs events and elements through the eventRender hook. 8571 resolveEventEl: function(event, el) { 8572 var custom = this.trigger('eventRender', event, event, el); 8573 8574 if (custom === false) { // means don't render at all 8575 el = null; 8576 } 8577 else if (custom && custom !== true) { 8578 el = $(custom); 8579 } 8580 8581 return el; 8582 }, 8583 8584 8585 // Hides all rendered event segments linked to the given event 8586 showEvent: function(event) { 8587 this.renderedEventSegEach(function(seg) { 8588 seg.el.css('visibility', ''); 8589 }, event); 8590 }, 8591 8592 8593 // Shows all rendered event segments linked to the given event 8594 hideEvent: function(event) { 8595 this.renderedEventSegEach(function(seg) { 8596 seg.el.css('visibility', 'hidden'); 8597 }, event); 8598 }, 8599 8600 8601 // Iterates through event segments that have been rendered (have an el). Goes through all by default. 8602 // If the optional `event` argument is specified, only iterates through segments linked to that event. 8603 // The `this` value of the callback function will be the view. 8604 renderedEventSegEach: function(func, event) { 8605 var segs = this.getEventSegs(); 8606 var i; 8607 8608 for (i = 0; i < segs.length; i++) { 8609 if (!event || segs[i].event._id === event._id) { 8610 if (segs[i].el) { 8611 func.call(this, segs[i]); 8612 } 8613 } 8614 } 8615 }, 8616 8617 8618 // Retrieves all the rendered segment objects for the view 8619 getEventSegs: function() { 8620 // subclasses must implement 8621 return []; 8622 }, 8623 8624 8625 /* Event Drag-n-Drop 8626 ------------------------------------------------------------------------------------------------------------------*/ 8627 8628 8629 // Computes if the given event is allowed to be dragged by the user 8630 isEventDraggable: function(event) { 8631 var source = event.source || {}; 8632 8633 return firstDefined( 8634 event.startEditable, 8635 source.startEditable, 8636 this.opt('eventStartEditable'), 8637 event.editable, 8638 source.editable, 8639 this.opt('editable') 8640 ); 8641 }, 8642 8643 8644 // Must be called when an event in the view is dropped onto new location. 8645 // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event. 8646 reportEventDrop: function(event, dropLocation, largeUnit, el, ev) { 8647 var calendar = this.calendar; 8648 var mutateResult = calendar.mutateEvent(event, dropLocation, largeUnit); 8649 var undoFunc = function() { 8650 mutateResult.undo(); 8651 calendar.reportEventChange(); 8652 }; 8653 8654 this.triggerEventDrop(event, mutateResult.dateDelta, undoFunc, el, ev); 8655 calendar.reportEventChange(); // will rerender events 8656 }, 8657 8658 8659 // Triggers event-drop handlers that have subscribed via the API 8660 triggerEventDrop: function(event, dateDelta, undoFunc, el, ev) { 8661 this.trigger('eventDrop', el[0], event, dateDelta, undoFunc, ev, {}); // {} = jqui dummy 8662 }, 8663 8664 8665 /* External Element Drag-n-Drop 8666 ------------------------------------------------------------------------------------------------------------------*/ 8667 8668 8669 // Must be called when an external element, via jQuery UI, has been dropped onto the calendar. 8670 // `meta` is the parsed data that has been embedded into the dragging event. 8671 // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event. 8672 reportExternalDrop: function(meta, dropLocation, el, ev, ui) { 8673 var eventProps = meta.eventProps; 8674 var eventInput; 8675 var event; 8676 8677 // Try to build an event object and render it. TODO: decouple the two 8678 if (eventProps) { 8679 eventInput = $.extend({}, eventProps, dropLocation); 8680 event = this.calendar.renderEvent(eventInput, meta.stick)[0]; // renderEvent returns an array 8681 } 8682 8683 this.triggerExternalDrop(event, dropLocation, el, ev, ui); 8684 }, 8685 8686 8687 // Triggers external-drop handlers that have subscribed via the API 8688 triggerExternalDrop: function(event, dropLocation, el, ev, ui) { 8689 8690 // trigger 'drop' regardless of whether element represents an event 8691 this.trigger('drop', el[0], dropLocation.start, ev, ui); 8692 8693 if (event) { 8694 this.trigger('eventReceive', null, event); // signal an external event landed 8695 } 8696 }, 8697 8698 8699 /* Drag-n-Drop Rendering (for both events and external elements) 8700 ------------------------------------------------------------------------------------------------------------------*/ 8701 8702 8703 // Renders a visual indication of a event or external-element drag over the given drop zone. 8704 // If an external-element, seg will be `null`. 8705 // Must return elements used for any mock events. 8706 renderDrag: function(dropLocation, seg) { 8707 // subclasses must implement 8708 }, 8709 8710 8711 // Unrenders a visual indication of an event or external-element being dragged. 8712 unrenderDrag: function() { 8713 // subclasses must implement 8714 }, 8715 8716 8717 /* Event Resizing 8718 ------------------------------------------------------------------------------------------------------------------*/ 8719 8720 8721 // Computes if the given event is allowed to be resized from its starting edge 8722 isEventResizableFromStart: function(event) { 8723 return this.opt('eventResizableFromStart') && this.isEventResizable(event); 8724 }, 8725 8726 8727 // Computes if the given event is allowed to be resized from its ending edge 8728 isEventResizableFromEnd: function(event) { 8729 return this.isEventResizable(event); 8730 }, 8731 8732 8733 // Computes if the given event is allowed to be resized by the user at all 8734 isEventResizable: function(event) { 8735 var source = event.source || {}; 8736 8737 return firstDefined( 8738 event.durationEditable, 8739 source.durationEditable, 8740 this.opt('eventDurationEditable'), 8741 event.editable, 8742 source.editable, 8743 this.opt('editable') 8744 ); 8745 }, 8746 8747 8748 // Must be called when an event in the view has been resized to a new length 8749 reportEventResize: function(event, resizeLocation, largeUnit, el, ev) { 8750 var calendar = this.calendar; 8751 var mutateResult = calendar.mutateEvent(event, resizeLocation, largeUnit);
8752 var undoFunc = function() { 8753 mutateResult.undo(); 8754 calendar.reportEventChange(); 8755 }; 8756 8757 this.triggerEventResize(event, mutateResult.durationDelta, undoFunc, el, ev); 8758 calendar.reportEventChange(); // will rerender events 8759 }, 8760 8761 8762 // Triggers event-resize handlers that have subscribed via the API 8763 triggerEventResize: function(event, durationDelta, undoFunc, el, ev) { 8764 this.trigger('eventResize', el[0], event, durationDelta, undoFunc, ev, {}); // {} = jqui dummy 8765 }, 8766 8767 8768 /* Selection (time range) 8769 ------------------------------------------------------------------------------------------------------------------*/ 8770 8771 8772 // Selects a date span on the view. `start` and `end` are both Moments. 8773 // `ev` is the native mouse event that begin the interaction. 8774 select: function(span, ev) { 8775 this.unselect(ev); 8776 this.renderSelection(span); 8777 this.reportSelection(span, ev); 8778 }, 8779 8780 8781 // Renders a visual indication of the selection 8782 renderSelection: function(span) { 8783 // subclasses should implement 8784 }, 8785 8786 8787 // Called when a new selection is made. Updates internal state and triggers handlers. 8788 reportSelection: function(span, ev) { 8789 this.isSelected = true; 8790 this.triggerSelect(span, ev); 8791 }, 8792 8793 8794 // Triggers handlers to 'select' 8795 triggerSelect: function(span, ev) { 8796 this.trigger( 8797 'select', 8798 null, 8799 this.calendar.applyTimezone(span.start), // convert to calendar's tz for external API 8800 this.calendar.applyTimezone(span.end), // " 8801 ev 8802 ); 8803 }, 8804 8805 8806 // Undoes a selection. updates in the internal state and triggers handlers. 8807 // `ev` is the native mouse event that began the interaction. 8808 unselect: function(ev) { 8809 if (this.isSelected) { 8810 this.isSelected = false; 8811 if (this.destroySelection) { 8812 this.destroySelection(); // TODO: deprecate 8813 } 8814 this.unrenderSelection(); 8815 this.trigger('unselect', null, ev); 8816 } 8817 }, 8818 8819 8820 // Unrenders a visual indication of selection 8821 unrenderSelection: function() { 8822 // subclasses should implement 8823 }, 8824 8825 8826 /* Event Selection 8827 ------------------------------------------------------------------------------------------------------------------*/ 8828 8829 8830 selectEvent: function(event) { 8831 if (!this.selectedEvent || this.selectedEvent !== event) { 8832 this.unselectEvent(); 8833 this.renderedEventSegEach(function(seg) { 8834 seg.el.addClass('fc-selected'); 8835 }, event); 8836 this.selectedEvent = event; 8837 } 8838 }, 8839 8840 8841 unselectEvent: function() { 8842 if (this.selectedEvent) { 8843 this.renderedEventSegEach(function(seg) { 8844 seg.el.removeClass('fc-selected'); 8845 }, this.selectedEvent); 8846 this.selectedEvent = null; 8847 } 8848 }, 8849 8850 8851 isEventSelected: function(event) { 8852 // event references might change on refetchEvents(), while selectedEvent doesn't, 8853 // so compare IDs 8854 return this.selectedEvent && this.selectedEvent._id === event._id; 8855 }, 8856 8857 8858 /* Mouse / Touch Unselecting (time range & event unselection) 8859 ------------------------------------------------------------------------------------------------------------------*/ 8860 // TODO: move consistently to down/start or up/end? 8861 // TODO: don't kill previous selection if touch scrolling 8862 8863 8864 handleDocumentMousedown: function(ev) { 8865 if (isPrimaryMouseButton(ev)) { 8866 this.processUnselect(ev); 8867 } 8868 }, 8869 8870 8871 processUnselect: function(ev) { 8872 this.processRangeUnselect(ev); 8873 this.processEventUnselect(ev); 8874 }, 8875 8876 8877 processRangeUnselect: function(ev) { 8878 var ignore; 8879 8880 // is there a time-range selection? 8881 if (this.isSelected && this.opt('unselectAuto')) { 8882 // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element 8883 ignore = this.opt('unselectCancel'); 8884 if (!ignore || !$(ev.target).closest(ignore).length) { 8885 this.unselect(ev); 8886 } 8887 } 8888 }, 8889 8890 8891 processEventUnselect: function(ev) { 8892 if (this.selectedEvent) { 8893 if (!$(ev.target).closest('.fc-selected').length) { 8894 this.unselectEvent(); 8895 } 8896 } 8897 }, 8898 8899 8900 /* Day Click 8901 ------------------------------------------------------------------------------------------------------------------*/ 8902 8903 8904 // Triggers handlers to 'dayClick' 8905 // Span has start/end of the clicked area. Only the start is useful. 8906 triggerDayClick: function(span, dayEl, ev) { 8907 this.trigger( 8908 'dayClick', 8909 dayEl, 8910 this.calendar.applyTimezone(span.start), // convert to calendar's timezone for external API 8911 ev 8912 ); 8913 }, 8914 8915 8916 /* Date Utils 8917 ------------------------------------------------------------------------------------------------------------------*/ 8918 8919 8920 // Initializes internal variables related to calculating hidden days-of-week 8921 initHiddenDays: function() { 8922 var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden
8923 var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool) 8924 var dayCnt = 0; 8925 var i; 8926 8927 if (this.opt('weekends') === false) { 8928 hiddenDays.push(0, 6); // 0=sunday, 6=saturday 8929 } 8930 8931 for (i = 0; i < 7; i++) { 8932 if ( 8933 !(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1) 8934 ) { 8935 dayCnt++; 8936 } 8937 } 8938 8939 if (!dayCnt) { 8940 throw 'invalid hiddenDays'; // all days were hidden? bad. 8941 } 8942 8943 this.isHiddenDayHash = isHiddenDayHash; 8944 }, 8945 8946 8947 // Is the current day hidden? 8948 // `day` is a day-of-week index (0-6), or a Moment 8949 isHiddenDay: function(day) { 8950 if (moment.isMoment(day)) { 8951 day = day.day(); 8952 } 8953 return this.isHiddenDayHash[day]; 8954 }, 8955 8956 8957 // Incrementing the current day until it is no longer a hidden day, returning a copy. 8958 // If the initial value of `date` is not a hidden day, don't do anything. 8959 // Pass `isExclusive` as `true` if you are dealing with an end date. 8960 // `inc` defaults to `1` (increment one day forward each time) 8961 skipHiddenDays: function(date, inc, isExclusive) { 8962 var out = date.clone(); 8963 inc = inc || 1; 8964 while ( 8965 this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7] 8966 ) { 8967 out.add(inc, 'days'); 8968 } 8969 return out; 8970 }, 8971 8972 8973 // Returns the date range of the full days the given range visually appears to occupy. 8974 // Returns a new range object. 8975 computeDayRange: function(range) { 8976 var startDay = range.start.clone().stripTime(); // the beginning of the day the range starts 8977 var end = range.end; 8978 var endDay = null; 8979 var endTimeMS; 8980 8981 if (end) { 8982 endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends 8983 endTimeMS = +end.time(); // # of milliseconds into `endDay` 8984 8985 // If the end time is actually inclusively part of the next day and is equal to or 8986 // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`. 8987 // Otherwise, leaving it as inclusive will cause it to exclude `endDay`. 8988 if (endTimeMS && endTimeMS >= this.nextDayThreshold) { 8989 endDay.add(1, 'days'); 8990 } 8991 } 8992 8993 // If no end was specified, or if it is within `startDay` but not past nextDayThreshold, 8994 // assign the default duration of one day. 8995 if (!end || endDay <= startDay) { 8996 endDay = startDay.clone().add(1, 'days'); 8997 } 8998 8999 return { start: startDay, end: endDay }; 9000 }, 9001 9002 9003 // Does the given event visually appear to occupy more than one day? 9004 isMultiDayEvent: function(event) { 9005 var range = this.computeDayRange(event); // event is range-ish 9006 9007 return range.end.diff(range.start, 'days') > 1; 9008 } 9009 9010}); 9011 9012;; 9013 9014/* 9015Embodies a div that has potential scrollbars 9016*/ 9017var Scroller = FC.Scroller = Class.extend({ 9018 9019 el: null, // the guaranteed outer element 9020 scrollEl: null, // the element with the scrollbars 9021 overflowX: null, 9022 overflowY: null, 9023 9024 9025 constructor: function(options) { 9026 options = options || {}; 9027 this.overflowX = options.overflowX || options.overflow || 'auto'; 9028 this.overflowY = options.overflowY || options.overflow || 'auto'; 9029 }, 9030 9031 9032 render: function() { 9033 this.el = this.renderEl(); 9034 this.applyOverflow(); 9035 }, 9036 9037 9038 renderEl: function() { 9039 return (this.scrollEl = $('<div class="fc-scroller"></div>')); 9040 }, 9041 9042 9043 // sets to natural height, unlocks overflow 9044 clear: function() { 9045 this.setHeight('auto'); 9046 this.applyOverflow(); 9047 }, 9048 9049 9050 destroy: function() { 9051 this.el.remove(); 9052 }, 9053 9054 9055 // Overflow 9056 // ----------------------------------------------------------------------------------------------------------------- 9057 9058 9059 applyOverflow: function() { 9060 this.scrollEl.css({ 9061 'overflow-x': this.overflowX, 9062 'overflow-y': this.overflowY 9063 }); 9064 }, 9065 9066 9067 // Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'. 9068 // Useful for preserving scrollbar widths regardless of future resizes. 9069 // Can pass in scrollbarWidths for optimization. 9070 lockOverflow: function(scrollbarWidths) { 9071 var overflowX = this.overflowX; 9072 var overflowY = this.overflowY; 9073
9074 scrollbarWidths = scrollbarWidths || this.getScrollbarWidths(); 9075 9076 if (overflowX === 'auto') { 9077 overflowX = ( 9078 scrollbarWidths.top || scrollbarWidths.bottom || // horizontal scrollbars? 9079 // OR scrolling pane with massless scrollbars? 9080 this.scrollEl[0].scrollWidth - 1 > this.scrollEl[0].clientWidth 9081 // subtract 1 because of IE off-by-one issue 9082 ) ? 'scroll' : 'hidden'; 9083 } 9084 9085 if (overflowY === 'auto') { 9086 overflowY = ( 9087 scrollbarWidths.left || scrollbarWidths.right || // vertical scrollbars? 9088 // OR scrolling pane with massless scrollbars? 9089 this.scrollEl[0].scrollHeight - 1 > this.scrollEl[0].clientHeight 9090 // subtract 1 because of IE off-by-one issue 9091 ) ? 'scroll' : 'hidden'; 9092 } 9093 9094 this.scrollEl.css({ 'overflow-x': overflowX, 'overflow-y': overflowY }); 9095 }, 9096 9097 9098 // Getters / Setters 9099 // ----------------------------------------------------------------------------------------------------------------- 9100 9101 9102 setHeight: function(height) { 9103 this.scrollEl.height(height); 9104 }, 9105 9106 9107 getScrollTop: function() { 9108 return this.scrollEl.scrollTop(); 9109 }, 9110 9111 9112 setScrollTop: function(top) { 9113 this.scrollEl.scrollTop(top); 9114 }, 9115 9116 9117 getClientWidth: function() { 9118 return this.scrollEl[0].clientWidth; 9119 }, 9120 9121 9122 getClientHeight: function() { 9123 return this.scrollEl[0].clientHeight; 9124 }, 9125 9126 9127 getScrollbarWidths: function() { 9128 return getScrollbarWidths(this.scrollEl); 9129 } 9130 9131}); 9132 9133;; 9134 9135var Calendar = FC.Calendar = Class.extend({ 9136 9137 dirDefaults: null, // option defaults related to LTR or RTL 9138 langDefaults: null, // option defaults related to current locale 9139 overrides: null, // option overrides given to the fullCalendar constructor 9140 dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides. 9141 options: null, // all defaults combined with overrides 9142 viewSpecCache: null, // cache of view definitions 9143 view: null, // current View object 9144 header: null, 9145 loadingLevel: 0, // number of simultaneous loading tasks 9146 9147 9148 // a lot of this class' OOP logic is scoped within this constructor function, 9149 // but in the future, write individual methods on the prototype. 9150 constructor: Calendar_constructor, 9151 9152 9153 // Subclasses can override this for initialization logic after the constructor has been called 9154 initialize: function() { 9155 }, 9156 9157 9158 // Computes the flattened options hash for the calendar and assigns to `this.options`. 9159 // Assumes this.overrides and this.dynamicOverrides have already been initialized. 9160 populateOptionsHash: function() { 9161 var lang, langDefaults; 9162 var isRTL, dirDefaults; 9163 9164 lang = firstDefined( // explicit lang option given? 9165 this.dynamicOverrides.lang, 9166 this.overrides.lang 9167 ); 9168 langDefaults = langOptionHash[lang]; 9169 if (!langDefaults) { // explicit lang option not given or invalid? 9170 lang = Calendar.defaults.lang; 9171 langDefaults = langOptionHash[lang] || {}; 9172 } 9173 9174 isRTL = firstDefined( // based on options computed so far, is direction RTL? 9175 this.dynamicOverrides.isRTL, 9176 this.overrides.isRTL, 9177 langDefaults.isRTL, 9178 Calendar.defaults.isRTL 9179 ); 9180 dirDefaults = isRTL ? Calendar.rtlDefaults : {}; 9181 9182 this.dirDefaults = dirDefaults; 9183 this.langDefaults = langDefaults; 9184 this.options = mergeOptions([ // merge defaults and overrides. lowest to highest precedence 9185 Calendar.defaults, // global defaults 9186 dirDefaults, 9187 langDefaults, 9188 this.overrides, 9189 this.dynamicOverrides 9190 ]); 9191 populateInstanceComputableOptions(this.options); // fill in gaps with computed options 9192 }, 9193 9194 9195 // Gets information about how to create a view. Will use a cache. 9196 getViewSpec: function(viewType) { 9197 var cache = this.viewSpecCache; 9198 9199 return cache[viewType] || (cache[viewType] = this.buildViewSpec(viewType)); 9200 }, 9201 9202 9203 // Given a duration singular unit, like "week" or "day", finds a matching view spec. 9204 // Preference is given to views that have corresponding buttons. 9205 getUnitViewSpec: function(unit) { 9206 var viewTypes; 9207 var i; 9208 var spec; 9209 9210 if ($.inArray(unit, intervalUnits) != -1) { 9211 9212 // put views that have buttons first. there will be duplicates, but oh well 9213 viewTypes = this.header.getViewsWithButtons(); 9214 $.each(FC.views, function(viewType) { // all views 9215 viewTypes.push(viewType); 9216 }); 9217 9218 for (i = 0; i < viewTypes.length; i++) { 9219 spec = this.getViewSpec(viewTypes[i]); 9220 if (spec) { 9221 if (spec.singleUnit == unit) { 9222 return spec; 9223 } 9224 } 9225 } 9226 } 9227 }, 9228 9229 9230 // Builds an object with information on how to create a given view 9231 buildViewSpec: function(requestedViewType) { 9232 var viewOverrides = this.overrides.views || {}; 9233 var specChain = []; // for the view. lowest to highest priority 9234 var defaultsChain = []; // for the view. lowest to highest priority 9235 var overridesChain = []; // for the view. lowest to highest priority 9236 var viewType = requestedViewType; 9237 var spec; // for the view 9238 var overrides; // for the view 9239 var duration; 9240 var unit; 9241 9242 // iterate from the specific view definition to a more general one until we hit an actual View class 9243 while (viewType) { 9244 spec = fcViews[viewType];
9245 overrides = viewOverrides[viewType]; 9246 viewType = null; // clear. might repopulate for another iteration 9247 9248 if (typeof spec === 'function') { // TODO: deprecate 9249 spec = { 'class': spec }; 9250 } 9251 9252 if (spec) { 9253 specChain.unshift(spec); 9254 defaultsChain.unshift(spec.defaults || {}); 9255 duration = duration || spec.duration; 9256 viewType = viewType || spec.type; 9257 } 9258 9259 if (overrides) { 9260 overridesChain.unshift(overrides); // view-specific option hashes have options at zero-level 9261 duration = duration || overrides.duration; 9262 viewType = viewType || overrides.type; 9263 } 9264 } 9265 9266 spec = mergeProps(specChain); 9267 spec.type = requestedViewType; 9268 if (!spec['class']) { 9269 return false; 9270 } 9271 9272 if (duration) { 9273 duration = moment.duration(duration); 9274 if (duration.valueOf()) { // valid? 9275 spec.duration = duration; 9276 unit = computeIntervalUnit(duration); 9277 9278 // view is a single-unit duration, like "week" or "day" 9279 // incorporate options for this. lowest priority 9280 if (duration.as(unit) === 1) { 9281 spec.singleUnit = unit; 9282 overridesChain.unshift(viewOverrides[unit] || {}); 9283 } 9284 } 9285 } 9286 9287 spec.defaults = mergeOptions(defaultsChain); 9288 spec.overrides = mergeOptions(overridesChain); 9289 9290 this.buildViewSpecOptions(spec); 9291 this.buildViewSpecButtonText(spec, requestedViewType); 9292 9293 return spec; 9294 }, 9295 9296 9297 // Builds and assigns a view spec's options object from its already-assigned defaults and overrides 9298 buildViewSpecOptions: function(spec) { 9299 spec.options = mergeOptions([ // lowest to highest priority 9300 Calendar.defaults, // global defaults 9301 spec.defaults, // view's defaults (from ViewSubclass.defaults) 9302 this.dirDefaults, 9303 this.langDefaults, // locale and dir take precedence over view's defaults! 9304 this.overrides, // calendar's overrides (options given to constructor) 9305 spec.overrides, // view's overrides (view-specific options) 9306 this.dynamicOverrides // dynamically set via setter. highest precedence 9307 ]); 9308 populateInstanceComputableOptions(spec.options); 9309 }, 9310 9311 9312 // Computes and assigns a view spec's buttonText-related options 9313 buildViewSpecButtonText: function(spec, requestedViewType) { 9314 9315 // given an options object with a possible `buttonText` hash, lookup the buttonText for the 9316 // requested view, falling back to a generic unit entry like "week" or "day" 9317 function queryButtonText(options) { 9318 var buttonText = options.buttonText || {}; 9319 return buttonText[requestedViewType] || 9320 (spec.singleUnit ? buttonText[spec.singleUnit] : null); 9321 } 9322 9323 // highest to lowest priority 9324 spec.buttonTextOverride = 9325 queryButtonText(this.dynamicOverrides) || 9326 queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence 9327 spec.overrides.buttonText; // `buttonText` for view-specific options is a string 9328 9329 // highest to lowest priority. mirrors buildViewSpecOptions 9330 spec.buttonTextDefault = 9331 queryButtonText(this.langDefaults) || 9332 queryButtonText(this.dirDefaults) || 9333 spec.defaults.buttonText || // a single string. from ViewSubclass.defaults 9334 queryButtonText(Calendar.defaults) || 9335 (spec.duration ? this.humanizeDuration(spec.duration) : null) || // like "3 days" 9336 requestedViewType; // fall back to given view name 9337 }, 9338 9339 9340 // Given a view name for a custom view or a standard view, creates a ready-to-go View object 9341 instantiateView: function(viewType) { 9342 var spec = this.getViewSpec(viewType); 9343 9344 return new spec['class'](this, viewType, spec.options, spec.duration); 9345 }, 9346 9347 9348 // Returns a boolean about whether the view is okay to instantiate at some point 9349 isValidViewType: function(viewType) { 9350 return Boolean(this.getViewSpec(viewType)); 9351 }, 9352 9353 9354 // Should be called when any type of async data fetching begins 9355 pushLoading: function() { 9356 if (!(this.loadingLevel++)) { 9357 this.trigger('loading', null, true, this.view); 9358 } 9359 }, 9360 9361 9362 // Should be called when any type of async data fetching completes 9363 popLoading: function() { 9364 if (!(--this.loadingLevel)) { 9365 this.trigger('loading', null, false, this.view); 9366 } 9367 }, 9368 9369 9370 // Given arguments to the select method in the API, returns a span (unzoned start/end and other info) 9371 buildSelectSpan: function(zonedStartInput, zonedEndInput) { 9372 var start = this.moment(zonedStartInput).stripZone(); 9373 var end; 9374 9375 if (zonedEndInput) { 9376 end = this.moment(zonedEndInput).stripZone(); 9377 } 9378 else if (start.hasTime()) { 9379 end = start.clone().add(this.defaultTimedEventDuration); 9380 } 9381 else { 9382 end = start.clone().add(this.defaultAllDayEventDuration); 9383 } 9384 9385 return { start: start, end: end }; 9386 } 9387 9388}); 9389 9390 9391Calendar.mixin(EmitterMixin); 9392 9393 9394function Calendar_constructor(element, overrides) { 9395 var t = this; 9396 9397 9398 // Exports 9399 // ----------------------------------------------------------------------------------- 9400 9401 t.render = render; 9402 t.destroy = destroy; 9403 t.refetchEvents = refetchEvents; 9404 t.refetchEventSources = refetchEventSources; 9405 t.reportEvents = reportEvents; 9406 t.reportEventChange = reportEventChange; 9407 t.rerenderEvents = renderEvents; // `renderEvents` serves as a rerender. an API method 9408 t.changeView = renderView; // `renderView` will switch to another view 9409 t.select = select; 9410 t.unselect = unselect; 9411 t.prev = prev; 9412 t.next = next; 9413 t.prevYear = prevYear; 9414 t.nextYear = nextYear; 9415 t.today = today; 9416 t.gotoDate = gotoDate; 9417 t.incrementDate = incrementDate; 9418 t.zoomTo = zoomTo; 9419 t.getDate = getDate; 9420 t.getCalendar = getCalendar; 9421 t.getView = getView; 9422 t.option = option; // getter/setter method 9423 t.trigger = trigger; 9424 9425 9426 // Options 9427 // ----------------------------------------------------------------------------------- 9428 9429 t.dynamicOverrides = {}; 9430 t.viewSpecCache = {}; 9431 t.optionHandlers = {}; // for Calendar.options.js 9432 9433 // convert legacy options into non-legacy ones. 9434 // in the future, when this is removed, don't use `overrides` reference. make a copy. 9435 t.overrides = massageOverrides(overrides || {}); 9436 9437 t.populateOptionsHash(); // sets this.options 9438 9439 9440 9441 // Language-data Internals 9442 // ----------------------------------------------------------------------------------- 9443 // Apply overrides to the current language's data 9444 9445 var localeData; 9446
9447 // Called immediately, and when any of the options change. 9448 // Happens before any internal objects rebuild or rerender, because this is very core. 9449 t.bindOptions([ 9450 'lang', 'monthNames', 'monthNamesShort', 'dayNames', 'dayNamesShort', 'firstDay', 'weekNumberCalculation' 9451 ], function(lang, monthNames, monthNamesShort, dayNames, dayNamesShort, firstDay, weekNumberCalculation) { 9452 9453 localeData = createObject( // make a cheap copy 9454 getMomentLocaleData(lang) // will fall back to en 9455 ); 9456 9457 if (monthNames) { 9458 localeData._months = monthNames; 9459 } 9460 if (monthNamesShort) { 9461 localeData._monthsShort = monthNamesShort; 9462 } 9463 if (dayNames) { 9464 localeData._weekdays = dayNames; 9465 } 9466 if (dayNamesShort) { 9467 localeData._weekdaysShort = dayNamesShort; 9468 } 9469 if (firstDay != null) { 9470 var _week = createObject(localeData._week); // _week: { dow: # } 9471 _week.dow = firstDay; 9472 localeData._week = _week; 9473 } 9474 9475 if (weekNumberCalculation === 'iso') { 9476 weekNumberCalculation = 'ISO'; // normalize 9477 } 9478 if ( // whitelist certain kinds of input 9479 weekNumberCalculation === 'ISO' || 9480 weekNumberCalculation === 'local' || 9481 typeof weekNumberCalculation === 'function' 9482 ) { 9483 localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it 9484 } 9485 9486 // If the internal current date object already exists, move to new locale. 9487 // We do NOT need to do this technique for event dates, because this happens when converting to "segments". 9488 if (date) { 9489 localizeMoment(date); // sets to localeData 9490 } 9491 }); 9492 9493 9494 9495 // Calendar-specific Date Utilities 9496 // ----------------------------------------------------------------------------------- 9497 9498 9499 t.defaultAllDayEventDuration = moment.duration(t.options.defaultAllDayEventDuration); 9500 t.defaultTimedEventDuration = moment.duration(t.options.defaultTimedEventDuration); 9501 9502 9503 // Builds a moment using the settings of the current calendar: timezone and language. 9504 // Accepts anything the vanilla moment() constructor accepts. 9505 t.moment = function() { 9506 var mom; 9507 9508 if (t.options.timezone === 'local') { 9509 mom = FC.moment.apply(null, arguments); 9510 9511 // Force the moment to be local, because FC.moment doesn't guarantee it. 9512 if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone 9513 mom.local(); 9514 } 9515 } 9516 else if (t.options.timezone === 'UTC') { 9517 mom = FC.moment.utc.apply(null, arguments); // process as UTC 9518 } 9519 else { 9520 mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone 9521 } 9522 9523 localizeMoment(mom); 9524 9525 return mom; 9526 }; 9527 9528 9529 // Updates the given moment's locale settings to the current calendar locale settings. 9530 function localizeMoment(mom) { 9531 if ('_locale' in mom) { // moment 2.8 and above 9532 mom._locale = localeData; 9533 } 9534 else { // pre-moment-2.8 9535 mom._lang = localeData; 9536 } 9537 } 9538 9539 9540 // Returns a boolean about whether or not the calendar knows how to calculate 9541 // the timezone offset of arbitrary dates in the current timezone. 9542 t.getIsAmbigTimezone = function() { 9543 return t.options.timezone !== 'local' && t.options.timezone !== 'UTC'; 9544 }; 9545 9546 9547 // Returns a copy of the given date in the current timezone. Has no effect on dates without times. 9548 t.applyTimezone = function(date) { 9549 if (!date.hasTime()) { 9550 return date.clone(); 9551 } 9552 9553 var zonedDate = t.moment(date.toArray()); 9554 var timeAdjust = date.time() - zonedDate.time(); 9555 var adjustedZonedDate; 9556 9557 // Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396) 9558 if (timeAdjust) { // is the time result different than expected? 9559 adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds 9560 if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now? 9561 zonedDate = adjustedZonedDate; 9562 } 9563 } 9564 9565 return zonedDate; 9566 }; 9567 9568 9569 // Returns a moment for the current date, as defined by the client's computer or from the `now` option. 9570 // Will return an moment with an ambiguous timezone. 9571 t.getNow = function() { 9572 var now = t.options.now; 9573 if (typeof now === 'function') { 9574 now = now(); 9575 } 9576 return t.moment(now).stripZone(); 9577 }; 9578 9579 9580 // Get an event's normalized end date. If not present, calculate it from the defaults. 9581 t.getEventEnd = function(event) {
9582 if (event.end) { 9583 return event.end.clone(); 9584 } 9585 else { 9586 return t.getDefaultEventEnd(event.allDay, event.start); 9587 } 9588 }; 9589 9590 9591 // Given an event's allDay status and start date, return what its fallback end date should be. 9592 // TODO: rename to computeDefaultEventEnd 9593 t.getDefaultEventEnd = function(allDay, zonedStart) { 9594 var end = zonedStart.clone(); 9595 9596 if (allDay) { 9597 end.stripTime().add(t.defaultAllDayEventDuration); 9598 } 9599 else { 9600 end.add(t.defaultTimedEventDuration); 9601 } 9602 9603 if (t.getIsAmbigTimezone()) { 9604 end.stripZone(); // we don't know what the tzo should be 9605 } 9606 9607 return end; 9608 }; 9609 9610 9611 // Produces a human-readable string for the given duration. 9612 // Side-effect: changes the locale of the given duration. 9613 t.humanizeDuration = function(duration) { 9614 return (duration.locale || duration.lang).call(duration, t.options.lang) // works moment-pre-2.8 9615 .humanize(); 9616 }; 9617 9618 9619 9620 // Imports 9621 // ----------------------------------------------------------------------------------- 9622 9623 9624 EventManager.call(t); 9625 var isFetchNeeded = t.isFetchNeeded; 9626 var fetchEvents = t.fetchEvents; 9627 var fetchEventSources = t.fetchEventSources; 9628 9629 9630 9631 // Locals 9632 // ----------------------------------------------------------------------------------- 9633 9634 9635 var _element = element[0]; 9636 var header; 9637 var content; 9638 var tm; // for making theme classes 9639 var currentView; // NOTE: keep this in sync with this.view 9640 var viewsByType = {}; // holds all instantiated view instances, current or not 9641 var suggestedViewHeight; 9642 var windowResizeProxy; // wraps the windowResize function 9643 var ignoreWindowResize = 0; 9644 var events = []; 9645 var date; // unzoned 9646 9647 9648 9649 // Main Rendering 9650 // ----------------------------------------------------------------------------------- 9651 9652 9653 // compute the initial ambig-timezone date 9654 if (t.options.defaultDate != null) { 9655 date = t.moment(t.options.defaultDate).stripZone(); 9656 } 9657 else { 9658 date = t.getNow(); // getNow already returns unzoned 9659 } 9660 9661 9662 function render() { 9663 if (!content) { 9664 initialRender(); 9665 } 9666 else if (elementVisible()) { 9667 // mainly for the public API 9668 calcSize(); 9669 renderView(); 9670 } 9671 } 9672 9673 9674 function initialRender() { 9675 element.addClass('fc'); 9676 9677 // called immediately, and upon option change 9678 t.bindOption('theme', function(theme) { 9679 tm = theme ? 'ui' : 'fc'; // affects a larger scope 9680 element.toggleClass('ui-widget', theme); 9681 element.toggleClass('fc-unthemed', !theme); 9682 }); 9683 9684 // called immediately, and upon option change. 9685 // HACK: lang often affects isRTL, so we explicitly listen to that too. 9686 t.bindOptions([ 'isRTL', 'lang' ], function(isRTL) { 9687 element.toggleClass('fc-ltr', !isRTL); 9688 element.toggleClass('fc-rtl', isRTL); 9689 }); 9690 9691 content = $("<div class='fc-view-container'/>").prependTo(element); 9692 9693 header = t.header = new Header(t); 9694 renderHeader(); 9695 9696 renderView(t.options.defaultView); 9697 9698 if (t.options.handleWindowResize) { 9699 windowResizeProxy = debounce(windowResize, t.options.windowResizeDelay); // prevents rapid calls 9700 $(window).resize(windowResizeProxy); 9701 } 9702 } 9703 9704 9705 // can be called repeatedly and Header will rerender 9706 function renderHeader() { 9707 header.render(); 9708 if (header.el) { 9709 element.prepend(header.el); 9710 } 9711 } 9712 9713 9714 function destroy() { 9715 9716 if (currentView) { 9717 currentView.removeElement(); 9718 9719 // NOTE: don't null-out currentView/t.view in case API methods are called after destroy. 9720 // It is still the "current" view, just not rendered. 9721 } 9722 9723 header.removeElement(); 9724 content.remove(); 9725 element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget'); 9726 9727 if (windowResizeProxy) { 9728 $(window).unbind('resize', windowResizeProxy); 9729 } 9730 } 9731 9732 9733 function elementVisible() { 9734 return element.is(':visible'); 9735 } 9736 9737 9738 9739 // View Rendering 9740 // ----------------------------------------------------------------------------------- 9741 9742 9743 // Renders a view because of a date change, view-type change, or for the first time. 9744 // If not given a viewType, keep the current view but render different dates. 9745 // Accepts an optional scroll state to restore to. 9746 function renderView(viewType, explicitScrollState) { 9747 ignoreWindowResize++; 9748 9749 // if viewType is changing, remove the old view's rendering 9750 if (currentView && viewType && currentView.type !== viewType) { 9751 freezeContentHeight(); // prevent a scroll jump when view element is removed 9752 clearView(); 9753 } 9754 9755 // if viewType changed, or the view was never created, create a fresh view 9756 if (!currentView && viewType) { 9757 currentView = t.view = 9758 viewsByType[viewType] || 9759 (viewsByType[viewType] = t.instantiateView(viewType)); 9760 9761 currentView.setElement( 9762 $("<div class='fc-view fc-" + viewType + "-view' />").appendTo(content) 9763 ); 9764 header.activateButton(viewType); 9765 } 9766 9767 if (currentView) { 9768 9769 // in case the view should render a period of time that is completely hidden 9770 date = currentView.massageCurrentDate(date); 9771 9772 // render or rerender the view 9773 if ( 9774 !currentView.displaying || 9775 !date.isWithin(currentView.intervalStart, currentView.intervalEnd) // implicit date window change 9776 ) { 9777 if (elementVisible()) { 9778 9779 currentView.display(date, explicitScrollState); // will call freezeContentHeight 9780 unfreezeContentHeight(); // immediately unfreeze regardless of whether display is async 9781 9782 // need to do this after View::render, so dates are calculated 9783 updateHeaderTitle(); 9784 updateTodayButton(); 9785 9786 getAndRenderEvents(); 9787 } 9788 } 9789 } 9790 9791 unfreezeContentHeight(); // undo any lone freezeContentHeight calls 9792 ignoreWindowResize--; 9793 } 9794 9795 9796 // Unrenders the current view and reflects this change in the Header. 9797 // Unregsiters the `currentView`, but does not remove from viewByType hash. 9798 function clearView() { 9799 header.deactivateButton(currentView.type);
9800 currentView.removeElement(); 9801 currentView = t.view = null; 9802 } 9803 9804 9805 // Destroys the view, including the view object. Then, re-instantiates it and renders it. 9806 // Maintains the same scroll state. 9807 // TODO: maintain any other user-manipulated state. 9808 function reinitView() { 9809 ignoreWindowResize++; 9810 freezeContentHeight(); 9811 9812 var viewType = currentView.type; 9813 var scrollState = currentView.queryScroll(); 9814 clearView(); 9815 renderView(viewType, scrollState); 9816 9817 unfreezeContentHeight(); 9818 ignoreWindowResize--; 9819 } 9820 9821 9822 9823 // Resizing 9824 // ----------------------------------------------------------------------------------- 9825 9826 9827 t.getSuggestedViewHeight = function() { 9828 if (suggestedViewHeight === undefined) { 9829 calcSize(); 9830 } 9831 return suggestedViewHeight; 9832 }; 9833 9834 9835 t.isHeightAuto = function() { 9836 return t.options.contentHeight === 'auto' || t.options.height === 'auto'; 9837 }; 9838 9839 9840 function updateSize(shouldRecalc) { 9841 if (elementVisible()) { 9842 9843 if (shouldRecalc) { 9844 _calcSize(); 9845 } 9846 9847 ignoreWindowResize++; 9848 currentView.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto() 9849 ignoreWindowResize--; 9850 9851 return true; // signal success 9852 } 9853 } 9854 9855 9856 function calcSize() { 9857 if (elementVisible()) { 9858 _calcSize(); 9859 } 9860 } 9861 9862 9863 function _calcSize() { // assumes elementVisible 9864 var contentHeightInput = t.options.contentHeight; 9865 var heightInput = t.options.height; 9866 9867 if (typeof contentHeightInput === 'number') { // exists and not 'auto' 9868 suggestedViewHeight = contentHeightInput; 9869 } 9870 else if (typeof contentHeightInput === 'function') { // exists and is a function 9871 suggestedViewHeight = contentHeightInput(); 9872 } 9873 else if (typeof heightInput === 'number') { // exists and not 'auto' 9874 suggestedViewHeight = heightInput - queryHeaderHeight(); 9875 } 9876 else if (typeof heightInput === 'function') { // exists and is a function 9877 suggestedViewHeight = heightInput() - queryHeaderHeight(); 9878 } 9879 else if (heightInput === 'parent') { // set to height of parent element 9880 suggestedViewHeight = element.parent().height() - queryHeaderHeight(); 9881 } 9882 else { 9883 suggestedViewHeight = Math.round(content.width() / Math.max(t.options.aspectRatio, .5)); 9884 } 9885 } 9886 9887 9888 function queryHeaderHeight() { 9889 return header.el ? header.el.outerHeight(true) : 0; // includes margin 9890 } 9891 9892 9893 function windowResize(ev) { 9894 if ( 9895 !ignoreWindowResize && 9896 ev.target === window && // so we don't process jqui "resize" events that have bubbled up 9897 currentView.start // view has already been rendered 9898 ) { 9899 if (updateSize(true)) { 9900 currentView.trigger('windowResize', _element); 9901 } 9902 } 9903 } 9904 9905 9906 9907 /* Event Fetching/Rendering 9908 -----------------------------------------------------------------------------*/ 9909 // TODO: going forward, most of this stuff should be directly handled by the view 9910 9911 9912 function refetchEvents() { // can be called as an API method 9913 fetchAndRenderEvents(); 9914 } 9915 9916 9917 // TODO: move this into EventManager? 9918 function refetchEventSources(matchInputs) { 9919 fetchEventSources(t.getEventSourcesByMatchArray(matchInputs)); 9920 } 9921 9922 9923 function renderEvents() { // destroys old events if previously rendered 9924 if (elementVisible()) { 9925 freezeContentHeight(); 9926 currentView.displayEvents(events); 9927 unfreezeContentHeight(); 9928 } 9929 } 9930 9931 9932 function getAndRenderEvents() { 9933 if (!t.options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) { 9934 fetchAndRenderEvents(); 9935 } 9936 else { 9937 renderEvents(); 9938 } 9939 } 9940 9941 9942 function fetchAndRenderEvents() { 9943 fetchEvents(currentView.start, currentView.end); 9944 // ... will call reportEvents 9945 // ... which will call renderEvents 9946 } 9947 9948 9949 // called when event data arrives 9950 function reportEvents(_events) { 9951 events = _events; 9952 renderEvents(); 9953 } 9954 9955 9956 // called when a single event's data has been changed 9957 function reportEventChange() { 9958 renderEvents(); 9959 } 9960 9961 9962 9963 /* Header Updating 9964 -----------------------------------------------------------------------------*/ 9965 9966 9967 function updateHeaderTitle() { 9968 header.updateTitle(currentView.title); 9969 } 9970 9971 9972 function updateTodayButton() { 9973 var now = t.getNow(); 9974 if (now.isWithin(currentView.intervalStart, currentView.intervalEnd)) { 9975 header.disableButton('today'); 9976 } 9977 else { 9978 header.enableButton('today'); 9979 } 9980 } 9981 9982 9983 9984 /* Selection 9985 -----------------------------------------------------------------------------*/ 9986 9987 9988 // this public method receives start/end dates in any format, with any timezone 9989 function select(zonedStartInput, zonedEndInput) { 9990 currentView.select( 9991 t.buildSelectSpan.apply(t, arguments) 9992 ); 9993 } 9994 9995 9996 function unselect() { // safe to be called before renderView 9997 if (currentView) { 9998 currentView.unselect(); 9999 } 10000 } 10001 10002 10003 10004 /* Date 10005 -----------------------------------------------------------------------------*/ 10006 10007 10008 function prev() { 10009 date = currentView.computePrevDate(date); 10010 renderView(); 10011 } 10012 10013 10014 function next() { 10015 date = currentView.computeNextDate(date); 10016 renderView(); 10017 } 10018 10019 10020 function prevYear() { 10021 date.add(-1, 'years'); 10022 renderView(); 10023 } 10024 10025 10026 function nextYear() { 10027 date.add(1, 'years'); 10028 renderView(); 10029 } 10030 10031 10032 function today() { 10033 date = t.getNow(); 10034 renderView(); 10035 } 10036 10037 10038 function gotoDate(zonedDateInput) { 10039 date = t.moment(zonedDateInput).stripZone(); 10040 renderView(); 10041 } 10042 10043 10044 function incrementDate(delta) { 10045 date.add(moment.duration(delta)); 10046 renderView(); 10047 } 10048 10049
10050 // Forces navigation to a view for the given date. 10051 // `viewType` can be a specific view name or a generic one like "week" or "day". 10052 function zoomTo(newDate, viewType) { 10053 var spec; 10054 10055 viewType = viewType || 'day'; // day is default zoom 10056 spec = t.getViewSpec(viewType) || t.getUnitViewSpec(viewType); 10057 10058 date = newDate.clone(); 10059 renderView(spec ? spec.type : null); 10060 } 10061 10062 10063 // for external API 10064 function getDate() { 10065 return t.applyTimezone(date); // infuse the calendar's timezone 10066 } 10067 10068 10069 10070 /* Height "Freezing" 10071 -----------------------------------------------------------------------------*/ 10072 // TODO: move this into the view 10073 10074 t.freezeContentHeight = freezeContentHeight; 10075 t.unfreezeContentHeight = unfreezeContentHeight; 10076 10077 10078 function freezeContentHeight() { 10079 content.css({ 10080 width: '100%', 10081 height: content.height(), 10082 overflow: 'hidden' 10083 }); 10084 } 10085 10086 10087 function unfreezeContentHeight() { 10088 content.css({ 10089 width: '', 10090 height: '', 10091 overflow: '' 10092 }); 10093 } 10094 10095 10096 10097 /* Misc 10098 -----------------------------------------------------------------------------*/ 10099 10100 10101 function getCalendar() { 10102 return t; 10103 } 10104 10105 10106 function getView() { 10107 return currentView; 10108 } 10109 10110 10111 function option(name, value) { 10112 var newOptionHash; 10113 10114 if (typeof name === 'string') { 10115 if (value === undefined) { // getter 10116 return t.options[name]; 10117 } 10118 else { // setter for individual option 10119 newOptionHash = {}; 10120 newOptionHash[name] = value; 10121 setOptions(newOptionHash); 10122 } 10123 } 10124 else if (typeof name === 'object') { // compound setter with object input 10125 setOptions(name); 10126 } 10127 } 10128 10129 10130 function setOptions(newOptionHash) { 10131 var optionCnt = 0; 10132 var optionName; 10133 10134 for (optionName in newOptionHash) { 10135 t.dynamicOverrides[optionName] = newOptionHash[optionName]; 10136 } 10137 10138 t.viewSpecCache = {}; // the dynamic override invalidates the options in this cache, so just clear it 10139 t.populateOptionsHash(); // this.options needs to be recomputed after the dynamic override 10140 10141 // trigger handlers after this.options has been updated 10142 for (optionName in newOptionHash) { 10143 t.triggerOptionHandlers(optionName); // recall bindOption/bindOptions 10144 optionCnt++; 10145 } 10146 10147 // special-case handling of single option change. 10148 // if only one option change, `optionName` will be its name. 10149 if (optionCnt === 1) { 10150 if (optionName === 'height' || optionName === 'contentHeight' || optionName === 'aspectRatio') { 10151 updateSize(true); // true = allow recalculation of height 10152 return; 10153 } 10154 else if (optionName === 'defaultDate') { 10155 return; // can't change date this way. use gotoDate instead 10156 } 10157 else if (optionName === 'businessHours') { 10158 if (currentView) { 10159 currentView.unrenderBusinessHours(); 10160 currentView.renderBusinessHours(); 10161 } 10162 return; 10163 } 10164 else if (optionName === 'timezone') { 10165 t.rezoneArrayEventSources(); 10166 refetchEvents(); 10167 return; 10168 } 10169 } 10170 10171 // catch-all. rerender the header and rebuild/rerender the current view 10172 renderHeader(); 10173 viewsByType = {}; // even non-current views will be affected by this option change. do before rerender 10174 reinitView(); 10175 } 10176 10177 10178 function trigger(name, thisObj) { // overrides the Emitter's trigger method :( 10179 var args = Array.prototype.slice.call(arguments, 2); 10180 10181 thisObj = thisObj || _element; 10182 this.triggerWith(name, thisObj, args); // Emitter's method 10183 10184 if (t.options[name]) { 10185 return t.options[name].apply(thisObj, args); 10186 } 10187 } 10188 10189 t.initialize(); 10190} 10191 10192;; 10193/* 10194Options binding/triggering system. 10195*/ 10196Calendar.mixin({ 10197 10198 // A map of option names to arrays of handler objects. Initialized to {} in Calendar. 10199 // Format for a handler object: 10200 // { 10201 // func // callback function to be called upon change 10202 // names // option names whose values should be given to func 10203 // } 10204 optionHandlers: null, 10205 10206 // Calls handlerFunc immediately, and when the given option has changed. 10207 // handlerFunc will be given the option value. 10208 bindOption: function(optionName, handlerFunc) { 10209 this.bindOptions([ optionName ], handlerFunc); 10210 }, 10211 10212 // Calls handlerFunc immediately, and when any of the given options change. 10213 // handlerFunc will be given each option value as ordered function arguments. 10214 bindOptions: function(optionNames, handlerFunc) { 10215 var handlerObj = { func: handlerFunc, names: optionNames }; 10216 var i; 10217 10218 for (i = 0; i < optionNames.length; i++) { 10219 this.registerOptionHandlerObj(optionNames[i], handlerObj); 10220 } 10221 10222 this.triggerOptionHandlerObj(handlerObj); 10223 }, 10224 10225 // Puts the given handler object into the internal hash 10226 registerOptionHandlerObj: function(optionName, handlerObj) { 10227 (this.optionHandlers[optionName] || (this.optionHandlers[optionName] = [])) 10228 .push(handlerObj); 10229 }, 10230 10231 // Reports that the given option has changed, and calls all appropriate handlers. 10232 triggerOptionHandlers: function(optionName) { 10233 var handlerObjs = this.optionHandlers[optionName] || []; 10234 var i; 10235 10236 for (i = 0; i < handlerObjs.length; i++) { 10237 this.triggerOptionHandlerObj(handlerObjs[i]); 10238 } 10239 }, 10240 10241 // Calls the callback for a specific handler object, passing in the appropriate arguments. 10242 triggerOptionHandlerObj: function(handlerObj) { 10243 var optionNames = handlerObj.names; 10244 var optionValues = []; 10245 var i; 10246 10247 for (i = 0; i < optionNames.length; i++) { 10248 optionValues.push(this.options[optionNames[i]]); 10249 } 10250 10251 handlerObj.func.apply(this, optionValues); // maintain the Calendar's `this` context 10252 } 10253 10254}); 10255 10256;; 10257 10258Calendar.defaults = { 10259 10260 titleRangeSeparator: ' \u2013 ', // en dash 10261 monthYearFormat: 'MMMM YYYY', // required for en. other languages rely on datepicker computable option 10262 10263 defaultTimedEventDuration: '02:00:00', 10264 defaultAllDayEventDuration: { days: 1 }, 10265 forceEventDuration: false, 10266 nextDayThreshold: '09:00:00', // 9am 10267 10268 // display 10269 defaultView: 'month', 10270 aspectRatio: 1.35, 10271 header: { 10272 left: 'title', 10273 center: '', 10274 right: 'today prev,next' 10275 }, 10276 weekends: true, 10277 weekNumbers: false, 10278 10279 weekNumberTitle: 'W', 10280 weekNumberCalculation: 'local', 10281 10282 //editable: false, 10283 10284 //nowIndicator: false, 10285 10286 scrollTime: '06:00:00', 10287 10288 // event ajax 10289 lazyFetching: true, 10290 startParam: 'start', 10291 endParam: 'end', 10292 timezoneParam: 'timezone', 10293 10294 timezone: false, 10295 10296 //allDayDefault: undefined, 10297 10298 // locale 10299 isRTL: false, 10300 buttonText: { 10301 prev: "prev", 10302 next: "next", 10303 prevYear: "prev year", 10304 nextYear: "next year", 10305 year: 'year', // TODO: locale files need to specify this 10306 today: 'today', 10307 month: 'month', 10308 week: 'week', 10309 day: 'day' 10310 }, 10311 10312 buttonIcons: { 10313 prev: 'left-single-arrow', 10314 next: 'right-single-arrow', 10315 prevYear: 'left-double-arrow', 10316 nextYear: 'right-double-arrow' 10317 }, 10318 10319 // jquery-ui theming 10320 theme: false, 10321 themeButtonIcons: { 10322 prev: 'circle-triangle-w', 10323 next: 'circle-triangle-e', 10324 prevYear: 'seek-prev', 10325 nextYear: 'seek-next' 10326 }, 10327 10328 //eventResizableFromStart: false, 10329 dragOpacity: .75, 10330 dragRevertDuration: 500, 10331 dragScroll: true, 10332 10333 //selectable: false, 10334 unselectAuto: true, 10335 10336 dropAccept: '*', 10337 10338 eventOrder: 'title', 10339 10340 eventLimit: false, 10341 eventLimitText: 'more', 10342 eventLimitClick: 'popover', 10343 dayPopoverFormat: 'LL', 10344 10345 handleWindowResize: true, 10346 windowResizeDelay: 100, // milliseconds before an updateSize happens 10347 10348 longPressDelay: 1000 10349 10350}; 10351 10352 10353Calendar.englishDefaults = { // used by lang.js 10354 dayPopoverFormat: 'dddd, MMMM D' 10355}; 10356 10357 10358Calendar.rtlDefaults = { // right-to-left defaults 10359 header: { // TODO: smarter solution (first/center/last ?) 10360 left: 'next,prev today', 10361 center: '', 10362 right: 'title' 10363 }, 10364 buttonIcons: { 10365 prev: 'right-single-arrow', 10366 next: 'left-single-arrow', 10367 prevYear: 'right-double-arrow', 10368 nextYear: 'left-double-arrow' 10369 }, 10370 themeButtonIcons: { 10371 prev: 'circle-triangle-e', 10372 next: 'circle-triangle-w', 10373 nextYear: 'seek-prev', 10374 prevYear: 'seek-next' 10375 } 10376}; 10377 10378;; 10379 10380var langOptionHash = FC.langs = {}; // initialize and expose 10381 10382 10383// TODO: document the structure and ordering of a FullCalendar lang file 10384// TODO: rename everything "lang" to "locale", like what the moment project did 10385 10386 10387// Initialize jQuery UI datepicker translations while using some of the translations 10388// Will set this as the default language for datepicker. 10389FC.datepickerLang = function(langCode, dpLangCode, dpOptions) { 10390 10391 // get the FullCalendar internal option hash for this language. create if necessary 10392 var fcOptions = langOptionHash[langCode] || (langOptionHash[langCode] = {}); 10393 10394 // transfer some simple options from datepicker to fc 10395 fcOptions.isRTL = dpOptions.isRTL; 10396 fcOptions.weekNumberTitle = dpOptions.weekHeader; 10397 10398 // compute some more complex options from datepicker 10399 $.each(dpComputableOptions, function(name, func) { 10400 fcOptions[name] = func(dpOptions); 10401 }); 10402 10403 // is jQuery UI Datepicker is on the page? 10404 if ($.datepicker) { 10405 10406 // Register the language data. 10407 // FullCalendar and MomentJS use language codes like "pt-br" but Datepicker 10408 // does it like "pt-BR" or if it doesn't have the language, maybe just "pt". 10409 // Make an alias so the language can be referenced either way. 10410 $.datepicker.regional[dpLangCode] = 10411 $.datepicker.regional[langCode] = // alias 10412 dpOptions; 10413 10414 // Alias 'en' to the default language data. Do this every time. 10415 $.datepicker.regional.en = $.datepicker.regional['']; 10416 10417 // Set as Datepicker's global defaults. 10418 $.datepicker.setDefaults(dpOptions); 10419 } 10420}; 10421 10422 10423// Sets FullCalendar-specific translations. Will set the language as the global default. 10424FC.lang = function(langCode, newFcOptions) { 10425 var fcOptions; 10426 var momOptions; 10427 10428 // get the FullCalendar internal option hash for this language. create if necessary 10429 fcOptions = langOptionHash[langCode] || (langOptionHash[langCode] = {}); 10430 10431 // provided new options for this language? merge them in 10432 if (newFcOptions) { 10433 fcOptions = langOptionHash[langCode] = mergeOptions([ fcOptions, newFcOptions ]); 10434 } 10435 10436 // compute language options that weren't defined. 10437 // always do this. newFcOptions can be undefined when initializing from i18n file, 10438 // so no way to tell if this is an initialization or a default-setting. 10439 momOptions = getMomentLocaleData(langCode); // will fall back to en 10440 $.each(momComputableOptions, function(name, func) { 10441 if (fcOptions[name] == null) { 10442 fcOptions[name] = func(momOptions, fcOptions); 10443 } 10444 }); 10445 10446 // set it as the default language for FullCalendar 10447 Calendar.defaults.lang = langCode;
10448}; 10449 10450 10451// NOTE: can't guarantee any of these computations will run because not every language has datepicker 10452// configs, so make sure there are English fallbacks for these in the defaults file. 10453var dpComputableOptions = { 10454 10455 buttonText: function(dpOptions) { 10456 return { 10457 // the translations sometimes wrongly contain HTML entities 10458 prev: stripHtmlEntities(dpOptions.prevText), 10459 next: stripHtmlEntities(dpOptions.nextText), 10460 today: stripHtmlEntities(dpOptions.currentText) 10461 }; 10462 }, 10463 10464 // Produces format strings like "MMMM YYYY" -> "September 2014" 10465 monthYearFormat: function(dpOptions) { 10466 return dpOptions.showMonthAfterYear ? 10467 'YYYY[' + dpOptions.yearSuffix + '] MMMM' : 10468 'MMMM YYYY[' + dpOptions.yearSuffix + ']'; 10469 } 10470 10471}; 10472 10473var momComputableOptions = { 10474 10475 // Produces format strings like "ddd M/D" -> "Fri 9/15" 10476 dayOfMonthFormat: function(momOptions, fcOptions) { 10477 var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY" 10478 10479 // strip the year off the edge, as well as other misc non-whitespace chars 10480 format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, ''); 10481 10482 if (fcOptions.isRTL) { 10483 format += ' ddd'; // for RTL, add day-of-week to end 10484 } 10485 else { 10486 format = 'ddd ' + format; // for LTR, add day-of-week to beginning 10487 } 10488 return format; 10489 }, 10490 10491 // Produces format strings like "h:mma" -> "6:00pm" 10492 mediumTimeFormat: function(momOptions) { // can't be called `timeFormat` because collides with option 10493 return momOptions.longDateFormat('LT') 10494 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand 10495 }, 10496 10497 // Produces format strings like "h(:mm)a" -> "6pm" / "6:30pm" 10498 smallTimeFormat: function(momOptions) { 10499 return momOptions.longDateFormat('LT') 10500 .replace(':mm', '(:mm)') 10501 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs 10502 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand 10503 }, 10504 10505 // Produces format strings like "h(:mm)t" -> "6p" / "6:30p" 10506 extraSmallTimeFormat: function(momOptions) { 10507 return momOptions.longDateFormat('LT') 10508 .replace(':mm', '(:mm)') 10509 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs 10510 .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand 10511 }, 10512 10513 // Produces format strings like "ha" / "H" -> "6pm" / "18" 10514 hourFormat: function(momOptions) { 10515 return momOptions.longDateFormat('LT') 10516 .replace(':mm', '') 10517 .replace(/(\Wmm)$/, '') // like above, but for foreign langs 10518 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand 10519 }, 10520 10521 // Produces format strings like "h:mm" -> "6:30" (with no AM/PM) 10522 noMeridiemTimeFormat: function(momOptions) { 10523 return momOptions.longDateFormat('LT') 10524 .replace(/\s*a$/i, ''); // remove trailing AM/PM 10525 } 10526 10527}; 10528 10529 10530// options that should be computed off live calendar options (considers override options) 10531// TODO: best place for this? related to lang? 10532// TODO: flipping text based on isRTL is a bad idea because the CSS `direction` might want to handle it 10533var instanceComputableOptions = { 10534 10535 // Produces format strings for results like "Mo 16" 10536 smallDayDateFormat: function(options) { 10537 return options.isRTL ? 10538 'D dd' : 10539 'dd D'; 10540 }, 10541 10542 // Produces format strings for results like "Wk 5" 10543 weekFormat: function(options) { 10544 return options.isRTL ? 10545 'w[ ' + options.weekNumberTitle + ']' : 10546 '[' + options.weekNumberTitle + ' ]w'; 10547 }, 10548 10549 // Produces format strings for results like "Wk5" 10550 smallWeekFormat: function(options) { 10551 return options.isRTL ? 10552 'w[' + options.weekNumberTitle + ']' : 10553 '[' + options.weekNumberTitle + ']w'; 10554 } 10555 10556}; 10557 10558function populateInstanceComputableOptions(options) { 10559 $.each(instanceComputableOptions, function(name, func) { 10560 if (options[name] == null) { 10561 options[name] = func(options); 10562 } 10563 }); 10564} 10565 10566 10567// Returns moment's internal locale data. If doesn't exist, returns English. 10568// Works with moment-pre-2.8 10569function getMomentLocaleData(langCode) { 10570 var func = moment.localeData || moment.langData; 10571 return func.call(moment, langCode) || 10572 func.call(moment, 'en'); // the newer localData could return null, so fall back to en 10573} 10574 10575 10576// Initialize English by forcing computation of moment-derived options. 10577// Also, sets it as the default. 10578FC.lang('en', Calendar.englishDefaults); 10579 10580;; 10581 10582/* Top toolbar area with buttons and title 10583----------------------------------------------------------------------------------------------------------------------*/ 10584// TODO: rename all header-related things to "toolbar" 10585 10586function Header(calendar) { 10587 var t = this; 10588 10589 // exports 10590 t.render = render; 10591 t.removeElement = removeElement; 10592 t.updateTitle = updateTitle; 10593 t.activateButton = activateButton; 10594 t.deactivateButton = deactivateButton; 10595 t.disableButton = disableButton; 10596 t.enableButton = enableButton; 10597 t.getViewsWithButtons = getViewsWithButtons; 10598 t.el = null; // mirrors local `el` 10599 10600 // locals 10601 var el; 10602 var viewsWithButtons = []; 10603 var tm; 10604 10605 10606 // can be called repeatedly and will rerender 10607 function render() { 10608 var options = calendar.options; 10609 var sections = options.header; 10610 10611 tm = options.theme ? 'ui' : 'fc'; 10612 10613 if (sections) { 10614 if (!el) { 10615 el = this.el = $("<div class='fc-toolbar'/>"); 10616 } 10617 else { 10618 el.empty(); 10619 } 10620 el.append(renderSection('left')) 10621 .append(renderSection('right')) 10622 .append(renderSection('center')) 10623 .append('<div class="fc-clear"/>'); 10624 } 10625 else { 10626 removeElement(); 10627 } 10628 } 10629 10630 10631 function removeElement() { 10632 if (el) { 10633 el.remove(); 10634 el = t.el = null; 10635 } 10636 } 10637 10638
10639 function renderSection(position) { 10640 var sectionEl = $('<div class="fc-' + position + '"/>'); 10641 var options = calendar.options; 10642 var buttonStr = options.header[position]; 10643 10644 if (buttonStr) { 10645 $.each(buttonStr.split(' '), function(i) { 10646 var groupChildren = $(); 10647 var isOnlyButtons = true; 10648 var groupEl; 10649 10650 $.each(this.split(','), function(j, buttonName) { 10651 var customButtonProps; 10652 var viewSpec; 10653 var buttonClick; 10654 var overrideText; // text explicitly set by calendar's constructor options. overcomes icons 10655 var defaultText; 10656 var themeIcon; 10657 var normalIcon; 10658 var innerHtml; 10659 var classes; 10660 var button; // the element 10661 10662 if (buttonName == 'title') { 10663 groupChildren = groupChildren.add($('<h2> </h2>')); // we always want it to take up height 10664 isOnlyButtons = false; 10665 } 10666 else { 10667 if ((customButtonProps = (options.customButtons || {})[buttonName])) { 10668 buttonClick = function(ev) { 10669 if (customButtonProps.click) { 10670 customButtonProps.click.call(button[0], ev); 10671 } 10672 }; 10673 overrideText = ''; // icons will override text 10674 defaultText = customButtonProps.text; 10675 } 10676 else if ((viewSpec = calendar.getViewSpec(buttonName))) { 10677 buttonClick = function() { 10678 calendar.changeView(buttonName); 10679 }; 10680 viewsWithButtons.push(buttonName); 10681 overrideText = viewSpec.buttonTextOverride; 10682 defaultText = viewSpec.buttonTextDefault; 10683 } 10684 else if (calendar[buttonName]) { // a calendar method 10685 buttonClick = function() { 10686 calendar[buttonName](); 10687 }; 10688 overrideText = (calendar.overrides.buttonText || {})[buttonName]; 10689 defaultText = options.buttonText[buttonName]; // everything else is considered default 10690 } 10691 10692 if (buttonClick) { 10693 10694 themeIcon = 10695 customButtonProps ? 10696 customButtonProps.themeIcon : 10697 options.themeButtonIcons[buttonName]; 10698 10699 normalIcon = 10700 customButtonProps ? 10701 customButtonProps.icon : 10702 options.buttonIcons[buttonName]; 10703 10704 if (overrideText) { 10705 innerHtml = htmlEscape(overrideText); 10706 } 10707 else if (themeIcon && options.theme) { 10708 innerHtml = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>"; 10709 } 10710 else if (normalIcon && !options.theme) { 10711 innerHtml = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>"; 10712 } 10713 else { 10714 innerHtml = htmlEscape(defaultText); 10715 } 10716 10717 classes = [ 10718 'fc-' + buttonName + '-button', 10719 tm + '-button', 10720 tm + '-state-default' 10721 ]; 10722 10723 button = $( // type="button" so that it doesn't submit a form 10724 '<button type="button" class="' + classes.join(' ') + '">' + 10725 innerHtml + 10726 '</button>' 10727 ) 10728 .click(function(ev) { 10729 // don't process clicks for disabled buttons 10730 if (!button.hasClass(tm + '-state-disabled')) { 10731 10732 buttonClick(ev); 10733 10734 // after the click action, if the button becomes the "active" tab, or disabled, 10735 // it should never have a hover class, so remove it now. 10736 if ( 10737 button.hasClass(tm + '-state-active') || 10738 button.hasClass(tm + '-state-disabled') 10739 ) { 10740 button.removeClass(tm + '-state-hover'); 10741 } 10742 } 10743 }) 10744 .mousedown(function() { 10745 // the *down* effect (mouse pressed in). 10746 // only on buttons that are not the "active" tab, or disabled 10747 button 10748 .not('.' + tm + '-state-active') 10749 .not('.' + tm + '-state-disabled') 10750 .addClass(tm + '-state-down'); 10751 }) 10752 .mouseup(function() { 10753 // undo the *down* effect 10754 button.removeClass(tm + '-state-down'); 10755 }) 10756 .hover( 10757 function() { 10758 // the *hover* effect. 10759 // only on buttons that are not the "active" tab, or disabled 10760 button 10761 .not('.' + tm + '-state-active') 10762 .not('.' + tm + '-state-disabled') 10763 .addClass(tm + '-state-hover'); 10764 }, 10765 function() {
10766 // undo the *hover* effect 10767 button 10768 .removeClass(tm + '-state-hover') 10769 .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup 10770 } 10771 ); 10772 10773 groupChildren = groupChildren.add(button); 10774 } 10775 } 10776 }); 10777 10778 if (isOnlyButtons) { 10779 groupChildren 10780 .first().addClass(tm + '-corner-left').end() 10781 .last().addClass(tm + '-corner-right').end(); 10782 } 10783 10784 if (groupChildren.length > 1) { 10785 groupEl = $('<div/>'); 10786 if (isOnlyButtons) { 10787 groupEl.addClass('fc-button-group'); 10788 } 10789 groupEl.append(groupChildren); 10790 sectionEl.append(groupEl); 10791 } 10792 else { 10793 sectionEl.append(groupChildren); // 1 or 0 children 10794 } 10795 }); 10796 } 10797 10798 return sectionEl; 10799 } 10800 10801 10802 function updateTitle(text) { 10803 if (el) { 10804 el.find('h2').text(text); 10805 } 10806 } 10807 10808 10809 function activateButton(buttonName) { 10810 if (el) { 10811 el.find('.fc-' + buttonName + '-button') 10812 .addClass(tm + '-state-active'); 10813 } 10814 } 10815 10816 10817 function deactivateButton(buttonName) { 10818 if (el) { 10819 el.find('.fc-' + buttonName + '-button') 10820 .removeClass(tm + '-state-active'); 10821 } 10822 } 10823 10824 10825 function disableButton(buttonName) { 10826 if (el) { 10827 el.find('.fc-' + buttonName + '-button') 10828 .prop('disabled', true) 10829 .addClass(tm + '-state-disabled'); 10830 } 10831 } 10832 10833 10834 function enableButton(buttonName) { 10835 if (el) { 10836 el.find('.fc-' + buttonName + '-button') 10837 .prop('disabled', false) 10838 .removeClass(tm + '-state-disabled'); 10839 } 10840 } 10841 10842 10843 function getViewsWithButtons() { 10844 return viewsWithButtons; 10845 } 10846 10847} 10848 10849;; 10850 10851FC.sourceNormalizers = []; 10852FC.sourceFetchers = []; 10853 10854var ajaxDefaults = { 10855 dataType: 'json', 10856 cache: false 10857}; 10858 10859var eventGUID = 1; 10860 10861 10862function EventManager() { // assumed to be a calendar 10863 var t = this; 10864 10865 10866 // exports 10867 t.isFetchNeeded = isFetchNeeded; 10868 t.fetchEvents = fetchEvents; 10869 t.fetchEventSources = fetchEventSources; 10870 t.getEventSources = getEventSources; 10871 t.getEventSourceById = getEventSourceById; 10872 t.getEventSourcesByMatchArray = getEventSourcesByMatchArray; 10873 t.getEventSourcesByMatch = getEventSourcesByMatch; 10874 t.addEventSource = addEventSource; 10875 t.removeEventSource = removeEventSource; 10876 t.removeEventSources = removeEventSources; 10877 t.updateEvent = updateEvent; 10878 t.renderEvent = renderEvent; 10879 t.removeEvents = removeEvents; 10880 t.clientEvents = clientEvents; 10881 t.mutateEvent = mutateEvent; 10882 t.normalizeEventDates = normalizeEventDates; 10883 t.normalizeEventTimes = normalizeEventTimes; 10884 10885 10886 // imports 10887 var reportEvents = t.reportEvents; 10888 10889 10890 // locals 10891 var stickySource = { events: [] }; 10892 var sources = [ stickySource ]; 10893 var rangeStart, rangeEnd; 10894 var pendingSourceCnt = 0; // outstanding fetch requests, max one per source 10895 var cache = []; // holds events that have already been expanded 10896 10897 10898 $.each( 10899 (t.options.events ? [ t.options.events ] : []).concat(t.options.eventSources || []),
10900 function(i, sourceInput) { 10901 var source = buildEventSource(sourceInput); 10902 if (source) { 10903 sources.push(source); 10904 } 10905 } 10906 ); 10907 10908 10909 10910 /* Fetching 10911 -----------------------------------------------------------------------------*/ 10912 10913 10914 // start and end are assumed to be unzoned 10915 function isFetchNeeded(start, end) { 10916 return !rangeStart || // nothing has been fetched yet? 10917 start < rangeStart || end > rangeEnd; // is part of the new range outside of the old range? 10918 } 10919 10920 10921 function fetchEvents(start, end) { 10922 rangeStart = start; 10923 rangeEnd = end; 10924 fetchEventSources(sources, 'reset'); 10925 } 10926 10927 10928 // expects an array of event source objects (the originals, not copies) 10929 // `specialFetchType` is an optimization parameter that affects purging of the event cache. 10930 function fetchEventSources(specificSources, specialFetchType) { 10931 var i, source; 10932 10933 if (specialFetchType === 'reset') { 10934 cache = []; 10935 } 10936 else if (specialFetchType !== 'add') { 10937 cache = excludeEventsBySources(cache, specificSources); 10938 } 10939 10940 for (i = 0; i < specificSources.length; i++) { 10941 source = specificSources[i]; 10942 10943 // already-pending sources have already been accounted for in pendingSourceCnt 10944 if (source._status !== 'pending') { 10945 pendingSourceCnt++; 10946 } 10947 10948 source._fetchId = (source._fetchId || 0) + 1; 10949 source._status = 'pending'; 10950 } 10951 10952 for (i = 0; i < specificSources.length; i++) { 10953 source = specificSources[i]; 10954 10955 tryFetchEventSource(source, source._fetchId); 10956 } 10957 } 10958 10959 10960 // fetches an event source and processes its result ONLY if it is still the current fetch. 10961 // caller is responsible for incrementing pendingSourceCnt first. 10962 function tryFetchEventSource(source, fetchId) { 10963 _fetchEventSource(source, function(eventInputs) { 10964 var isArraySource = $.isArray(source.events); 10965 var i, eventInput; 10966 var abstractEvent; 10967 10968 if ( 10969 // is this the source's most recent fetch? 10970 // if not, rely on an upcoming fetch of this source to decrement pendingSourceCnt 10971 fetchId === source._fetchId && 10972 // event source no longer valid? 10973 source._status !== 'rejected' 10974 ) { 10975 source._status = 'resolved'; 10976 10977 if (eventInputs) { 10978 for (i = 0; i < eventInputs.length; i++) { 10979 eventInput = eventInputs[i]; 10980 10981 if (isArraySource) { // array sources have already been convert to Event Objects 10982 abstractEvent = eventInput; 10983 } 10984 else { 10985 abstractEvent = buildEventFromInput(eventInput, source); 10986 } 10987 10988 if (abstractEvent) { // not false (an invalid event) 10989 cache.push.apply( 10990 cache, 10991 expandEvent(abstractEvent) // add individual expanded events to the cache 10992 ); 10993 } 10994 } 10995 } 10996 10997 decrementPendingSourceCnt(); 10998 } 10999 }); 11000 } 11001 11002 11003 function rejectEventSource(source) { 11004 var wasPending = source._status === 'pending'; 11005 11006 source._status = 'rejected'; 11007 11008 if (wasPending) { 11009 decrementPendingSourceCnt(); 11010 } 11011 } 11012 11013 11014 function decrementPendingSourceCnt() { 11015 pendingSourceCnt--; 11016 if (!pendingSourceCnt) { 11017 reportEvents(cache); 11018 } 11019 } 11020 11021 11022 function _fetchEventSource(source, callback) { 11023 var i; 11024 var fetchers = FC.sourceFetchers; 11025 var res; 11026 11027 for (i=0; i<fetchers.length; i++) { 11028 res = fetchers[i].call( 11029 t, // this, the Calendar object 11030 source, 11031 rangeStart.clone(), 11032 rangeEnd.clone(), 11033 t.options.timezone, 11034 callback 11035 ); 11036 11037 if (res === true) { 11038 // the fetcher is in charge. made its own async request 11039 return; 11040 } 11041 else if (typeof res == 'object') { 11042 // the fetcher returned a new source. process it 11043 _fetchEventSource(res, callback); 11044 return; 11045 } 11046 } 11047 11048 var events = source.events; 11049 if (events) { 11050 if ($.isFunction(events)) { 11051 t.pushLoading(); 11052 events.call( 11053 t, // this, the Calendar object 11054 rangeStart.clone(), 11055 rangeEnd.clone(), 11056 t.options.timezone, 11057 function(events) { 11058 callback(events); 11059 t.popLoading(); 11060 } 11061 ); 11062 } 11063 else if ($.isArray(events)) { 11064 callback(events); 11065 } 11066 else { 11067 callback(); 11068 } 11069 }else{ 11070 var url = source.url; 11071 if (url) { 11072 var success = source.success; 11073 var error = source.error; 11074 var complete = source.complete; 11075 11076 // retrieve any outbound GET/POST $.ajax data from the options 11077 var customData; 11078 if ($.isFunction(source.data)) { 11079 // supplied as a function that returns a key/value object 11080 customData = source.data(); 11081 } 11082 else { 11083 // supplied as a straight key/value object 11084 customData = source.data; 11085 } 11086 11087 // use a copy of the custom data so we can modify the parameters 11088 // and not affect the passed-in object. 11089 var data = $.extend({}, customData || {}); 11090 11091 var startParam = firstDefined(source.startParam, t.options.startParam); 11092 var endParam = firstDefined(source.endParam, t.options.endParam); 11093 var timezoneParam = firstDefined(source.timezoneParam, t.options.timezoneParam); 11094 11095 if (startParam) { 11096 data[startParam] = rangeStart.format(); 11097 } 11098 if (endParam) { 11099 data[endParam] = rangeEnd.format(); 11100 } 11101 if (t.options.timezone && t.options.timezone != 'local') { 11102 data[timezoneParam] = t.options.timezone; 11103 } 11104 11105 t.pushLoading(); 11106 $.ajax($.extend({}, ajaxDefaults, source, { 11107 data: data, 11108 success: function(events) { 11109 events = events || []; 11110 var res = applyAll(success, this, arguments); 11111 if ($.isArray(res)) { 11112 events = res; 11113 } 11114 callback(events); 11115 }, 11116 error: function() { 11117 applyAll(error, this, arguments); 11118 callback(); 11119 }, 11120 complete: function() { 11121 applyAll(complete, this, arguments); 11122 t.popLoading(); 11123 } 11124 })); 11125 }else{ 11126 callback(); 11127 } 11128 } 11129 } 11130 11131 11132 11133 /* Sources 11134 -----------------------------------------------------------------------------*/ 11135 11136 11137 function addEventSource(sourceInput) { 11138 var source = buildEventSource(sourceInput); 11139 if (source) { 11140 sources.push(source); 11141 fetchEventSources([ source ], 'add'); // will eventually call reportEvents 11142 } 11143 } 11144 11145 11146 function buildEventSource(sourceInput) { // will return undefined if invalid source 11147 var normalizers = FC.sourceNormalizers; 11148 var source; 11149 var i; 11150 11151 if ($.isFunction(sourceInput) || $.isArray(sourceInput)) {
11152 source = { events: sourceInput }; 11153 } 11154 else if (typeof sourceInput === 'string') { 11155 source = { url: sourceInput }; 11156 } 11157 else if (typeof sourceInput === 'object') { 11158 source = $.extend({}, sourceInput); // shallow copy 11159 } 11160 11161 if (source) { 11162 11163 // TODO: repeat code, same code for event classNames 11164 if (source.className) { 11165 if (typeof source.className === 'string') { 11166 source.className = source.className.split(/\s+/); 11167 } 11168 // otherwise, assumed to be an array 11169 } 11170 else { 11171 source.className = []; 11172 } 11173 11174 // for array sources, we convert to standard Event Objects up front 11175 if ($.isArray(source.events)) { 11176 source.origArray = source.events; // for removeEventSource 11177 source.events = $.map(source.events, function(eventInput) { 11178 return buildEventFromInput(eventInput, source); 11179 }); 11180 } 11181 11182 for (i=0; i<normalizers.length; i++) { 11183 normalizers[i].call(t, source); 11184 } 11185 11186 return source; 11187 } 11188 } 11189 11190 11191 function removeEventSource(matchInput) { 11192 removeSpecificEventSources( 11193 getEventSourcesByMatch(matchInput) 11194 ); 11195 } 11196 11197 11198 // if called with no arguments, removes all. 11199 function removeEventSources(matchInputs) { 11200 if (matchInputs == null) { 11201 removeSpecificEventSources(sources, true); // isAll=true 11202 } 11203 else { 11204 removeSpecificEventSources( 11205 getEventSourcesByMatchArray(matchInputs) 11206 ); 11207 } 11208 } 11209 11210 11211 function removeSpecificEventSources(targetSources, isAll) { 11212 var i; 11213 11214 // cancel pending requests 11215 for (i = 0; i < targetSources.length; i++) { 11216 rejectEventSource(targetSources[i]); 11217 } 11218 11219 if (isAll) { // an optimization 11220 sources = []; 11221 cache = []; 11222 } 11223 else { 11224 // remove from persisted source list 11225 sources = $.grep(sources, function(source) { 11226 for (i = 0; i < targetSources.length; i++) { 11227 if (source === targetSources[i]) { 11228 return false; // exclude 11229 } 11230 } 11231 return true; // include 11232 }); 11233 11234 cache = excludeEventsBySources(cache, targetSources); 11235 } 11236 11237 reportEvents(cache); 11238 } 11239 11240 11241 function getEventSources() { 11242 return sources.slice(1); // returns a shallow copy of sources with stickySource removed 11243 } 11244 11245 11246 function getEventSourceById(id) { 11247 return $.grep(sources, function(source) { 11248 return source.id && source.id === id; 11249 })[0]; 11250 } 11251 11252 11253 // like getEventSourcesByMatch, but accepts multple match criteria (like multiple IDs) 11254 function getEventSourcesByMatchArray(matchInputs) { 11255 11256 // coerce into an array 11257 if (!matchInputs) { 11258 matchInputs = []; 11259 } 11260 else if (!$.isArray(matchInputs)) { 11261 matchInputs = [ matchInputs ]; 11262 } 11263 11264 var matchingSources = []; 11265 var i; 11266 11267 // resolve raw inputs to real event source objects 11268 for (i = 0; i < matchInputs.length; i++) { 11269 matchingSources.push.apply( // append 11270 matchingSources, 11271 getEventSourcesByMatch(matchInputs[i]) 11272 ); 11273 } 11274 11275 return matchingSources; 11276 } 11277 11278 11279 // matchInput can either by a real event source object, an ID, or the function/URL for the source. 11280 // returns an array of matching source objects. 11281 function getEventSourcesByMatch(matchInput) { 11282 var i, source; 11283 11284 // given an proper event source object 11285 for (i = 0; i < sources.length; i++) { 11286 source = sources[i]; 11287 if (source === matchInput) { 11288 return [ source ]; 11289 } 11290 } 11291 11292 // an ID match 11293 source = getEventSourceById(matchInput); 11294 if (source) { 11295 return [ source ]; 11296 } 11297 11298 return $.grep(sources, function(source) { 11299 return isSourcesEquivalent(matchInput, source); 11300 }); 11301 } 11302 11303 11304 function isSourcesEquivalent(source1, source2) { 11305 return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2); 11306 } 11307 11308 11309 function getSourcePrimitive(source) { 11310 return ( 11311 (typeof source === 'object') ? // a normalized event source? 11312 (source.origArray || source.googleCalendarId || source.url || source.events) : // get the primitive 11313 null 11314 ) || 11315 source; // the given argument *is* the primitive 11316 } 11317 11318 11319 // util 11320 // returns a filtered array without events that are part of any of the given sources 11321 function excludeEventsBySources(specificEvents, specificSources) { 11322 return $.grep(specificEvents, function(event) { 11323 for (var i = 0; i < specificSources.length; i++) { 11324 if (event.source === specificSources[i]) { 11325 return false; // exclude 11326 } 11327 } 11328 return true; // keep 11329 }); 11330 } 11331 11332 11333 11334 /* Manipulation 11335 -----------------------------------------------------------------------------*/ 11336 11337 11338 // Only ever called from the externally-facing API 11339 function updateEvent(event) { 11340 11341 // massage start/end values, even if date string values 11342 event.start = t.moment(event.start);
11343 if (event.end) { 11344 event.end = t.moment(event.end); 11345 } 11346 else { 11347 event.end = null; 11348 } 11349 11350 mutateEvent(event, getMiscEventProps(event)); // will handle start/end/allDay normalization 11351 reportEvents(cache); // reports event modifications (so we can redraw) 11352 } 11353 11354 11355 // Returns a hash of misc event properties that should be copied over to related events. 11356 function getMiscEventProps(event) { 11357 var props = {}; 11358 11359 $.each(event, function(name, val) { 11360 if (isMiscEventPropName(name)) { 11361 if (val !== undefined && isAtomic(val)) { // a defined non-object 11362 props[name] = val; 11363 } 11364 } 11365 }); 11366 11367 return props; 11368 } 11369 11370 // non-date-related, non-id-related, non-secret 11371 function isMiscEventPropName(name) { 11372 return !/^_|^(id|allDay|start|end)$/.test(name); 11373 } 11374 11375 11376 // returns the expanded events that were created 11377 function renderEvent(eventInput, stick) { 11378 var abstractEvent = buildEventFromInput(eventInput); 11379 var events; 11380 var i, event; 11381 11382 if (abstractEvent) { // not false (a valid input) 11383 events = expandEvent(abstractEvent); 11384 11385 for (i = 0; i < events.length; i++) { 11386 event = events[i]; 11387 11388 if (!event.source) { 11389 if (stick) { 11390 stickySource.events.push(event); 11391 event.source = stickySource; 11392 } 11393 cache.push(event); 11394 } 11395 } 11396 11397 reportEvents(cache); 11398 11399 return events; 11400 } 11401 11402 return []; 11403 } 11404 11405 11406 function removeEvents(filter) { 11407 var eventID; 11408 var i; 11409 11410 if (filter == null) { // null or undefined. remove all events 11411 filter = function() { return true; }; // will always match 11412 } 11413 else if (!$.isFunction(filter)) { // an event ID 11414 eventID = filter + ''; 11415 filter = function(event) { 11416 return event._id == eventID; 11417 }; 11418 } 11419 11420 // Purge event(s) from our local cache 11421 cache = $.grep(cache, filter, true); // inverse=true 11422 11423 // Remove events from array sources. 11424 // This works because they have been converted to official Event Objects up front. 11425 // (and as a result, event._id has been calculated). 11426 for (i=0; i<sources.length; i++) { 11427 if ($.isArray(sources[i].events)) { 11428 sources[i].events = $.grep(sources[i].events, filter, true); 11429 } 11430 } 11431 11432 reportEvents(cache); 11433 } 11434 11435 11436 function clientEvents(filter) { 11437 if ($.isFunction(filter)) { 11438 return $.grep(cache, filter); 11439 } 11440 else if (filter != null) { // not null, not undefined. an event ID 11441 filter += ''; 11442 return $.grep(cache, function(e) { 11443 return e._id == filter; 11444 }); 11445 } 11446 return cache; // else, return all 11447 } 11448 11449 11450 // Makes sure all array event sources have their internal event objects 11451 // converted over to the Calendar's current timezone. 11452 t.rezoneArrayEventSources = function() { 11453 var i; 11454 var events; 11455 var j; 11456 11457 for (i = 0; i < sources.length; i++) { 11458 events = sources[i].events; 11459 if ($.isArray(events)) { 11460 11461 for (j = 0; j < events.length; j++) { 11462 rezoneEventDates(events[j]); 11463 } 11464 } 11465 } 11466 }; 11467 11468 function rezoneEventDates(event) { 11469 event.start = t.moment(event.start); 11470 if (event.end) { 11471 event.end = t.moment(event.end); 11472 } 11473 backupEventDates(event); 11474 } 11475 11476 11477 /* Event Normalization 11478 -----------------------------------------------------------------------------*/ 11479 11480 11481 // Given a raw object with key/value properties, returns an "abstract" Event object. 11482 // An "abstract" event is an event that, if recurring, will not have been expanded yet. 11483 // Will return `false` when input is invalid. 11484 // `source` is optional 11485 function buildEventFromInput(input, source) { 11486 var out = {}; 11487 var start, end; 11488 var allDay; 11489 11490 if (t.options.eventDataTransform) { 11491 input = t.options.eventDataTransform(input); 11492 } 11493 if (source && source.eventDataTransform) { 11494 input = source.eventDataTransform(input); 11495 } 11496 11497 // Copy all properties over to the resulting object. 11498 // The special-case properties will be copied over afterwards. 11499 $.extend(out, input); 11500 11501 if (source) { 11502 out.source = source; 11503 } 11504 11505 out._id = input._id || (input.id === undefined ? '_fc' + eventGUID++ : input.id + ''); 11506 11507 if (input.className) { 11508 if (typeof input.className == 'string') { 11509 out.className = input.className.split(/\s+/); 11510 } 11511 else { // assumed to be an array 11512 out.className = input.className; 11513 } 11514 } 11515 else { 11516 out.className = []; 11517 } 11518 11519 start = input.start || input.date; // "date" is an alias for "start" 11520 end = input.end; 11521 11522 // parse as a time (Duration) if applicable
11523 if (isTimeString(start)) { 11524 start = moment.duration(start); 11525 } 11526 if (isTimeString(end)) { 11527 end = moment.duration(end); 11528 } 11529 11530 if (input.dow || moment.isDuration(start) || moment.isDuration(end)) { 11531 11532 // the event is "abstract" (recurring) so don't calculate exact start/end dates just yet 11533 out.start = start ? moment.duration(start) : null; // will be a Duration or null 11534 out.end = end ? moment.duration(end) : null; // will be a Duration or null 11535 out._recurring = true; // our internal marker 11536 } 11537 else { 11538 11539 if (start) { 11540 start = t.moment(start); 11541 if (!start.isValid()) { 11542 return false; 11543 } 11544 } 11545 11546 if (end) { 11547 end = t.moment(end); 11548 if (!end.isValid()) { 11549 end = null; // let defaults take over 11550 } 11551 } 11552 11553 allDay = input.allDay; 11554 if (allDay === undefined) { // still undefined? fallback to default 11555 allDay = firstDefined( 11556 source ? source.allDayDefault : undefined, 11557 t.options.allDayDefault 11558 ); 11559 // still undefined? normalizeEventDates will calculate it 11560 } 11561 11562 assignDatesToEvent(start, end, allDay, out); 11563 } 11564 11565 t.normalizeEvent(out); // hook for external use. a prototype method 11566 11567 return out; 11568 } 11569 t.buildEventFromInput = buildEventFromInput; 11570 11571 11572 // Normalizes and assigns the given dates to the given partially-formed event object. 11573 // NOTE: mutates the given start/end moments. does not make a copy. 11574 function assignDatesToEvent(start, end, allDay, event) { 11575 event.start = start; 11576 event.end = end; 11577 event.allDay = allDay; 11578 normalizeEventDates(event); 11579 backupEventDates(event); 11580 } 11581 11582 11583 // Ensures proper values for allDay/start/end. Accepts an Event object, or a plain object with event-ish properties. 11584 // NOTE: Will modify the given object. 11585 function normalizeEventDates(eventProps) { 11586 11587 normalizeEventTimes(eventProps); 11588 11589 if (eventProps.end && !eventProps.end.isAfter(eventProps.start)) { 11590 eventProps.end = null; 11591 } 11592 11593 if (!eventProps.end) { 11594 if (t.options.forceEventDuration) { 11595 eventProps.end = t.getDefaultEventEnd(eventProps.allDay, eventProps.start); 11596 } 11597 else { 11598 eventProps.end = null; 11599 } 11600 } 11601 } 11602 11603 11604 // Ensures the allDay property exists and the timeliness of the start/end dates are consistent 11605 function normalizeEventTimes(eventProps) { 11606 if (eventProps.allDay == null) { 11607 eventProps.allDay = !(eventProps.start.hasTime() || (eventProps.end && eventProps.end.hasTime())); 11608 } 11609 11610 if (eventProps.allDay) { 11611 eventProps.start.stripTime(); 11612 if (eventProps.end) { 11613 // TODO: consider nextDayThreshold here? If so, will require a lot of testing and adjustment 11614 eventProps.end.stripTime(); 11615 } 11616 } 11617 else { 11618 if (!eventProps.start.hasTime()) { 11619 eventProps.start = t.applyTimezone(eventProps.start.time(0)); // will assign a 00:00 time 11620 } 11621 if (eventProps.end && !eventProps.end.hasTime()) { 11622 eventProps.end = t.applyTimezone(eventProps.end.time(0)); // will assign a 00:00 time 11623 } 11624 } 11625 } 11626 11627 11628 // If the given event is a recurring event, break it down into an array of individual instances. 11629 // If not a recurring event, return an array with the single original event. 11630 // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array. 11631 // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours). 11632 function expandEvent(abstractEvent, _rangeStart, _rangeEnd) { 11633 var events = []; 11634 var dowHash; 11635 var dow; 11636 var i; 11637 var date; 11638 var startTime, endTime; 11639 var start, end; 11640 var event; 11641 11642 _rangeStart = _rangeStart || rangeStart; 11643 _rangeEnd = _rangeEnd || rangeEnd; 11644 11645 if (abstractEvent) { 11646 if (abstractEvent._recurring) { 11647 11648 // make a boolean hash as to whether the event occurs on each day-of-week 11649 if ((dow = abstractEvent.dow)) { 11650 dowHash = {}; 11651 for (i = 0; i < dow.length; i++) { 11652 dowHash[dow[i]] = true; 11653 } 11654 } 11655 11656 // iterate through every day in the current range 11657 date = _rangeStart.clone().stripTime(); // holds the date of the current day 11658 while (date.isBefore(_rangeEnd)) { 11659 11660 if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week 11661 11662 startTime = abstractEvent.start; // the stored start and end properties are times (Durations) 11663 endTime = abstractEvent.end; // " 11664 start = date.clone(); 11665 end = null; 11666 11667 if (startTime) { 11668 start = start.time(startTime); 11669 } 11670 if (endTime) { 11671 end = date.clone().time(endTime); 11672 } 11673 11674 event = $.extend({}, abstractEvent); // make a copy of the original 11675 assignDatesToEvent( 11676 start, end, 11677 !startTime && !endTime, // allDay? 11678 event 11679 ); 11680 events.push(event); 11681 } 11682 11683 date.add(1, 'days'); 11684 } 11685 } 11686 else { 11687 events.push(abstractEvent); // return the original event. will be a one-item array 11688 } 11689 } 11690 11691 return events; 11692 } 11693 t.expandEvent = expandEvent; 11694 11695 11696 11697 /* Event Modification Math 11698 -----------------------------------------------------------------------------------------*/ 11699 11700 11701 // Modifies an event and all related events by applying the given properties. 11702 // Special date-diffing logic is used for manipulation of dates. 11703 // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end. 11704 // All date comparisons are done against the event's pristine _start and _end dates. 11705 // Returns an object with delta information and a function to undo all operations. 11706 // For making computations in a granularity greater than day/time, specify largeUnit. 11707 // NOTE: The given `newProps` might be mutated for normalization purposes. 11708 function mutateEvent(event, newProps, largeUnit) { 11709 var miscProps = {}; 11710 var oldProps; 11711 var clearEnd; 11712 var startDelta; 11713 var endDelta; 11714 var durationDelta;
11715 var undoFunc; 11716 11717 // diffs the dates in the appropriate way, returning a duration 11718 function diffDates(date1, date0) { // date1 - date0 11719 if (largeUnit) { 11720 return diffByUnit(date1, date0, largeUnit); 11721 } 11722 else if (newProps.allDay) { 11723 return diffDay(date1, date0); 11724 } 11725 else { 11726 return diffDayTime(date1, date0); 11727 } 11728 } 11729 11730 newProps = newProps || {}; 11731 11732 // normalize new date-related properties 11733 if (!newProps.start) { 11734 newProps.start = event.start.clone(); 11735 } 11736 if (newProps.end === undefined) { 11737 newProps.end = event.end ? event.end.clone() : null; 11738 } 11739 if (newProps.allDay == null) { // is null or undefined? 11740 newProps.allDay = event.allDay; 11741 } 11742 normalizeEventDates(newProps); 11743 11744 // create normalized versions of the original props to compare against 11745 // need a real end value, for diffing 11746 oldProps = { 11747 start: event._start.clone(), 11748 end: event._end ? event._end.clone() : t.getDefaultEventEnd(event._allDay, event._start), 11749 allDay: newProps.allDay // normalize the dates in the same regard as the new properties 11750 }; 11751 normalizeEventDates(oldProps); 11752 11753 // need to clear the end date if explicitly changed to null 11754 clearEnd = event._end !== null && newProps.end === null; 11755 11756 // compute the delta for moving the start date 11757 startDelta = diffDates(newProps.start, oldProps.start); 11758 11759 // compute the delta for moving the end date 11760 if (newProps.end) { 11761 endDelta = diffDates(newProps.end, oldProps.end); 11762 durationDelta = endDelta.subtract(startDelta); 11763 } 11764 else { 11765 durationDelta = null; 11766 } 11767 11768 // gather all non-date-related properties 11769 $.each(newProps, function(name, val) { 11770 if (isMiscEventPropName(name)) { 11771 if (val !== undefined) { 11772 miscProps[name] = val; 11773 } 11774 } 11775 }); 11776 11777 // apply the operations to the event and all related events 11778 undoFunc = mutateEvents( 11779 clientEvents(event._id), // get events with this ID 11780 clearEnd, 11781 newProps.allDay, 11782 startDelta, 11783 durationDelta, 11784 miscProps 11785 ); 11786 11787 return { 11788 dateDelta: startDelta, 11789 durationDelta: durationDelta, 11790 undo: undoFunc 11791 }; 11792 } 11793 11794 11795 // Modifies an array of events in the following ways (operations are in order): 11796 // - clear the event's `end` 11797 // - convert the event to allDay 11798 // - add `dateDelta` to the start and end 11799 // - add `durationDelta` to the event's duration 11800 // - assign `miscProps` to the event 11801 // 11802 // Returns a function that can be called to undo all the operations. 11803 // 11804 // TODO: don't use so many closures. possible memory issues when lots of events with same ID. 11805 // 11806 function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) { 11807 var isAmbigTimezone = t.getIsAmbigTimezone(); 11808 var undoFunctions = []; 11809 11810 // normalize zero-length deltas to be null 11811 if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; } 11812 if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; } 11813 11814 $.each(events, function(i, event) { 11815 var oldProps; 11816 var newProps; 11817 11818 // build an object holding all the old values, both date-related and misc. 11819 // for the undo function. 11820 oldProps = { 11821 start: event.start.clone(), 11822 end: event.end ? event.end.clone() : null, 11823 allDay: event.allDay 11824 }; 11825 $.each(miscProps, function(name) { 11826 oldProps[name] = event[name]; 11827 }); 11828 11829 // new date-related properties. work off the original date snapshot. 11830 // ok to use references because they will be thrown away when backupEventDates is called. 11831 newProps = { 11832 start: event._start, 11833 end: event._end, 11834 allDay: allDay // normalize the dates in the same regard as the new properties 11835 }; 11836 normalizeEventDates(newProps); // massages start/end/allDay 11837 11838 // strip or ensure the end date 11839 if (clearEnd) { 11840 newProps.end = null; 11841 } 11842 else if (durationDelta && !newProps.end) { // the duration translation requires an end date 11843 newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start); 11844 } 11845 11846 if (dateDelta) { 11847 newProps.start.add(dateDelta); 11848 if (newProps.end) { 11849 newProps.end.add(dateDelta); 11850 } 11851 } 11852 11853 if (durationDelta) { 11854 newProps.end.add(durationDelta); // end already ensured above 11855 } 11856 11857 // if the dates have changed, and we know it is impossible to re
11857compute the 11858 // timezone offsets, strip the zone. 11859 if ( 11860 isAmbigTimezone && 11861 !newProps.allDay && 11862 (dateDelta || durationDelta) 11863 ) { 11864 newProps.start.stripZone(); 11865 if (newProps.end) { 11866 newProps.end.stripZone(); 11867 } 11868 } 11869 11870 $.extend(event, miscProps, newProps); // copy over misc props, then date-related props 11871 backupEventDates(event); // regenerate internal _start/_end/_allDay 11872 11873 undoFunctions.push(function() { 11874 $.extend(event, oldProps); 11875 backupEventDates(event); // regenerate internal _start/_end/_allDay 11876 }); 11877 }); 11878 11879 return function() { 11880 for (var i = 0; i < undoFunctions.length; i++) { 11881 undoFunctions[i](); 11882 } 11883 }; 11884 } 11885 11886 11887 /* Overlapping / Constraining 11888 -----------------------------------------------------------------------------------------*/ 11889 11890 t.isEventSpanAllowed = isEventSpanAllowed; 11891 t.isExternalSpanAllowed = isExternalSpanAllowed; 11892 t.isSelectionSpanAllowed = isSelectionSpanAllowed; 11893 11894 11895 // Determines if the given event can be relocated to the given span (unzoned start/end with other misc data) 11896 function isEventSpanAllowed(span, event) { 11897 var source = event.source || {}; 11898 var constraint = firstDefined( 11899 event.constraint, 11900 source.constraint, 11901 t.options.eventConstraint 11902 ); 11903 var overlap = firstDefined( 11904 event.overlap, 11905 source.overlap, 11906 t.options.eventOverlap 11907 ); 11908 return isSpanAllowed(span, constraint, overlap, event); 11909 } 11910 11911 11912 // Determines if an external event can be relocated to the given span (unzoned start/end with other misc data) 11913 function isExternalSpanAllowed(eventSpan, eventLocation, eventProps) { 11914 var eventInput; 11915 var event; 11916 11917 // note: very similar logic is in View's reportExternalDrop 11918 if (eventProps) { 11919 eventInput = $.extend({}, eventProps, eventLocation); 11920 event = expandEvent(buildEventFromInput(eventInput))[0]; 11921 } 11922 11923 if (event) { 11924 return isEventSpanAllowed(eventSpan, event); 11925 } 11926 else { // treat it as a selection 11927 11928 return isSelectionSpanAllowed(eventSpan); 11929 } 11930 } 11931 11932 11933 // Determines the given span (unzoned start/end with other misc data) can be selected. 11934 function isSelectionSpanAllowed(span) { 11935 return isSpanAllowed(span, t.options.selectConstraint, t.options.selectOverlap); 11936 } 11937 11938 11939 // Returns true if the given span (caused by an event drop/resize or a selection) is allowed to exist 11940 // according to the constraint/overlap settings. 11941 // `event` is not required if checking a selection. 11942 function isSpanAllowed(span, constraint, overlap, event) { 11943 var constraintEvents; 11944 var anyContainment; 11945 var peerEvents; 11946 var i, peerEvent; 11947 var peerOverlap; 11948 11949 // the range must be fully contained by at least one of produced constraint events 11950 if (constraint != null) { 11951 11952 // not treated as an event! intermediate data structure 11953 // TODO: use ranges in the future 11954 constraintEvents = constraintToEvents(constraint); 11955 11956 anyContainment = false; 11957 for (i = 0; i < constraintEvents.length; i++) { 11958 if (t.spanContainsSpan(constraintEvents[i], span)) { 11959 anyContainment = true; 11960 break; 11961 } 11962 } 11963 11964 if (!anyContainment) { 11965 return false; 11966 } 11967 } 11968 11969 peerEvents = t.getPeerEvents(span, event); 11970 11971 for (i = 0; i < peerEvents.length; i++) { 11972 peerEvent = peerEvents[i]; 11973 11974 // there needs to be an actual intersection before disallowing anything 11975 if (eventIntersectsRange(peerEvent, span)) { 11976 11977 // evaluate overlap for the given range and short-circuit if necessary 11978 if (overlap === false) { 11979 return false; 11980 } 11981 // if the event's overlap is a test function, pass the peer event in question as the first param 11982 else if (typeof overlap === 'function' && !overlap(peerEvent, event)) { 11983 return false; 11984 } 11985 11986 // if we are computing if the given range is allowable for an event, consider the other event's 11987 // EventObject-specific or Source-specific `overlap` property 11988 if (event) { 11989 peerOverlap = firstDefined( 11990 peerEvent.overlap, 11991 (peerEvent.source || {}).overlap 11992 // we already considered the global `eventOverlap` 11993 ); 11994 if (peerOverlap === false) { 11995 return false; 11996 } 11997 // if the peer event's overlap is a test function, pass the subject event as the first param 11998 if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) { 11999 return false;
12000 } 12001 } 12002 } 12003 } 12004 12005 return true; 12006 } 12007 12008 12009 // Given an event input from the API, produces an array of event objects. Possible event inputs: 12010 // 'businessHours' 12011 // An event ID (number or string) 12012 // An object with specific start/end dates or a recurring event (like what businessHours accepts) 12013 function constraintToEvents(constraintInput) { 12014 12015 if (constraintInput === 'businessHours') { 12016 return t.getCurrentBusinessHourEvents(); 12017 } 12018 12019 if (typeof constraintInput === 'object') { 12020 return expandEvent(buildEventFromInput(constraintInput)); 12021 } 12022 12023 return clientEvents(constraintInput); // probably an ID 12024 } 12025 12026 12027 // Does the event's date range intersect with the given range? 12028 // start/end already assumed to have stripped zones :( 12029 function eventIntersectsRange(event, range) { 12030 var eventStart = event.start.clone().stripZone(); 12031 var eventEnd = t.getEventEnd(event).stripZone(); 12032 12033 return range.start < eventEnd && range.end > eventStart; 12034 } 12035 12036 12037 t.getEventCache = function() { 12038 return cache; 12039 }; 12040 12041} 12042 12043 12044// hook for external libs to manipulate event properties upon creation. 12045// should manipulate the event in-place. 12046Calendar.prototype.normalizeEvent = function(event) { 12047}; 12048 12049 12050// Does the given span (start, end, and other location information) 12051// fully contain the other? 12052Calendar.prototype.spanContainsSpan = function(outerSpan, innerSpan) { 12053 var eventStart = outerSpan.start.clone().stripZone(); 12054 var eventEnd = this.getEventEnd(outerSpan).stripZone(); 12055 12056 return innerSpan.start >= eventStart && innerSpan.end <= eventEnd; 12057}; 12058 12059 12060// Returns a list of events that the given event should be compared against when being considered for a move to 12061// the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar. 12062Calendar.prototype.getPeerEvents = function(span, event) { 12063 var cache = this.getEventCache(); 12064 var peerEvents = []; 12065 var i, otherEvent; 12066 12067 for (i = 0; i < cache.length; i++) { 12068 otherEvent = cache[i]; 12069 if ( 12070 !event || 12071 event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events 12072 ) { 12073 peerEvents.push(otherEvent); 12074 } 12075 } 12076 12077 return peerEvents; 12078}; 12079 12080 12081// updates the "backup" properties, which are preserved in order to compute diffs later on. 12082function backupEventDates(event) { 12083 event._allDay = event.allDay; 12084 event._start = event.start.clone(); 12085 event._end = event.end ? event.end.clone() : null; 12086} 12087 12088 12089/* Business Hours 12090-----------------------------------------------------------------------------------------*/ 12091 12092var BUSINESS_HOUR_EVENT_DEFAULTS = { 12093 id: '_fcBusinessHours', // will relate events from different calls to expandEvent 12094 start: '09:00', 12095 end: '17:00', 12096 dow: [ 1, 2, 3, 4, 5 ], // monday - friday 12097 rendering: 'inverse-background' 12098 // classNames are defined in businessHoursSegClasses 12099}; 12100 12101// Return events objects for business hours within the current view. 12102// Abuse of our event system :( 12103Calendar.prototype.getCurrentBusinessHourEvents = function(wholeDay) { 12104 return this.computeBusinessHourEvents(wholeDay, this.options.businessHours); 12105}; 12106 12107// Given a raw input value from options, return events objects for business hours within the current view. 12108Calendar.prototype.computeBusinessHourEvents = function(wholeDay, input) { 12109 if (input === true) { 12110 return this.expandBusinessHourEvents(wholeDay, [ {} ]); 12111 } 12112 else if ($.isPlainObject(input)) { 12113 return this.expandBusinessHourEvents(wholeDay, [ input ]); 12114 } 12115 else if ($.isArray(input)) { 12116 return this.expandBusinessHourEvents(wholeDay, input, true); 12117 } 12118 else { 12119 return []; 12120 } 12121}; 12122
12123// inputs expected to be an array of objects. 12124// if ignoreNoDow is true, will ignore entries that don't specify a day-of-week (dow) key. 12125Calendar.prototype.expandBusinessHourEvents = function(wholeDay, inputs, ignoreNoDow) { 12126 var view = this.getView(); 12127 var events = []; 12128 var i, input; 12129 12130 for (i = 0; i < inputs.length; i++) { 12131 input = inputs[i]; 12132 12133 if (ignoreNoDow && !input.dow) { 12134 continue; 12135 } 12136 12137 // give defaults. will make a copy 12138 input = $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, input); 12139 12140 // if a whole-day series is requested, clear the start/end times 12141 if (wholeDay) { 12142 input.start = null; 12143 input.end = null; 12144 } 12145 12146 events.push.apply(events, // append 12147 this.expandEvent( 12148 this.buildEventFromInput(input), 12149 view.start, 12150 view.end 12151 ) 12152 ); 12153 } 12154 12155 return events; 12156}; 12157 12158;; 12159 12160/* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells. 12161----------------------------------------------------------------------------------------------------------------------*/ 12162// It is a manager for a DayGrid subcomponent, which does most of the heavy lifting. 12163// It is responsible for managing width/height. 12164 12165var BasicView = FC.BasicView = View.extend({ 12166 12167 scroller: null, 12168 12169 dayGridClass: DayGrid, // class the dayGrid will be instantiated from (overridable by subclasses) 12170 dayGrid: null, // the main subcomponent that does most of the heavy lifting 12171 12172 dayNumbersVisible: false, // display day numbers on each day cell? 12173 weekNumbersVisible: false, // display week numbers along the side? 12174 12175 weekNumberWidth: null, // width of all the week-number cells running down the side 12176 12177 headContainerEl: null, // div that hold's the dayGrid's rendered date header 12178 headRowEl: null, // the fake row element of the day-of-week header 12179 12180 12181 initialize: function() { 12182 this.dayGrid = this.instantiateDayGrid(); 12183 12184 this.scroller = new Scroller({ 12185 overflowX: 'hidden', 12186 overflowY: 'auto' 12187 }); 12188 }, 12189 12190 12191 // Generates the DayGrid object this view needs. Draws from this.dayGridClass 12192 instantiateDayGrid: function() { 12193 // generate a subclass on the fly with BasicView-specific behavior 12194 // TODO: cache this subclass 12195 var subclass = this.dayGridClass.extend(basicDayGridMethods); 12196 12197 return new subclass(this); 12198 }, 12199 12200 12201 // Sets the display range and computes all necessary dates 12202 setRange: function(range) { 12203 View.prototype.setRange.call(this, range); // call the super-method 12204 12205 this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange 12206 this.dayGrid.setRange(range); 12207 }, 12208 12209 12210 // Compute the value to feed into setRange. Overrides superclass. 12211 computeRange: function(date) { 12212 var range = View.prototype.computeRange.call(this, date); // get value from the super-method 12213 12214 // year and month views should be aligned with weeks. this is already done for week 12215 if (/year|month/.test(range.intervalUnit)) { 12216 range.start.startOf('week'); 12217 range.start = this.skipHiddenDays(range.start); 12218 12219 // make end-of-week if not already 12220 if (range.end.weekday()) { 12221 range.end.add(1, 'week').startOf('week'); 12222 range.end = this.skipHiddenDays(range.end, -1, true); // exclusively move backwards 12223 } 12224 } 12225 12226 return range; 12227 }, 12228 12229 12230 // Renders the view into `this.el`, which should already be assigned 12231 renderDates: function() { 12232 12233 this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible 12234 this.weekNumbersVisible = this.opt('weekNumbers'); 12235 this.dayGrid.numbersVisible = this.dayNumbersVisible || this.weekNumbersVisible; 12236 12237 this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml()); 12238 this.renderHead(); 12239 12240 this.scroller.render(); 12241 var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container'); 12242 var dayGridEl = $('<div class="fc-day-grid" />').appendTo(dayGridContainerEl); 12243 this.el.find('.fc-body > tr > td').append(dayGridContainerEl); 12244 12245 this.dayGrid.setElement(dayGridEl); 12246 this.dayGrid.renderDates(this.hasRigidRows()); 12247 }, 12248 12249 12250 // render the day-of-week headers 12251 renderHead: function() { 12252 this.headContainerEl = 12253 this.el.find('.fc-head-container') 12254 .html(this.dayGrid.renderHeadHtml()); 12255 this.headRowEl = this.headContainerEl.find('.fc-row'); 12256 }, 12257 12258 12259 // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, 12260 // always completely kill the dayGrid's rendering. 12261 unrenderDates: function() { 12262 this.dayGrid.unrenderDates(); 12263 this.dayGrid.removeElement(); 12264 this.scroller.destroy(); 12265 }, 12266 12267 12268 renderBusinessHours: function() { 12269 this.dayGrid.renderBusinessHours(); 12270 }, 12271 12272 12273 unrenderBusinessHours: function() { 12274 this.dayGrid.unrenderBusinessHours(); 12275 }, 12276 12277 12278 // Builds the HTML skeleton for the view. 12279 // The day-grid component will render inside of a container defined by this HTML. 12280 renderSkeletonHtml: function() { 12281 return '' + 12282 '<table>' + 12283 '<thead class="fc-head">' + 12284 '<tr>' + 12285 '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' + 12286 '</tr>' + 12287 '</thead>' + 12288 '<tbody class="fc-body">' + 12289 '<tr>' + 12290 '<td class="' + this.widgetContentClass + '"></td>' + 12291 '</tr>' + 12292 '</tbody>' + 12293 '</table>'; 12294 }, 12295 12296 12297 // Generates an HTML attribute string for setting the width of the week number column, if it is known 12298 weekNumberStyleAttr: function() { 12299 if (this.weekNumberWidth !== null) { 12300 return 'style="width:' + this.weekNumberWidth + 'px"'; 12301 } 12302 return ''; 12303 }, 12304 12305 12306 // Determines whether each row should have a constant height 12307 hasRigidRows: function() { 12308 var eventLimit = this.opt('eventLimit'); 12309 return eventLimit && typeof eventLimit !== 'number'; 12310 }, 12311 12312 12313 /* Dimensions 12314 ------------------------------------------------------------------------------------------------------------------*/ 12315 12316 12317 // Refreshes the horizontal dimensions of the view 12318 updateWidth: function() { 12319 if (this.weekNumbersVisible) { 12320 // Make sure all week number cells running down the side have the same width. 12321 // Record the width for cells created later. 12322 this.weekNumberWidth = matchCellWidths( 12323 this.el.find('.fc-week-number') 12324 ); 12325 } 12326 }, 12327 12328 12329 // Adjusts the vertical dimensions of the view to the specified values 12330 setHeight: function(totalHeight, isAuto) { 12331 var eventLimit = this.opt('eventLimit'); 12332 var scrollerHeight; 12333 var scrollbarWidths; 12334 12335 // reset all heights to be natural 12336 this.scroller.clear(); 12337 uncompensateScroll(this.headRowEl); 12338 12339 this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed 12340 12341 // is the event limit a constant level number? 12342 if (eventLimit && typeof eventLimit === 'number') { 12343 this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after 12344 } 12345 12346 // distribute the height to the rows 12347 // (totalHeight is a "recommended" value if isAuto) 12348 scrollerHeight = this.computeScrollerHeight(totalHeight); 12349 this.setGridHeight(scrollerHeight, isAuto); 12350 12351 // is the event limit dynamically calculated? 12352 if (eventLimit && typeof eventLimit !== 'number') { 12353 this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set 12354 } 12355 12356 if (!isAuto) { // should we force dimensions of the scroll container? 12357 12358 this.scroller.setHeight(scrollerHeight);
12359 scrollbarWidths = this.scroller.getScrollbarWidths(); 12360 12361 if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars? 12362 12363 compensateScroll(this.headRowEl, scrollbarWidths); 12364 12365 // doing the scrollbar compensation might have created text overflow which created more height. redo 12366 scrollerHeight = this.computeScrollerHeight(totalHeight); 12367 this.scroller.setHeight(scrollerHeight); 12368 } 12369 12370 // guarantees the same scrollbar widths 12371 this.scroller.lockOverflow(scrollbarWidths); 12372 } 12373 }, 12374 12375 12376 // given a desired total height of the view, returns what the height of the scroller should be 12377 computeScrollerHeight: function(totalHeight) { 12378 return totalHeight - 12379 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller 12380 }, 12381 12382 12383 // Sets the height of just the DayGrid component in this view 12384 setGridHeight: function(height, isAuto) { 12385 if (isAuto) { 12386 undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding 12387 } 12388 else { 12389 distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows 12390 } 12391 }, 12392 12393 12394 /* Scroll 12395 ------------------------------------------------------------------------------------------------------------------*/ 12396 12397 12398 queryScroll: function() { 12399 return this.scroller.getScrollTop(); 12400 }, 12401 12402 12403 setScroll: function(top) { 12404 this.scroller.setScrollTop(top); 12405 }, 12406 12407 12408 /* Hit Areas 12409 ------------------------------------------------------------------------------------------------------------------*/ 12410 // forward all hit-related method calls to dayGrid 12411 12412 12413 prepareHits: function() { 12414 this.dayGrid.prepareHits(); 12415 }, 12416 12417 12418 releaseHits: function() { 12419 this.dayGrid.releaseHits(); 12420 }, 12421 12422 12423 queryHit: function(left, top) { 12424 return this.dayGrid.queryHit(left, top); 12425 }, 12426 12427 12428 getHitSpan: function(hit) { 12429 return this.dayGrid.getHitSpan(hit); 12430 }, 12431 12432 12433 getHitEl: function(hit) { 12434 return this.dayGrid.getHitEl(hit); 12435 }, 12436 12437 12438 /* Events 12439 ------------------------------------------------------------------------------------------------------------------*/ 12440 12441 12442 // Renders the given events onto the view and populates the segments array 12443 renderEvents: function(events) { 12444 this.dayGrid.renderEvents(events); 12445 12446 this.updateHeight(); // must compensate for events that overflow the row 12447 }, 12448 12449 12450 // Retrieves all segment objects that are rendered in the view 12451 getEventSegs: function() { 12452 return this.dayGrid.getEventSegs(); 12453 }, 12454 12455 12456 // Unrenders all event elements and clears internal segment data 12457 unrenderEvents: function() { 12458 this.dayGrid.unrenderEvents(); 12459 12460 // we DON'T need to call updateHeight() because: 12461 // A) a renderEvents() call always happens after this, which will eventually call updateHeight() 12462 // B) in IE8, this causes a flash whenever events are rerendered 12463 }, 12464 12465 12466 /* Dragging (for both events and external elements) 12467 ------------------------------------------------------------------------------------------------------------------*/ 12468 12469 12470 // A returned value of `true` signals that a mock "helper" event has been rendered. 12471 renderDrag: function(dropLocation, seg) { 12472 return this.dayGrid.renderDrag(dropLocation, seg); 12473 }, 12474 12475 12476 unrenderDrag: function() { 12477 this.dayGrid.unrenderDrag(); 12478 }, 12479 12480 12481 /* Selection 12482 ------------------------------------------------------------------------------------------------------------------*/ 12483 12484 12485 // Renders a visual indication of a selection 12486 renderSelection: function(span) { 12487 this.dayGrid.renderSelection(span); 12488 }, 12489 12490 12491 // Unrenders a visual indications of a selection 12492 unrenderSelection: function() { 12493 this.dayGrid.unrenderSelection(); 12494 } 12495 12496}); 12497 12498 12499// Methods that will customize the rendering behavior of the BasicView's dayGrid 12500var basicDayGridMethods = { 12501 12502 12503 // Generates the HTML that will go before the day-of week header cells 12504 renderHeadIntroHtml: function() { 12505 var view = this.view; 12506 12507 if (view.weekNumbersVisible) { 12508 return '' + 12509 '<th class="fc-week-number ' + view.widgetHeaderClass + '" ' + view.weekNumberStyleAttr() + '>' + 12510 '<span>' + // needed for matchCellWidths 12511 htmlEscape(view.opt('weekNumberTitle')) + 12512 '</span>' + 12513 '</th>'; 12514 } 12515 12516 return ''; 12517 }, 12518 12519 12520 // Generates the HTML that will go before content-skeleton cells that display the day/week numbers 12521 renderNumberIntroHtml: function(row) { 12522 var view = this.view; 12523 12524 if (view.weekNumbersVisible) { 12525 return '' + 12526 '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '>' + 12527 '<span>' + // needed for matchCellWidths 12528 this.getCellDate(row, 0).format('w') + 12529 '</span>' + 12530 '</td>'; 12531 } 12532 12533 return ''; 12534 }, 12535 12536 12537 // Generates the HTML that goes before the day bg cells for each day-row 12538 renderBgIntroHtml: function() { 12539 var view = this.view; 12540 12541 if (view.weekNumbersVisible) { 12542 return '<td class="fc-week-number ' + view.widgetContentClass + '" ' + 12543 view.weekNumberStyleAttr() + '></td>'; 12544 } 12545 12546 return ''; 12547 }, 12548 12549 12550 // Generates the HTML that goes before every other type of row generated by DayGrid. 12551 // Affects helper-skeleton and highlight-skeleton rows. 12552 renderIntroHtml: function() { 12553 var view = this.view; 12554 12555 if (view.weekNumbersVisible) { 12556 return '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '></td>'; 12557 } 12558 12559 return ''; 12560 } 12561 12562}; 12563 12564;; 12565 12566/* A month view with day cells running in rows (one-per-week) and columns 12567----------------------------------------------------------------------------------------------------------------------*/ 12568 12569var MonthView = FC.MonthView = BasicView.extend({ 12570 12571 // Produces information about what range to display 12572 computeRange: function(date) { 12573 var range = BasicView.prototype.computeRange.call(this, date); // get value from super-method 12574 var rowCnt; 12575 12576 // ensure 6 weeks 12577 if (this.isFixedWeeks()) {
12578 rowCnt = Math.ceil(range.end.diff(range.start, 'weeks', true)); // could be partial weeks due to hiddenDays 12579 range.end.add(6 - rowCnt, 'weeks'); 12580 } 12581 12582 return range; 12583 }, 12584 12585 12586 // Overrides the default BasicView behavior to have special multi-week auto-height logic 12587 setGridHeight: function(height, isAuto) { 12588 12589 isAuto = isAuto || this.opt('weekMode') === 'variable'; // LEGACY: weekMode is deprecated 12590 12591 // if auto, make the height of each row the height that it would be if there were 6 weeks 12592 if (isAuto) { 12593 height *= this.rowCnt / 6; 12594 } 12595 12596 distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows 12597 }, 12598 12599 12600 isFixedWeeks: function() { 12601 var weekMode = this.opt('weekMode'); // LEGACY: weekMode is deprecated 12602 if (weekMode) { 12603 return weekMode === 'fixed'; // if any other type of weekMode, assume NOT fixed 12604 } 12605 12606 return this.opt('fixedWeekCount'); 12607 } 12608 12609}); 12610 12611;; 12612 12613fcViews.basic = { 12614 'class': BasicView 12615}; 12616 12617fcViews.basicDay = { 12618 type: 'basic', 12619 duration: { days: 1 } 12620}; 12621 12622fcViews.basicWeek = { 12623 type: 'basic', 12624 duration: { weeks: 1 } 12625}; 12626 12627fcViews.month = { 12628 'class': MonthView, 12629 duration: { months: 1 }, // important for prev/next 12630 defaults: { 12631 fixedWeekCount: true 12632 } 12633}; 12634;; 12635 12636/* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically. 12637----------------------------------------------------------------------------------------------------------------------*/ 12638// Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on). 12639// Responsible for managing width/height. 12640 12641var AgendaView = FC.AgendaView = View.extend({ 12642 12643 scroller: null, 12644 12645 timeGridClass: TimeGrid, // class used to instantiate the timeGrid. subclasses can override 12646 timeGrid: null, // the main time-grid subcomponent of this view 12647 12648 dayGridClass: DayGrid, // class used to instantiate the dayGrid. subclasses can override 12649 dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null 12650 12651 axisWidth: null, // the width of the time axis running down the side 12652 12653 headContainerEl: null, // div that hold's the timeGrid's rendered date header 12654 noScrollRowEls: null, // set of fake row elements that must compensate when scroller has scrollbars 12655 12656 // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath 12657 bottomRuleEl: null, 12658 12659 12660 initialize: function() { 12661 this.timeGrid = this.instantiateTimeGrid(); 12662 12663 if (this.opt('allDaySlot')) { // should we display the "all-day" area? 12664 this.dayGrid = this.instantiateDayGrid(); // the all-day subcomponent of this view 12665 } 12666 12667 this.scroller = new Scroller({ 12668 overflowX: 'hidden', 12669 overflowY: 'auto' 12670 }); 12671 }, 12672 12673 12674 // Instantiates the TimeGrid object this view needs. Draws from this.timeGridClass 12675 instantiateTimeGrid: function() { 12676 var subclass = this.timeGridClass.extend(agendaTimeGridMethods); 12677 12678 return new subclass(this); 12679 }, 12680 12681 12682 // Instantiates the DayGrid object this view might need. Draws from this.dayGridClass 12683 instantiateDayGrid: function() { 12684 var subclass = this.dayGridClass.extend(agendaDayGridMethods); 12685 12686 return new subclass(this); 12687 }, 12688 12689 12690 /* Rendering 12691 ------------------------------------------------------------------------------------------------------------------*/ 12692 12693 12694 // Sets the display range and computes all necessary dates 12695 setRange: function(range) { 12696 View.prototype.setRange.call(this, range); // call the super-method 12697 12698 this.timeGrid.setRange(range); 12699 if (this.dayGrid) { 12700 this.dayGrid.setRange(range); 12701 } 12702 }, 12703 12704 12705 // Renders the view into `this.el`, which has already been assigned 12706 renderDates: function() { 12707 12708 this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml()); 12709 this.renderHead(); 12710 12711 this.scroller.render(); 12712 var timeGridWrapEl = this.scroller.el.addClass('fc-time-grid-container'); 12713 var timeGridEl = $('<div class="fc-time-grid" />').appendTo(timeGridWrapEl); 12714 this.el.find('.fc-body > tr > td').append(timeGridWrapEl); 12715 12716 this.timeGrid.setElement(timeGridEl); 12717 this.timeGrid.renderDates(); 12718 12719 // the <hr> that sometimes displays under the time-grid 12720 this.bottomRuleEl = $('<hr class="fc-divider ' + this.widgetHeaderClass + '"/>') 12721 .appendTo(this.timeGrid.el); // inject it into the time-grid 12722 12723 if (this.dayGrid) { 12724 this.dayGrid.setElement(this.el.find('.fc-day-grid')); 12725 this.dayGrid.renderDates(); 12726 12727 // have the day-grid extend it's coordinate area over the <hr> dividing the two grids 12728 this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight(); 12729 } 12730 12731 this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller 12732 }, 12733 12734 12735 // render the day-of-week headers 12736 renderHead: function() { 12737 this.headContainerEl = 12738 this.el.find('.fc-head-container') 12739 .html(this.timeGrid.renderHeadHtml()); 12740 }, 12741 12742 12743 // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, 12744 // always completely kill each grid's rendering. 12745 unrenderDates: function() { 12746 this.timeGrid.unrenderDates(); 12747 this.timeGrid.removeElement(); 12748 12749 if (this.dayGrid) { 12750 this.dayGrid.unrenderDates(); 12751 this.dayGrid.removeElement(); 12752 } 12753 12754 this.scroller.destroy(); 12755 }, 12756 12757 12758 // Builds the HTML skeleton for the view. 12759 // The day-grid and time-grid components will render inside containers defined by this HTML. 12760 renderSkeletonHtml: function() { 12761 return '' + 12762 '<table>' + 12763 '<thead class="fc-head">' + 12764 '<tr>' + 12765 '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' + 12766 '</tr>' + 12767 '</thead>' + 12768 '<tbody class="fc-body">' + 12769 '<tr>' + 12770 '<td class="' + this.widgetContentClass + '">' + 12771 (this.dayGrid ? 12772 '<div class="fc-day-grid"/>' + 12773 '<hr class="fc-divider ' + this.widgetHeaderClass + '"/>' : 12774 '' 12775 ) + 12776 '</td>' + 12777 '</tr>' + 12778 '</tbody>' + 12779 '</table>'; 12780 }, 12781 12782 12783 // Generates an HTML attribute string for setting the width of the axis, if it is known 12784 axisStyleAttr: function() { 12785 if (this.axisWidth !== null) { 12786 return 'style="width:' + this.axisWidth + 'px"'; 12787 } 12788 return ''; 12789 }, 12790 12791 12792 /* Business Hours 12793 ------------------------------------------------------------------------------------------------------------------*/ 12794 12795 12796 renderBusinessHours: function() { 12797 this.timeGrid.renderBusinessHours(); 12798 12799 if (this.dayGrid) { 12800 this.dayGrid.renderBusinessHours(); 12801 } 12802 }, 12803 12804 12805 unrenderBusinessHours: function() { 12806 this.timeGrid.unrenderBusinessHours(); 12807 12808 if (this.dayGrid) { 12809 this.dayGrid.unrenderBusinessHours(); 12810 } 12811 }, 12812 12813 12814 /* Now Indicator 12815 ------------------------------------------------------------------------------------------------------------------*/ 12816 12817 12818 getNowIndicatorUnit: function() { 12819 return this.timeGrid.getNowIndicatorUnit(); 12820 }, 12821 12822 12823 renderNowIndicator: function(date) { 12824 this.timeGrid.renderNowIndicator(date); 12825 }, 12826 12827 12828 unrenderNowIndicator: function() { 12829 this.timeGrid.unrenderNowIndicator(); 12830 }, 12831 12832 12833 /* Dimensions 12834 ------------------------------------------------------------------------------------------------------------------*/ 12835 12836 12837 updateSize: function(isResize) { 12838 this.timeGrid.updateSize(isResize); 12839 12840 View.prototype.updateSize.call(this, isResize); // call the super-method 12841 }, 12842 12843 12844 // Refreshes the horizontal dimensions of the view 12845 updateWidth: function() { 12846 // make all axis cells line up, and record the width so newly created axis cells will have it 12847 this.axisWidth = matchCellWidths(this.el.find('.fc-axis')); 12848 }, 12849 12850 12851 // Adjusts the vertical dimensions of the view to the specified values 12852 setHeight: function(totalHeight, isAuto) { 12853 var eventLimit; 12854 var scrollerHeight; 12855 var scrollbarWidths; 12856 12857 // reset all dimensions back to the original state 12858 this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary 12859 this.scroller.clear(); // sets height to 'auto' and clears overflow 12860 uncompensateScroll(this.noScrollRowEls); 12861 12862 // limit number of events in the all-day area 12863 if (this.dayGrid) { 12864 this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed 12865 12866 eventLimit = this.opt('eventLimit'); 12867 if (eventLimit && typeof eventLimit !== 'number') { 12868 eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number 12869 } 12870 if (eventLimit) { 12871 this.dayGrid.limitRows(eventLimit); 12872 } 12873 } 12874 12875 if (!isAuto) { // should we force dimensions of the scroll container? 12876 12877 scrollerHeight = this.computeScrollerHeight(totalHeight); 12878 this.scroller.setHeight(scrollerHeight);
12879 scrollbarWidths = this.scroller.getScrollbarWidths(); 12880 12881 if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars? 12882 12883 // make the all-day and header rows lines up 12884 compensateScroll(this.noScrollRowEls, scrollbarWidths); 12885 12886 // the scrollbar compensation might have changed text flow, which might affect height, so recalculate 12887 // and reapply the desired height to the scroller. 12888 scrollerHeight = this.computeScrollerHeight(totalHeight); 12889 this.scroller.setHeight(scrollerHeight); 12890 } 12891 12892 // guarantees the same scrollbar widths 12893 this.scroller.lockOverflow(scrollbarWidths); 12894 12895 // if there's any space below the slats, show the horizontal rule. 12896 // this won't cause any new overflow, because lockOverflow already called. 12897 if (this.timeGrid.getTotalSlatHeight() < scrollerHeight) { 12898 this.bottomRuleEl.show(); 12899 } 12900 } 12901 }, 12902 12903 12904 // given a desired total height of the view, returns what the height of the scroller should be 12905 computeScrollerHeight: function(totalHeight) { 12906 return totalHeight - 12907 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller 12908 }, 12909 12910 12911 /* Scroll 12912 ------------------------------------------------------------------------------------------------------------------*/ 12913 12914 12915 // Computes the initial pre-configured scroll state prior to allowing the user to change it 12916 computeInitialScroll: function() { 12917 var scrollTime = moment.duration(this.opt('scrollTime')); 12918 var top = this.timeGrid.computeTimeTop(scrollTime); 12919 12920 // zoom can give weird floating-point values. rather scroll a little bit further 12921 top = Math.ceil(top); 12922 12923 if (top) { 12924 top++; // to overcome top border that slots beyond the first have. looks better 12925 } 12926 12927 return top; 12928 }, 12929 12930 12931 queryScroll: function() { 12932 return this.scroller.getScrollTop(); 12933 }, 12934 12935 12936 setScroll: function(top) { 12937 this.scroller.setScrollTop(top); 12938 }, 12939 12940 12941 /* Hit Areas 12942 ------------------------------------------------------------------------------------------------------------------*/ 12943 // forward all hit-related method calls to the grids (dayGrid might not be defined) 12944 12945 12946 prepareHits: function() { 12947 this.timeGrid.prepareHits(); 12948 if (this.dayGrid) { 12949 this.dayGrid.prepareHits(); 12950 } 12951 }, 12952 12953 12954 releaseHits: function() { 12955 this.timeGrid.releaseHits(); 12956 if (this.dayGrid) { 12957 this.dayGrid.releaseHits(); 12958 } 12959 }, 12960 12961 12962 queryHit: function(left, top) { 12963 var hit = this.timeGrid.queryHit(left, top); 12964 12965 if (!hit && this.dayGrid) { 12966 hit = this.dayGrid.queryHit(left, top); 12967 } 12968 12969 return hit; 12970 }, 12971 12972 12973 getHitSpan: function(hit) { 12974 // TODO: hit.component is set as a hack to identify where the hit came from 12975 return hit.component.getHitSpan(hit); 12976 }, 12977 12978 12979 getHitEl: function(hit) { 12980 // TODO: hit.component is set as a hack to identify where the hit came from 12981 return hit.component.getHitEl(hit); 12982 }, 12983 12984 12985 /* Events 12986 ------------------------------------------------------------------------------------------------------------------*/ 12987 12988 12989 // Renders events onto the view and populates the View's segment array 12990 renderEvents: function(events) { 12991 var dayEvents = []; 12992 var timedEvents = []; 12993 var daySegs = []; 12994 var timedSegs; 12995 var i; 12996 12997 // separate the events into all-day and timed 12998 for (i = 0; i < events.length; i++) { 12999 if (events[i].allDay) { 13000 dayEvents.push(events[i]); 13001 } 13002 else { 13003 timedEvents.push(events[i]); 13004 } 13005 } 13006 13007 // render the events in the subcomponents 13008 timedSegs = this.timeGrid.renderEvents(timedEvents); 13009 if (this.dayGrid) { 13010 daySegs = this.dayGrid.renderEvents(dayEvents); 13011 } 13012 13013 // the all-day area is flexible and might have a lot of events, so shift the height 13014 this.updateHeight(); 13015 }, 13016 13017 13018 // Retrieves all segment objects that are rendered in the view 13019 getEventSegs: function() { 13020 return this.timeGrid.getEventSegs().concat( 13021 this.dayGrid ? this.dayGrid.getEventSegs() : [] 13022 ); 13023 }, 13024 13025 13026 // Unrenders all event elements and clears internal segment data 13027 unrenderEvents: function() { 13028 13029 // unrender the events in the subcomponents 13030 this.timeGrid.unrenderEvents(); 13031 if (this.dayGrid) { 13032 this.dayGrid.unrenderEvents(); 13033 } 13034 13035 // we DON'T need to call updateHeight() because: 13036 // A) a renderEvents() call always happens after this, which will eventually call updateHeight() 13037 // B) in IE8, this causes a flash whenever events are rerendered 13038 }, 13039 13040 13041 /* Dragging (for events and external elements) 13042 ------------------------------------------------------------------------------------------------------------------*/ 13043 13044 13045 // A returned value of `true` signals that a mock "helper" event has been rendered. 13046 renderDrag: function(dropLocation, seg) { 13047 if (dropLocation.start.hasTime()) { 13048 return this.timeGrid.renderDrag(dropLocation, seg); 13049 } 13050 else if (this.dayGrid) { 13051 return this.dayGrid.renderDrag(dropLocation, seg); 13052 } 13053 }, 13054 13055 13056 unrenderDrag: function() { 13057 this.timeGrid.unrenderDrag(); 13058 if (this.dayGrid) { 13059 this.dayGrid.unrenderDrag(); 13060 } 13061 }, 13062 13063 13064 /* Selection 13065 ------------------------------------------------------------------------------------------------------------------*/ 13066 13067 13068 // Renders a visual indication of a selection
13069 renderSelection: function(span) { 13070 if (span.start.hasTime() || span.end.hasTime()) { 13071 this.timeGrid.renderSelection(span); 13072 } 13073 else if (this.dayGrid) { 13074 this.dayGrid.renderSelection(span); 13075 } 13076 }, 13077 13078 13079 // Unrenders a visual indications of a selection 13080 unrenderSelection: function() { 13081 this.timeGrid.unrenderSelection(); 13082 if (this.dayGrid) { 13083 this.dayGrid.unrenderSelection(); 13084 } 13085 } 13086 13087}); 13088 13089 13090// Methods that will customize the rendering behavior of the AgendaView's timeGrid 13091// TODO: move into TimeGrid 13092var agendaTimeGridMethods = { 13093 13094 13095 // Generates the HTML that will go before the day-of week header cells 13096 renderHeadIntroHtml: function() { 13097 var view = this.view; 13098 var weekText; 13099 13100 if (view.opt('weekNumbers')) { 13101 weekText = this.start.format(view.opt('smallWeekFormat')); 13102 13103 return '' + 13104 '<th class="fc-axis fc-week-number ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '>' + 13105 '<span>' + // needed for matchCellWidths 13106 htmlEscape(weekText) + 13107 '</span>' + 13108 '</th>'; 13109 } 13110 else { 13111 return '<th class="fc-axis ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '></th>'; 13112 } 13113 }, 13114 13115 13116 // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column. 13117 renderBgIntroHtml: function() { 13118 var view = this.view; 13119 13120 return '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '></td>'; 13121 }, 13122 13123 13124 // Generates the HTML that goes before all other types of cells. 13125 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. 13126 renderIntroHtml: function() { 13127 var view = this.view; 13128 13129 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>'; 13130 } 13131 13132}; 13133 13134 13135// Methods that will customize the rendering behavior of the AgendaView's dayGrid 13136var agendaDayGridMethods = { 13137 13138 13139 // Generates the HTML that goes before the all-day cells 13140 renderBgIntroHtml: function() { 13141 var view = this.view; 13142 13143 return '' + 13144 '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' + 13145 '<span>' + // needed for matchCellWidths 13146 (view.opt('allDayHtml') || htmlEscape(view.opt('allDayText'))) + 13147 '</span>' + 13148 '</td>'; 13149 }, 13150 13151 13152 // Generates the HTML that goes before all other types of cells. 13153 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. 13154 renderIntroHtml: function() { 13155 var view = this.view; 13156 13157 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>'; 13158 } 13159 13160}; 13161 13162;; 13163 13164var AGENDA_ALL_DAY_EVENT_LIMIT = 5; 13165 13166// potential nice values for the slot-duration and interval-duration 13167// from largest to smallest 13168var AGENDA_STOCK_SUB_DURATIONS = [ 13169 { hours: 1 }, 13170 { minutes: 30 }, 13171 { minutes: 15 }, 13172 { seconds: 30 }, 13173 { seconds: 15 } 13174]; 13175 13176fcViews.agenda = { 13177 'class': AgendaView, 13178 defaults: { 13179 allDaySlot: true, 13180 allDayText: 'all-day', 13181 slotDuration: '00:30:00', 13182 minTime: '00:00:00', 13183 maxTime: '24:00:00', 13184 slotEventOverlap: true // a bad name. confused with overlap/constraint system 13185 } 13186}; 13187 13188fcViews.agendaDay = { 13189 type: 'agenda', 13190 duration: { days: 1 } 13191}; 13192 13193fcViews.agendaWeek = { 13194 type: 'agenda', 13195 duration: { weeks: 1 } 13196}; 13197;; 13198 13199return FC; // export for Node/CommonJS 13200});
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.