1/*! 2 * FullCalendar v3.0.0 3 * Docs & License: http://fullcalendar.io/ 4 * (c) 2016 Adam Shaw 5 */ 6 7(function(factory) { 8 if (typeof define === 'function' && define.amd) { 9 define([ 'jquery', 'moment' ], factory); 10 } 11 else if (typeof exports === 'object') { // Node/CommonJS 12 module.exports = factory(require('jquery'), require('moment')); 13 } 14 else { 15 factory(jQuery, moment); 16 } 17})(function($, moment) { 18 19;; 20 21var FC = $.fullCalendar = { 22 version: "3.0.0", 23 internalApiVersion: 6 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 76// exports 77FC.intersectRanges = intersectRanges; 78FC.applyAll = applyAll; 79FC.debounce = debounce; 80FC.isInt = isInt; 81FC.htmlEscape = htmlEscape; 82FC.cssToStr = cssToStr; 83FC.proxy = proxy; 84FC.capitaliseFirstLetter = capitaliseFirstLetter; 85 86 87/* FullCalendar-specific DOM Utilities 88----------------------------------------------------------------------------------------------------------------------*/ 89 90 91// Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left 92// and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that. 93function compensateScroll(rowEls, scrollbarWidths) { 94 if (scrollbarWidths.left) { 95 rowEls.css({ 96 'border-left-width': 1, 97 'margin-left': scrollbarWidths.left - 1 98 }); 99 } 100 if (scrollbarWidths.right) { 101 rowEls.css({ 102 'border-right-width': 1, 103 'margin-right': scrollbarWidths.right - 1 104 }); 105 } 106} 107 108 109// Undoes compensateScroll and restores all borders/margins 110function uncompensateScroll(rowEls) { 111 rowEls.css({ 112 'margin-left': '', 113 'margin-right': '', 114 'border-left-width': '', 115 'border-right-width': '' 116 }); 117} 118 119 120// Make the mouse cursor express that an event is not allowed in the current area 121function disableCursor() { 122 $('body').addClass('fc-not-allowed'); 123} 124 125 126// Returns the mouse cursor to its original look 127function enableCursor() { 128 $('body').removeClass('fc-not-allowed'); 129} 130 131 132// Given a total available height to fill, have `els` (essentially child rows) expand to accomodate. 133// By default, all elements that are shorter than the recommended height are expanded uniformly, not considering 134// any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and 135// reduces the available height. 136function distributeHeight(els, availableHeight, shouldRedistribute) { 137 138 // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions, 139 // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars. 140 141 var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element 142 var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE* 143 var flexEls = []; // elements that are allowed to expand. array of DOM nodes 144 var flexOffsets = []; // amount of vertical space it takes up 145 var flexHeights = []; // actual css height 146 var usedHeight = 0; 147 148 undistributeHeight(els); // give all elements their natural height 149 150 // find elements that are below the recommended height (expandable). 151 // important to query for heights in a single first pass (to avoid reflow oscillation). 152 els.each(function(i, el) { 153 var minOffset = i === els.length - 1 ? minOffset2 : minOffset1; 154 var naturalOffset = $(el).outerHeight(true); 155 156 if (naturalOffset < minOffset) { 157 flexEls.push(el); 158 flexOffsets.push(naturalOffset); 159 flexHeights.push($(el).height()); 160 } 161 else { 162 // this element stretches past recommended height (non-expandable). mark the space as occupied. 163 usedHeight += naturalOffset; 164 } 165 }); 166 167 // readjust the recommended height to only consider the height available to non-maxed-out rows. 168 if (shouldRedistribute) { 169 availableHeight -= usedHeight; 170 minOffset1 = Math.floor(availableHeight / flexEls.length); 171 minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE* 172 } 173 174 // assign heights to all expandable elements 175 $(flexEls).each(function(i, el) { 176 var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1; 177 var naturalOffset = flexOffsets[i]; 178 var naturalHeight = flexHeights[i]; 179 var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding 180 181 if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things 182 $(el).height(newHeight); 183 } 184 }); 185} 186 187 188// Undoes distrubuteHeight, restoring all els to their natural height 189function undistributeHeight(els) { 190 els.height(''); 191} 192 193 194// Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the 195// cells to be that width. 196// PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline 197function matchCellWidths(els) { 198 var maxInnerWidth = 0; 199 200 els.find('> *').each(function(i, innerEl) { 201 var innerWidth = $(innerEl).outerWidth(); 202 if (innerWidth > maxInnerWidth) { 203 maxInnerWidth = innerWidth; 204 } 205 }); 206 207 maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance 208 209 els.width(maxInnerWidth);
210 211 return maxInnerWidth; 212} 213 214 215// Given one element that resides inside another, 216// Subtracts the height of the inner element from the outer element. 217function subtractInnerElHeight(outerEl, innerEl) { 218 var both = outerEl.add(innerEl); 219 var diff; 220 221 // effin' IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked 222 both.css({ 223 position: 'relative', // cause a reflow, which will force fresh dimension recalculation 224 left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll 225 }); 226 diff = outerEl.outerHeight() - innerEl.outerHeight(); // grab the dimensions 227 both.css({ position: '', left: '' }); // undo hack 228 229 return diff; 230} 231 232 233/* Element Geom Utilities 234----------------------------------------------------------------------------------------------------------------------*/ 235 236FC.getOuterRect = getOuterRect; 237FC.getClientRect = getClientRect; 238FC.getContentRect = getContentRect; 239FC.getScrollbarWidths = getScrollbarWidths; 240 241 242// borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51 243function getScrollParent(el) { 244 var position = el.css('position'), 245 scrollParent = el.parents().filter(function() { 246 var parent = $(this); 247 return (/(auto|scroll)/).test( 248 parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x') 249 ); 250 }).eq(0); 251 252 return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent; 253} 254 255 256// Queries the outer bounding area of a jQuery element. 257// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). 258// Origin is optional. 259function getOuterRect(el, origin) { 260 var offset = el.offset(); 261 var left = offset.left - (origin ? origin.left : 0); 262 var top = offset.top - (origin ? origin.top : 0); 263 264 return { 265 left: left, 266 right: left + el.outerWidth(), 267 top: top, 268 bottom: top + el.outerHeight() 269 }; 270} 271 272 273// Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding. 274// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). 275// Origin is optional. 276// NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. 277function getClientRect(el, origin) { 278 var offset = el.offset(); 279 var scrollbarWidths = getScrollbarWidths(el); 280 var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left - (origin ? origin.left : 0); 281 var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top - (origin ? origin.top : 0); 282 283 return { 284 left: left, 285 right: left + el[0].clientWidth, // clientWidth includes padding but NOT scrollbars 286 top: top, 287 bottom: top + el[0].clientHeight // clientHeight includes padding but NOT scrollbars 288 }; 289} 290 291 292// Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars. 293// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). 294// Origin is optional. 295function getContentRect(el, origin) { 296 var offset = el.offset(); // just outside of border, margin not included 297 var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left') - 298 (origin ? origin.left : 0); 299 var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top') - 300 (origin ? origin.top : 0); 301 302 return { 303 left: left, 304 right: left + el.width(), 305 top: top, 306 bottom: top + el.height() 307 }; 308} 309 310 311// Returns the computed left/right/top/bottom scrollbar widths for the given jQuery element. 312// NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. 313function getScrollbarWidths(el) { 314 var leftRightWidth = el.innerWidth() - el[0].clientWidth; // the paddings cancel out, leaving the scrollbars 315 var widths = { 316 left: 0, 317 right: 0, 318 top: 0,
319 bottom: el.innerHeight() - el[0].clientHeight // the paddings cancel out, leaving the bottom scrollbar 320 }; 321 322 if (getIsLeftRtlScrollbars() && el.css('direction') == 'rtl') { // is the scrollbar on the left side? 323 widths.left = leftRightWidth; 324 } 325 else { 326 widths.right = leftRightWidth; 327 } 328 329 return widths; 330} 331 332 333// Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side 334 335var _isLeftRtlScrollbars = null; 336 337function getIsLeftRtlScrollbars() { // responsible for caching the computation 338 if (_isLeftRtlScrollbars === null) { 339 _isLeftRtlScrollbars = computeIsLeftRtlScrollbars(); 340 } 341 return _isLeftRtlScrollbars; 342} 343 344function computeIsLeftRtlScrollbars() { // creates an offscreen test element, then removes it 345 var el = $('<div><div/></div>') 346 .css({ 347 position: 'absolute', 348 top: -1000, 349 left: 0, 350 border: 0, 351 padding: 0, 352 overflow: 'scroll', 353 direction: 'rtl' 354 }) 355 .appendTo('body'); 356 var innerEl = el.children(); 357 var res = innerEl.offset().left > el.offset().left; // is the inner div shifted to accommodate a left scrollbar? 358 el.remove(); 359 return res; 360} 361 362 363// Retrieves a jQuery element's computed CSS value as a floating-point number. 364// If the queried value is non-numeric (ex: IE can return "medium" for border width), will just return zero. 365function getCssFloat(el, prop) { 366 return parseFloat(el.css(prop)) || 0; 367} 368 369 370/* Mouse / Touch Utilities 371----------------------------------------------------------------------------------------------------------------------*/ 372 373FC.preventDefault = preventDefault; 374 375 376// Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac) 377function isPrimaryMouseButton(ev) { 378 return ev.which == 1 && !ev.ctrlKey; 379} 380 381 382function getEvX(ev) { 383 if (ev.pageX !== undefined) { 384 return ev.pageX; 385 } 386 var touches = ev.originalEvent.touches; 387 if (touches) { 388 return touches[0].pageX; 389 } 390} 391 392 393function getEvY(ev) { 394 if (ev.pageY !== undefined) { 395 return ev.pageY; 396 } 397 var touches = ev.originalEvent.touches; 398 if (touches) { 399 return touches[0].pageY; 400 } 401} 402 403 404function getEvIsTouch(ev) { 405 return /^touch/.test(ev.type); 406} 407 408 409function preventSelection(el) { 410 el.addClass('fc-unselectable') 411 .on('selectstart', preventDefault); 412} 413 414 415// Stops a mouse/touch event from doing it's native browser action 416function preventDefault(ev) { 417 ev.preventDefault(); 418} 419 420 421// attach a handler to get called when ANY scroll action happens on the page. 422// this was impossible to do with normal on/off because 'scroll' doesn't bubble. 423// http://stackoverflow.com/a/32954565/96342 424// returns `true` on success. 425function bindAnyScroll(handler) { 426 if (window.addEventListener) { 427 window.addEventListener('scroll', handler, true); // useCapture=true 428 return true; 429 } 430 return false; 431} 432 433 434// undoes bindAnyScroll. must pass in the original function. 435// returns `true` on success. 436function unbindAnyScroll(handler) { 437 if (window.removeEventListener) { 438 window.removeEventListener('scroll', handler, true); // useCapture=true 439 return true; 440 } 441 return false; 442} 443 444 445/* General Geometry Utils 446----------------------------------------------------------------------------------------------------------------------*/ 447 448FC.intersectRects = intersectRects; 449 450// Returns a new rectangle that is the intersection of the two rectangles. If they don't intersect, returns false 451function intersectRects(rect1, rect2) { 452 var res = { 453 left: Math.max(rect1.left, rect2.left), 454 right: Math.min(rect1.right, rect2.right), 455 top: Math.max(rect1.top, rect2.top), 456 bottom: Math.min(rect1.bottom, rect2.bottom) 457 }; 458 459 if (res.left < res.right && res.top < res.bottom) { 460 return res; 461 } 462 return false; 463} 464 465 466// Returns a new point that will have been moved to reside within the given rectangle 467function constrainPoint(point, rect) { 468 return { 469 left: Math.min(Math.max(point.left, rect.left), rect.right), 470 top: Math.min(Math.max(point.top, rect.top), rect.bottom) 471 }; 472} 473 474 475// Returns a point that is the center of the given rectangle 476function getRectCenter(rect) { 477 return { 478 left: (rect.left + rect.right) / 2, 479 top: (rect.top + rect.bottom) / 2 480 }; 481} 482 483 484// Subtracts point2's coordinates from point1's coordinates, returning a delta 485function diffPoints(point1, point2) { 486 return { 487 left: point1.left - point2.left, 488 top: point1.top - point2.top 489 }; 490} 491 492 493/* Object Ordering by Field 494----------------------------------------------------------------------------------------------------------------------*/ 495 496FC.parseFieldSpecs = parseFieldSpecs; 497FC.compareByFieldSpecs = compareByFieldSpecs; 498FC.compareByFieldSpec = compareByFieldSpec; 499FC.flexibleCompare = flexibleCompare; 500 501 502function parseFieldSpecs(input) { 503 var specs = []; 504 var tokens = []; 505 var i, token; 506 507 if (typeof input === 'string') { 508 tokens = input.split(/\s*,\s*/); 509 } 510 else if (typeof input === 'function') { 511 tokens = [ input ]; 512 } 513 else if ($.isArray(input)) { 514 tokens = input; 515 } 516 517 for (i = 0; i < tokens.length; i++) { 518 token = tokens[i]; 519 520 if (typeof token === 'string') { 521 specs.push( 522 token.charAt(0) == '-' ? 523 { field: token.substring(1), order: -1 } : 524 { field: token, order: 1 } 525 ); 526 } 527 else if (typeof token === 'function') { 528 specs.push({ func: token }); 529 } 530 } 531 532 return specs; 533} 534 535 536function compareByFieldSpecs(obj1, obj2, fieldSpecs) { 537 var i; 538 var cmp; 539 540 for (i = 0; i < fieldSpecs.length; i++) { 541 cmp = compareByFieldSpec(obj1, obj2, fieldSpecs[i]); 542 if (cmp) { 543 return cmp; 544 } 545 } 546 547 return 0; 548} 549 550 551function compareByFieldSpec(obj1, obj2, fieldSpec) { 552 if (fieldSpec.func) { 553 return fieldSpec.func(obj1, obj2); 554 } 555 return flexibleCompare(obj1[fieldSpec.field], obj2[fieldSpec.field]) * 556 (fieldSpec.order || 1); 557} 558 559 560function flexibleCompare(a, b) { 561 if (!a && !b) { 562 return 0; 563 } 564 if (b == null) { 565 return -1; 566 } 567 if (a == null) { 568 return 1; 569 } 570 if ($.type(a) === 'string' || $.type(b) === 'string') { 571 return String(a).localeCompare(String(b)); 572 } 573 return a - b; 574} 575 576 577/* FullCalendar-specific Misc Utilities 578----------------------------------------------------------------------------------------------------------------------*/ 579 580 581// Computes the intersection of the two ranges. Will return fresh date clones in a range. 582// Returns undefined if no intersection. 583// Expects all dates to be normalized to the same timezone beforehand. 584// TODO: move to date section? 585function intersectRanges(subjectRange, constraintRange) { 586 var subjectStart = subjectRange.start; 587 var subjectEnd = subjectRange.end; 588 var constraintStart = constraintRange.start; 589 var constraintEnd = constraintRange.end; 590 var segStart, segEnd; 591 var isStart, isEnd; 592 593 if (subjectEnd > constraintStart && subjectStart < constraintEnd) { // in bounds at all? 594 595 if (subjectStart >= constraintStart) { 596 segStart = subjectStart.clone(); 597 isStart = true; 598 } 599 else { 600 segStart = constraintStart.clone(); 601 isStart = false; 602 } 603 604 if (subjectEnd <= constraintEnd) { 605 segEnd = subjectEnd.clone(); 606 isEnd = true; 607 } 608 else { 609 segEnd = constraintEnd.clone(); 610 isEnd = false; 611 } 612 613 return { 614 start: segStart, 615 end: segEnd, 616 isStart: isStart, 617 isEnd: isEnd 618 }; 619 } 620} 621 622 623/* Date Utilities 624----------------------------------------------------------------------------------------------------------------------*/ 625 626FC.computeIntervalUnit = computeIntervalUnit; 627FC.divideRangeByDuration = divideRangeByDuration; 628FC.divideDurationByDuration = divideDurationByDuration; 629FC.multiplyDuration = multiplyDuration; 630FC.durationHasTime = durationHasTime; 631 632var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ]; 633var intervalUnits = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ]; 634 635 636// Diffs the two moments into a Duration where full-days are recorded first, then the remaining time. 637// Moments will have their timezones normalized. 638function diffDayTime(a, b) { 639 return moment.duration({ 640 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'), 641 ms: a.time() - b.time() // time-of-day from day start. disregards timezone 642 }); 643} 644 645 646// Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations. 647function diffDay(a, b) { 648 return moment.duration({ 649 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days') 650 }); 651} 652 653 654// Diffs two moments, producing a duration, made of a whole-unit-increment of the given unit. Uses rounding. 655function diffByUnit(a, b, unit) { 656 return moment.duration( 657 Math.round(a.diff(b, unit, true)), // returnFloat=true 658 unit 659 ); 660} 661 662 663// Computes the unit name of the largest whole-unit period of time. 664// For example, 48 hours will be "days" whereas 49 hours will be "hours". 665// Accepts start/end, a range object, or an original duration object. 666function computeIntervalUnit(start, end) { 667 var i, unit; 668 var val; 669 670 for (i = 0; i < intervalUnits.length; i++) { 671 unit = intervalUnits[i]; 672 val = computeRangeAs(unit, start, end); 673 674 if (val >= 1 && isInt(val)) { 675 break; 676 } 677 } 678 679 return unit; // will be "milliseconds" if nothing else matches 680} 681 682 683// Computes the number of units (like "hours") in the given range. 684// Range can be a {start,end} object, separate start/end args, or a Duration. 685// Results are based on Moment's .as() and .diff() methods, so results can depend on internal handling 686// of month-diffing logic (which tends to vary from version to version). 687function computeRangeAs(unit, start, end) { 688 689 if (end != null) { // given start, end 690 return end.diff(start, unit, true); 691 } 692 else if (moment.isDuration(start)) { // given duration 693 return start.as(unit); 694 } 695 else { // given { start, end } range object 696 return start.end.diff(start.start, unit, true); 697 } 698} 699 700 701// Intelligently divides a range (specified by a start/end params) by a duration 702function divideRangeByDuration(start, end, dur) { 703 var months; 704 705 if (durationHasTime(dur)) { 706 return (end - start) / dur; 707 } 708 months = dur.asMonths(); 709 if (Math.abs(months) >= 1 && isInt(months)) { 710 return end.diff(start, 'months', true) / months; 711 } 712 return end.diff(start, 'days', true) / dur.asDays(); 713} 714 715 716// Intelligently divides one duration by another 717function divideDurationByDuration(dur1, dur2) { 718 var months1, months2; 719 720 if (durationHasTime(dur1) || durationHasTime(dur2)) { 721 return dur1 / dur2; 722 } 723 months1 = dur1.asMonths(); 724 months2 = dur2.asMonths(); 725 if ( 726 Math.abs(months1) >= 1 && isInt(months1) && 727 Math.abs(months2) >= 1 && isInt(months2) 728 ) { 729 return months1 / months2; 730 } 731 return dur1.asDays() / dur2.asDays(); 732} 733 734 735// Intelligently multiplies a duration by a number 736function multiplyDuration(dur, n) { 737 var months; 738 739 if (durationHasTime(dur)) { 740 return moment.duration(dur * n); 741 } 742 months = dur.asMonths(); 743 if (Math.abs(months) >= 1 && isInt(months)) { 744 return moment.duration({ months: months * n }); 745 } 746 return moment.duration({ days: dur.asDays() * n }); 747} 748 749 750// Returns a boolean about whether the given duration has any time parts (hours/minutes/seconds/ms) 751function durationHasTime(dur) { 752 return Boolean(dur.hours() || dur.minutes() || dur.seconds() || dur.milliseconds()); 753} 754 755 756function isNativeDate(input) { 757 return Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date; 758} 759 760 761// Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00"
762function isTimeString(str) { 763 return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str); 764} 765 766 767/* Logging and Debug 768----------------------------------------------------------------------------------------------------------------------*/ 769 770FC.log = function() { 771 var console = window.console; 772 773 if (console && console.log) { 774 return console.log.apply(console, arguments); 775 } 776}; 777 778FC.warn = function() { 779 var console = window.console; 780 781 if (console && console.warn) { 782 return console.warn.apply(console, arguments); 783 } 784 else { 785 return FC.log.apply(FC, arguments); 786 } 787}; 788 789 790/* General Utilities 791----------------------------------------------------------------------------------------------------------------------*/ 792 793var hasOwnPropMethod = {}.hasOwnProperty; 794 795 796// Merges an array of objects into a single object. 797// The second argument allows for an array of property names who's object values will be merged together. 798function mergeProps(propObjs, complexProps) { 799 var dest = {}; 800 var i, name; 801 var complexObjs; 802 var j, val; 803 var props; 804 805 if (complexProps) { 806 for (i = 0; i < complexProps.length; i++) { 807 name = complexProps[i]; 808 complexObjs = []; 809 810 // collect the trailing object values, stopping when a non-object is discovered 811 for (j = propObjs.length - 1; j >= 0; j--) { 812 val = propObjs[j][name]; 813 814 if (typeof val === 'object') { 815 complexObjs.unshift(val); 816 } 817 else if (val !== undefined) { 818 dest[name] = val; // if there were no objects, this value will be used 819 break; 820 } 821 } 822 823 // if the trailing values were objects, use the merged value 824 if (complexObjs.length) { 825 dest[name] = mergeProps(complexObjs); 826 } 827 } 828 } 829 830 // copy values into the destination, going from last to first 831 for (i = propObjs.length - 1; i >= 0; i--) { 832 props = propObjs[i]; 833 834 for (name in props) { 835 if (!(name in dest)) { // if already assigned by previous props or complex props, don't reassign 836 dest[name] = props[name]; 837 } 838 } 839 } 840 841 return dest; 842} 843 844 845// Create an object that has the given prototype. Just like Object.create 846function createObject(proto) { 847 var f = function() {}; 848 f.prototype = proto; 849 return new f(); 850} 851 852 853function copyOwnProps(src, dest) { 854 for (var name in src) { 855 if (hasOwnProp(src, name)) { 856 dest[name] = src[name]; 857 } 858 } 859} 860 861 862function hasOwnProp(obj, name) { 863 return hasOwnPropMethod.call(obj, name); 864} 865 866 867// Is the given value a non-object non-function value? 868function isAtomic(val) { 869 return /undefined|null|boolean|number|string/.test($.type(val)); 870} 871 872 873function applyAll(functions, thisObj, args) { 874 if ($.isFunction(functions)) { 875 functions = [ functions ]; 876 } 877 if (functions) { 878 var i; 879 var ret; 880 for (i=0; i<functions.length; i++) { 881 ret = functions[i].apply(thisObj, args) || ret; 882 } 883 return ret; 884 } 885} 886 887 888function firstDefined() { 889 for (var i=0; i<arguments.length; i++) { 890 if (arguments[i] !== undefined) { 891 return arguments[i]; 892 } 893 } 894} 895 896 897function htmlEscape(s) { 898 return (s + '').replace(/&/g, '&') 899 .replace(/</g, '<') 900 .replace(/>/g, '>') 901 .replace(/'/g, ''') 902 .replace(/"/g, '"') 903 .replace(/\n/g, '<br />'); 904} 905 906 907function stripHtmlEntities(text) { 908 return text.replace(/&.*?;/g, ''); 909} 910 911 912// Given a hash of CSS properties, returns a string of CSS. 913// Uses property names as-is (no camel-case conversion). Will not make statements for null/undefined values. 914function cssToStr(cssProps) { 915 var statements = []; 916 917 $.each(cssProps, function(name, val) { 918 if (val != null) { 919 statements.push(name + ':' + val); 920 } 921 }); 922 923 return statements.join(';'); 924} 925 926 927// Given an object hash of HTML attribute names to values, 928// generates a string that can be injected between < > in HTML 929function attrsToStr(attrs) { 930 var parts = []; 931 932 $.each(attrs, function(name, val) { 933 if (val != null) { 934 parts.push(name + '="' + htmlEscape(val) + '"'); 935 } 936 }); 937 938 return parts.join(' '); 939} 940 941 942function capitaliseFirstLetter(str) { 943 return str.charAt(0).toUpperCase() + str.slice(1); 944} 945 946 947function compareNumbers(a, b) { // for .sort() 948 return a - b; 949} 950 951 952function isInt(n) { 953 return n % 1 === 0; 954} 955 956
957// Returns a method bound to the given object context. 958// Just like one of the jQuery.proxy signatures, but without the undesired behavior of treating the same method with 959// different contexts as identical when binding/unbinding events. 960function proxy(obj, methodName) { 961 var method = obj[methodName]; 962 963 return function() { 964 return method.apply(obj, arguments); 965 }; 966} 967 968 969// Returns a function, that, as long as it continues to be invoked, will not 970// be triggered. The function will be called after it stops being called for 971// N milliseconds. If `immediate` is passed, trigger the function on the 972// leading edge, instead of the trailing. 973// https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714 974function debounce(func, wait, immediate) { 975 var timeout, args, context, timestamp, result; 976 977 var later = function() { 978 var last = +new Date() - timestamp; 979 if (last < wait) { 980 timeout = setTimeout(later, wait - last); 981 } 982 else { 983 timeout = null; 984 if (!immediate) { 985 result = func.apply(context, args); 986 context = args = null; 987 } 988 } 989 }; 990 991 return function() { 992 context = this; 993 args = arguments; 994 timestamp = +new Date(); 995 var callNow = immediate && !timeout; 996 if (!timeout) { 997 timeout = setTimeout(later, wait); 998 } 999 if (callNow) { 1000 result = func.apply(context, args); 1001 context = args = null; 1002 } 1003 return result; 1004 }; 1005} 1006 1007 1008// HACK around jQuery's now A+ promises: execute callback synchronously if already resolved. 1009// thenFunc shouldn't accept args. 1010// similar to whenResources in Scheduler plugin. 1011function syncThen(promise, thenFunc) { 1012 // not a promise, or an already-resolved promise? 1013 if (!promise || !promise.then || promise.state() === 'resolved') { 1014 return $.when(thenFunc()); // resolve immediately 1015 } 1016 else if (thenFunc) { 1017 return promise.then(thenFunc); 1018 } 1019} 1020 1021;; 1022 1023/* 1024GENERAL NOTE on moments throughout the *entire rest* of the codebase: 1025All moments are assumed to be ambiguously-zoned unless otherwise noted, 1026with the NOTABLE EXCEOPTION of start/end dates that live on *Event Objects*. 1027Ambiguously-TIMED moments are assumed to be ambiguously-zoned by nature. 1028*/ 1029 1030var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/; 1031var ambigTimeOrZoneRegex = 1032 /^\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+)?)?)?)?)?$/; 1033var newMomentProto = moment.fn; // where we will attach our new methods 1034var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods 1035 1036// tell momentjs to transfer these properties upon clone 1037var momentProperties = moment.momentProperties; 1038momentProperties.push('_fullCalendar'); 1039momentProperties.push('_ambigTime'); 1040momentProperties.push('_ambigZone'); 1041 1042 1043// Creating 1044// ------------------------------------------------------------------------------------------------- 1045 1046// Creates a new moment, similar to the vanilla moment(...) constructor, but with 1047// extra features (ambiguous time, enhanced formatting). When given an existing moment, 1048// it will function as a clone (and retain the zone of the moment). Anything else will 1049// result in a moment in the local zone. 1050FC.moment = function() { 1051 return makeMoment(arguments); 1052}; 1053 1054// Sames as FC.moment, but forces the resulting moment to be in the UTC timezone. 1055FC.moment.utc = function() { 1056 var mom = makeMoment(arguments, true); 1057 1058 // Force it into UTC because makeMoment doesn't guarantee it 1059 // (if given a pre-existing moment for example) 1060 if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone 1061 mom.utc(); 1062 } 1063 1064 return mom; 1065}; 1066 1067// Same as FC.moment, but when given an ISO8601 string, the timezone offset is preserved. 1068// ISO8601 strings with no timezone offset will become ambiguously zoned. 1069FC.moment.parseZone = function() { 1070 return makeMoment(arguments, true, true); 1071}; 1072 1073// Builds an enhanced moment from args. When given an existing moment, it clones. When given a 1074// native Date, or called with no arguments (the current time), the resulting moment will be local. 1075// Anything else needs to be "parsed" (a string or an array), and will be affected by: 1076// parseAsUTC - if there is no zone information, should we parse the input in UTC?
1077// parseZone - if there is zone information, should we force the zone of the moment? 1078function makeMoment(args, parseAsUTC, parseZone) { 1079 var input = args[0]; 1080 var isSingleString = args.length == 1 && typeof input === 'string'; 1081 var isAmbigTime; 1082 var isAmbigZone; 1083 var ambigMatch; 1084 var mom; 1085 1086 if (moment.isMoment(input) || isNativeDate(input) || input === undefined) { 1087 mom = moment.apply(null, args); 1088 } 1089 else { // "parsing" is required 1090 isAmbigTime = false; 1091 isAmbigZone = false; 1092 1093 if (isSingleString) { 1094 if (ambigDateOfMonthRegex.test(input)) { 1095 // accept strings like '2014-05', but convert to the first of the month 1096 input += '-01'; 1097 args = [ input ]; // for when we pass it on to moment's constructor 1098 isAmbigTime = true; 1099 isAmbigZone = true; 1100 } 1101 else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) { 1102 isAmbigTime = !ambigMatch[5]; // no time part? 1103 isAmbigZone = true; 1104 } 1105 } 1106 else if ($.isArray(input)) { 1107 // arrays have no timezone information, so assume ambiguous zone 1108 isAmbigZone = true; 1109 } 1110 // otherwise, probably a string with a format 1111 1112 if (parseAsUTC || isAmbigTime) { 1113 mom = moment.utc.apply(moment, args); 1114 } 1115 else { 1116 mom = moment.apply(null, args); 1117 } 1118 1119 if (isAmbigTime) { 1120 mom._ambigTime = true; 1121 mom._ambigZone = true; // ambiguous time always means ambiguous zone 1122 } 1123 else if (parseZone) { // let's record the inputted zone somehow 1124 if (isAmbigZone) { 1125 mom._ambigZone = true; 1126 } 1127 else if (isSingleString) { 1128 mom.utcOffset(input); // if not a valid zone, will assign UTC 1129 } 1130 } 1131 } 1132 1133 mom._fullCalendar = true; // flag for extended functionality 1134 1135 return mom; 1136} 1137 1138 1139// Week Number 1140// ------------------------------------------------------------------------------------------------- 1141 1142 1143// Returns the week number, considering the locale's custom week number calcuation 1144// `weeks` is an alias for `week` 1145newMomentProto.week = newMomentProto.weeks = function(input) { 1146 var weekCalc = this._locale._fullCalendar_weekCalc; 1147 1148 if (input == null && typeof weekCalc === 'function') { // custom function only works for getter 1149 return weekCalc(this); 1150 } 1151 else if (weekCalc === 'ISO') { 1152 return oldMomentProto.isoWeek.apply(this, arguments); // ISO getter/setter 1153 } 1154 1155 return oldMomentProto.week.apply(this, arguments); // local getter/setter 1156}; 1157 1158 1159// Time-of-day 1160// ------------------------------------------------------------------------------------------------- 1161 1162// GETTER 1163// Returns a Duration with the hours/minutes/seconds/ms values of the moment. 1164// If the moment has an ambiguous time, a duration of 00:00 will be returned. 1165// 1166// SETTER 1167// You can supply a Duration, a Moment, or a Duration-like argument. 1168// When setting the time, and the moment has an ambiguous time, it then becomes unambiguous. 1169newMomentProto.time = function(time) { 1170 1171 // Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar. 1172 // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins. 1173 if (!this._fullCalendar) { 1174 return oldMomentProto.time.apply(this, arguments); 1175 } 1176 1177 if (time == null) { // getter 1178 return moment.duration({ 1179 hours: this.hours(), 1180 minutes: this.minutes(), 1181 seconds: this.seconds(), 1182 milliseconds: this.milliseconds() 1183 }); 1184 } 1185 else { // setter 1186 1187 this._ambigTime = false; // mark that the moment now has a time 1188 1189 if (!moment.isDuration(time) && !moment.isMoment(time)) { 1190 time = moment.duration(time); 1191 } 1192 1193 // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day). 1194 // Only for Duration times, not Moment times. 1195 var dayHours = 0; 1196 if (moment.isDuration(time)) { 1197 dayHours = Math.floor(time.asDays()) * 24; 1198 } 1199 1200 // We need to set the individual fields. 1201 // Can't use startOf('day') then add duration. In case of DST at start of day. 1202 return this.hours(dayHours + time.hours()) 1203 .minutes(time.minutes()) 1204 .seconds(time.seconds()) 1205 .milliseconds(time.milliseconds()); 1206 } 1207}; 1208 1209// Converts the moment to UTC, stripping out its time-of-day and timezone offset, 1210// but preserving its YMD. A moment with a stripped time will display no time 1211// nor timezone offset when .format() is called. 1212newMomentProto.stripTime = function() { 1213 1214 if (!this._ambigTime) { 1215 1216 this.utc(true); // keepLocalTime=true (for keeping *date* value) 1217 1218 // set time to zero 1219 this.set({ 1220 hours: 0, 1221 minutes: 0, 1222 seconds: 0, 1223 ms: 0 1224 }); 1225 1226 // Mark the time as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), 1227 // which clears all ambig flags. 1228 this._ambigTime = true; 1229 this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset 1230 } 1231 1232 return this; // for chaining 1233}; 1234
1235// Returns if the moment has a non-ambiguous time (boolean) 1236newMomentProto.hasTime = function() { 1237 return !this._ambigTime; 1238}; 1239 1240 1241// Timezone 1242// ------------------------------------------------------------------------------------------------- 1243 1244// Converts the moment to UTC, stripping out its timezone offset, but preserving its 1245// YMD and time-of-day. A moment with a stripped timezone offset will display no 1246// timezone offset when .format() is called. 1247newMomentProto.stripZone = function() { 1248 var wasAmbigTime; 1249 1250 if (!this._ambigZone) { 1251 1252 wasAmbigTime = this._ambigTime; 1253 1254 this.utc(true); // keepLocalTime=true (for keeping date and time values) 1255 1256 // the above call to .utc()/.utcOffset() unfortunately might clear the ambig flags, so restore 1257 this._ambigTime = wasAmbigTime || false; 1258 1259 // Mark the zone as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), 1260 // which clears the ambig flags. 1261 this._ambigZone = true; 1262 } 1263 1264 return this; // for chaining 1265}; 1266 1267// Returns of the moment has a non-ambiguous timezone offset (boolean) 1268newMomentProto.hasZone = function() { 1269 return !this._ambigZone; 1270}; 1271 1272 1273// implicitly marks a zone 1274newMomentProto.local = function(keepLocalTime) { 1275 1276 // for when converting from ambiguously-zoned to local, 1277 // keep the time values when converting from UTC -> local 1278 oldMomentProto.local.call(this, this._ambigZone || keepLocalTime); 1279 1280 // ensure non-ambiguous 1281 // this probably already happened via local() -> utcOffset(), but don't rely on Moment's internals 1282 this._ambigTime = false; 1283 this._ambigZone = false; 1284 1285 return this; // for chaining 1286}; 1287 1288 1289// implicitly marks a zone 1290newMomentProto.utc = function(keepLocalTime) { 1291 1292 oldMomentProto.utc.call(this, keepLocalTime); 1293 1294 // ensure non-ambiguous 1295 // this probably already happened via utc() -> utcOffset(), but don't rely on Moment's internals 1296 this._ambigTime = false; 1297 this._ambigZone = false; 1298 1299 return this; 1300}; 1301 1302 1303// implicitly marks a zone (will probably get called upon .utc() and .local()) 1304newMomentProto.utcOffset = function(tzo) { 1305 1306 if (tzo != null) { // setter 1307 // these assignments needs to happen before the original zone method is called. 1308 // I forget why, something to do with a browser crash. 1309 this._ambigTime = false; 1310 this._ambigZone = false; 1311 } 1312 1313 return oldMomentProto.utcOffset.apply(this, arguments); 1314}; 1315 1316 1317// Formatting 1318// ------------------------------------------------------------------------------------------------- 1319 1320newMomentProto.format = function() { 1321 if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided? 1322 return formatDate(this, arguments[0]); // our extended formatting 1323 } 1324 if (this._ambigTime) { 1325 return oldMomentFormat(this, 'YYYY-MM-DD'); 1326 } 1327 if (this._ambigZone) { 1328 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); 1329 } 1330 return oldMomentProto.format.apply(this, arguments); 1331}; 1332 1333newMomentProto.toISOString = function() { 1334 if (this._ambigTime) { 1335 return oldMomentFormat(this, 'YYYY-MM-DD'); 1336 } 1337 if (this._ambigZone) { 1338 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); 1339 } 1340 return oldMomentProto.toISOString.apply(this, arguments); 1341}; 1342 1343;; 1344 1345// Single Date Formatting 1346// ------------------------------------------------------------------------------------------------- 1347 1348 1349// call this if you want Moment's original format method to be used 1350function oldMomentFormat(mom, formatStr) { 1351 return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js 1352} 1353 1354 1355// Formats `date` with a Moment formatting string, but allow our non-zero areas and 1356// additional token. 1357function formatDate(date, formatStr) { 1358 return formatDateWithChunks(date, getFormatStringChunks(formatStr)); 1359} 1360 1361 1362function formatDateWithChunks(date, chunks) { 1363 var s = ''; 1364 var i; 1365 1366 for (i=0; i<chunks.length; i++) { 1367 s += formatDateWithChunk(date, chunks[i]); 1368 } 1369 1370 return s; 1371} 1372 1373 1374// addition formatting tokens we want recognized 1375var tokenOverrides = { 1376 t: function(date) { // "a" or "p" 1377 return oldMomentFormat(date, 'a').charAt(0);
1378 }, 1379 T: function(date) { // "A" or "P" 1380 return oldMomentFormat(date, 'A').charAt(0); 1381 } 1382}; 1383 1384 1385function formatDateWithChunk(date, chunk) { 1386 var token; 1387 var maybeStr; 1388 1389 if (typeof chunk === 'string') { // a literal string 1390 return chunk; 1391 } 1392 else if ((token = chunk.token)) { // a token, like "YYYY" 1393 if (tokenOverrides[token]) { 1394 return tokenOverrides[token](date); // use our custom token 1395 } 1396 return oldMomentFormat(date, token); 1397 } 1398 else if (chunk.maybe) { // a grouping of other chunks that must be non-zero 1399 maybeStr = formatDateWithChunks(date, chunk.maybe); 1400 if (maybeStr.match(/[1-9]/)) { 1401 return maybeStr; 1402 } 1403 } 1404 1405 return ''; 1406} 1407 1408 1409// Date Range Formatting 1410// ------------------------------------------------------------------------------------------------- 1411// TODO: make it work with timezone offset 1412 1413// Using a formatting string meant for a single date, generate a range string, like 1414// "Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ. 1415// If the dates are the same as far as the format string is concerned, just return a single 1416// rendering of one date, without any separator. 1417function formatRange(date1, date2, formatStr, separator, isRTL) { 1418 var localeData; 1419 1420 date1 = FC.moment.parseZone(date1); 1421 date2 = FC.moment.parseZone(date2); 1422 1423 localeData = date1.localeData(); 1424 1425 // Expand localized format strings, like "LL" -> "MMMM D YYYY" 1426 formatStr = localeData.longDateFormat(formatStr) || formatStr; 1427 // BTW, this is not important for `formatDate` because it is impossible to put custom tokens 1428 // or non-zero areas in Moment's localized format strings. 1429 1430 separator = separator || ' - '; 1431 1432 return formatRangeWithChunks( 1433 date1, 1434 date2, 1435 getFormatStringChunks(formatStr), 1436 separator, 1437 isRTL 1438 ); 1439} 1440FC.formatRange = formatRange; // expose 1441 1442 1443function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { 1444 var unzonedDate1 = date1.clone().stripZone(); // for formatSimilarChunk 1445 var unzonedDate2 = date2.clone().stripZone(); // " 1446 var chunkStr; // the rendering of the chunk 1447 var leftI; 1448 var leftStr = ''; 1449 var rightI; 1450 var rightStr = ''; 1451 var middleI; 1452 var middleStr1 = ''; 1453 var middleStr2 = ''; 1454 var middleStr = ''; 1455 1456 // Start at the leftmost side of the formatting string and continue until you hit a token 1457 // that is not the same between dates. 1458 for (leftI=0; leftI<chunks.length; leftI++) { 1459 chunkStr = formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunks[leftI]); 1460 if (chunkStr === false) { 1461 break; 1462 } 1463 leftStr += chunkStr; 1464 } 1465 1466 // Similarly, start at the rightmost side of the formatting string and move left 1467 for (rightI=chunks.length-1; rightI>leftI; rightI--) { 1468 chunkStr = formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunks[rightI]); 1469 if (chunkStr === false) { 1470 break; 1471 } 1472 rightStr = chunkStr + rightStr; 1473 } 1474 1475 // The area in the middle is different for both of the dates. 1476 // Collect them distinctly so we can jam them together later. 1477 for (middleI=leftI; middleI<=rightI; middleI++) { 1478 middleStr1 += formatDateWithChunk(date1, chunks[middleI]); 1479 middleStr2 += formatDateWithChunk(date2, chunks[middleI]); 1480 } 1481 1482 if (middleStr1 || middleStr2) { 1483 if (isRTL) { 1484 middleStr = middleStr2 + separator + middleStr1; 1485 } 1486 else { 1487 middleStr = middleStr1 + separator + middleStr2; 1488 } 1489 } 1490 1491 return leftStr + middleStr + rightStr; 1492} 1493 1494 1495var similarUnitMap = { 1496 Y: 'year', 1497 M: 'month', 1498 D: 'day', // day of month 1499 d: 'day', // day of week 1500 // prevents a separator between anything time-related... 1501 A: 'second', // AM/PM 1502 a: 'second', // am/pm
1503 T: 'second', // A/P 1504 t: 'second', // a/p 1505 H: 'second', // hour (24) 1506 h: 'second', // hour (12) 1507 m: 'second', // minute 1508 s: 'second' // second 1509}; 1510// TODO: week maybe? 1511 1512 1513// Given a formatting chunk, and given that both dates are similar in the regard the 1514// formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`. 1515function formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunk) { 1516 var token; 1517 var unit; 1518 1519 if (typeof chunk === 'string') { // a literal string 1520 return chunk; 1521 } 1522 else if ((token = chunk.token)) { 1523 unit = similarUnitMap[token.charAt(0)]; 1524 1525 // are the dates the same for this unit of measurement? 1526 // use the unzoned dates for this calculation because unreliable when near DST (bug #2396) 1527 if (unit && unzonedDate1.isSame(unzonedDate2, unit)) { 1528 return oldMomentFormat(date1, token); // would be the same if we used `date2` 1529 // BTW, don't support custom tokens 1530 } 1531 } 1532 1533 return false; // the chunk is NOT the same for the two dates 1534 // BTW, don't support splitting on non-zero areas 1535} 1536 1537 1538// Chunking Utils 1539// ------------------------------------------------------------------------------------------------- 1540 1541 1542var formatStringChunkCache = {}; 1543 1544 1545function getFormatStringChunks(formatStr) { 1546 if (formatStr in formatStringChunkCache) { 1547 return formatStringChunkCache[formatStr]; 1548 } 1549 return (formatStringChunkCache[formatStr] = chunkFormatString(formatStr)); 1550} 1551 1552 1553// Break the formatting string into an array of chunks 1554function chunkFormatString(formatStr) { 1555 var chunks = []; 1556 var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g; // TODO: more descrimination 1557 var match; 1558 1559 while ((match = chunker.exec(formatStr))) { 1560 if (match[1]) { // a literal string inside [ ... ] 1561 chunks.push(match[1]); 1562 } 1563 else if (match[2]) { // non-zero formatting inside ( ... ) 1564 chunks.push({ maybe: chunkFormatString(match[2]) }); 1565 } 1566 else if (match[3]) { // a formatting token 1567 chunks.push({ token: match[3] }); 1568 } 1569 else if (match[5]) { // an unenclosed literal string 1570 chunks.push(match[5]); 1571 } 1572 } 1573 1574 return chunks; 1575} 1576 1577;; 1578 1579FC.Class = Class; // export 1580 1581// Class that all other classes will inherit from 1582function Class() { } 1583 1584 1585// Called on a class to create a subclass. 1586// Last argument contains instance methods. Any argument before the last are considered mixins. 1587Class.extend = function() { 1588 var len = arguments.length; 1589 var i; 1590 var members; 1591 1592 for (i = 0; i < len; i++) { 1593 members = arguments[i]; 1594 if (i < len - 1) { // not the last argument? 1595 mixIntoClass(this, members); 1596 } 1597 } 1598 1599 return extendClass(this, members || {}); // members will be undefined if no arguments 1600}; 1601 1602 1603// Adds new member variables/methods to the class's prototype. 1604// Can be called with another class, or a plain object hash containing new members. 1605Class.mixin = function(members) { 1606 mixIntoClass(this, members); 1607}; 1608 1609 1610function extendClass(superClass, members) { 1611 var subClass; 1612 1613 // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist 1614 if (hasOwnProp(members, 'constructor')) { 1615 subClass = members.constructor; 1616 } 1617 if (typeof subClass !== 'function') { 1618 subClass = members.constructor = function() { 1619 superClass.apply(this, arguments); 1620 }; 1621 } 1622 1623 // build the base prototype for the subclass, which is an new object chained to the superclass's prototype 1624 subClass.prototype = createObject(superClass.prototype); 1625 1626 // copy each member variable/method onto the the subclass's prototype 1627 copyOwnProps(members, subClass.prototype); 1628 1629 // copy over all class variables/methods to the subclass, such as `extend` and `mixin` 1630 copyOwnProps(superClass, subClass); 1631 1632 return subClass; 1633} 1634 1635 1636function mixIntoClass(theClass, members) { 1637 copyOwnProps(members, theClass.prototype); 1638} 1639;; 1640 1641var EmitterMixin = FC.EmitterMixin = { 1642 1643 // jQuery-ification via $(this) allows a non-DOM object to have 1644 // the same event handling capabilities (including namespaces). 1645 1646 1647 on: function(types, handler) { 1648 1649 // handlers are always called with an "event" object as their first param. 1650 // sneak the `this` context and arguments into the extra parameter object 1651 // and forward them on to the original handler. 1652 var intercept = function(ev, extra) { 1653 return handler.apply( 1654 extra.context || this, 1655 extra.args || [] 1656 ); 1657 }; 1658 1659 // mimick jQuery's internal "proxy" system (risky, I know) 1660 // causing all functions with the same .guid to appear to be the same. 1661 // https://github.com/jquery/jquery/blob/2.2.4/src/core.js#L448 1662 // this is needed for calling .off with the original non-intercept handler. 1663 if (!handler.guid) { 1664 handler.guid = $.guid++; 1665 } 1666 intercept.guid = handler.guid; 1667 1668 $(this).on(types, intercept); 1669 1670 return this; // for chaining 1671 }, 1672 1673 1674 off: function(types, handler) { 1675 $(this).off(types, handler); 1676 1677 return this; // for chaining 1678 }, 1679 1680 1681 trigger: function(types) { 1682 var args = Array.prototype.slice.call(arguments, 1); // arguments after the first 1683 1684 // pass in "extra" info to the intercept 1685 $(this).triggerHandler(types, { args: args }); 1686 1687 return this; // for chaining 1688 }, 1689 1690
1691 triggerWith: function(types, context, args) { 1692 1693 // `triggerHandler` is less reliant on the DOM compared to `trigger`. 1694 // pass in "extra" info to the intercept. 1695 $(this).triggerHandler(types, { context: context, args: args }); 1696 1697 return this; // for chaining 1698 } 1699 1700}; 1701 1702;; 1703 1704/* 1705Utility methods for easily listening to events on another object, 1706and more importantly, easily unlistening from them. 1707*/ 1708var ListenerMixin = FC.ListenerMixin = (function() { 1709 var guid = 0; 1710 var ListenerMixin = { 1711 1712 listenerId: null, 1713 1714 /* 1715 Given an `other` object that has on/off methods, bind the given `callback` to an event by the given name. 1716 The `callback` will be called with the `this` context of the object that .listenTo is being called on. 1717 Can be called: 1718 .listenTo(other, eventName, callback) 1719 OR 1720 .listenTo(other, { 1721 eventName1: callback1, 1722 eventName2: callback2 1723 }) 1724 */ 1725 listenTo: function(other, arg, callback) { 1726 if (typeof arg === 'object') { // given dictionary of callbacks 1727 for (var eventName in arg) { 1728 if (arg.hasOwnProperty(eventName)) { 1729 this.listenTo(other, eventName, arg[eventName]); 1730 } 1731 } 1732 } 1733 else if (typeof arg === 'string') { 1734 other.on( 1735 arg + '.' + this.getListenerNamespace(), // use event namespacing to identify this object 1736 $.proxy(callback, this) // always use `this` context 1737 // the usually-undesired jQuery guid behavior doesn't matter, 1738 // because we always unbind via namespace 1739 ); 1740 } 1741 }, 1742 1743 /* 1744 Causes the current object to stop listening to events on the `other` object. 1745 `eventName` is optional. If omitted, will stop listening to ALL events on `other`. 1746 */ 1747 stopListeningTo: function(other, eventName) { 1748 other.off((eventName || '') + '.' + this.getListenerNamespace()); 1749 }, 1750 1751 /* 1752 Returns a string, unique to this object, to be used for event namespacing 1753 */ 1754 getListenerNamespace: function() { 1755 if (this.listenerId == null) { 1756 this.listenerId = guid++; 1757 } 1758 return '_listener' + this.listenerId; 1759 } 1760 1761 }; 1762 return ListenerMixin; 1763})(); 1764;; 1765 1766// simple class for toggle a `isIgnoringMouse` flag on delay 1767// initMouseIgnoring must first be called, with a millisecond delay setting. 1768var MouseIgnorerMixin = { 1769 1770 isIgnoringMouse: false, // bool 1771 delayUnignoreMouse: null, // method 1772 1773 1774 initMouseIgnoring: function(delay) { 1775 this.delayUnignoreMouse = debounce(proxy(this, 'unignoreMouse'), delay || 1000); 1776 }, 1777 1778 1779 // temporarily ignore mouse actions on segments 1780 tempIgnoreMouse: function() { 1781 this.isIgnoringMouse = true; 1782 this.delayUnignoreMouse(); 1783 }, 1784 1785 1786 // delayUnignoreMouse eventually calls this 1787 unignoreMouse: function() { 1788 this.isIgnoringMouse = false; 1789 } 1790 1791}; 1792 1793;; 1794 1795/* A rectangular panel that is absolutely positioned over other content 1796------------------------------------------------------------------------------------------------------------------------ 1797Options: 1798 - className (string) 1799 - content (HTML string or jQuery element set) 1800 - parentEl 1801 - top 1802 - left 1803 - right (the x coord of where the right edge should be. not a "CSS" right) 1804 - autoHide (boolean) 1805 - show (callback) 1806 - hide (callback) 1807*/ 1808 1809var Popover = Class.extend(ListenerMixin, { 1810 1811 isHidden: true, 1812 options: null, 1813 el: null, // the container element for the popover. generated by this object 1814 margin: 10, // the space required between the popover and the edges of the scroll container 1815 1816 1817 constructor: function(options) { 1818 this.options = options || {}; 1819 }, 1820 1821 1822 // Shows the popover on the specified position. Renders it if not already 1823 show: function() { 1824 if (this.isHidden) { 1825 if (!this.el) { 1826 this.render(); 1827 } 1828 this.el.show(); 1829 this.position(); 1830 this.isHidden = false; 1831 this.trigger('show'); 1832 } 1833 }, 1834 1835 1836 // Hides the popover, through CSS, but does not remove it from the DOM 1837 hide: function() { 1838 if (!this.isHidden) { 1839 this.el.hide(); 1840 this.isHidden = true; 1841 this.trigger('hide'); 1842 } 1843 }, 1844 1845 1846 // Creates `this.el` and renders content inside of it 1847 render: function() { 1848 var _this = this; 1849 var options = this.options; 1850 1851 this.el = $('<div class="fc-popover"/>') 1852 .addClass(options.className || '') 1853 .css({ 1854 // position initially to the top left to avoid creating scrollbars 1855 top: 0, 1856 left: 0 1857 }) 1858 .append(options.content) 1859 .appendTo(options.parentEl); 1860 1861 // when a click happens on anything inside with a 'fc-close' className, hide the popover 1862 this.el.on('click', '.fc-close', function() { 1863 _this.hide(); 1864 }); 1865 1866 if (options.autoHide) { 1867 this.listenTo($(document), 'mousedown', this.documentMousedown);
1868 } 1869 }, 1870 1871 1872 // Triggered when the user clicks *anywhere* in the document, for the autoHide feature 1873 documentMousedown: function(ev) { 1874 // only hide the popover if the click happened outside the popover 1875 if (this.el && !$(ev.target).closest(this.el).length) { 1876 this.hide(); 1877 } 1878 }, 1879 1880 1881 // Hides and unregisters any handlers 1882 removeElement: function() { 1883 this.hide(); 1884 1885 if (this.el) { 1886 this.el.remove(); 1887 this.el = null; 1888 } 1889 1890 this.stopListeningTo($(document), 'mousedown'); 1891 }, 1892 1893 1894 // Positions the popover optimally, using the top/left/right options 1895 position: function() { 1896 var options = this.options; 1897 var origin = this.el.offsetParent().offset(); 1898 var width = this.el.outerWidth(); 1899 var height = this.el.outerHeight(); 1900 var windowEl = $(window); 1901 var viewportEl = getScrollParent(this.el); 1902 var viewportTop; 1903 var viewportLeft; 1904 var viewportOffset; 1905 var top; // the "position" (not "offset") values for the popover 1906 var left; // 1907 1908 // compute top and left 1909 top = options.top || 0; 1910 if (options.left !== undefined) { 1911 left = options.left; 1912 } 1913 else if (options.right !== undefined) { 1914 left = options.right - width; // derive the left value from the right value 1915 } 1916 else { 1917 left = 0; 1918 } 1919 1920 if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result 1921 viewportEl = windowEl; 1922 viewportTop = 0; // the window is always at the top left 1923 viewportLeft = 0; // (and .offset() won't work if called here) 1924 } 1925 else { 1926 viewportOffset = viewportEl.offset(); 1927 viewportTop = viewportOffset.top; 1928 viewportLeft = viewportOffset.left; 1929 } 1930 1931 // if the window is scrolled, it causes the visible area to be further down 1932 viewportTop += windowEl.scrollTop(); 1933 viewportLeft += windowEl.scrollLeft(); 1934 1935 // constrain to the view port. if constrained by two edges, give precedence to top/left 1936 if (options.viewportConstrain !== false) { 1937 top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin); 1938 top = Math.max(top, viewportTop + this.margin); 1939 left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin); 1940 left = Math.max(left, viewportLeft + this.margin); 1941 } 1942 1943 this.el.css({ 1944 top: top - origin.top, 1945 left: left - origin.left 1946 }); 1947 }, 1948 1949 1950 // Triggers a callback. Calls a function in the option hash of the same name. 1951 // Arguments beyond the first `name` are forwarded on. 1952 // TODO: better code reuse for this. Repeat code 1953 trigger: function(name) { 1954 if (this.options[name]) { 1955 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); 1956 } 1957 } 1958 1959}); 1960 1961;; 1962 1963/* 1964A cache for the left/right/top/bottom/width/height values for one or more elements. 1965Works with both offset (from topleft document) and position (from offsetParent). 1966 1967options: 1968- els 1969- isHorizontal 1970- isVertical 1971*/ 1972var CoordCache = FC.CoordCache = Class.extend({ 1973 1974 els: null, // jQuery set (assumed to be siblings) 1975 forcedOffsetParentEl: null, // options can override the natural offsetParent 1976 origin: null, // {left,top} position of offsetParent of els 1977 boundingRect: null, // constrain cordinates to this rectangle. {left,right,top,bottom} or null 1978 isHorizontal: false, // whether to query for left/right/width 1979 isVertical: false, // whether to query for top/bottom/height 1980 1981 // arrays of coordinates (offsets from topleft of document) 1982 lefts: null, 1983 rights: null, 1984 tops: null, 1985 bottoms: null, 1986 1987 1988 constructor: function(options) { 1989 this.els = $(options.els); 1990 this.isHorizontal = options.isHorizontal; 1991 this.isVertical = options.isVertical; 1992 this.forcedOffsetParentEl = options.offsetParent ? $(options.offsetParent) : null; 1993 }, 1994 1995 1996 // Queries the els for coordinates and stores them. 1997 // Call this method before using and of the get* methods below. 1998 build: function() { 1999 var offsetParentEl = this.forcedOffsetParentEl || this.els.eq(0).offsetParent(); 2000 2001 this.origin = offsetParentEl.offset();
2002 this.boundingRect = this.queryBoundingRect(); 2003 2004 if (this.isHorizontal) { 2005 this.buildElHorizontals(); 2006 } 2007 if (this.isVertical) { 2008 this.buildElVerticals(); 2009 } 2010 }, 2011 2012 2013 // Destroys all internal data about coordinates, freeing memory 2014 clear: function() { 2015 this.origin = null; 2016 this.boundingRect = null; 2017 this.lefts = null; 2018 this.rights = null; 2019 this.tops = null; 2020 this.bottoms = null; 2021 }, 2022 2023 2024 // When called, if coord caches aren't built, builds them 2025 ensureBuilt: function() { 2026 if (!this.origin) { 2027 this.build(); 2028 } 2029 }, 2030 2031 2032 // Populates the left/right internal coordinate arrays 2033 buildElHorizontals: function() { 2034 var lefts = []; 2035 var rights = []; 2036 2037 this.els.each(function(i, node) { 2038 var el = $(node); 2039 var left = el.offset().left; 2040 var width = el.outerWidth(); 2041 2042 lefts.push(left); 2043 rights.push(left + width); 2044 }); 2045 2046 this.lefts = lefts; 2047 this.rights = rights; 2048 }, 2049 2050 2051 // Populates the top/bottom internal coordinate arrays 2052 buildElVerticals: function() { 2053 var tops = []; 2054 var bottoms = []; 2055 2056 this.els.each(function(i, node) { 2057 var el = $(node); 2058 var top = el.offset().top; 2059 var height = el.outerHeight(); 2060 2061 tops.push(top); 2062 bottoms.push(top + height); 2063 }); 2064 2065 this.tops = tops; 2066 this.bottoms = bottoms; 2067 }, 2068 2069 2070 // Given a left offset (from document left), returns the index of the el that it horizontally intersects. 2071 // If no intersection is made, returns undefined. 2072 getHorizontalIndex: function(leftOffset) { 2073 this.ensureBuilt(); 2074 2075 var lefts = this.lefts; 2076 var rights = this.rights; 2077 var len = lefts.length; 2078 var i; 2079 2080 for (i = 0; i < len; i++) { 2081 if (leftOffset >= lefts[i] && leftOffset < rights[i]) { 2082 return i; 2083 } 2084 } 2085 }, 2086 2087 2088 // Given a top offset (from document top), returns the index of the el that it vertically intersects. 2089 // If no intersection is made, returns undefined. 2090 getVerticalIndex: function(topOffset) { 2091 this.ensureBuilt(); 2092 2093 var tops = this.tops; 2094 var bottoms = this.bottoms; 2095 var len = tops.length; 2096 var i; 2097 2098 for (i = 0; i < len; i++) { 2099 if (topOffset >= tops[i] && topOffset < bottoms[i]) { 2100 return i; 2101 } 2102 } 2103 }, 2104 2105 2106 // Gets the left offset (from document left) of the element at the given index 2107 getLeftOffset: function(leftIndex) { 2108 this.ensureBuilt(); 2109 return this.lefts[leftIndex]; 2110 }, 2111 2112 2113 // Gets the left position (from offsetParent left) of the element at the given index 2114 getLeftPosition: function(leftIndex) { 2115 this.ensureBuilt(); 2116 return this.lefts[leftIndex] - this.origin.left; 2117 }, 2118 2119 2120 // Gets the right offset (from document left) of the element at the given index. 2121 // This value is NOT relative to the document's right edge, like the CSS concept of "right" would be. 2122 getRightOffset: function(leftIndex) { 2123 this.ensureBuilt(); 2124 return this.rights[leftIndex]; 2125 }, 2126 2127 2128 // Gets the right position (from offsetParent left) of the element at the given index. 2129 // This value is NOT relative to the offsetParent's right edge, like the CSS concept of "right" would be. 2130 getRightPosition: function(leftIndex) { 2131 this.ensureBuilt(); 2132 return this.rights[leftIndex] - this.origin.left; 2133 }, 2134 2135 2136 // Gets the width of the element at the given index 2137 getWidth: function(leftIndex) { 2138 this.ensureBuilt(); 2139 return this.rights[leftIndex] - this.lefts[leftIndex]; 2140 }, 2141 2142 2143 // Gets the top offset (from document top) of the element at the given index 2144 getTopOffset: function(topIndex) { 2145 this.ensureBuilt(); 2146 return this.tops[topIndex]; 2147 }, 2148 2149 2150 // Gets the top position (from offsetParent top) of the element at the given position 2151 getTopPosition: function(topIndex) { 2152 this.ensureBuilt(); 2153 return this.tops[topIndex] - this.origin.top; 2154 }, 2155 2156 // Gets the bottom offset (from the document top) of the element at the given index. 2157 // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be. 2158 getBottomOffset: function(topIndex) { 2159 this.ensureBuilt(); 2160 return this.bottoms[topIndex]; 2161 }, 2162 2163 2164 // Gets the bottom position (from the offsetParent top) of the element at the given index. 2165 // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be. 2166 getBottomPosition: function(topIndex) { 2167 this.ensureBuilt(); 2168 return this.bottoms[topIndex] - this.origin.top; 2169 }, 2170 2171 2172 // Gets the height of the element at the given index 2173 getHeight: function(topIndex) { 2174 this.ensureBuilt(); 2175 return this.bottoms[topIndex] - this.tops[topIndex]; 2176 }, 2177 2178 2179 // Bounding Rect 2180 // TODO: decouple this from CoordCache 2181 2182 // Compute and return what the elements' bounding rectangle is, from the user's perspective. 2183 // Right now, only returns a rectangle if constrained by an overflow:scroll element. 2184 queryBoundingRect: function() { 2185 var scrollParentEl = getScrollParent(this.els.eq(0)); 2186 2187 if (!scrollParentEl.is(document)) { 2188 return getClientRect(scrollParentEl); 2189 } 2190 }, 2191 2192 isPointInBounds: function(leftOffset, topOffset) { 2193 return this.isLeftInBounds(leftOffset) && this.isTopInBounds(topOffset); 2194 }, 2195 2196 isLeftInBounds: function(leftOffset) { 2197 return !this.boundingRect || (leftOffset >= this.boundingRect.left && leftOffset < this.boundingRect.right); 2198 }, 2199 2200 isTopInBounds: function(topOffset) { 2201 return !this.boundingRect || (topOffset >= this.boundingRect.top && topOffset < this.boundingRect.bottom); 2202 } 2203 2204}); 2205 2206;; 2207 2208/* Tracks a drag's mouse movement, firing various handlers 2209----------------------------------------------------------------------------------------------------------------------*/ 2210// TODO: use Emitter 2211 2212var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMixin, { 2213 2214 options: null, 2215 subjectEl: null, 2216
2217 // coordinates of the initial mousedown 2218 originX: null, 2219 originY: null, 2220 2221 // the wrapping element that scrolls, or MIGHT scroll if there's overflow. 2222 // TODO: do this for wrappers that have overflow:hidden as well. 2223 scrollEl: null, 2224 2225 isInteracting: false, 2226 isDistanceSurpassed: false, 2227 isDelayEnded: false, 2228 isDragging: false, 2229 isTouch: false, 2230 2231 delay: null, 2232 delayTimeoutId: null, 2233 minDistance: null, 2234 2235 handleTouchScrollProxy: null, // calls handleTouchScroll, always bound to `this` 2236 2237 2238 constructor: function(options) { 2239 this.options = options || {}; 2240 this.handleTouchScrollProxy = proxy(this, 'handleTouchScroll'); 2241 this.initMouseIgnoring(500); 2242 }, 2243 2244 2245 // Interaction (high-level) 2246 // ----------------------------------------------------------------------------------------------------------------- 2247 2248 2249 startInteraction: function(ev, extraOptions) { 2250 var isTouch = getEvIsTouch(ev); 2251 2252 if (ev.type === 'mousedown') { 2253 if (this.isIgnoringMouse) { 2254 return; 2255 } 2256 else if (!isPrimaryMouseButton(ev)) { 2257 return; 2258 } 2259 else { 2260 ev.preventDefault(); // prevents native selection in most browsers 2261 } 2262 } 2263 2264 if (!this.isInteracting) { 2265 2266 // process options 2267 extraOptions = extraOptions || {}; 2268 this.delay = firstDefined(extraOptions.delay, this.options.delay, 0); 2269 this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0); 2270 this.subjectEl = this.options.subjectEl; 2271 2272 this.isInteracting = true; 2273 this.isTouch = isTouch; 2274 this.isDelayEnded = false; 2275 this.isDistanceSurpassed = false; 2276 2277 this.originX = getEvX(ev); 2278 this.originY = getEvY(ev); 2279 this.scrollEl = getScrollParent($(ev.target)); 2280 2281 this.bindHandlers(); 2282 this.initAutoScroll(); 2283 this.handleInteractionStart(ev); 2284 this.startDelay(ev); 2285 2286 if (!this.minDistance) { 2287 this.handleDistanceSurpassed(ev); 2288 } 2289 } 2290 }, 2291 2292 2293 handleInteractionStart: function(ev) { 2294 this.trigger('interactionStart', ev); 2295 }, 2296 2297 2298 endInteraction: function(ev, isCancelled) { 2299 if (this.isInteracting) { 2300 this.endDrag(ev); 2301 2302 if (this.delayTimeoutId) { 2303 clearTimeout(this.delayTimeoutId); 2304 this.delayTimeoutId = null; 2305 } 2306 2307 this.destroyAutoScroll(); 2308 this.unbindHandlers(); 2309 2310 this.isInteracting = false; 2311 this.handleInteractionEnd(ev, isCancelled); 2312 2313 // a touchstart+touchend on the same element will result in the following addition simulated events: 2314 // mouseover + mouseout + click 2315 // let's ignore these bogus events 2316 if (this.isTouch) { 2317 this.tempIgnoreMouse(); 2318 } 2319 } 2320 }, 2321 2322 2323 handleInteractionEnd: function(ev, isCancelled) { 2324 this.trigger('interactionEnd', ev, isCancelled || false); 2325 }, 2326 2327 2328 // Binding To DOM 2329 // ----------------------------------------------------------------------------------------------------------------- 2330 2331 2332 bindHandlers: function() { 2333 var _this = this; 2334 var touchStartIgnores = 1; 2335 2336 if (this.isTouch) { 2337 this.listenTo($(document), { 2338 touchmove: this.handleTouchMove, 2339 touchend: this.endInteraction, 2340 touchcancel: this.endInteraction, 2341 2342 // Sometimes touchend doesn't fire 2343 // (can't figure out why. touchcancel doesn't fire either. has to do with scrolling?) 2344 // If another touchstart happens, we know it's bogus, so cancel the drag. 2345 // touchend will continue to be broken until user does a shorttap/scroll, but this is best we can do. 2346 touchstart: function(ev) { 2347 if (touchStartIgnores) { // bindHandlers is called from within a touchstart, 2348 touchStartIgnores--; // and we don't want this to fire immediately, so ignore. 2349 } 2350 else { 2351 _this.endInteraction(ev, true); // isCancelled=true 2352 } 2353 } 2354 }); 2355 2356 // listen to ALL scroll actions on the page 2357 if ( 2358 !bindAnyScroll(this.handleTouchScrollProxy) && // hopefully this works and short-circuits the rest 2359 this.scrollEl // otherwise, attach a single handler to this 2360 ) { 2361 this.listenTo(this.scrollEl, 'scroll', this.handleTouchScroll); 2362 } 2363 } 2364 else { 2365 this.listenTo($(document), { 2366 mousemove: this.handleMouseMove, 2367 mouseup: this.endInteraction 2368 }); 2369 } 2370 2371 this.listenTo($(document), { 2372 selectstart: preventDefault, // don't allow selection while dragging 2373 contextmenu: preventDefault // long taps would open menu on Chrome dev tools 2374 }); 2375 }, 2376 2377 2378 unbindHandlers: function() { 2379 this.stopListeningTo($(document)); 2380 2381 // unbind scroll listening 2382 unbindAnyScroll(this.handleTouchScrollProxy); 2383 if (this.scrollEl) { 2384 this.stopListeningTo(this.scrollEl, 'scroll'); 2385 } 2386 }, 2387 2388 2389 // Drag (high-level) 2390 // ----------------------------------------------------------------------------------------------------------------- 2391 2392 2393 // extraOptions ignored if drag already started 2394 startDrag: function(ev, extraOptions) { 2395 this.startInteraction(ev, extraOptions); // ensure interaction began 2396 2397 if (!this.isDragging) { 2398 this.isDragging = true; 2399 this.handleDragStart(ev); 2400 } 2401 }, 2402 2403 2404 handleDragStart: function(ev) { 2405 this.trigger('dragStart', ev); 2406 }, 2407 2408 2409 handleMove: function(ev) { 2410 var dx = getEvX(ev) - this.originX; 2411 var dy = getEvY(ev) - this.originY; 2412 var minDistance = this.minDistance; 2413 var distanceSq; // current distance from the origin, squared 2414 2415 if (!this.isDistanceSurpassed) { 2416 distanceSq = dx * dx + dy * dy; 2417 if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem 2418 this.handleDistanceSurpassed(ev); 2419 } 2420 } 2421 2422 if (this.isDragging) { 2423 this.handleDrag(dx, dy, ev); 2424 } 2425 }, 2426 2427 2428 // Called while the mouse is being moved and when we know a legitimate drag is taking place 2429 handleDrag: function(dx, dy, ev) { 2430 this.trigger('drag', dx, dy, ev); 2431 this.updateAutoScroll(ev); // will possibly cause scrolling 2432 }, 2433 2434 2435 endDrag: function(ev) { 2436 if (this.isDragging) { 2437 this.isDragging = false; 2438 this.handleDragEnd(ev); 2439 } 2440 }, 2441 2442 2443 handleDragEnd: function(ev) { 2444 this.trigger('dragEnd', ev); 2445 }, 2446 2447 2448 // Delay 2449 // ----------------------------------------------------------------------------------------------------------------- 2450 2451 2452 startDelay: function(initialEv) { 2453 var _this = this; 2454 2455 if (this.delay) { 2456 this.delayTimeoutId = setTimeout(function() { 2457 _this.handleDelayEnd(initialEv); 2458 }, this.delay); 2459 } 2460 else { 2461 this.handleDelayEnd(initialEv); 2462 } 2463 }, 2464 2465 2466 handleDelayEnd: function(initialEv) { 2467 this.isDelayEnded = true; 2468 2469 if (this.isDistanceSurpassed) { 2470 this.startDrag(initialEv); 2471 } 2472 }, 2473 2474 2475 // Distance 2476 // ----------------------------------------------------------------------------------------------------------------- 2477 2478 2479 handleDistanceSurpassed: function(ev) { 2480 this.isDistanceSurpassed = true; 2481 2482 if (this.isDelayEnded) { 2483 this.startDrag(ev); 2484 } 2485 }, 2486 2487 2488 // Mouse / Touch 2489 // ----------------------------------------------------------------------------------------------------------------- 2490 2491 2492 handleTouchMove: function(ev) { 2493 // prevent inertia and touchmove-scrolling while dragging 2494 if (this.isDragging) { 2495 ev.preventDefault(); 2496 } 2497 2498 this.handleMove(ev); 2499 }, 2500 2501 2502 handleMouseMove: function(ev) { 2503 this.handleMove(ev); 2504 }, 2505 2506 2507 // Scrolling (unrelated to auto-scroll) 2508 // ----------------------------------------------------------------------------------------------------------------- 2509 2510 2511 handleTouchScroll: function(ev) { 2512 // if the drag is being initiated by touch, but a scroll happens before 2513 // the drag-initiating delay is over, cancel the drag 2514 if (!this.isDragging) { 2515 this.endInteraction(ev, true); // isCancelled=true 2516 } 2517 }, 2518 2519 2520 // Utils 2521 // ----------------------------------------------------------------------------------------------------------------- 2522 2523 2524 // Triggers a callback. Calls a function in the option hash of the same name. 2525 // Arguments beyond the first `name` are forwarded on. 2526 trigger: function(name) { 2527 if (this.options[name]) { 2528 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); 2529 } 2530 // makes _methods callable by event name. TODO: kill this 2531 if (this['_' + name]) { 2532 this['_' + name].apply(this, Array.prototype.slice.call(arguments, 1)); 2533 } 2534 } 2535 2536 2537}); 2538 2539;; 2540/* 2541this.scrollEl is set in DragListener 2542*/ 2543DragListener.mixin({ 2544 2545 isAutoScroll: false, 2546 2547 scrollBounds: null, // { top, bottom, left, right } 2548 scrollTopVel: null, // pixels per second 2549 scrollLeftVel: null, // pixels per second 2550 scrollIntervalId: null, // ID of setTimeout for scrolling animation loop 2551 2552 // defaults 2553 scrollSensitivity: 30, // pixels from edge for scrolling to start 2554 scrollSpeed: 200, // pixels per second, at maximum speed 2555 scrollIntervalMs: 50, // millisecond wait between scroll increment 2556 2557 2558 initAutoScroll: function() { 2559 var scrollEl = this.scrollEl; 2560 2561 this.isAutoScroll = 2562 this.options.scroll && 2563 scrollEl && 2564 !scrollEl.is(window) && 2565 !scrollEl.is(document); 2566 2567 if (this.isAutoScroll) { 2568 // debounce makes sure rapid calls don't happen 2569 this.listenTo(scrollEl, 'scroll', debounce(this.handleDebouncedScroll, 100)); 2570 } 2571 }, 2572 2573 2574 destroyAutoScroll: function() { 2575 this.endAutoScroll(); // kill any animation loop 2576 2577 // remove the scroll handler if there is a scrollEl 2578 if (this.isAutoScroll) { 2579 this.stopListeningTo(this.scrollEl, 'scroll'); // will probably get removed by unbindHandlers too :( 2580 } 2581 }, 2582 2583 2584 // Computes and stores the bounding rectangle of scrollEl 2585 computeScrollBounds: function() { 2586 if (this.isAutoScroll) { 2587 this.scrollBounds = getOuterRect(this.scrollEl); 2588 // TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars 2589 } 2590 }, 2591 2592 2593 // Called when the dragging is in progress and scrolling should be updated 2594 updateAutoScroll: function(ev) { 2595 var sensitivity = this.scrollSensitivity; 2596 var bounds = this.scrollBounds; 2597 var topCloseness, bottomCloseness; 2598 var leftCloseness, rightCloseness; 2599 var topVel = 0; 2600 var leftVel = 0; 2601 2602 if (bounds) { // only scroll if scrollEl exists 2603
2604 // compute closeness to edges. valid range is from 0.0 - 1.0 2605 topCloseness = (sensitivity - (getEvY(ev) - bounds.top)) / sensitivity; 2606 bottomCloseness = (sensitivity - (bounds.bottom - getEvY(ev))) / sensitivity; 2607 leftCloseness = (sensitivity - (getEvX(ev) - bounds.left)) / sensitivity; 2608 rightCloseness = (sensitivity - (bounds.right - getEvX(ev))) / sensitivity; 2609 2610 // translate vertical closeness into velocity. 2611 // mouse must be completely in bounds for velocity to happen. 2612 if (topCloseness >= 0 && topCloseness <= 1) { 2613 topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up 2614 } 2615 else if (bottomCloseness >= 0 && bottomCloseness <= 1) { 2616 topVel = bottomCloseness * this.scrollSpeed; 2617 } 2618 2619 // translate horizontal closeness into velocity 2620 if (leftCloseness >= 0 && leftCloseness <= 1) { 2621 leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left 2622 } 2623 else if (rightCloseness >= 0 && rightCloseness <= 1) { 2624 leftVel = rightCloseness * this.scrollSpeed; 2625 } 2626 } 2627 2628 this.setScrollVel(topVel, leftVel); 2629 }, 2630 2631 2632 // Sets the speed-of-scrolling for the scrollEl 2633 setScrollVel: function(topVel, leftVel) { 2634 2635 this.scrollTopVel = topVel; 2636 this.scrollLeftVel = leftVel; 2637 2638 this.constrainScrollVel(); // massages into realistic values 2639 2640 // if there is non-zero velocity, and an animation loop hasn't already started, then START 2641 if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) { 2642 this.scrollIntervalId = setInterval( 2643 proxy(this, 'scrollIntervalFunc'), // scope to `this` 2644 this.scrollIntervalMs 2645 ); 2646 } 2647 }, 2648 2649 2650 // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way 2651 constrainScrollVel: function() { 2652 var el = this.scrollEl; 2653 2654 if (this.scrollTopVel < 0) { // scrolling up? 2655 if (el.scrollTop() <= 0) { // already scrolled all the way up? 2656 this.scrollTopVel = 0; 2657 } 2658 } 2659 else if (this.scrollTopVel > 0) { // scrolling down? 2660 if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down? 2661 this.scrollTopVel = 0; 2662 } 2663 } 2664 2665 if (this.scrollLeftVel < 0) { // scrolling left? 2666 if (el.scrollLeft() <= 0) { // already scrolled all the left? 2667 this.scrollLeftVel = 0; 2668 } 2669 } 2670 else if (this.scrollLeftVel > 0) { // scrolling right? 2671 if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right? 2672 this.scrollLeftVel = 0; 2673 } 2674 } 2675 }, 2676 2677 2678 // This function gets called during every iteration of the scrolling animation loop 2679 scrollIntervalFunc: function() { 2680 var el = this.scrollEl; 2681 var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by 2682 2683 // change the value of scrollEl's scroll 2684 if (this.scrollTopVel) { 2685 el.scrollTop(el.scrollTop() + this.scrollTopVel * frac); 2686 } 2687 if (this.scrollLeftVel) { 2688 el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac); 2689 } 2690 2691 this.constrainScrollVel(); // since the scroll values changed, recompute the velocities 2692 2693 // if scrolled all the way, which causes the vels to be zero, stop the animation loop 2694 if (!this.scrollTopVel && !this.scrollLeftVel) { 2695 this.endAutoScroll(); 2696 } 2697 }, 2698 2699 2700 // Kills any existing scrolling animation loop 2701 endAutoScroll: function() { 2702 if (this.scrollIntervalId) { 2703 clearInterval(this.scrollIntervalId); 2704 this.scrollIntervalId = null; 2705 2706 this.handleScrollEnd(); 2707 } 2708 }, 2709 2710 2711 // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce) 2712 handleDebouncedScroll: function() { 2713 // recompute all coordinates, but *only* if this is *not* part of our scrolling animation 2714 if (!this.scrollIntervalId) { 2715 this.handleScrollEnd(); 2716 } 2717 }, 2718 2719 2720 // Called when scrolling has stopped, whether through auto scroll, or the user scrolling 2721 handleScrollEnd: function() { 2722 } 2723 2724}); 2725;; 2726 2727/* Tracks mouse movements over a component and raises events about which hit the mouse is over. 2728------------------------------------------------------------------------------------------------------------------------ 2729options: 2730- subjectEl 2731- subjectCenter 2732*/ 2733 2734var HitDragListener = DragListener.extend({ 2735 2736 component: null, // converts coordinates to hits 2737 // methods: prepareHits, releaseHits, queryHit 2738 2739 origHit: null, // the hit the mouse was over when listening started 2740 hit: null, // the hit the mouse is over
2741 coordAdjust: null, // delta that will be added to the mouse coordinates when computing collisions 2742 2743 2744 constructor: function(component, options) { 2745 DragListener.call(this, options); // call the super-constructor 2746 2747 this.component = component; 2748 }, 2749 2750 2751 // Called when drag listening starts (but a real drag has not necessarily began). 2752 // ev might be undefined if dragging was started manually. 2753 handleInteractionStart: function(ev) { 2754 var subjectEl = this.subjectEl; 2755 var subjectRect; 2756 var origPoint; 2757 var point; 2758 2759 this.computeCoords(); 2760 2761 if (ev) { 2762 origPoint = { left: getEvX(ev), top: getEvY(ev) }; 2763 point = origPoint; 2764 2765 // constrain the point to bounds of the element being dragged 2766 if (subjectEl) { 2767 subjectRect = getOuterRect(subjectEl); // used for centering as well 2768 point = constrainPoint(point, subjectRect); 2769 } 2770 2771 this.origHit = this.queryHit(point.left, point.top); 2772 2773 // treat the center of the subject as the collision point? 2774 if (subjectEl && this.options.subjectCenter) { 2775 2776 // only consider the area the subject overlaps the hit. best for large subjects. 2777 // TODO: skip this if hit didn't supply left/right/top/bottom 2778 if (this.origHit) { 2779 subjectRect = intersectRects(this.origHit, subjectRect) || 2780 subjectRect; // in case there is no intersection 2781 } 2782 2783 point = getRectCenter(subjectRect); 2784 } 2785 2786 this.coordAdjust = diffPoints(point, origPoint); // point - origPoint 2787 } 2788 else { 2789 this.origHit = null; 2790 this.coordAdjust = null; 2791 } 2792 2793 // call the super-method. do it after origHit has been computed 2794 DragListener.prototype.handleInteractionStart.apply(this, arguments); 2795 }, 2796 2797 2798 // Recomputes the drag-critical positions of elements 2799 computeCoords: function() { 2800 this.component.prepareHits(); 2801 this.computeScrollBounds(); // why is this here?????? 2802 }, 2803 2804 2805 // Called when the actual drag has started 2806 handleDragStart: function(ev) { 2807 var hit; 2808 2809 DragListener.prototype.handleDragStart.apply(this, arguments); // call the super-method 2810 2811 // might be different from this.origHit if the min-distance is large 2812 hit = this.queryHit(getEvX(ev), getEvY(ev)); 2813 2814 // report the initial hit the mouse is over 2815 // especially important if no min-distance and drag starts immediately 2816 if (hit) { 2817 this.handleHitOver(hit); 2818 } 2819 }, 2820 2821 2822 // Called when the drag moves 2823 handleDrag: function(dx, dy, ev) { 2824 var hit; 2825 2826 DragListener.prototype.handleDrag.apply(this, arguments); // call the super-method 2827 2828 hit = this.queryHit(getEvX(ev), getEvY(ev)); 2829 2830 if (!isHitsEqual(hit, this.hit)) { // a different hit than before? 2831 if (this.hit) { 2832 this.handleHitOut(); 2833 } 2834 if (hit) { 2835 this.handleHitOver(hit); 2836 } 2837 } 2838 }, 2839 2840 2841 // Called when dragging has been stopped 2842 handleDragEnd: function() { 2843 this.handleHitDone(); 2844 DragListener.prototype.handleDragEnd.apply(this, arguments); // call the super-method 2845 }, 2846 2847 2848 // Called when a the mouse has just moved over a new hit 2849 handleHitOver: function(hit) { 2850 var isOrig = isHitsEqual(hit, this.origHit); 2851 2852 this.hit = hit; 2853 2854 this.trigger('hitOver', this.hit, isOrig, this.origHit); 2855 }, 2856 2857 2858 // Called when the mouse has just moved out of a hit 2859 handleHitOut: function() { 2860 if (this.hit) { 2861 this.trigger('hitOut', this.hit); 2862 this.handleHitDone(); 2863 this.hit = null; 2864 } 2865 }, 2866 2867 2868 // Called after a hitOut. Also called before a dragStop 2869 handleHitDone: function() { 2870 if (this.hit) { 2871 this.trigger('hitDone', this.hit); 2872 } 2873 }, 2874 2875 2876 // Called when the interaction ends, whether there was a real drag or not 2877 handleInteractionEnd: function() { 2878 DragListener.prototype.handleInteractionEnd.apply(this, arguments); // call the super-method 2879 2880 this.origHit = null; 2881 this.hit = null; 2882 2883 this.component.releaseHits(); 2884 }, 2885 2886 2887 // Called when scrolling has stopped, whether through auto scroll, or the user scrolling 2888 handleScrollEnd: function() { 2889 DragListener.prototype.handleScrollEnd.apply(this, arguments); // call the super-method 2890 2891 this.computeCoords(); // hits' absolute positions will be in new places. recompute 2892 }, 2893 2894 2895 // Gets the hit underneath the coordinates for the given mouse event 2896 queryHit: function(left, top) { 2897 2898 if (this.coordAdjust) { 2899 left += this.coordAdjust.left; 2900 top += this.coordAdjust.top; 2901 } 2902 2903 return this.component.queryHit(left, top); 2904 } 2905 2906}); 2907 2908 2909// Returns `true` if the hits are identically equal. `false` otherwise. Must be from the same component. 2910// Two null values will be considered equal, as two "out of the component" states are the same. 2911function isHitsEqual(hit0, hit1) { 2912 2913 if (!hit0 && !hit1) { 2914 return true; 2915 } 2916 2917 if (hit0 && hit1) { 2918 return hit0.component === hit1.component && 2919 isHitPropsWithin(hit0, hit1) && 2920 isHitPropsWithin(hit1, hit0); // ensures all props are identical 2921 } 2922 2923 return false; 2924} 2925 2926 2927// Returns true if all of subHit's non-standard properties are within superHit 2928function isHitPropsWithin(subHit, superHit) { 2929 for (var propName in subHit) { 2930 if (!/^(component|left|right|top|bottom)$/.test(propName)) { 2931 if (subHit[propName] !== superHit[propName]) { 2932 return false; 2933 } 2934 } 2935 } 2936 return true; 2937} 2938 2939;; 2940 2941/* Creates a clone of an element and lets it track the mouse as it moves 2942----------------------------------------------------------------------------------------------------------------------*/ 2943 2944var MouseFollower = Class.extend(ListenerMixin, { 2945 2946 options: null, 2947 2948 sourceEl: null, // the element that will be cloned and made to look like it is dragging 2949 el: null, // the clone of `sourceEl` that will track the mouse 2950 parentEl: null, // the element that `el` (the clone) will be attached to 2951 2952 // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl 2953 top0: null, 2954 left0: null, 2955 2956 // the absolute coordinates of the initiating touch/mouse action 2957 y0: null, 2958 x0: null, 2959 2960 // the number of pixels the mouse has moved from its initial position 2961 topDelta: null, 2962 leftDelta: null, 2963 2964 isFollowing: false, 2965 isHidden: false, 2966 isAnimating: false, // doing the revert animation? 2967 2968 constructor: function(sourceEl, options) { 2969 this.options = options = options || {}; 2970 this.sourceEl = sourceEl; 2971 this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent 2972 }, 2973 2974 2975 // Causes the element to start following the mouse 2976 start: function(ev) { 2977 if (!this.isFollowing) { 2978 this.isFollowing = true; 2979 2980 this.y0 = getEvY(ev); 2981 this.x0 = getEvX(ev); 2982 this.topDelta = 0; 2983 this.leftDelta = 0; 2984 2985 if (!this.isHidden) { 2986 this.updatePosition(); 2987 } 2988 2989 if (getEvIsTouch(ev)) { 2990 this.listenTo($(document), 'touchmove', this.handleMove); 2991 } 2992 else { 2993 this.listenTo($(document), 'mousemove', this.handleMove); 2994 } 2995 } 2996 }, 2997 2998 2999 // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position. 3000 // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately.
3001 stop: function(shouldRevert, callback) { 3002 var _this = this; 3003 var revertDuration = this.options.revertDuration; 3004 3005 function complete() { // might be called by .animate(), which might change `this` context 3006 _this.isAnimating = false; 3007 _this.removeElement(); 3008 3009 _this.top0 = _this.left0 = null; // reset state for future updatePosition calls 3010 3011 if (callback) { 3012 callback(); 3013 } 3014 } 3015 3016 if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time 3017 this.isFollowing = false; 3018 3019 this.stopListeningTo($(document)); 3020 3021 if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation? 3022 this.isAnimating = true; 3023 this.el.animate({ 3024 top: this.top0, 3025 left: this.left0 3026 }, { 3027 duration: revertDuration, 3028 complete: complete 3029 }); 3030 } 3031 else { 3032 complete(); 3033 } 3034 } 3035 }, 3036 3037 3038 // Gets the tracking element. Create it if necessary 3039 getEl: function() { 3040 var el = this.el; 3041 3042 if (!el) { 3043 el = this.el = this.sourceEl.clone() 3044 .addClass(this.options.additionalClass || '') 3045 .css({ 3046 position: 'absolute', 3047 visibility: '', // in case original element was hidden (commonly through hideEvents()) 3048 display: this.isHidden ? 'none' : '', // for when initially hidden 3049 margin: 0, 3050 right: 'auto', // erase and set width instead 3051 bottom: 'auto', // erase and set height instead 3052 width: this.sourceEl.width(), // explicit height in case there was a 'right' value 3053 height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value 3054 opacity: this.options.opacity || '', 3055 zIndex: this.options.zIndex 3056 }); 3057 3058 // we don't want long taps or any mouse interaction causing selection/menus. 3059 // would use preventSelection(), but that prevents selectstart, causing problems. 3060 el.addClass('fc-unselectable'); 3061 3062 el.appendTo(this.parentEl); 3063 } 3064 3065 return el; 3066 }, 3067 3068 3069 // Removes the tracking element if it has already been created 3070 removeElement: function() { 3071 if (this.el) { 3072 this.el.remove(); 3073 this.el = null; 3074 } 3075 }, 3076 3077 3078 // Update the CSS position of the tracking element 3079 updatePosition: function() { 3080 var sourceOffset; 3081 var origin; 3082 3083 this.getEl(); // ensure this.el 3084 3085 // make sure origin info was computed 3086 if (this.top0 === null) { 3087 sourceOffset = this.sourceEl.offset(); 3088 origin = this.el.offsetParent().offset(); 3089 this.top0 = sourceOffset.top - origin.top; 3090 this.left0 = sourceOffset.left - origin.left; 3091 } 3092 3093 this.el.css({ 3094 top: this.top0 + this.topDelta, 3095 left: this.left0 + this.leftDelta 3096 }); 3097 }, 3098 3099 3100 // Gets called when the user moves the mouse 3101 handleMove: function(ev) { 3102 this.topDelta = getEvY(ev) - this.y0; 3103 this.leftDelta = getEvX(ev) - this.x0; 3104 3105 if (!this.isHidden) { 3106 this.updatePosition(); 3107 } 3108 }, 3109 3110 3111 // Temporarily makes the tracking element invisible. Can be called before following starts 3112 hide: function() { 3113 if (!this.isHidden) { 3114 this.isHidden = true; 3115 if (this.el) { 3116 this.el.hide(); 3117 } 3118 } 3119 }, 3120 3121 3122 // Show the tracking element after it has been temporarily hidden 3123 show: function() { 3124 if (this.isHidden) { 3125 this.isHidden = false; 3126 this.updatePosition(); 3127 this.getEl().show(); 3128 } 3129 } 3130 3131}); 3132 3133;; 3134 3135/* An abstract class comprised of a "grid" of areas that each represent a specific datetime 3136----------------------------------------------------------------------------------------------------------------------*/ 3137 3138var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { 3139 3140 // self-config, overridable by subclasses 3141 hasDayInteractions: true, // can user click/select ranges of time? 3142 3143 view: null, // a View object 3144 isRTL: null, // shortcut to the view's isRTL option 3145 3146 start: null, 3147 end: null, 3148 3149 el: null, // the containing element 3150 elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name. 3151 3152 // derived from options 3153 eventTimeFormat: null, 3154 displayEventTime: null, 3155 displayEventEnd: null, 3156 3157 minResizeDuration: null, // TODO: hack. set by subclasses. minumum event resize duration 3158 3159 // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity 3160 // of the date areas. if not defined, assumes to be day and time granularity. 3161 // TODO: port isTimeScale into same system? 3162 largeUnit: null, 3163 3164 dayDragListener: null, 3165 segDragListener: null, 3166 segResizeListener: null, 3167 externalDragListener: null, 3168 3169 3170 constructor: function(view) { 3171 this.view = view; 3172 this.isRTL = view.opt('isRTL'); 3173 this.elsByFill = {}; 3174 3175 this.dayDragListener = this.buildDayDragListener(); 3176 this.initMouseIgnoring(); 3177 }, 3178 3179 3180 /* Options 3181 ------------------------------------------------------------------------------------------------------------------*/ 3182 3183
3184 // Generates the format string used for event time text, if not explicitly defined by 'timeFormat' 3185 computeEventTimeFormat: function() { 3186 return this.view.opt('smallTimeFormat'); 3187 }, 3188 3189 3190 // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventTime'. 3191 // Only applies to non-all-day events. 3192 computeDisplayEventTime: function() { 3193 return true; 3194 }, 3195 3196 3197 // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd' 3198 computeDisplayEventEnd: function() { 3199 return true; 3200 }, 3201 3202 3203 /* Dates 3204 ------------------------------------------------------------------------------------------------------------------*/ 3205 3206 3207 // Tells the grid about what period of time to display. 3208 // Any date-related internal data should be generated. 3209 setRange: function(range) { 3210 this.start = range.start.clone(); 3211 this.end = range.end.clone(); 3212 3213 this.rangeUpdated(); 3214 this.processRangeOptions(); 3215 }, 3216 3217 3218 // Called when internal variables that rely on the range should be updated 3219 rangeUpdated: function() { 3220 }, 3221 3222 3223 // Updates values that rely on options and also relate to range 3224 processRangeOptions: function() { 3225 var view = this.view; 3226 var displayEventTime; 3227 var displayEventEnd; 3228 3229 this.eventTimeFormat = 3230 view.opt('eventTimeFormat') || 3231 view.opt('timeFormat') || // deprecated 3232 this.computeEventTimeFormat(); 3233 3234 displayEventTime = view.opt('displayEventTime'); 3235 if (displayEventTime == null) { 3236 displayEventTime = this.computeDisplayEventTime(); // might be based off of range 3237 } 3238 3239 displayEventEnd = view.opt('displayEventEnd'); 3240 if (displayEventEnd == null) { 3241 displayEventEnd = this.computeDisplayEventEnd(); // might be based off of range 3242 } 3243 3244 this.displayEventTime = displayEventTime; 3245 this.displayEventEnd = displayEventEnd; 3246 }, 3247 3248 3249 // Converts a span (has unzoned start/end and any other grid-specific location information) 3250 // into an array of segments (pieces of events whose format is decided by the grid). 3251 spanToSegs: function(span) { 3252 // subclasses must implement 3253 }, 3254 3255 3256 // Diffs the two dates, returning a duration, based on granularity of the grid 3257 // TODO: port isTimeScale into this system? 3258 diffDates: function(a, b) { 3259 if (this.largeUnit) { 3260 return diffByUnit(a, b, this.largeUnit); 3261 } 3262 else { 3263 return diffDayTime(a, b); 3264 } 3265 }, 3266 3267 3268 /* Hit Area 3269 ------------------------------------------------------------------------------------------------------------------*/ 3270 3271 3272 // Called before one or more queryHit calls might happen. Should prepare any cached coordinates for queryHit 3273 prepareHits: function() { 3274 }, 3275 3276 3277 // Called when queryHit calls have subsided. Good place to clear any coordinate caches. 3278 releaseHits: function() { 3279 }, 3280 3281 3282 // Given coordinates from the topleft of the document, return data about the date-related area underneath. 3283 // Can return an object with arbitrary properties (although top/right/left/bottom are encouraged). 3284 // Must have a `grid` property, a reference to this current grid. TODO: avoid this 3285 // The returned object will be processed by getHitSpan and getHitEl. 3286 queryHit: function(leftOffset, topOffset) { 3287 }, 3288 3289 3290 // Given position-level information about a date-related area within the grid, 3291 // should return an object with at least a start/end date. Can provide other information as well. 3292 getHitSpan: function(hit) { 3293 }, 3294 3295 3296 // Given position-level information about a date-related area within the grid, 3297 // should return a jQuery element that best represents it. passed to dayClick callback. 3298 getHitEl: function(hit) { 3299 }, 3300 3301 3302 /* Rendering 3303 ------------------------------------------------------------------------------------------------------------------*/ 3304 3305 3306 // Sets the container element that the grid should render inside of. 3307 // Does other DOM-related initializations. 3308 setElement: function(el) { 3309 this.el = el; 3310 3311 if (this.hasDayInteractions) { 3312 preventSelection(el); 3313 3314 this.bindDayHandler('touchstart', this.dayTouchStart); 3315 this.bindDayHandler('mousedown', this.dayMousedown);
3316 } 3317 3318 // attach event-element-related handlers. in Grid.events 3319 // same garbage collection note as above. 3320 this.bindSegHandlers(); 3321 3322 this.bindGlobalHandlers(); 3323 }, 3324 3325 3326 bindDayHandler: function(name, handler) { 3327 var _this = this; 3328 3329 // attach a handler to the grid's root element. 3330 // jQuery will take care of unregistering them when removeElement gets called. 3331 this.el.on(name, function(ev) { 3332 if ( 3333 !$(ev.target).is( 3334 _this.segSelector + ',' + // directly on an event element 3335 _this.segSelector + ' *,' + // within an event element 3336 '.fc-more,' + // a "more.." link 3337 'a[data-goto]' // a clickable nav link 3338 ) 3339 ) { 3340 return handler.call(_this, ev); 3341 } 3342 }); 3343 }, 3344 3345 3346 // Removes the grid's container element from the DOM. Undoes any other DOM-related attachments. 3347 // DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View 3348 removeElement: function() { 3349 this.unbindGlobalHandlers(); 3350 this.clearDragListeners(); 3351 3352 this.el.remove(); 3353 3354 // NOTE: we don't null-out this.el for the same reasons we don't do it within View::removeElement 3355 }, 3356 3357 3358 // Renders the basic structure of grid view before any content is rendered 3359 renderSkeleton: function() { 3360 // subclasses should implement 3361 }, 3362 3363 3364 // Renders the grid's date-related content (like areas that represent days/times). 3365 // Assumes setRange has already been called and the skeleton has already been rendered. 3366 renderDates: function() { 3367 // subclasses should implement 3368 }, 3369 3370 3371 // Unrenders the grid's date-related content 3372 unrenderDates: function() { 3373 // subclasses should implement 3374 }, 3375 3376 3377 /* Handlers 3378 ------------------------------------------------------------------------------------------------------------------*/ 3379 3380 3381 // Binds DOM handlers to elements that reside outside the grid, such as the document 3382 bindGlobalHandlers: function() { 3383 this.listenTo($(document), { 3384 dragstart: this.externalDragStart, // jqui 3385 sortstart: this.externalDragStart // jqui 3386 }); 3387 }, 3388 3389 3390 // Unbinds DOM handlers from elements that reside outside the grid 3391 unbindGlobalHandlers: function() { 3392 this.stopListeningTo($(document)); 3393 }, 3394 3395 3396 // Process a mousedown on an element that represents a day. For day clicking and selecting. 3397 dayMousedown: function(ev) { 3398 if (!this.isIgnoringMouse) { 3399 this.dayDragListener.startInteraction(ev, { 3400 //distance: 5, // needs more work if we want dayClick to fire correctly 3401 }); 3402 } 3403 }, 3404 3405 3406 dayTouchStart: function(ev) { 3407 var view = this.view; 3408 3409 // HACK to prevent a user's clickaway for unselecting a range or an event 3410 // from causing a dayClick. 3411 if (view.isSelected || view.selectedEvent) { 3412 this.tempIgnoreMouse(); 3413 } 3414 3415 this.dayDragListener.startInteraction(ev, { 3416 delay: this.view.opt('longPressDelay') 3417 }); 3418 }, 3419 3420 3421 // Creates a listener that tracks the user's drag across day elements. 3422 // For day clicking and selecting. 3423 buildDayDragListener: function() { 3424 var _this = this; 3425 var view = this.view; 3426 var isSelectable = view.opt('selectable'); 3427 var dayClickHit; // null if invalid dayClick 3428 var selectionSpan; // null if invalid selection 3429 3430 // this listener tracks a mousedown on a day element, and a subsequent drag. 3431 // if the drag ends on the same day, it is a 'dayClick'. 3432 // if 'selectable' is enabled, this listener also detects selections. 3433 var dragListener = new HitDragListener(this, { 3434 scroll: view.opt('dragScroll'), 3435 interactionStart: function() { 3436 dayClickHit = dragListener.origHit; // for dayClick, where no dragging happens 3437 selectionSpan = null; 3438 }, 3439 dragStart: function() { 3440 view.unselect(); // since we could be rendering a new selection, we want to clear any old one 3441 }, 3442 hitOver: function(hit, isOrig, origHit) { 3443 if (origHit) { // click needs to have started on a hit 3444 3445 // if user dragged to another cell at any point, it can no longer be a dayClick 3446 if (!isOrig) { 3447 dayClickHit = null; 3448 } 3449 3450 if (isSelectable) { 3451 selectionSpan = _this.computeSelection( 3452 _this.getHitSpan(origHit), 3453 _this.getHitSpan(hit) 3454 ); 3455 if (selectionSpan) { 3456 _this.renderSelection(selectionSpan); 3457 } 3458 else if (selectionSpan === false) {
3459 disableCursor(); 3460 } 3461 } 3462 } 3463 }, 3464 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits 3465 dayClickHit = null; 3466 selectionSpan = null; 3467 _this.unrenderSelection(); 3468 }, 3469 hitDone: function() { // called after a hitOut OR before a dragEnd 3470 enableCursor(); 3471 }, 3472 interactionEnd: function(ev, isCancelled) { 3473 if (!isCancelled) { 3474 if ( 3475 dayClickHit && 3476 !_this.isIgnoringMouse // see hack in dayTouchStart 3477 ) { 3478 view.triggerDayClick( 3479 _this.getHitSpan(dayClickHit), 3480 _this.getHitEl(dayClickHit), 3481 ev 3482 ); 3483 } 3484 if (selectionSpan) { 3485 // the selection will already have been rendered. just report it 3486 view.reportSelection(selectionSpan, ev); 3487 } 3488 } 3489 } 3490 }); 3491 3492 return dragListener; 3493 }, 3494 3495 3496 // Kills all in-progress dragging. 3497 // Useful for when public API methods that result in re-rendering are invoked during a drag. 3498 // Also useful for when touch devices misbehave and don't fire their touchend. 3499 clearDragListeners: function() { 3500 this.dayDragListener.endInteraction(); 3501 3502 if (this.segDragListener) { 3503 this.segDragListener.endInteraction(); // will clear this.segDragListener 3504 } 3505 if (this.segResizeListener) { 3506 this.segResizeListener.endInteraction(); // will clear this.segResizeListener 3507 } 3508 if (this.externalDragListener) { 3509 this.externalDragListener.endInteraction(); // will clear this.externalDragListener 3510 } 3511 }, 3512 3513 3514 /* Event Helper 3515 ------------------------------------------------------------------------------------------------------------------*/ 3516 // TODO: should probably move this to Grid.events, like we did event dragging / resizing 3517 3518 3519 // Renders a mock event at the given event location, which contains zoned start/end properties. 3520 // Returns all mock event elements. 3521 renderEventLocationHelper: function(eventLocation, sourceSeg) { 3522 var fakeEvent = this.fabricateHelperEvent(eventLocation, sourceSeg); 3523 3524 return this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering 3525 }, 3526 3527 3528 // Builds a fake event given zoned event date properties and a segment is should be inspired from. 3529 // The range's end can be null, in which case the mock event that is rendered will have a null end time. 3530 // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging. 3531 fabricateHelperEvent: function(eventLocation, sourceSeg) { 3532 var fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible 3533 3534 fakeEvent.start = eventLocation.start.clone(); 3535 fakeEvent.end = eventLocation.end ? eventLocation.end.clone() : null; 3536 fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventDates 3537 this.view.calendar.normalizeEventDates(fakeEvent); 3538 3539 // this extra className will be useful for differentiating real events from mock events in CSS 3540 fakeEvent.className = (fakeEvent.className || []).concat('fc-helper'); 3541 3542 // if something external is being dragged in, don't render a resizer 3543 if (!sourceSeg) { 3544 fakeEvent.editable = false; 3545 } 3546 3547 return fakeEvent; 3548 }, 3549 3550 3551 // Renders a mock event. Given zoned event date properties. 3552 // Must return all mock event elements. 3553 renderHelper: function(eventLocation, sourceSeg) { 3554 // subclasses must implement 3555 }, 3556 3557 3558 // Unrenders a mock event 3559 unrenderHelper: function() { 3560 // subclasses must implement 3561 }, 3562 3563 3564 /* Selection 3565 ------------------------------------------------------------------------------------------------------------------*/ 3566 3567 3568 // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses. 3569 // Given a span (unzoned start/end and other misc data) 3570 renderSelection: function(span) { 3571 this.renderHighlight(span); 3572 }, 3573 3574 3575 // Unrenders any visual indications of a selection. Will unrender a highlight by default. 3576 unrenderSelection: function() { 3577 this.unrenderHighlight(); 3578 }, 3579 3580 3581 // Given the first and last date-spans of a selection, returns another date-span object. 3582 // Subclasses can override and provide additional data in the span object. Will be passed to render
3582Selection(). 3583 // Will return false if the selection is invalid and this should be indicated to the user. 3584 // Will return null/undefined if a selection invalid but no error should be reported. 3585 computeSelection: function(span0, span1) { 3586 var span = this.computeSelectionSpan(span0, span1); 3587 3588 if (span && !this.view.calendar.isSelectionSpanAllowed(span)) { 3589 return false; 3590 } 3591 3592 return span; 3593 }, 3594 3595 3596 // Given two spans, must return the combination of the two. 3597 // TODO: do this separation of concerns (combining VS validation) for event dnd/resize too. 3598 computeSelectionSpan: function(span0, span1) { 3599 var dates = [ span0.start, span0.end, span1.start, span1.end ]; 3600 3601 dates.sort(compareNumbers); // sorts chronologically. works with Moments 3602 3603 return { start: dates[0].clone(), end: dates[3].clone() }; 3604 }, 3605 3606 3607 /* Highlight 3608 ------------------------------------------------------------------------------------------------------------------*/ 3609 3610 3611 // Renders an emphasis on the given date range. Given a span (unzoned start/end and other misc data) 3612 renderHighlight: function(span) { 3613 this.renderFill('highlight', this.spanToSegs(span)); 3614 }, 3615 3616 3617 // Unrenders the emphasis on a date range 3618 unrenderHighlight: function() { 3619 this.unrenderFill('highlight'); 3620 }, 3621 3622 3623 // Generates an array of classNames for rendering the highlight. Used by the fill system. 3624 highlightSegClasses: function() { 3625 return [ 'fc-highlight' ]; 3626 }, 3627 3628 3629 /* Business Hours 3630 ------------------------------------------------------------------------------------------------------------------*/ 3631 3632 3633 renderBusinessHours: function() { 3634 }, 3635 3636 3637 unrenderBusinessHours: function() { 3638 }, 3639 3640 3641 /* Now Indicator 3642 ------------------------------------------------------------------------------------------------------------------*/ 3643 3644 3645 getNowIndicatorUnit: function() { 3646 }, 3647 3648 3649 renderNowIndicator: function(date) { 3650 }, 3651 3652 3653 unrenderNowIndicator: function() { 3654 }, 3655 3656 3657 /* Fill System (highlight, background events, business hours) 3658 -------------------------------------------------------------------------------------------------------------------- 3659 TODO: remove this system. like we did in TimeGrid 3660 */ 3661 3662 3663 // Renders a set of rectangles over the given segments of time. 3664 // MUST RETURN a subset of segs, the segs that were actually rendered. 3665 // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement 3666 renderFill: function(type, segs) { 3667 // subclasses must implement 3668 }, 3669 3670 3671 // Unrenders a specific type of fill that is currently rendered on the grid 3672 unrenderFill: function(type) { 3673 var el = this.elsByFill[type]; 3674 3675 if (el) { 3676 el.remove(); 3677 delete this.elsByFill[type]; 3678 } 3679 }, 3680 3681 3682 // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types. 3683 // Only returns segments that successfully rendered. 3684 // To be harnessed by renderFill (implemented by subclasses). 3685 // Analagous to renderFgSegEls. 3686 renderFillSegEls: function(type, segs) { 3687 var _this = this; 3688 var segElMethod = this[type + 'SegEl']; 3689 var html = ''; 3690 var renderedSegs = []; 3691 var i; 3692 3693 if (segs.length) { 3694 3695 // build a large concatenation of segment HTML 3696 for (i = 0; i < segs.length; i++) { 3697 html += this.fillSegHtml(type, segs[i]); 3698 } 3699 3700 // Grab individual elements from the combined HTML string. Use each as the default rendering. 3701 // Then, compute the 'el' for each segment. 3702 $(html).each(function(i, node) { 3703 var seg = segs[i]; 3704 var el = $(node); 3705 3706 // allow custom filter methods per-type 3707 if (segElMethod) { 3708 el = segElMethod.call(_this, seg, el); 3709 } 3710 3711 if (el) { // custom filters did not cancel the render 3712 el = $(el); // allow custom filter to return raw DOM node 3713 3714 // correct element type? (would be bad if a non-TD were inserted into a table for example) 3715 if (el.is(_this.fillSegTag)) { 3716 seg.el = el; 3717 renderedSegs.push(seg); 3718 } 3719 } 3720 }); 3721 } 3722 3723 return renderedSegs; 3724 }, 3725 3726 3727 fillSegTag: 'div', // subclasses can override 3728 3729 3730 // Builds the HTML needed for one fill segment. Generic enough to work with different types. 3731 fillSegHtml: function(type, seg) { 3732 3733 // custom hooks per-type 3734 var classesMethod = this[type + 'SegClasses']; 3735 var cssMethod = this[type + 'SegCss']; 3736 3737 var classes = classesMethod ? classesMethod.call(this, seg) : []; 3738 var css = cssToStr(cssMethod ? cssMethod.call(this, seg) : {}); 3739 3740 return '<' + this.fillSegTag + 3741 (classes.length ? ' class="' + classes.join(' ') + '"' : '') + 3742 (css ? ' style="' + css + '"' : '') + 3743 ' />'; 3744 }, 3745 3746 3747 3748 /* Generic rendering utilities for subclasses 3749 ------------------------------------------------------------------------------------------------------------------*/ 3750 3751 3752 // Computes HTML classNames for a single-day element 3753 getDayClasses: function(date) { 3754 var view = this.view; 3755 var today = view.calendar.getNow(); 3756 var classes = [ 'fc-' + dayIDs[date.day()] ]; 3757 3758 if ( 3759 view.intervalDuration.as('months') == 1 && 3760 date.month() != view.intervalStart.month() 3761 ) { 3762 classes.push('fc-other-month'); 3763 } 3764 3765 if (date.isSame(today, 'day')) { 3766 classes.push( 3767 'fc-today', 3768 view.highlightStateClass 3769 ); 3770 } 3771 else if (date < today) { 3772 classes.push('fc-past'); 3773 } 3774 else { 3775 classes.push('fc-future'); 3776 } 3777 3778 return classes; 3779 } 3780 3781}); 3782 3783;; 3784 3785/* Event-rendering and event-interaction methods for the abstract Grid class 3786----------------------------------------------------------------------------------------------------------------------*/ 3787 3788Grid.mixin({ 3789 3790 // self-config, overridable by subclasses 3791 segSelector: '.fc-event-container > *', // what constitutes an event element? 3792 3793 mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing 3794 isDraggingSeg: false, // is a segment being dragged? boolean 3795 isResizingSeg: false, // is a segment being resized? boolean
3796 isDraggingExternal: false, // jqui-dragging an external element? boolean 3797 segs: null, // the *event* segments currently rendered in the grid. TODO: rename to `eventSegs` 3798 3799 3800 // Renders the given events onto the grid 3801 renderEvents: function(events) { 3802 var bgEvents = []; 3803 var fgEvents = []; 3804 var i; 3805 3806 for (i = 0; i < events.length; i++) { 3807 (isBgEvent(events[i]) ? bgEvents : fgEvents).push(events[i]); 3808 } 3809 3810 this.segs = [].concat( // record all segs 3811 this.renderBgEvents(bgEvents), 3812 this.renderFgEvents(fgEvents) 3813 ); 3814 }, 3815 3816 3817 renderBgEvents: function(events) { 3818 var segs = this.eventsToSegs(events); 3819 3820 // renderBgSegs might return a subset of segs, segs that were actually rendered 3821 return this.renderBgSegs(segs) || segs; 3822 }, 3823 3824 3825 renderFgEvents: function(events) { 3826 var segs = this.eventsToSegs(events); 3827 3828 // renderFgSegs might return a subset of segs, segs that were actually rendered 3829 return this.renderFgSegs(segs) || segs; 3830 }, 3831 3832 3833 // Unrenders all events currently rendered on the grid 3834 unrenderEvents: function() { 3835 this.handleSegMouseout(); // trigger an eventMouseout if user's mouse is over an event 3836 this.clearDragListeners(); 3837 3838 this.unrenderFgSegs(); 3839 this.unrenderBgSegs(); 3840 3841 this.segs = null; 3842 }, 3843 3844 3845 // Retrieves all rendered segment objects currently rendered on the grid 3846 getEventSegs: function() { 3847 return this.segs || []; 3848 }, 3849 3850 3851 /* Foreground Segment Rendering 3852 ------------------------------------------------------------------------------------------------------------------*/ 3853 3854 3855 // Renders foreground event segments onto the grid. May return a subset of segs that were rendered. 3856 renderFgSegs: function(segs) { 3857 // subclasses must implement 3858 }, 3859 3860 3861 // Unrenders all currently rendered foreground segments 3862 unrenderFgSegs: function() { 3863 // subclasses must implement 3864 }, 3865 3866 3867 // Renders and assigns an `el` property for each foreground event segment. 3868 // Only returns segments that successfully rendered. 3869 // A utility that subclasses may use. 3870 renderFgSegEls: function(segs, disableResizing) { 3871 var view = this.view; 3872 var html = ''; 3873 var renderedSegs = []; 3874 var i; 3875 3876 if (segs.length) { // don't build an empty html string 3877 3878 // build a large concatenation of event segment HTML 3879 for (i = 0; i < segs.length; i++) { 3880 html += this.fgSegHtml(segs[i], disableResizing); 3881 } 3882 3883 // Grab individual elements from the combined HTML string. Use each as the default rendering. 3884 // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false. 3885 $(html).each(function(i, node) { 3886 var seg = segs[i]; 3887 var el = view.resolveEventEl(seg.event, $(node)); 3888 3889 if (el) { 3890 el.data('fc-seg', seg); // used by handlers 3891 seg.el = el; 3892 renderedSegs.push(seg); 3893 } 3894 }); 3895 } 3896 3897 return renderedSegs; 3898 }, 3899 3900 3901 // Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls() 3902 fgSegHtml: function(seg, disableResizing) { 3903 // subclasses should implement 3904 }, 3905 3906 3907 /* Background Segment Rendering 3908 ------------------------------------------------------------------------------------------------------------------*/ 3909 3910 3911 // Renders the given background event segments onto the grid. 3912 // Returns a subset of the segs that were actually rendered. 3913 renderBgSegs: function(segs) { 3914 return this.renderFill('bgEvent', segs); 3915 }, 3916 3917 3918 // Unrenders all the currently rendered background event segments 3919 unrenderBgSegs: function() { 3920 this.unrenderFill('bgEvent'); 3921 }, 3922 3923 3924 // Renders a background event element, given the default rendering. Called by the fill system. 3925 bgEventSegEl: function(seg, el) { 3926 return this.view.resolveEventEl(seg.event, el); // will filter through eventRender 3927 }, 3928 3929 3930 // Generates an array of classNames to be used for the default rendering of a background event. 3931 // Called by fillSegHtml. 3932 bgEventSegClasses: function(seg) { 3933 var event = seg.event; 3934 var source = event.source || {}; 3935 3936 return [ 'fc-bgevent' ].concat( 3937 event.className, 3938 source.className || [] 3939 ); 3940 }, 3941 3942 3943 // Generates a semicolon-separated CSS string to be used for the default rendering of a background event. 3944 // Called by fillSegHtml. 3945 bgEventSegCss: function(seg) { 3946 return {
3947 'background-color': this.getSegSkinCss(seg)['background-color'] 3948 }; 3949 }, 3950 3951 3952 // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system. 3953 // Called by fillSegHtml. 3954 businessHoursSegClasses: function(seg) { 3955 return [ 'fc-nonbusiness', 'fc-bgevent' ]; 3956 }, 3957 3958 3959 /* Business Hours 3960 ------------------------------------------------------------------------------------------------------------------*/ 3961 3962 3963 // Compute business hour segs for the grid's current date range. 3964 // Caller must ask if whole-day business hours are needed. 3965 buildBusinessHourSegs: function(wholeDay) { 3966 var events = this.view.calendar.getCurrentBusinessHourEvents(wholeDay); 3967 3968 // HACK. Eventually refactor business hours "events" system. 3969 // If no events are given, but businessHours is activated, this means the entire visible range should be 3970 // marked as *not* business-hours, via inverse-background rendering. 3971 if ( 3972 !events.length && 3973 this.view.calendar.options.businessHours // don't access view option. doesn't update with dynamic options 3974 ) { 3975 events = [ 3976 $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, { 3977 start: this.view.end, // guaranteed out-of-range 3978 end: this.view.end, // " 3979 dow: null 3980 }) 3981 ]; 3982 } 3983 3984 return this.eventsToSegs(events); 3985 }, 3986 3987 3988 /* Handlers 3989 ------------------------------------------------------------------------------------------------------------------*/ 3990 3991 3992 // Attaches event-element-related handlers for *all* rendered event segments of the view. 3993 bindSegHandlers: function() { 3994 this.bindSegHandlersToEl(this.el); 3995 }, 3996 3997 3998 // Attaches event-element-related handlers to an arbitrary container element. leverages bubbling. 3999 bindSegHandlersToEl: function(el) { 4000 this.bindSegHandlerToEl(el, 'touchstart', this.handleSegTouchStart); 4001 this.bindSegHandlerToEl(el, 'touchend', this.handleSegTouchEnd); 4002 this.bindSegHandlerToEl(el, 'mouseenter', this.handleSegMouseover); 4003 this.bindSegHandlerToEl(el, 'mouseleave', this.handleSegMouseout); 4004 this.bindSegHandlerToEl(el, 'mousedown', this.handleSegMousedown); 4005 this.bindSegHandlerToEl(el, 'click', this.handleSegClick); 4006 }, 4007 4008 4009 // Executes a handler for any a user-interaction on a segment. 4010 // Handler gets called with (seg, ev), and with the `this` context of the Grid 4011 bindSegHandlerToEl: function(el, name, handler) { 4012 var _this = this; 4013 4014 el.on(name, this.segSelector, function(ev) { 4015 var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents 4016 4017 // only call the handlers if there is not a drag/resize in progress 4018 if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) { 4019 return handler.call(_this, seg, ev); // context will be the Grid 4020 } 4021 }); 4022 }, 4023 4024 4025 handleSegClick: function(seg, ev) { 4026 var res = this.view.trigger('eventClick', seg.el[0], seg.event, ev); // can return `false` to cancel 4027 if (res === false) { 4028 ev.preventDefault(); 4029 } 4030 }, 4031 4032 4033 // Updates internal state and triggers handlers for when an event element is moused over 4034 handleSegMouseover: function(seg, ev) { 4035 if ( 4036 !this.isIgnoringMouse && 4037 !this.mousedOverSeg 4038 ) { 4039 this.mousedOverSeg = seg; 4040 if (this.view.isEventResizable(seg.event)) { 4041 seg.el.addClass('fc-allow-mouse-resize'); 4042 } 4043 this.view.trigger('eventMouseover', seg.el[0], seg.event, ev); 4044 } 4045 }, 4046 4047 4048 // Updates internal state and triggers handlers for when an event element is moused out. 4049 // Can be given no arguments, in which case it will mouseout the segment that was previously moused over. 4050 handleSegMouseout: function(seg, ev) { 4051 ev = ev || {}; // if given no args, make a mock mouse event 4052 4053 if (this.mousedOverSeg) { 4054 seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment 4055 this.mousedOverSeg = null; 4056 if (this.view.isEventResizable(seg.event)) { 4057 seg.el.removeClass('fc-allow-mouse-resize'); 4058 } 4059 this.view.trigger('eventMouseout', seg.el[0], seg.event, ev); 4060 } 4061 }, 4062 4063 4064 handleSegMousedown: function(seg, ev) { 4065 var isResizing = this.startSegResize(seg, ev, { distance: 5 }); 4066 4067 if (!isResizing && this.view.isEventDraggable(seg.event)) { 4068 this.buildSegDragListener(seg) 4069 .startInteraction(ev, { 4070 distance: 5 4071 }); 4072 } 4073 }, 4074 4075 4076 handleSegTouchStart: function(seg, ev) { 4077 var view = this.view; 4078 var event = seg.event; 4079 var isSelected = view.isEventSelected(event); 4080 var isDraggable = view.isEventDraggable(event); 4081 var isResizable = view.isEventResizable(event); 4082 var isResizing = false; 4083 var dragListener; 4084 4085 if (isSelected && isResizable) { 4086 // only allow resizing of the event is selected 4087 isResizing = this.startSegResize(seg, ev); 4088 } 4089 4090 if (!isResizing && (isDraggable || isResizable)) { // allowed to be selected? 4091 4092 dragListener = isDraggable ? 4093 this.buildSegDragListener(seg) : 4094 this.buildSegSelectListener(seg); // seg isn't draggable, but still needs to be selected 4095 4096 dragListener.startInteraction(ev, { // won't start if already started 4097 delay: isSelected ? 0 : this.view.opt('longPressDelay') // do delay if not already selected 4098 }); 4099 } 4100 4101 // a long tap simulates a mouseover. ignore this bogus mouseover. 4102 this.tempIgnoreMouse(); 4103 }, 4104 4105 4106 handleSegTouchEnd: function(seg, ev) { 4107 // touchstart+touchend = click, which simulates a mouseover. 4108 // ignore this bogus mouseover. 4109 this.tempIgnoreMouse(); 4110 }, 4111 4112 4113 // returns boolean whether resizing actually started or not. 4114 // assumes the seg allows resizing. 4115 // `dragOptions` are optional. 4116 startSegResize: function(seg, ev, dragOptions) { 4117 if ($(ev.target).is('.fc-resizer')) { 4118 this.buildSegResizeListener(seg, $(ev.target).is('.fc-start-resizer')) 4119 .startInteraction(ev, dragOptions); 4120 return true; 4121 } 4122 return false; 4123 }, 4124 4125 4126 4127 /* Event Dragging 4128 ------------------------------------------------------------------------------------------------------------------*/ 4129 4130 4131 // Builds a listener that will track user-dragging on an event segment. 4132 // Generic enough to work with any type of Grid. 4133 // Has side effect of setting/unsetting `segDragListener` 4134 buildSegDragListener: function(seg) { 4135 var _this = this; 4136 var view = this.view; 4137 var calendar = view.calendar; 4138 var el = seg.el; 4139 var event = seg.event; 4140 var isDragging; 4141 var mouseFollower; // A clone of the original element that will move with the mouse 4142 var dropLocation; // zoned event date properties 4143 4144 if (this.segDragListener) { 4145 return this.segDragListener; 4146 } 4147 4148 // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents 4149 // of the view. 4150 var dragListener = this.segDragListener = new HitDragListener(view, { 4151 scroll: view.opt('dragScroll'), 4152 subjectEl: el, 4153 subjectCenter: true, 4154 interactionStart: function(ev) { 4155 seg.component = _this; // for renderDrag 4156 isDragging = false; 4157 mouseFollower = new MouseFollower(seg.el, { 4158 additionalClass: 'fc-dragging', 4159 parentEl: view.el, 4160 opacity: dragListener.isTouch ? null : view.opt('dragOpacity'), 4161 revertDuration: view.opt('dragRevertDuration'), 4162 zIndex: 2 // one above the .fc-view 4163 }); 4164 mouseFollower.hide(); // don't show until we know this is a real drag 4165 mouseFollower.start(ev); 4166 }, 4167 dragStart: function(ev) { 4168 if (dragListener.isTouch && !view.isEventSelected(event)) { 4169 // if not previously selected, will fire after a delay. then, select the event 4170 view.selectEvent(event); 4171 } 4172 isDragging = true; 4173 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported 4174 _this.segDragStart(seg, ev); 4175 view.hideEvent(event);
4175 // hide all event segments. our mouseFollower will take over 4176 }, 4177 hitOver: function(hit, isOrig, origHit) { 4178 var dragHelperEls; 4179 4180 // starting hit could be forced (DayGrid.limit) 4181 if (seg.hit) { 4182 origHit = seg.hit; 4183 } 4184 4185 // since we are querying the parent view, might not belong to this grid 4186 dropLocation = _this.computeEventDrop( 4187 origHit.component.getHitSpan(origHit), 4188 hit.component.getHitSpan(hit), 4189 event 4190 ); 4191 4192 if (dropLocation && !calendar.isEventSpanAllowed(_this.eventToSpan(dropLocation), event)) { 4193 disableCursor(); 4194 dropLocation = null; 4195 } 4196 4197 // if a valid drop location, have the subclass render a visual indication 4198 if (dropLocation && (dragHelperEls = view.renderDrag(dropLocation, seg))) { 4199 4200 dragHelperEls.addClass('fc-dragging'); 4201 if (!dragListener.isTouch) { 4202 _this.applyDragOpacity(dragHelperEls); 4203 } 4204 4205 mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own 4206 } 4207 else { 4208 mouseFollower.show(); // otherwise, have the helper follow the mouse (no snapping) 4209 } 4210 4211 if (isOrig) { 4212 dropLocation = null; // needs to have moved hits to be a valid drop 4213 } 4214 }, 4215 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits 4216 view.unrenderDrag(); // unrender whatever was done in renderDrag 4217 mouseFollower.show(); // show in case we are moving out of all hits 4218 dropLocation = null; 4219 }, 4220 hitDone: function() { // Called after a hitOut OR before a dragEnd 4221 enableCursor(); 4222 }, 4223 interactionEnd: function(ev) { 4224 delete seg.component; // prevent side effects 4225 4226 // do revert animation if hasn't changed. calls a callback when finished (whether animation or not) 4227 mouseFollower.stop(!dropLocation, function() { 4228 if (isDragging) { 4229 view.unrenderDrag(); 4230 view.showEvent(event); 4231 _this.segDragStop(seg, ev); 4232 } 4233 if (dropLocation) { 4234 view.reportEventDrop(event, dropLocation, this.largeUnit, el, ev); 4235 } 4236 }); 4237 _this.segDragListener = null; 4238 } 4239 }); 4240 4241 return dragListener; 4242 }, 4243 4244 4245 // seg isn't draggable, but let's use a generic DragListener 4246 // simply for the delay, so it can be selected. 4247 // Has side effect of setting/unsetting `segDragListener` 4248 buildSegSelectListener: function(seg) { 4249 var _this = this; 4250 var view = this.view; 4251 var event = seg.event; 4252 4253 if (this.segDragListener) { 4254 return this.segDragListener; 4255 } 4256 4257 var dragListener = this.segDragListener = new DragListener({ 4258 dragStart: function(ev) { 4259 if (dragListener.isTouch && !view.isEventSelected(event)) { 4260 // if not previously selected, will fire after a delay. then, select the event 4261 view.selectEvent(event); 4262 } 4263 }, 4264 interactionEnd: function(ev) { 4265 _this.segDragListener = null; 4266 } 4267 }); 4268 4269 return dragListener; 4270 }, 4271 4272 4273 // Called before event segment dragging starts 4274 segDragStart: function(seg, ev) { 4275 this.isDraggingSeg = true; 4276 this.view.trigger('eventDragStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy 4277 }, 4278 4279 4280 // Called after event segment dragging stops 4281 segDragStop: function(seg, ev) { 4282 this.isDraggingSeg = false; 4283 this.view.trigger('eventDragStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy 4284 }, 4285 4286 4287 // Given the spans an event drag began, and the span event was dropped, calculates the new zoned start/end/allDay 4288 // values for the event. Subclasses may override and set additional properties to be used by renderDrag. 4289 // A falsy returned value indicates an invalid drop. 4290 // DOES NOT consider overlap/constraint. 4291 computeEventDrop: function(startSpan, endSpan, event) { 4292 var calendar = this.view.calendar; 4293 var dragStart = startSpan.start; 4294 var dragEnd = endSpan.start; 4295 var delta; 4296 var dropLocation; // zoned event date properties 4297 4298 if (dragStart.hasTime() === dragEnd.hasTime()) { 4299 delta = this.diffDates(dragEnd, dragStart); 4300 4301 // if an all-day event was in a timed area and it was dragged to a different time, 4302 // guarantee an end and adjust start/end to have times 4303 if (event.allDay && durationHasTime(delta)) { 4304 dropLocation = { 4305 start: event.start.clone(), 4306 end: calendar.getEventEnd(event), // will be an ambig day 4307 allDay: false // for normalizeEventTimes 4308 }; 4309 calendar.normalizeEventTimes(dropLocation); 4310 } 4311 // othewise, work off existing values 4312 else { 4313 dropLocation = pluckEventDateProps(event); 4314 } 4315 4316 dropLocation.start.add(delta); 4317 if (dropLocation.end) { 4318 dropLocation.end.add(delta); 4319 } 4320 } 4321 else { 4322 // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared 4323 dropLocation = { 4324 start: dragEnd.clone(), 4325 end: null, // end should be cleared 4326 allDay: !dragEnd.hasTime() 4327 }; 4328 } 4329 4330 return dropLocation; 4331 }, 4332 4333 4334 // Utility for apply dragOpacity to a jQuery set 4335 applyDragOpacity: function(els) { 4336 var opacity = this.view.opt('dragOpacity'); 4337 4338 if (opacity != null) { 4339 els.css('opacity', opacity); 4340 } 4341 }, 4342 4343 4344 /* External Element Dragging 4345 ------------------------------------------------------------------------------------------------------------------*/ 4346 4347 4348 // Called when a jQuery UI drag is initiated anywhere in the DOM 4349 externalDragStart: function(ev, ui) { 4350 var view = this.view; 4351 var el; 4352 var accept; 4353 4354 if (view.opt('droppable')) { // only listen if this setting is on 4355 el = $((ui ? ui.item : null) || ev.target); 4356 4357 // Test that the dragged element passes the dropAccept selector or filter function. 4358 // FYI, the default is "*" (matches all) 4359 accept = view.opt('dropAccept'); 4360 if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) {
4361 if (!this.isDraggingExternal) { // prevent double-listening if fired twice 4362 this.listenToExternalDrag(el, ev, ui); 4363 } 4364 } 4365 } 4366 }, 4367 4368 4369 // Called when a jQuery UI drag starts and it needs to be monitored for dropping 4370 listenToExternalDrag: function(el, ev, ui) { 4371 var _this = this; 4372 var calendar = this.view.calendar; 4373 var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create 4374 var dropLocation; // a null value signals an unsuccessful drag 4375 4376 // listener that tracks mouse movement over date-associated pixel regions 4377 var dragListener = _this.externalDragListener = new HitDragListener(this, { 4378 interactionStart: function() { 4379 _this.isDraggingExternal = true; 4380 }, 4381 hitOver: function(hit) { 4382 dropLocation = _this.computeExternalDrop( 4383 hit.component.getHitSpan(hit), // since we are querying the parent view, might not belong to this grid 4384 meta 4385 ); 4386 4387 if ( // invalid hit? 4388 dropLocation && 4389 !calendar.isExternalSpanAllowed(_this.eventToSpan(dropLocation), dropLocation, meta.eventProps) 4390 ) { 4391 disableCursor(); 4392 dropLocation = null; 4393 } 4394 4395 if (dropLocation) { 4396 _this.renderDrag(dropLocation); // called without a seg parameter 4397 } 4398 }, 4399 hitOut: function() { 4400 dropLocation = null; // signal unsuccessful 4401 }, 4402 hitDone: function() { // Called after a hitOut OR before a dragEnd 4403 enableCursor(); 4404 _this.unrenderDrag(); 4405 }, 4406 interactionEnd: function(ev) { 4407 if (dropLocation) { // element was dropped on a valid hit 4408 _this.view.reportExternalDrop(meta, dropLocation, el, ev, ui); 4409 }
4410 _this.isDraggingExternal = false; 4411 _this.externalDragListener = null; 4412 } 4413 }); 4414 4415 dragListener.startDrag(ev); // start listening immediately 4416 }, 4417 4418 4419 // Given a hit to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object), 4420 // returns the zoned start/end dates for the event that would result from the hypothetical drop. end might be null. 4421 // Returning a null value signals an invalid drop hit. 4422 // DOES NOT consider overlap/constraint. 4423 computeExternalDrop: function(span, meta) { 4424 var calendar = this.view.calendar; 4425 var dropLocation = { 4426 start: calendar.applyTimezone(span.start), // simulate a zoned event start date 4427 end: null 4428 }; 4429 4430 // if dropped on an all-day span, and element's metadata specified a time, set it 4431 if (meta.startTime && !dropLocation.start.hasTime()) { 4432 dropLocation.start.time(meta.startTime); 4433 } 4434 4435 if (meta.duration) { 4436 dropLocation.end = dropLocation.start.clone().add(meta.duration); 4437 } 4438 4439 return dropLocation; 4440 }, 4441 4442 4443 4444 /* Drag Rendering (for both events and an external elements) 4445 ------------------------------------------------------------------------------------------------------------------*/ 4446 4447 4448 // Renders a visual indication of an event or external element being dragged. 4449 // `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null. 4450 // `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null. 4451 // A truthy returned value indicates this method has rendered a helper element. 4452 // Must return elements used for any mock events. 4453 renderDrag: function(dropLocation, seg) { 4454 // subclasses must implement 4455 }, 4456 4457 4458 // Unrenders a visual indication of an event or external element being dragged 4459 unrenderDrag: function() { 4460 // subclasses must implement 4461 }, 4462 4463 4464 /* Resizing 4465 ------------------------------------------------------------------------------------------------------------------*/ 4466 4467 4468 // Creates a listener that tracks the user as they resize an event segment. 4469 // Generic enough to work with any type of Grid. 4470 buildSegResizeListener: function(seg, isStart) { 4471 var _this = this; 4472 var view = this.view; 4473 var calendar = view.calendar; 4474 var el = seg.el; 4475 var event = seg.event; 4476 var eventEnd = calendar.getEventEnd(event); 4477 var isDragging; 4478 var resizeLocation; // zoned event date properties. falsy if invalid resize 4479 4480 // Tracks mouse movement over the *grid's* coordinate map 4481 var dragListener = this.segResizeListener = new HitDragListener(this, { 4482 scroll: view.opt('dragScroll'), 4483 subjectEl: el, 4484 interactionStart: function() { 4485 isDragging = false; 4486 }, 4487 dragStart: function(ev) { 4488 isDragging = true; 4489 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported 4490 _this.segResizeStart(seg, ev); 4491 }, 4492 hitOver: function(hit, isOrig, origHit) { 4493 var origHitSpan = _this.getHitSpan(origHit); 4494 var hitSpan = _this.getHitSpan(hit); 4495 4496 resizeLocation = isStart ? 4497 _this.computeEventStartResize(origHitSpan, hitSpan, event) : 4498 _this.computeEventEndResize(origHitSpan, hitSpan, event); 4499 4500 if (resizeLocation) { 4501 if (!calendar.isEventSpanAllowed(_this.eventToSpan(resizeLocation), event)) { 4502 disableCursor(); 4503 resizeLocation = null; 4504 } 4505 // no change? (FYI, event dates might have zones) 4506 else if ( 4507 resizeLocation.start.isSame(event.start.clone().stripZone()) && 4508 resizeLocation.end.isSame(eventEnd.clone().stripZone()) 4509 ) { 4510 resizeLocation = null; 4511 } 4512 } 4513 4514 if (resizeLocation) { 4515 view.hideEvent(event); 4516 _this.renderEventResize(resizeLocation, seg); 4517 } 4518 }, 4519 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits 4520 resizeLocation = null; 4521 }, 4522 hitDone: function() { // resets the rendering to show the original event 4523 _this.unrenderEventResize(); 4524 view.showEvent(event);
4525 enableCursor(); 4526 }, 4527 interactionEnd: function(ev) { 4528 if (isDragging) { 4529 _this.segResizeStop(seg, ev); 4530 } 4531 if (resizeLocation) { // valid date to resize to? 4532 view.reportEventResize(event, resizeLocation, this.largeUnit, el, ev); 4533 } 4534 _this.segResizeListener = null; 4535 } 4536 }); 4537 4538 return dragListener; 4539 }, 4540 4541 4542 // Called before event segment resizing starts 4543 segResizeStart: function(seg, ev) { 4544 this.isResizingSeg = true; 4545 this.view.trigger('eventResizeStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy 4546 }, 4547 4548 4549 // Called after event segment resizing stops 4550 segResizeStop: function(seg, ev) { 4551 this.isResizingSeg = false; 4552 this.view.trigger('eventResizeStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy 4553 }, 4554 4555 4556 // Returns new date-information for an event segment being resized from its start 4557 computeEventStartResize: function(startSpan, endSpan, event) { 4558 return this.computeEventResize('start', startSpan, endSpan, event); 4559 }, 4560 4561 4562 // Returns new date-information for an event segment being resized from its end 4563 computeEventEndResize: function(startSpan, endSpan, event) { 4564 return this.computeEventResize('end', startSpan, endSpan, event); 4565 }, 4566 4567 4568 // Returns new zoned date information for an event segment being resized from its start OR end 4569 // `type` is either 'start' or 'end'. 4570 // DOES NOT consider overlap/constraint. 4571 computeEventResize: function(type, startSpan, endSpan, event) { 4572 var calendar = this.view.calendar; 4573 var delta = this.diffDates(endSpan[type], startSpan[type]); 4574 var resizeLocation; // zoned event date properties 4575 var defaultDuration; 4576 4577 // build original values to work from, guaranteeing a start and end 4578 resizeLocation = { 4579 start: event.start.clone(), 4580 end: calendar.getEventEnd(event), 4581 allDay: event.allDay 4582 }; 4583 4584 // if an all-day event was in a timed area and was resized to a time, adjust start/end to have times 4585 if (resizeLocation.allDay && durationHasTime(delta)) { 4586 resizeLocation.allDay = false; 4587 calendar.normalizeEventTimes(resizeLocation); 4588 } 4589 4590 resizeLocation[type].add(delta); // apply delta to start or end 4591 4592 // if the event was compressed too small, find a new reasonable duration for it 4593 if (!resizeLocation.start.isBefore(resizeLocation.end)) { 4594 4595 defaultDuration = 4596 this.minResizeDuration || // TODO: hack 4597 (event.allDay ? 4598 calendar.defaultAllDayEventDuration : 4599 calendar.defaultTimedEventDuration); 4600 4601 if (type == 'start') { // resizing the start? 4602 resizeLocation.start = resizeLocation.end.clone().subtract(defaultDuration); 4603 } 4604 else { // resizing the end? 4605 resizeLocation.end = resizeLocation.start.clone().add(defaultDuration); 4606 } 4607 } 4608 4609 return resizeLocation; 4610 }, 4611 4612 4613 // Renders a visual indication of an event being resized. 4614 // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag. 4615 // Must return elements used for any mock events. 4616 renderEventResize: function(range, seg) { 4617 // subclasses must implement 4618 }, 4619 4620 4621 // Unrenders a visual indication of an event being resized. 4622 unrenderEventResize: function() { 4623 // subclasses must implement 4624 }, 4625 4626 4627 /* Rendering Utils 4628 ------------------------------------------------------------------------------------------------------------------*/ 4629 4630 4631 // Compute the text that should be displayed on an event's element. 4632 // `range` can be the Event object itself, or something range-like, with at least a `start`. 4633 // If event times are disabled, or the event has no time, will return a blank string. 4634 // If not specified, formatStr will default to the eventTimeFormat setting, 4635 // and displayEnd will default to the displayEventEnd setting. 4636 getEventTimeText: function(range, formatStr, displayEnd) { 4637 4638 if (formatStr == null) { 4639 formatStr = this.eventTimeFormat; 4640 } 4641 4642 if (displayEnd == null) { 4643 displayEnd = this.displayEventEnd; 4644 } 4645 4646 if (this.displayEventTime && range.start.hasTime()) { 4647 if (displayEnd && range.end) { 4648 return this.view.formatRange(range, formatStr); 4649 } 4650 else { 4651 return range.start.format(formatStr); 4652 } 4653 } 4654 4655 return ''; 4656 }, 4657 4658 4659 // Generic utility for generating the HTML classNames for an event segment's element 4660 getSegClasses: function(seg, isDraggable, isResizable) { 4661 var view = this.view; 4662 var classes = [ 4663 'fc-event', 4664 seg.isStart ? 'fc-start' : 'fc-not-start', 4665 seg.isEnd ? 'fc-end' : 'fc-not-end' 4666 ].concat(this.getSegCustomClasses(seg)); 4667 4668 if (isDraggable) { 4669 classes.push('fc-draggable'); 4670 } 4671 if (isResizable) { 4672 classes.push('fc-resizable'); 4673 } 4674 4675 // event is currently selected? attach a className. 4676 if (view.isEventSelected(seg.event)) { 4677 classes.push('fc-selected'); 4678 } 4679 4680 return classes; 4681 }, 4682 4683 4684 // List of classes that were defined by the caller of the API in some way 4685 getSegCustomClasses: function(seg) { 4686 var event = seg.event; 4687 4688 return [].concat( 4689 event.className, // guaranteed to be an array 4690 event.source ? event.source.className : [] 4691 ); 4692 }, 4693 4694 4695 // Utility for generating event skin-related CSS properties 4696 getSegSkinCss: function(seg) { 4697 return {
4698 'background-color': this.getSegBackgroundColor(seg), 4699 'border-color': this.getSegBorderColor(seg), 4700 color: this.getSegTextColor(seg) 4701 }; 4702 }, 4703 4704 4705 // Queries for caller-specified color, then falls back to default 4706 getSegBackgroundColor: function(seg) { 4707 return seg.event.backgroundColor || 4708 seg.event.color || 4709 this.getSegDefaultBackgroundColor(seg); 4710 }, 4711 4712 4713 getSegDefaultBackgroundColor: function(seg) { 4714 var source = seg.event.source || {}; 4715 4716 return source.backgroundColor || 4717 source.color || 4718 this.view.opt('eventBackgroundColor') || 4719 this.view.opt('eventColor'); 4720 }, 4721 4722 4723 // Queries for caller-specified color, then falls back to default 4724 getSegBorderColor: function(seg) { 4725 return seg.event.borderColor || 4726 seg.event.color || 4727 this.getSegDefaultBorderColor(seg); 4728 }, 4729 4730 4731 getSegDefaultBorderColor: function(seg) { 4732 var source = seg.event.source || {}; 4733 4734 return source.borderColor || 4735 source.color || 4736 this.view.opt('eventBorderColor') || 4737 this.view.opt('eventColor'); 4738 }, 4739 4740 4741 // Queries for caller-specified color, then falls back to default 4742 getSegTextColor: function(seg) { 4743 return seg.event.textColor || 4744 this.getSegDefaultTextColor(seg); 4745 }, 4746 4747 4748 getSegDefaultTextColor: function(seg) { 4749 var source = seg.event.source || {}; 4750 4751 return source.textColor || 4752 this.view.opt('eventTextColor'); 4753 }, 4754 4755 4756 /* Converting events -> eventRange -> eventSpan -> eventSegs 4757 ------------------------------------------------------------------------------------------------------------------*/ 4758 4759 4760 // Generates an array of segments for the given single event 4761 // Can accept an event "location" as well (which only has start/end and no allDay) 4762 eventToSegs: function(event) { 4763 return this.eventsToSegs([ event ]); 4764 }, 4765 4766 4767 eventToSpan: function(event) { 4768 return this.eventToSpans(event)[0]; 4769 }, 4770 4771 4772 // Generates spans (always unzoned) for the given event. 4773 // Does not do any inverting for inverse-background events. 4774 // Can accept an event "location" as well (which only has start/end and no allDay) 4775 eventToSpans: function(event) { 4776 var range = this.eventToRange(event); 4777 return this.eventRangeToSpans(range, event); 4778 }, 4779 4780 4781 4782 // Converts an array of event objects into an array of event segment objects. 4783 // A custom `segSliceFunc` may be given for arbitrarily slicing up events. 4784 // Doesn't guarantee an order for the resulting array. 4785 eventsToSegs: function(allEvents, segSliceFunc) { 4786 var _this = this; 4787 var eventsById = groupEventsById(allEvents); 4788 var segs = []; 4789 4790 $.each(eventsById, function(id, events) { 4791 var ranges = []; 4792 var i; 4793 4794 for (i = 0; i < events.length; i++) { 4795 ranges.push(_this.eventToRange(events[i])); 4796 } 4797 4798 // inverse-background events (utilize only the first event in calculations) 4799 if (isInverseBgEvent(events[0])) { 4800 ranges = _this.invertRanges(ranges); 4801 4802 for (i = 0; i < ranges.length; i++) { 4803 segs.push.apply(segs, // append to 4804 _this.eventRangeToSegs(ranges[i], events[0], segSliceFunc)); 4805 } 4806 } 4807 // normal event ranges 4808 else { 4809 for (i = 0; i < ranges.length; i++) { 4810 segs.push.apply(segs, // append to 4811 _this.eventRangeToSegs(ranges[i], events[i], segSliceFunc)); 4812 } 4813 } 4814 }); 4815 4816 return segs; 4817 }, 4818 4819 4820 // Generates the unzoned start/end dates an event appears to occupy 4821 // Can accept an event "location" as well (which only has start/end and no allDay) 4822 eventToRange: function(event) { 4823 var calendar = this.view.calendar; 4824 var start = event.start.clone().stripZone(); 4825 var end = ( 4826 event.end ? 4827 event.end.clone() : 4828 // derive the end from the start and allDay. compute allDay if necessary 4829 calendar.getDefaultEventEnd( 4830 event.allDay != null ? 4831 event.allDay : 4832 !event.start.hasTime(), 4833 event.start 4834 ) 4835 ).stripZone(); 4836 4837 // hack: dynamic locale change forgets to upate stored event localed 4838 calendar.localizeMoment(start); 4839 calendar.localizeMoment(end); 4840 4841 return { start: start, end: end }; 4842 }, 4843 4844 4845 // Given an event's range (unzoned start/end), and the event itself, 4846 // slice into segments (using the segSliceFunc function if specified) 4847 eventRangeToSegs: function(range, event, segSliceFunc) { 4848 var spans = this.eventRangeToSpans(range, event); 4849 var segs = []; 4850 var i; 4851 4852 for (i = 0; i < spans.length; i++) { 4853 segs.push.apply(segs, // append to 4854 this.eventSpanToSegs(spans[i], event, segSliceFunc)); 4855 } 4856 4857 return segs; 4858 }, 4859 4860 4861 // Given an event's unzoned date range, return an array of "span" objects. 4862 // Subclasses can override. 4863 eventRangeToSpans: function(range, event) { 4864 return [ $.extend({}, range) ]; // copy into a single-item array 4865 }, 4866 4867 4868 // Given an event's span (unzoned start/end and other misc data), and the event itself, 4869 // slices into segments and attaches event-derived properties to them. 4870 eventSpanToSegs: function(span, event, segSliceFunc) { 4871 var segs = segSliceFunc ? segSliceFunc(span) : this.spanToSegs(span); 4872 var i, seg; 4873 4874 for (i = 0; i < segs.length; i++) { 4875 seg = segs[i]; 4876 seg.event = event; 4877 seg.eventStartMS = +span.start; // TODO: not the best name after making spans unzoned 4878 seg.eventDurationMS = span.end - span.start; 4879 } 4880 4881 return segs; 4882 }, 4883 4884 4885 // Produces a new array of range objects that will cover all the time NOT covered by the given ranges. 4886 // SIDE EFFECT: will mutate the given array and will use its date references. 4887 invertRanges: function(ranges) { 4888 var view = this.view; 4889 var viewStart = view.start.clone(); // need a copy 4890 var viewEnd = view.end.clone(); // need a copy 4891 var inverseRanges = []; 4892 var start = viewStart; // the end of the previous range. the start of the new range 4893 var i, range; 4894 4895 // ranges need to be in order. required for our date-walking algorithm 4896 ranges.sort(compareRanges); 4897 4898 for (i = 0; i < ranges.length; i++) { 4899 range = ranges[i]; 4900 4901 // add the span of time before the event (if there is any) 4902 if (range.start > start) { // compare millisecond time (skip any ambig logic) 4903 inverseRanges.push({ 4904 start: start, 4905 end: range.start 4906 }); 4907 } 4908 4909 start = range.end; 4910 } 4911 4912 // add the span of time after the last event (if there is any) 4913 if (start < viewEnd) { // compare millisecond time (skip any ambig logic) 4914 inverseRanges.push({ 4915 start: start, 4916 end: viewEnd 4917 }); 4918 } 4919 4920 return inverseRanges;
4921 }, 4922 4923 4924 sortEventSegs: function(segs) { 4925 segs.sort(proxy(this, 'compareEventSegs')); 4926 }, 4927 4928 4929 // A cmp function for determining which segments should take visual priority 4930 compareEventSegs: function(seg1, seg2) { 4931 return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first 4932 seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first 4933 seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1) 4934 compareByFieldSpecs(seg1.event, seg2.event, this.view.eventOrderSpecs); 4935 } 4936 4937}); 4938 4939 4940/* Utilities 4941----------------------------------------------------------------------------------------------------------------------*/ 4942 4943 4944function pluckEventDateProps(event) { 4945 return { 4946 start: event.start.clone(), 4947 end: event.end ? event.end.clone() : null, 4948 allDay: event.allDay // keep it the same 4949 }; 4950} 4951FC.pluckEventDateProps = pluckEventDateProps; 4952 4953 4954function isBgEvent(event) { // returns true if background OR inverse-background 4955 var rendering = getEventRendering(event); 4956 return rendering === 'background' || rendering === 'inverse-background'; 4957} 4958FC.isBgEvent = isBgEvent; // export 4959 4960 4961function isInverseBgEvent(event) { 4962 return getEventRendering(event) === 'inverse-background'; 4963} 4964 4965 4966function getEventRendering(event) { 4967 return firstDefined((event.source || {}).rendering, event.rendering); 4968} 4969 4970 4971function groupEventsById(events) { 4972 var eventsById = {}; 4973 var i, event; 4974 4975 for (i = 0; i < events.length; i++) { 4976 event = events[i]; 4977 (eventsById[event._id] || (eventsById[event._id] = [])).push(event); 4978 } 4979 4980 return eventsById; 4981} 4982 4983 4984// A cmp function for determining which non-inverted "ranges" (see above) happen earlier 4985function compareRanges(range1, range2) { 4986 return range1.start - range2.start; // earlier ranges go first 4987} 4988 4989 4990/* External-Dragging-Element Data 4991----------------------------------------------------------------------------------------------------------------------*/ 4992 4993// Require all HTML5 data-* attributes used by FullCalendar to have this prefix. 4994// A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event. 4995FC.dataAttrPrefix = ''; 4996 4997// Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure 4998// to be used for Event Object creation. 4999// A defined `.eventProps`, even when empty, indicates that an event should be created. 5000function getDraggedElMeta(el) { 5001 var prefix = FC.dataAttrPrefix; 5002 var eventProps; // properties for creating the event, not related to date/time 5003 var startTime; // a Duration 5004 var duration; 5005 var stick; 5006 5007 if (prefix) { prefix += '-'; } 5008 eventProps = el.data(prefix + 'event') || null; 5009 5010 if (eventProps) { 5011 if (typeof eventProps === 'object') { 5012 eventProps = $.extend({}, eventProps); // make a copy 5013 } 5014 else { // something like 1 or true. still signal event creation 5015 eventProps = {}; 5016 } 5017 5018 // pluck special-cased date/time properties 5019 startTime = eventProps.start; 5020 if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well 5021 duration = eventProps.duration; 5022 stick = eventProps.stick; 5023 delete eventProps.start; 5024 delete eventProps.time; 5025 delete eventProps.duration; 5026 delete eventProps.stick; 5027 } 5028 5029 // fallback to standalone attribute values for each of the date/time properties 5030 if (startTime == null) { startTime = el.data(prefix + 'start'); } 5031 if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well 5032 if (duration == null) { duration = el.data(prefix + 'duration'); } 5033 if (stick == null) { stick = el.data(prefix + 'stick'); } 5034 5035 // massage into correct data types 5036 startTime = startTime != null ? moment.duration(startTime) : null; 5037 duration = duration != null ? moment.duration(duration) : null; 5038 stick = Boolean(stick); 5039 5040 return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick }; 5041} 5042 5043 5044;; 5045 5046/* 5047A set of rendering and date-related methods for a visual component comprised of one or more rows of day columns. 5048Prerequisite: the object being mixed into needs to be a *Grid* 5049*/ 5050var DayTableMixin = FC.DayTableMixin = { 5051 5052 breakOnWeeks: false, // should create a new row for each week? 5053 dayDates: null, // whole-day dates for each column. left to right 5054 dayIndices: null, // for each day from start, the offset 5055 daysPerRow: null, 5056 rowCnt: null, 5057 colCnt: null, 5058 colHeadFormat: null, 5059 5060 5061 // Populates internal variables used for date calculation and rendering 5062 updateDayTable: function() { 5063 var view = this.view; 5064 var date = this.start.clone(); 5065 var dayIndex = -1; 5066 var dayIndices = []; 5067 var dayDates = []; 5068 var daysPerRow; 5069 var firstDay; 5070 var rowCnt; 5071 5072 while (date.isBefore(this.end)) { // loop each day from start to end
5073 if (view.isHiddenDay(date)) { 5074 dayIndices.push(dayIndex + 0.5); // mark that it's between indices 5075 } 5076 else { 5077 dayIndex++; 5078 dayIndices.push(dayIndex); 5079 dayDates.push(date.clone()); 5080 } 5081 date.add(1, 'days'); 5082 } 5083 5084 if (this.breakOnWeeks) { 5085 // count columns until the day-of-week repeats 5086 firstDay = dayDates[0].day(); 5087 for (daysPerRow = 1; daysPerRow < dayDates.length; daysPerRow++) { 5088 if (dayDates[daysPerRow].day() == firstDay) { 5089 break; 5090 } 5091 } 5092 rowCnt = Math.ceil(dayDates.length / daysPerRow); 5093 } 5094 else { 5095 rowCnt = 1; 5096 daysPerRow = dayDates.length; 5097 } 5098 5099 this.dayDates = dayDates; 5100 this.dayIndices = dayIndices; 5101 this.daysPerRow = daysPerRow; 5102 this.rowCnt = rowCnt; 5103 5104 this.updateDayTableCols(); 5105 }, 5106 5107 5108 // Computes and assigned the colCnt property and updates any options that may be computed from it 5109 updateDayTableCols: function() { 5110 this.colCnt = this.computeColCnt(); 5111 this.colHeadFormat = this.view.opt('columnFormat') || this.computeColHeadFormat(); 5112 }, 5113 5114 5115 // Determines how many columns there should be in the table 5116 computeColCnt: function() { 5117 return this.daysPerRow; 5118 }, 5119 5120 5121 // Computes the ambiguously-timed moment for the given cell 5122 getCellDate: function(row, col) { 5123 return this.dayDates[ 5124 this.getCellDayIndex(row, col) 5125 ].clone(); 5126 }, 5127 5128 5129 // Computes the ambiguously-timed date range for the given cell 5130 getCellRange: function(row, col) { 5131 var start = this.getCellDate(row, col); 5132 var end = start.clone().add(1, 'days'); 5133 5134 return { start: start, end: end }; 5135 }, 5136 5137 5138 // Returns the number of day cells, chronologically, from the first of the grid (0-based) 5139 getCellDayIndex: function(row, col) { 5140 return row * this.daysPerRow + this.getColDayIndex(col); 5141 }, 5142 5143 5144 // Returns the numner of day cells, chronologically, from the first cell in *any given row* 5145 getColDayIndex: function(col) { 5146 if (this.isRTL) { 5147 return this.colCnt - 1 - col; 5148 } 5149 else { 5150 return col; 5151 } 5152 }, 5153 5154 5155 // Given a date, returns its chronolocial cell-index from the first cell of the grid. 5156 // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets. 5157 // If before the first offset, returns a negative number. 5158 // If after the last offset, returns an offset past the last cell offset. 5159 // Only works for *start* dates of cells. Will not work for exclusive end dates for cells. 5160 getDateDayIndex: function(date) { 5161 var dayIndices = this.dayIndices; 5162 var dayOffset = date.diff(this.start, 'days'); 5163 5164 if (dayOffset < 0) { 5165 return dayIndices[0] - 1; 5166 } 5167 else if (dayOffset >= dayIndices.length) { 5168 return dayIndices[dayIndices.length - 1] + 1; 5169 } 5170 else { 5171 return dayIndices[dayOffset]; 5172 } 5173 }, 5174 5175 5176 /* Options 5177 ------------------------------------------------------------------------------------------------------------------*/ 5178 5179 5180 // Computes a default column header formatting string if `colFormat` is not explicitly defined 5181 computeColHeadFormat: function() { 5182 // if more than one week row, or if there are a lot of columns with not much space, 5183 // put just the day numbers will be in each cell 5184 if (this.rowCnt > 1 || this.colCnt > 10) { 5185 return 'ddd'; // "Sat" 5186 } 5187 // multiple days, so full single date string WON'T be in title text 5188 else if (this.colCnt > 1) { 5189 return this.view.opt('dayOfMonthFormat'); // "Sat 12/10" 5190 } 5191 // single day, so full single date string will probably be in title text 5192 else { 5193 return 'dddd'; // "Saturday" 5194 } 5195 }, 5196 5197 5198 /* Slicing 5199 ------------------------------------------------------------------------------------------------------------------*/ 5200 5201 5202 // Slices up a date range into a segment for every week-row it intersects with 5203 sliceRangeByRow: function(range) { 5204 var daysPerRow = this.daysPerRow; 5205 var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold 5206 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index 5207 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index 5208 var segs = []; 5209 var row; 5210 var rowFirst, rowLast; // inclusive day-index range for current row 5211 var segFirst, segLast; // inclusive day-index range for segment 5212 5213 for (row = 0; row < this.rowCnt; row++) { 5214 rowFirst = row * daysPerRow; 5215 rowLast = rowFirst + daysPerRow - 1; 5216 5217 // intersect segment's offset range with the row's 5218 segFirst = Math.max(rangeFirst, rowFirst); 5219 segLast = Math.min(rangeLast, rowLast); 5220 5221 // deal with in-between indices
5222 segFirst = Math.ceil(segFirst); // in-between starts round to next cell 5223 segLast = Math.floor(segLast); // in-between ends round to prev cell 5224 5225 if (segFirst <= segLast) { // was there any intersection with the current row? 5226 segs.push({ 5227 row: row, 5228 5229 // normalize to start of row 5230 firstRowDayIndex: segFirst - rowFirst, 5231 lastRowDayIndex: segLast - rowFirst, 5232 5233 // must be matching integers to be the segment's start/end 5234 isStart: segFirst === rangeFirst, 5235 isEnd: segLast === rangeLast 5236 }); 5237 } 5238 } 5239 5240 return segs; 5241 }, 5242 5243 5244 // Slices up a date range into a segment for every day-cell it intersects with. 5245 // TODO: make more DRY with sliceRangeByRow somehow. 5246 sliceRangeByDay: function(range) { 5247 var daysPerRow = this.daysPerRow; 5248 var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold 5249 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index 5250 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index 5251 var segs = []; 5252 var row; 5253 var rowFirst, rowLast; // inclusive day-index range for current row 5254 var i; 5255 var segFirst, segLast; // inclusive day-index range for segment 5256 5257 for (row = 0; row < this.rowCnt; row++) { 5258 rowFirst = row * daysPerRow; 5259 rowLast = rowFirst + daysPerRow - 1; 5260 5261 for (i = rowFirst; i <= rowLast; i++) { 5262 5263 // intersect segment's offset range with the row's 5264 segFirst = Math.max(rangeFirst, i); 5265 segLast = Math.min(rangeLast, i); 5266 5267 // deal with in-between indices 5268 segFirst = Math.ceil(segFirst); // in-between starts round to next cell 5269 segLast = Math.floor(segLast); // in-between ends round to prev cell 5270 5271 if (segFirst <= segLast) { // was there any intersection with the current row? 5272 segs.push({ 5273 row: row, 5274 5275 // normalize to start of row 5276 firstRowDayIndex: segFirst - rowFirst, 5277 lastRowDayIndex: segLast - rowFirst, 5278 5279 // must be matching integers to be the segment's start/end 5280 isStart: segFirst === rangeFirst, 5281 isEnd: segLast === rangeLast 5282 }); 5283 } 5284 } 5285 } 5286 5287 return segs; 5288 }, 5289 5290 5291 /* Header Rendering 5292 ------------------------------------------------------------------------------------------------------------------*/ 5293 5294 5295 renderHeadHtml: function() { 5296 var view = this.view; 5297 5298 return '' + 5299 '<div class="fc-row ' + view.widgetHeaderClass + '">' + 5300 '<table>' + 5301 '<thead>' + 5302 this.renderHeadTrHtml() + 5303 '</thead>' + 5304 '</table>' + 5305 '</div>'; 5306 }, 5307 5308 5309 renderHeadIntroHtml: function() { 5310 return this.renderIntroHtml(); // fall back to generic 5311 }, 5312 5313 5314 renderHeadTrHtml: function() { 5315 return '' + 5316 '<tr>' + 5317 (this.isRTL ? '' : this.renderHeadIntroHtml()) + 5318 this.renderHeadDateCellsHtml() + 5319 (this.isRTL ? this.renderHeadIntroHtml() : '') + 5320 '</tr>'; 5321 }, 5322 5323 5324 renderHeadDateCellsHtml: function() { 5325 var htmls = []; 5326 var col, date; 5327 5328 for (col = 0; col < this.colCnt; col++) { 5329 date = this.getCellDate(0, col); 5330 htmls.push(this.renderHeadDateCellHtml(date)); 5331 } 5332 5333 return htmls.join(''); 5334 }, 5335 5336 5337 // TODO: when internalApiVersion, accept an object for HTML attributes 5338 // (colspan should be no different) 5339 renderHeadDateCellHtml: function(date, colspan, otherAttrs) { 5340 var view = this.view; 5341 5342 return '' + 5343 '<th class="fc-day-header ' + view.widgetHeaderClass + ' fc-' + dayIDs[date.day()] + '"' + 5344 (this.rowCnt === 1 ? 5345 ' data-date="' + date.format('YYYY-MM-DD') + '"' : 5346 '') + 5347 (colspan > 1 ? 5348 ' colspan="' + colspan + '"' : 5349 '') + 5350 (otherAttrs ? 5351 ' ' + otherAttrs : 5352 '') + 5353 '>' + 5354 // don't make a link if the heading could represent multiple days, or if there's only one day (forceOff) 5355 view.buildGotoAnchorHtml( 5356 { date: date, forceOff: this.rowCnt > 1 || this.colCnt === 1 }, 5357 htmlEscape(date.format(this.colHeadFormat)) // inner HTML 5358 ) + 5359 '</th>'; 5360 }, 5361 5362 5363 /* Background Rendering 5364 ------------------------------------------------------------------------------------------------------------------*/ 5365 5366 5367 renderBgTrHtml: function(row) { 5368 return '' + 5369 '<tr>' + 5370 (this.isRTL ? '' : this.renderBgIntroHtml(row)) + 5371 this.renderBgCellsHtml(row) + 5372 (this.isRTL ? this.renderBgIntroHtml(row) : '') + 5373 '</tr>'; 5374 }, 5375 5376 5377 renderBgIntroHtml: function(row) { 5378 return this.renderIntroHtml(); // fall back to generic 5379 }, 5380 5381 5382 renderBgCellsHtml: function(row) { 5383 var htmls = []; 5384 var col, date; 5385 5386 for (col = 0; col < this.colCnt; col++) { 5387 date = this.getCellDate(row, col); 5388 htmls.push(this.renderBgCellHtml(date)); 5389 } 5390 5391 return htmls.join(''); 5392 }, 5393 5394 5395 renderBgCellHtml: function(date, otherAttrs) { 5396 var view = this.view; 5397 var classes = this.getDayClasses(date); 5398 5399 classes.unshift('fc-day', view.widgetContentClass); 5400 5401 return '<td class="' + classes.join(' ') + '"' + 5402 ' data-date="' + date.format('YYYY-MM-DD') + '"' + // if date has a time, won't format it 5403 (otherAttrs ? 5404 ' ' + otherAttrs : 5405 '') + 5406 '></td>'; 5407 }, 5408 5409 5410 /* Generic 5411 ------------------------------------------------------------------------------------------------------------------*/ 5412 5413 5414 // Generates the default HTML intro for any row. User classes should override 5415 renderIntroHtml: function() { 5416 }, 5417 5418 5419 // TODO: a generic method for dealing with <tr>, RTL, intro 5420 // when increment internalApiVersion 5421 // wrapTr (scheduler) 5422 5423 5424 /* Utils 5425 ------------------------------------------------------------------------------------------------------------------*/ 5426 5427 5428 // Applies the generic "intro" and "outro" HTML to the given cells. 5429 // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro. 5430 bookendCells: function(trEl) { 5431 var introHtml = this.renderIntroHtml(); 5432 5433 if (introHtml) { 5434 if (this.isRTL) { 5435 trEl.append(introHtml); 5436 } 5437 else { 5438 trEl.prepend(introHtml); 5439 } 5440 } 5441 } 5442 5443}; 5444 5445;; 5446 5447/* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week. 5448----------------------------------------------------------------------------------------------------------------------*/ 5449 5450var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { 5451 5452 numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal 5453 bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid 5454 5455 rowEls: null, // set of fake row elements 5456 cellEls: null, // set of whole-day elements comprising the row's background 5457 helperEls: null, // set of cell skeleton elements for rendering the mock event "helper" 5458 5459 rowCoordCache: null, 5460 colCoordCache: null, 5461 5462 5463 // Renders the rows and columns into the component's `this.el`, which should already be assigned. 5464 // isRigid determins whether the individual rows should ignore the contents and be a constant height. 5465 // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient. 5466 renderDates: function(isRigid) { 5467 var view = this.view; 5468 var rowCnt = this.rowCnt; 5469 var colCnt = this.colCnt; 5470 var html = ''; 5471 var row; 5472 var col; 5473 5474 for (row = 0; row < rowCnt; row++) { 5475 html += this.renderDayRowHtml(row, isRigid); 5476 } 5477 this.el.html(html); 5478 5479 this.rowEls = this.el.find('.fc-row'); 5480 this.cellEls = this.el.find('.fc-day'); 5481 5482 this.rowCoordCache = new CoordCache({ 5483 els: this.rowEls, 5484 isVertical: true 5485 }); 5486 this.colCoordCache = new CoordCache({ 5487 els: this.cellEls.slice(0, this.colCnt), // only the first row 5488 isHorizontal: true 5489 }); 5490 5491 // trigger dayRender with each cell's element 5492 for (row = 0; row < rowCnt; row++) { 5493 for (col = 0; col < colCnt; col++) { 5494 view.trigger( 5495 'dayRender', 5496 null, 5497 this.getCellDate(row, col), 5498 this.getCellEl(row, col) 5499 ); 5500 } 5501 } 5502 }, 5503 5504 5505 unrenderDates: function() { 5506 this.removeSegPopover(); 5507 }, 5508 5509 5510 renderBusinessHours: function() { 5511 var segs = this.buildBusinessHourSegs(true); // wholeDay=true 5512 this.renderFill('businessHours', segs, 'bgevent'); 5513 }, 5514 5515 5516 unrenderBusinessHours: function() { 5517 this.unrenderFill('businessHours'); 5518 }, 5519 5520 5521 // Generates the HTML for a single row, which is a div that wraps a table. 5522 // `row` is the row number. 5523 renderDayRowHtml: function(row, isRigid) { 5524 var view = this.view; 5525 var classes = [ 'fc-row', 'fc-week', view.widgetContentClass ]; 5526 5527 if (isRigid) { 5528 classes.push('fc-rigid'); 5529 } 5530 5531 return '' + 5532 '<div class="' + classes.join(' ') + '">' + 5533 '<div class="fc-bg">' + 5534 '<table>' + 5535 this.renderBgTrHtml(row) + 5536 '</table>' + 5537 '</div>' + 5538 '<div class="fc-content-skeleton">' + 5539 '<table>' + 5540 (this.numbersVisible ? 5541 '<thead>' + 5542 this.renderNumberTrHtml(row) + 5543 '</thead>' : 5544 '' 5545 ) + 5546 '</table>' + 5547 '</div>' + 5548 '</div>'; 5549 }, 5550 5551 5552 /* Grid Number Rendering 5553 ------------------------------------------------------------------------------------------------------------------*/ 5554 5555 5556 renderNumberTrHtml: function(row) { 5557 return '' + 5558 '<tr>' + 5559 (this.isRTL ? '' : this.renderNumberIntroHtml(row)) + 5560 this.renderNumberCellsHtml(row) + 5561 (this.isRTL ? this.renderNumberIntroHtml(row) : '') + 5562 '</tr>'; 5563 }, 5564 5565 5566 renderNumberIntroHtml: function(row) { 5567 return this.renderIntroHtml(); 5568 }, 5569 5570 5571 renderNumberCellsHtml: function(row) { 5572 var htmls = []; 5573 var col, date; 5574 5575 for (col = 0; col < this.colCnt; col++) { 5576 date = this.getCellDate(row, col); 5577 htmls.push(this.renderNumberCellHtml(date)); 5578 } 5579 5580 return htmls.join(''); 5581 }, 5582 5583 5584 // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton. 5585 // The number row will only exist if either day numbers or week numbers are turned on. 5586 renderNumberCellHtml: function(date) { 5587 var html = ''; 5588 var classes; 5589 var weekCalcFirstDoW; 5590 5591 if (!this.view.dayNumbersVisible && !this.view.cellWeekNumbersVisible) { 5592 // no numbers in day cell (week number must be along the side) 5593 return '<td/>'; // will create an empty space above events :( 5594 } 5595 5596 classes = this.getDayClasses(date); 5597 classes.unshift('fc-day-top'); 5598 5599 if (this.view.cellWeekNumbersVisible) { 5600 // To determine the day of week number change under ISO, we cannot 5601 // rely on moment.js methods such as firstDayOfWeek() or weekday(), 5602 // because they rely on the locale's dow (possibly overridden by 5603 // our firstDay option), which may not be Monday. We cannot change 5604 // dow, because that would affect the calendar start day as well. 5605 if (date._locale._fullCalendar_weekCalc === 'ISO') { 5606 weekCalcFirstDoW = 1; // Monday by ISO 8601 definition 5607 } 5608 else { 5609 weekCalcFirstDoW = date._locale.firstDayOfWeek(); 5610 } 5611 } 5612 5613 html += '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '">'; 5614 5615 if (this.view.cellWeekNumbersVisible && (date.day() == weekCalcFirstDoW)) { 5616 html += this.view.buildGotoAnchorHtml( 5617 { date: date, type: 'week' }, 5618 { 'class': 'fc-week-number' }, 5619 date.format('w') // inner HTML 5620 ); 5621 } 5622 5623 if (this.view.dayNumbersVisible) { 5624 html += this.view.buildGotoAnchorHtml( 5625 date, 5626 { 'class': 'fc-day-number' }, 5627 date.date() // inner HTML 5628 ); 5629 } 5630 5631 html += '</td>'; 5632 5633 return html; 5634 }, 5635 5636 5637 /* Options 5638 ------------------------------------------------------------------------------------------------------------------*/ 5639 5640 5641 // Computes a default event time formatting string if `timeFormat` is not explicitly defined 5642 computeEventTimeFormat: function() { 5643 return this.view.opt('extraSmallTimeFormat'); // like "6p" or "6:30p" 5644 }, 5645 5646 5647 // Computes a default `displayEventEnd` value if one is not expliclty defined 5648 computeDisplayEventEnd: function() { 5649 return this.colCnt == 1; // we'll likely have space if there's only one day 5650 }, 5651 5652 5653 /* Dates 5654 ------------------------------------------------------------------------------------------------------------------*/ 5655 5656 5657 rangeUpdated: function() { 5658 this.updateDayTable(); 5659 }, 5660 5661 5662 // Slices up the given span (unzoned start/end with other misc data) into an array of segments 5663 spanToSegs: function(span) { 5664 var segs = this.sliceRangeByRow(span); 5665 var i, seg; 5666 5667 for (i = 0; i < segs.length; i++) { 5668 seg = segs[i]; 5669 if (this.isRTL) { 5670 seg.leftCol = this.daysPerRow - 1 - seg.lastRowDayIndex; 5671 seg.rightCol = this.daysPerRow - 1 - seg.firstRowDayIndex; 5672 } 5673 else {
5674 seg.leftCol = seg.firstRowDayIndex; 5675 seg.rightCol = seg.lastRowDayIndex; 5676 } 5677 } 5678 5679 return segs; 5680 }, 5681 5682 5683 /* Hit System 5684 ------------------------------------------------------------------------------------------------------------------*/ 5685 5686 5687 prepareHits: function() { 5688 this.colCoordCache.build(); 5689 this.rowCoordCache.build(); 5690 this.rowCoordCache.bottoms[this.rowCnt - 1] += this.bottomCoordPadding; // hack 5691 }, 5692 5693 5694 releaseHits: function() { 5695 this.colCoordCache.clear(); 5696 this.rowCoordCache.clear(); 5697 }, 5698 5699 5700 queryHit: function(leftOffset, topOffset) { 5701 if (this.colCoordCache.isLeftInBounds(leftOffset) && this.rowCoordCache.isTopInBounds(topOffset)) { 5702 var col = this.colCoordCache.getHorizontalIndex(leftOffset); 5703 var row = this.rowCoordCache.getVerticalIndex(topOffset); 5704 5705 if (row != null && col != null) { 5706 return this.getCellHit(row, col); 5707 } 5708 } 5709 }, 5710 5711 5712 getHitSpan: function(hit) { 5713 return this.getCellRange(hit.row, hit.col); 5714 }, 5715 5716 5717 getHitEl: function(hit) { 5718 return this.getCellEl(hit.row, hit.col); 5719 }, 5720 5721 5722 /* Cell System 5723 ------------------------------------------------------------------------------------------------------------------*/ 5724 // FYI: the first column is the leftmost column, regardless of date 5725 5726 5727 getCellHit: function(row, col) { 5728 return { 5729 row: row, 5730 col: col, 5731 component: this, // needed unfortunately :( 5732 left: this.colCoordCache.getLeftOffset(col), 5733 right: this.colCoordCache.getRightOffset(col), 5734 top: this.rowCoordCache.getTopOffset(row), 5735 bottom: this.rowCoordCache.getBottomOffset(row) 5736 }; 5737 }, 5738 5739 5740 getCellEl: function(row, col) { 5741 return this.cellEls.eq(row * this.colCnt + col); 5742 }, 5743 5744 5745 /* Event Drag Visualization 5746 ------------------------------------------------------------------------------------------------------------------*/ 5747 // TODO: move to DayGrid.event, similar to what we did with Grid's drag methods 5748 5749 5750 // Renders a visual indication of an event or external element being dragged. 5751 // `eventLocation` has zoned start and end (optional) 5752 renderDrag: function(eventLocation, seg) { 5753 5754 // always render a highlight underneath 5755 this.renderHighlight(this.eventToSpan(eventLocation)); 5756 5757 // if a segment from the same calendar but another component is being dragged, render a helper event 5758 if (seg && seg.component !== this) { 5759 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements 5760 } 5761 }, 5762 5763 5764 // Unrenders any visual indication of a hovering event 5765 unrenderDrag: function() { 5766 this.unrenderHighlight(); 5767 this.unrenderHelper(); 5768 }, 5769 5770 5771 /* Event Resize Visualization 5772 ------------------------------------------------------------------------------------------------------------------*/ 5773 5774 5775 // Renders a visual indication of an event being resized 5776 renderEventResize: function(eventLocation, seg) { 5777 this.renderHighlight(this.eventToSpan(eventLocation)); 5778 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements 5779 }, 5780 5781 5782 // Unrenders a visual indication of an event being resized 5783 unrenderEventResize: function() { 5784 this.unrenderHighlight(); 5785 this.unrenderHelper(); 5786 }, 5787 5788 5789 /* Event Helper 5790 ------------------------------------------------------------------------------------------------------------------*/ 5791 5792 5793 // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null. 5794 renderHelper: function(event, sourceSeg) { 5795 var helperNodes = []; 5796 var segs = this.eventToSegs(event); 5797 var rowStructs; 5798 5799 segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered 5800 rowStructs = this.renderSegRows(segs); 5801 5802 // inject each new event skeleton into each associated row 5803 this.rowEls.each(function(row, rowNode) { 5804 var rowEl = $(rowNode); // the .fc-row 5805 var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned 5806 var skeletonTop; 5807 5808 // If there is an original segment, match the top position. Otherwise, put it at the row's top level 5809 if (sourceSeg && sourceSeg.row === row) { 5810 skeletonTop = sourceSeg.el.position().top; 5811 } 5812 else { 5813 skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top; 5814 } 5815 5816 skeletonEl.css('top', skeletonTop) 5817 .find('table') 5818 .append(rowStructs[row].tbodyEl); 5819 5820 rowEl.append(skeletonEl); 5821 helperNodes.push(skeletonEl[0]); 5822 }); 5823 5824 return ( // must return the elements rendered 5825 this.helperEls = $(helperNodes) // array -> jQuery set 5826 ); 5827 }, 5828 5829 5830 // Unrenders any visual indication of a mock helper event 5831 unrenderHelper: function() { 5832 if (this.helperEls) { 5833 this.helperEls.remove(); 5834 this.helperEls = null; 5835 } 5836 }, 5837 5838 5839 /* Fill System (highlight, background events, business hours) 5840 ------------------------------------------------------------------------------------------------------------------*/ 5841 5842 5843 fillSegTag: 'td', // override the default tag name 5844 5845 5846 // Renders a set of rectangles over the given segments of days. 5847 // Only returns segments that successfully rendered. 5848 renderFill: function(type, segs, className) { 5849 var nodes = []; 5850 var i, seg; 5851 var skeletonEl; 5852 5853 segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs 5854 5855 for (i = 0; i < segs.length; i++) { 5856 seg = segs[i]; 5857 skeletonEl = this.renderFillRow(type, seg, className); 5858 this.rowEls.eq(seg.row).append(skeletonEl); 5859 nodes.push(skeletonEl[0]); 5860 } 5861 5862 this.elsByFill[type] = $(nodes); 5863 5864 return segs; 5865 }, 5866 5867 5868 // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered. 5869 renderFillRow: function(type, seg, className) { 5870 var colCnt = this.colCnt; 5871 var startCol = seg.leftCol; 5872 var endCol = seg.rightCol + 1; 5873 var skeletonEl; 5874 var trEl; 5875 5876 className = className || type.toLowerCase(); 5877 5878 skeletonEl = $(
5879 '<div class="fc-' + className + '-skeleton">' + 5880 '<table><tr/></table>' + 5881 '</div>' 5882 ); 5883 trEl = skeletonEl.find('tr'); 5884 5885 if (startCol > 0) { 5886 trEl.append('<td colspan="' + startCol + '"/>'); 5887 } 5888 5889 trEl.append( 5890 seg.el.attr('colspan', endCol - startCol) 5891 ); 5892 5893 if (endCol < colCnt) { 5894 trEl.append('<td colspan="' + (colCnt - endCol) + '"/>'); 5895 } 5896 5897 this.bookendCells(trEl); 5898 5899 return skeletonEl; 5900 } 5901 5902}); 5903 5904;; 5905 5906/* Event-rendering methods for the DayGrid class 5907----------------------------------------------------------------------------------------------------------------------*/ 5908 5909DayGrid.mixin({ 5910 5911 rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering 5912 5913 5914 // Unrenders all events currently rendered on the grid 5915 unrenderEvents: function() { 5916 this.removeSegPopover(); // removes the "more.." events popover 5917 Grid.prototype.unrenderEvents.apply(this, arguments); // calls the super-method 5918 }, 5919 5920 5921 // Retrieves all rendered segment objects currently rendered on the grid 5922 getEventSegs: function() { 5923 return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method 5924 .concat(this.popoverSegs || []); // append the segments from the "more..." popover 5925 }, 5926 5927 5928 // Renders the given background event segments onto the grid 5929 renderBgSegs: function(segs) { 5930 5931 // don't render timed background events 5932 var allDaySegs = $.grep(segs, function(seg) { 5933 return seg.event.allDay; 5934 }); 5935 5936 return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method 5937 }, 5938 5939 5940 // Renders the given foreground event segments onto the grid 5941 renderFgSegs: function(segs) { 5942 var rowStructs; 5943 5944 // render an `.el` on each seg 5945 // returns a subset of the segs. segs that were actually rendered 5946 segs = this.renderFgSegEls(segs); 5947 5948 rowStructs = this.rowStructs = this.renderSegRows(segs); 5949 5950 // append to each row's content skeleton 5951 this.rowEls.each(function(i, rowNode) { 5952 $(rowNode).find('.fc-content-skeleton > table').append( 5953 rowStructs[i].tbodyEl 5954 ); 5955 }); 5956 5957 return segs; // return only the segs that were actually rendered 5958 }, 5959 5960 5961 // Unrenders all currently rendered foreground event segments 5962 unrenderFgSegs: function() { 5963 var rowStructs = this.rowStructs || []; 5964 var rowStruct; 5965 5966 while ((rowStruct = rowStructs.pop())) { 5967 rowStruct.tbodyEl.remove(); 5968 } 5969 5970 this.rowStructs = null; 5971 }, 5972 5973 5974 // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton. 5975 // Returns an array of rowStruct objects (see the bottom of `renderSegRow`). 5976 // PRECONDITION: each segment shoud already have a rendered and assigned `.el` 5977 renderSegRows: function(segs) { 5978 var rowStructs = []; 5979 var segRows; 5980 var row; 5981 5982 segRows = this.groupSegRows(segs); // group into nested arrays 5983 5984 // iterate each row of segment groupings 5985 for (row = 0; row < segRows.length; row++) { 5986 rowStructs.push( 5987 this.renderSegRow(row, segRows[row]) 5988 ); 5989 } 5990 5991 return rowStructs; 5992 }, 5993 5994 5995 // Builds the HTML to be used for the default element for an individual segment 5996 fgSegHtml: function(seg, disableResizing) { 5997 var view = this.view; 5998 var event = seg.event; 5999 var isDraggable = view.isEventDraggable(event); 6000 var isResizableFromStart = !disableResizing && event.allDay && 6001 seg.isStart && view.isEventResizableFromStart(event); 6002 var isResizableFromEnd = !disableResizing && event.allDay && 6003 seg.isEnd && view.isEventResizableFromEnd(event); 6004 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); 6005 var skinCss = cssToStr(this.getSegSkinCss(seg)); 6006 var timeHtml = ''; 6007 var timeText; 6008 var titleHtml; 6009 6010 classes.unshift('fc-day-grid-event', 'fc-h-event'); 6011
6012 // Only display a timed events time if it is the starting segment 6013 if (seg.isStart) { 6014 timeText = this.getEventTimeText(event); 6015 if (timeText) { 6016 timeHtml = '<span class="fc-time">' + htmlEscape(timeText) + '</span>'; 6017 } 6018 } 6019 6020 titleHtml = 6021 '<span class="fc-title">' + 6022 (htmlEscape(event.title || '') || ' ') + // we always want one line of height 6023 '</span>'; 6024 6025 return '<a class="' + classes.join(' ') + '"' + 6026 (event.url ? 6027 ' href="' + htmlEscape(event.url) + '"' : 6028 '' 6029 ) + 6030 (skinCss ? 6031 ' style="' + skinCss + '"' : 6032 '' 6033 ) + 6034 '>' + 6035 '<div class="fc-content">' + 6036 (this.isRTL ? 6037 titleHtml + ' ' + timeHtml : // put a natural space in between 6038 timeHtml + ' ' + titleHtml // 6039 ) + 6040 '</div>' + 6041 (isResizableFromStart ? 6042 '<div class="fc-resizer fc-start-resizer" />' : 6043 '' 6044 ) + 6045 (isResizableFromEnd ? 6046 '<div class="fc-resizer fc-end-resizer" />' : 6047 '' 6048 ) + 6049 '</a>'; 6050 }, 6051 6052 6053 // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains 6054 // the segments. Returns object with a bunch of internal data about how the render was calculated. 6055 // NOTE: modifies rowSegs 6056 renderSegRow: function(row, rowSegs) { 6057 var colCnt = this.colCnt; 6058 var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels 6059 var levelCnt = Math.max(1, segLevels.length); // ensure at least one level 6060 var tbody = $('<tbody/>'); 6061 var segMatrix = []; // lookup for which segments are rendered into which level+col cells 6062 var cellMatrix = []; // lookup for all <td> elements of the level+col matrix 6063 var loneCellMatrix = []; // lookup for <td> elements that only take up a single column 6064 var i, levelSegs; 6065 var col; 6066 var tr; 6067 var j, seg; 6068 var td; 6069 6070 // populates empty cells from the current column (`col`) to `endCol` 6071 function emptyCellsUntil(endCol) { 6072 while (col < endCol) { 6073 // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell 6074 td = (loneCellMatrix[i - 1] || [])[col]; 6075 if (td) { 6076 td.attr( 6077 'rowspan', 6078 parseInt(td.attr('rowspan') || 1, 10) + 1 6079 ); 6080 } 6081 else { 6082 td = $('<td/>'); 6083 tr.append(td); 6084 } 6085 cellMatrix[i][col] = td; 6086 loneCellMatrix[i][col] = td; 6087 col++; 6088 } 6089 } 6090 6091 for (i = 0; i < levelCnt; i++) { // iterate through all levels 6092 levelSegs = segLevels[i]; 6093 col = 0; 6094 tr = $('<tr/>'); 6095 6096 segMatrix.push([]); 6097 cellMatrix.push([]); 6098 loneCellMatrix.push([]); 6099 6100 // levelCnt might be 1 even though there are no actual levels. protect against this. 6101 // this single empty row is useful for styling. 6102 if (levelSegs) { 6103 for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level 6104 seg = levelSegs[j]; 6105 6106 emptyCellsUntil(seg.leftCol); 6107 6108 // create a container that occupies or more columns. append the event element. 6109 td = $('<td class="fc-event-container"/>').append(seg.el); 6110 if (seg.leftCol != seg.rightCol) { 6111 td.attr('colspan', seg.rightCol - seg.leftCol + 1); 6112 } 6113 else { // a single-column segment 6114 loneCellMatrix[i][col] = td; 6115 } 6116 6117 while (col <= seg.rightCol) { 6118 cellMatrix[i][col] = td; 6119 segMatrix[i][col] = seg; 6120 col++; 6121 } 6122 6123 tr.append(td); 6124 } 6125 } 6126 6127 emptyCellsUntil(colCnt); // finish off the row 6128 this.bookendCells(tr); 6129 tbody.append(tr); 6130 } 6131 6132 return { // a "rowStruct" 6133 row: row, // the row number 6134 tbodyEl: tbody, 6135 cellMatrix: cellMatrix, 6136 segMatrix: segMatrix, 6137 segLevels: segLevels, 6138 segs: rowSegs 6139 }; 6140 }, 6141 6142 6143 // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels. 6144 // NOTE: modifies segs 6145 buildSegLevels: function(segs) { 6146 var levels = []; 6147 var i, seg; 6148 var j; 6149 6150 // Give preference to elements with certain criteria, so they have 6151 // a chance to be closer to the top. 6152 this.sortEventSegs(segs); 6153 6154 for (i = 0; i < segs.length; i++) { 6155 seg = segs[i]; 6156 6157 // loop through levels, starting with the topmost, until the segment doesn't collide with other segments 6158 for (j = 0; j < levels.length; j++) {
6159 if (!isDaySegCollision(seg, levels[j])) { 6160 break; 6161 } 6162 } 6163 // `j` now holds the desired subrow index 6164 seg.level = j; 6165 6166 // create new level array if needed and append segment 6167 (levels[j] || (levels[j] = [])).push(seg); 6168 } 6169 6170 // order segments left-to-right. very important if calendar is RTL 6171 for (j = 0; j < levels.length; j++) { 6172 levels[j].sort(compareDaySegCols); 6173 } 6174 6175 return levels; 6176 }, 6177 6178 6179 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row 6180 groupSegRows: function(segs) { 6181 var segRows = []; 6182 var i; 6183 6184 for (i = 0; i < this.rowCnt; i++) { 6185 segRows.push([]); 6186 } 6187 6188 for (i = 0; i < segs.length; i++) { 6189 segRows[segs[i].row].push(segs[i]); 6190 } 6191 6192 return segRows; 6193 } 6194 6195}); 6196 6197 6198// Computes whether two segments' columns collide. They are assumed to be in the same row. 6199function isDaySegCollision(seg, otherSegs) { 6200 var i, otherSeg; 6201 6202 for (i = 0; i < otherSegs.length; i++) { 6203 otherSeg = otherSegs[i]; 6204 6205 if ( 6206 otherSeg.leftCol <= seg.rightCol && 6207 otherSeg.rightCol >= seg.leftCol 6208 ) { 6209 return true; 6210 } 6211 } 6212 6213 return false; 6214} 6215 6216 6217// A cmp function for determining the leftmost event 6218function compareDaySegCols(a, b) { 6219 return a.leftCol - b.leftCol; 6220} 6221 6222;; 6223 6224/* Methods relate to limiting the number events for a given day on a DayGrid 6225----------------------------------------------------------------------------------------------------------------------*/ 6226// NOTE: all the segs being passed around in here are foreground segs 6227 6228DayGrid.mixin({ 6229 6230 segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible 6231 popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible 6232 6233 6234 removeSegPopover: function() { 6235 if (this.segPopover) { 6236 this.segPopover.hide(); // in handler, will call segPopover's removeElement 6237 } 6238 }, 6239 6240 6241 // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid. 6242 // `levelLimit` can be false (don't limit), a number, or true (should be computed). 6243 limitRows: function(levelLimit) { 6244 var rowStructs = this.rowStructs || []; 6245 var row; // row # 6246 var rowLevelLimit; 6247 6248 for (row = 0; row < rowStructs.length; row++) { 6249 this.unlimitRow(row); 6250 6251 if (!levelLimit) { 6252 rowLevelLimit = false; 6253 } 6254 else if (typeof levelLimit === 'number') { 6255 rowLevelLimit = levelLimit; 6256 } 6257 else { 6258 rowLevelLimit = this.computeRowLevelLimit(row); 6259 } 6260 6261 if (rowLevelLimit !== false) { 6262 this.limitRow(row, rowLevelLimit); 6263 } 6264 } 6265 }, 6266 6267 6268 // Computes the number of levels a row will accomodate without going outside its bounds. 6269 // Assumes the row is "rigid" (maintains a constant height regardless of what is inside). 6270 // `row` is the row number. 6271 computeRowLevelLimit: function(row) { 6272 var rowEl = this.rowEls.eq(row); // the containing "fake" row div 6273 var rowHeight = rowEl.height(); // TODO: cache somehow? 6274 var trEls = this.rowStructs[row].tbodyEl.children(); 6275 var i, trEl; 6276 var trHeight; 6277 6278 function iterInnerHeights(i, childNode) { 6279 trHeight = Math.max(trHeight, $(childNode).outerHeight()); 6280 } 6281 6282 // Reveal one level <tr> at a time and stop when we find one out of bounds 6283 for (i = 0; i < trEls.length; i++) { 6284 trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal) 6285 6286 // with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell, 6287 // so instead, find the tallest inner content element. 6288 trHeight = 0; 6289 trEl.find('> td > :first-child').each(iterInnerHeights); 6290 6291 if (trEl.position().top + trHeight > rowHeight) { 6292 return i; 6293 } 6294 } 6295 6296 return false; // should not limit at all 6297 }, 6298 6299 6300 // Limits the given grid row to the maximum number of levels and injects "more" links if necessary. 6301 // `row` is the row number. 6302 // `levelLimit` is a number for the maximum (inclusive) number of levels allowed. 6303 limitRow: function(row, levelLimit) { 6304 var _this = this; 6305 var rowStruct = this.rowStructs[row]; 6306 var moreNodes = []; // array of "more" <a> links and <td> DOM nodes 6307 var col = 0; // col #, left-to-right (not chronologically) 6308 var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right 6309 var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row 6310 var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes 6311 var i, seg; 6312 var segsBelow; // array of segment objects below `seg` in the current `col` 6313 var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies 6314 var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column) 6315 var td, rowspan; 6316 var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell 6317 var j; 6318 var moreTd, moreWrap, moreLink; 6319 6320 // Iterates through empty level cells and places "more" links inside if need be 6321 function emptyCellsUntil(endCol) { // goes from current `col` to `endCol` 6322 while (col < endCol) { 6323 segsBelow = _this.getCellSegs(row, col, levelLimit); 6324 if (segsBelow.length) { 6325 td = cellMatrix[levelLimit - 1][col]; 6326 moreLink = _this.renderMoreLink(row, col, segsBelow);
6327 moreWrap = $('<div/>').append(moreLink); 6328 td.append(moreWrap); 6329 moreNodes.push(moreWrap[0]); 6330 } 6331 col++; 6332 } 6333 } 6334 6335 if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit? 6336 levelSegs = rowStruct.segLevels[levelLimit - 1]; 6337 cellMatrix = rowStruct.cellMatrix; 6338 6339 limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit 6340 .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array 6341 6342 // iterate though segments in the last allowable level 6343 for (i = 0; i < levelSegs.length; i++) { 6344 seg = levelSegs[i]; 6345 emptyCellsUntil(seg.leftCol); // process empty cells before the segment 6346 6347 // determine *all* segments below `seg` that occupy the same columns 6348 colSegsBelow = []; 6349 totalSegsBelow = 0; 6350 while (col <= seg.rightCol) { 6351 segsBelow = this.getCellSegs(row, col, levelLimit); 6352 colSegsBelow.push(segsBelow); 6353 totalSegsBelow += segsBelow.length; 6354 col++; 6355 } 6356 6357 if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links? 6358 td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell 6359 rowspan = td.attr('rowspan') || 1; 6360 segMoreNodes = []; 6361 6362 // make a replacement <td> for each column the segment occupies. will be one for each colspan 6363 for (j = 0; j < colSegsBelow.length; j++) { 6364 moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan); 6365 segsBelow = colSegsBelow[j]; 6366 moreLink = this.renderMoreLink( 6367 row, 6368 seg.leftCol + j, 6369 [ seg ].concat(segsBelow) // count seg as hidden too 6370 ); 6371 moreWrap = $('<div/>').append(moreLink); 6372 moreTd.append(moreWrap); 6373 segMoreNodes.push(moreTd[0]); 6374 moreNodes.push(moreTd[0]); 6375 } 6376 6377 td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements 6378 limitedNodes.push(td[0]); 6379 } 6380 } 6381 6382 emptyCellsUntil(this.colCnt); // finish off the level 6383 rowStruct.moreEls = $(moreNodes); // for easy undoing later 6384 rowStruct.limitedEls = $(limitedNodes); // for easy undoing later 6385 } 6386 }, 6387 6388 6389 // Reveals all levels and removes all "more"-related elements for a grid's row. 6390 // `row` is a row number. 6391 unlimitRow: function(row) { 6392 var rowStruct = this.rowStructs[row]; 6393 6394 if (rowStruct.moreEls) { 6395 rowStruct.moreEls.remove(); 6396 rowStruct.moreEls = null; 6397 } 6398 6399 if (rowStruct.limitedEls) { 6400 rowStruct.limitedEls.removeClass('fc-limited'); 6401 rowStruct.limitedEls = null; 6402 } 6403 }, 6404 6405 6406 // Renders an <a> element that represents hidden event element for a cell. 6407 // Responsible for attaching click handler as well. 6408 renderMoreLink: function(row, col, hiddenSegs) { 6409 var _this = this; 6410 var view = this.view; 6411 6412 return $('<a class="fc-more"/>') 6413 .text( 6414 this.getMoreLinkText(hiddenSegs.length) 6415 ) 6416 .on('click', function(ev) { 6417 var clickOption = view.opt('eventLimitClick'); 6418 var date = _this.getCellDate(row, col); 6419 var moreEl = $(this); 6420 var dayEl = _this.getCellEl(row, col); 6421 var allSegs = _this.getCellSegs(row, col); 6422 6423 // rescope the segments to be within the cell's date 6424 var reslicedAllSegs = _this.resliceDaySegs(allSegs, date); 6425 var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date); 6426 6427 if (typeof clickOption === 'function') { 6428 // the returned value can be an atomic option 6429 clickOption = view.trigger('eventLimitClick', null, { 6430 date: date, 6431 dayEl: dayEl, 6432 moreEl: moreEl, 6433 segs: reslicedAllSegs, 6434 hiddenSegs: reslicedHiddenSegs 6435 }, ev); 6436 } 6437 6438 if (clickOption === 'popover') { 6439 _this.showSegPopover(row, col, moreEl, reslicedAllSegs); 6440 } 6441 else if (typeof clickOption === 'string') { // a view name 6442 view.calendar.zoomTo(date, clickOption); 6443 } 6444 }); 6445 }, 6446 6447 6448 // Reveals the popover that displays all events within a cell 6449 showSegPopover: function(row, col, moreLink, segs) { 6450 var _this = this; 6451 var view = this.view; 6452 var moreWrap = moreLink.parent(); // the <div> wrapper around the <a> 6453 var topEl; // the element we want to match the top coordinate of 6454 var options; 6455 6456 if (this.rowCnt == 1) { 6457 topEl = view.el; // will cause the popover to cover any sort of header 6458 } 6459 else { 6460 topEl = this.rowEls.eq(row); // will align with top of row 6461 } 6462 6463 options = { 6464 className: 'fc-more-popover', 6465 content: this.renderSegPopoverContent(row, col, segs), 6466 parentEl: this.view.el, // attach to root of view. guarantees outside of scrollbars. 6467 top: topEl.offset().top, 6468 autoHide: true, // when the user clicks elsewhere, hide the popover 6469 viewportConstrain: view.opt('popoverViewportConstrain'), 6470 hide: function() { 6471 // kill everything when the popover is hidden 6472 _this.segPopover.removeElement(); 6473 _this.segPopover = null; 6474 _this.popoverSegs = null; 6475 } 6476 }; 6477 6478 // Determine horizontal coordinate. 6479 // We use the moreWrap instead of the <td> to avoid border confusion. 6480 if (this.isRTL) { 6481 options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border 6482 } 6483 else { 6484 options.left = moreWrap.offset().left - 1; // -1 to be over cell border 6485 } 6486 6487 this.segPopover = new Popover(options); 6488 this.segPopover.show(); 6489 6490 // the popover doesn't live within the grid's container element, and thus won't get the event 6491 // delegated-handlers for free. attach event-related handlers to the popover. 6492 this.bindSegHandlersToEl(this.segPopover.el); 6493 }, 6494 6495 6496 // Builds the inner DOM contents of the segment popover 6497 renderSegPopoverContent: function(row, col, segs) { 6498 var view = this.view; 6499 var isTheme = view.opt('theme'); 6500 var title = this.getCellDate(row, col).format(view.opt('dayPopoverFormat'));
6501 var content = $( 6502 '<div class="fc-header ' + view.widgetHeaderClass + '">' + 6503 '<span class="fc-close ' + 6504 (isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') + 6505 '"></span>' + 6506 '<span class="fc-title">' + 6507 htmlEscape(title) + 6508 '</span>' + 6509 '<div class="fc-clear"/>' + 6510 '</div>' + 6511 '<div class="fc-body ' + view.widgetContentClass + '">' + 6512 '<div class="fc-event-container"></div>' + 6513 '</div>' 6514 ); 6515 var segContainer = content.find('.fc-event-container'); 6516 var i; 6517 6518 // render each seg's `el` and only return the visible segs 6519 segs = this.renderFgSegEls(segs, true); // disableResizing=true 6520 this.popoverSegs = segs; 6521 6522 for (i = 0; i < segs.length; i++) { 6523 6524 // because segments in the popover are not part of a grid coordinate system, provide a hint to any 6525 // grids that want to do drag-n-drop about which cell it came from 6526 this.prepareHits(); 6527 segs[i].hit = this.getCellHit(row, col); 6528 this.releaseHits(); 6529 6530 segContainer.append(segs[i].el); 6531 } 6532 6533 return content; 6534 }, 6535 6536 6537 // Given the events within an array of segment objects, reslice them to be in a single day 6538 resliceDaySegs: function(segs, dayDate) { 6539 6540 // build an array of the original events 6541 var events = $.map(segs, function(seg) { 6542 return seg.event; 6543 }); 6544 6545 var dayStart = dayDate.clone(); 6546 var dayEnd = dayStart.clone().add(1, 'days'); 6547 var dayRange = { start: dayStart, end: dayEnd }; 6548 6549 // slice the events with a custom slicing function 6550 segs = this.eventsToSegs( 6551 events, 6552 function(range) { 6553 var seg = intersectRanges(range, dayRange); // undefind if no intersection 6554 return seg ? [ seg ] : []; // must return an array of segments 6555 } 6556 ); 6557 6558 // force an order because eventsToSegs doesn't guarantee one 6559 this.sortEventSegs(segs); 6560 6561 return segs; 6562 }, 6563 6564 6565 // Generates the text that should be inside a "more" link, given the number of events it represents 6566 getMoreLinkText: function(num) { 6567 var opt = this.view.opt('eventLimitText'); 6568 6569 if (typeof opt === 'function') { 6570 return opt(num); 6571 } 6572 else { 6573 return '+' + num + ' ' + opt; 6574 } 6575 }, 6576 6577 6578 // Returns segments within a given cell. 6579 // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs. 6580 getCellSegs: function(row, col, startLevel) { 6581 var segMatrix = this.rowStructs[row].segMatrix; 6582 var level = startLevel || 0; 6583 var segs = []; 6584 var seg; 6585 6586 while (level < segMatrix.length) { 6587 seg = segMatrix[level][col]; 6588 if (seg) { 6589 segs.push(seg); 6590 } 6591 level++; 6592 } 6593 6594 return segs; 6595 } 6596 6597}); 6598 6599;; 6600 6601/* A component that renders one or more columns of vertical time slots 6602----------------------------------------------------------------------------------------------------------------------*/ 6603// We mixin DayTable, even though there is only a single row of days 6604 6605var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { 6606 6607 slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines 6608 snapDuration: null, // granularity of time for dragging and selecting 6609 snapsPerSlot: null, 6610 minTime: null, // Duration object that denotes the first visible time of any given day 6611 maxTime: null, // Duration object that denotes the exclusive visible end time of any given day 6612 labelFormat: null, // formatting string for times running along vertical axis 6613 labelInterval: null, // duration of how often a label should be displayed for a slot 6614 6615 colEls: null, // cells elements in the day-row background 6616 slatContainerEl: null, // div that wraps all the slat rows 6617 slatEls: null, // elements running horizontally across all columns 6618 nowIndicatorEls: null, 6619 6620 colCoordCache: null, 6621 slatCoordCache: null, 6622 6623 6624 constructor: function() { 6625 Grid.apply(this, arguments); // call the super-constructor 6626 6627 this.processOptions(); 6628 }, 6629 6630 6631 // Renders the time grid into `this.el`, which should already be assigned. 6632 // Relies on the view's colCnt. In the future, this component should probably be self-sufficient. 6633 renderDates: function() { 6634 this.el.html(this.renderHtml()); 6635 this.colEls = this.el.find('.fc-day');
6636 this.slatContainerEl = this.el.find('.fc-slats'); 6637 this.slatEls = this.slatContainerEl.find('tr'); 6638 6639 this.colCoordCache = new CoordCache({ 6640 els: this.colEls, 6641 isHorizontal: true 6642 }); 6643 this.slatCoordCache = new CoordCache({ 6644 els: this.slatEls, 6645 isVertical: true 6646 }); 6647 6648 this.renderContentSkeleton(); 6649 }, 6650 6651 6652 // Renders the basic HTML skeleton for the grid 6653 renderHtml: function() { 6654 return '' + 6655 '<div class="fc-bg">' + 6656 '<table>' + 6657 this.renderBgTrHtml(0) + // row=0 6658 '</table>' + 6659 '</div>' + 6660 '<div class="fc-slats">' + 6661 '<table>' + 6662 this.renderSlatRowHtml() + 6663 '</table>' + 6664 '</div>'; 6665 }, 6666 6667 6668 // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL. 6669 renderSlatRowHtml: function() { 6670 var view = this.view; 6671 var isRTL = this.isRTL; 6672 var html = ''; 6673 var slotTime = moment.duration(+this.minTime); // wish there was .clone() for durations 6674 var slotDate; // will be on the view's first day, but we only care about its time 6675 var isLabeled; 6676 var axisHtml; 6677 6678 // Calculate the time for each slot 6679 while (slotTime < this.maxTime) { 6680 slotDate = this.start.clone().time(slotTime); 6681 isLabeled = isInt(divideDurationByDuration(slotTime, this.labelInterval)); 6682 6683 axisHtml = 6684 '<td class="fc-axis fc-time ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' + 6685 (isLabeled ? 6686 '<span>' + // for matchCellWidths 6687 htmlEscape(slotDate.format(this.labelFormat)) + 6688 '</span>' : 6689 '' 6690 ) + 6691 '</td>'; 6692 6693 html += 6694 '<tr data-time="' + slotDate.format('HH:mm:ss') + '"' + 6695 (isLabeled ? '' : ' class="fc-minor"') + 6696 '>' + 6697 (!isRTL ? axisHtml : '') + 6698 '<td class="' + view.widgetContentClass + '"/>' + 6699 (isRTL ? axisHtml : '') + 6700 "</tr>"; 6701 6702 slotTime.add(this.slotDuration); 6703 } 6704 6705 return html; 6706 }, 6707 6708 6709 /* Options 6710 ------------------------------------------------------------------------------------------------------------------*/ 6711 6712 6713 // Parses various options into properties of this object 6714 processOptions: function() { 6715 var view = this.view; 6716 var slotDuration = view.opt('slotDuration'); 6717 var snapDuration = view.opt('snapDuration'); 6718 var input; 6719 6720 slotDuration = moment.duration(slotDuration); 6721 snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration; 6722 6723 this.slotDuration = slotDuration; 6724 this.snapDuration = snapDuration; 6725 this.snapsPerSlot = slotDuration / snapDuration; // TODO: ensure an integer multiple? 6726 6727 this.minResizeDuration = snapDuration; // hack 6728 6729 this.minTime = moment.duration(view.opt('minTime')); 6730 this.maxTime = moment.duration(view.opt('maxTime')); 6731 6732 // might be an array value (for TimelineView). 6733 // if so, getting the most granular entry (the last one probably). 6734 input = view.opt('slotLabelFormat'); 6735 if ($.isArray(input)) { 6736 input = input[input.length - 1]; 6737 } 6738 6739 this.labelFormat = 6740 input || 6741 view.opt('smallTimeFormat'); // the computed default 6742 6743 input = view.opt('slotLabelInterval'); 6744 this.labelInterval = input ? 6745 moment.duration(input) : 6746 this.computeLabelInterval(slotDuration); 6747 }, 6748 6749 6750 // Computes an automatic value for slotLabelInterval 6751 computeLabelInterval: function(slotDuration) { 6752 var i; 6753 var labelInterval; 6754 var slotsPerLabel; 6755 6756 // find the smallest stock label interval that results in more than one slots-per-label 6757 for (i = AGENDA_STOCK_SUB_DURATIONS.length - 1; i >= 0; i--) { 6758 labelInterval = moment.duration(AGENDA_STOCK_SUB_DURATIONS[i]); 6759 slotsPerLabel = divideDurationByDuration(labelInterval, slotDuration); 6760 if (isInt(slotsPerLabel) && slotsPerLabel > 1) { 6761 return labelInterval; 6762 } 6763 } 6764 6765 return moment.duration(slotDuration); // fall back. clone 6766 }, 6767 6768 6769 // Computes a default event time formatting string if `timeFormat` is not explicitly defined 6770 computeEventTimeFormat: function() { 6771 return this.view.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM) 6772 }, 6773 6774 6775 // Computes a default `displayEventEnd` value if one is not expliclty defined 6776 computeDisplayEventEnd: function() { 6777 return true; 6778 }, 6779 6780 6781 /* Hit System 6782 ------------------------------------------------------------------------------------------------------------------*/ 6783 6784 6785 prepareHits: function() { 6786 this.colCoordCache.build();
6787 this.slatCoordCache.build(); 6788 }, 6789 6790 6791 releaseHits: function() { 6792 this.colCoordCache.clear(); 6793 // NOTE: don't clear slatCoordCache because we rely on it for computeTimeTop 6794 }, 6795 6796 6797 queryHit: function(leftOffset, topOffset) { 6798 var snapsPerSlot = this.snapsPerSlot; 6799 var colCoordCache = this.colCoordCache; 6800 var slatCoordCache = this.slatCoordCache; 6801 6802 if (colCoordCache.isLeftInBounds(leftOffset) && slatCoordCache.isTopInBounds(topOffset)) { 6803 var colIndex = colCoordCache.getHorizontalIndex(leftOffset); 6804 var slatIndex = slatCoordCache.getVerticalIndex(topOffset); 6805 6806 if (colIndex != null && slatIndex != null) { 6807 var slatTop = slatCoordCache.getTopOffset(slatIndex); 6808 var slatHeight = slatCoordCache.getHeight(slatIndex); 6809 var partial = (topOffset - slatTop) / slatHeight; // floating point number between 0 and 1 6810 var localSnapIndex = Math.floor(partial * snapsPerSlot); // the snap # relative to start of slat 6811 var snapIndex = slatIndex * snapsPerSlot + localSnapIndex; 6812 var snapTop = slatTop + (localSnapIndex / snapsPerSlot) * slatHeight; 6813 var snapBottom = slatTop + ((localSnapIndex + 1) / snapsPerSlot) * slatHeight; 6814 6815 return { 6816 col: colIndex, 6817 snap: snapIndex, 6818 component: this, // needed unfortunately :( 6819 left: colCoordCache.getLeftOffset(colIndex), 6820 right: colCoordCache.getRightOffset(colIndex), 6821 top: snapTop, 6822 bottom: snapBottom 6823 }; 6824 } 6825 } 6826 }, 6827 6828 6829 getHitSpan: function(hit) { 6830 var start = this.getCellDate(0, hit.col); // row=0 6831 var time = this.computeSnapTime(hit.snap); // pass in the snap-index 6832 var end; 6833 6834 start.time(time); 6835 end = start.clone().add(this.snapDuration); 6836 6837 return { start: start, end: end }; 6838 }, 6839 6840 6841 getHitEl: function(hit) { 6842 return this.colEls.eq(hit.col); 6843 }, 6844 6845 6846 /* Dates 6847 ------------------------------------------------------------------------------------------------------------------*/ 6848 6849 6850 rangeUpdated: function() { 6851 this.updateDayTable(); 6852 }, 6853 6854 6855 // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day 6856 computeSnapTime: function(snapIndex) { 6857 return moment.duration(this.minTime + this.snapDuration * snapIndex); 6858 }, 6859 6860 6861 // Slices up the given span (unzoned start/end with other misc data) into an array of segments 6862 spanToSegs: function(span) { 6863 var segs = this.sliceRangeByTimes(span); 6864 var i; 6865 6866 for (i = 0; i < segs.length; i++) { 6867 if (this.isRTL) { 6868 segs[i].col = this.daysPerRow - 1 - segs[i].dayIndex; 6869 } 6870 else { 6871 segs[i].col = segs[i].dayIndex; 6872 } 6873 } 6874 6875 return segs; 6876 }, 6877 6878 6879 sliceRangeByTimes: function(range) { 6880 var segs = []; 6881 var seg; 6882 var dayIndex; 6883 var dayDate; 6884 var dayRange; 6885 6886 for (dayIndex = 0; dayIndex < this.daysPerRow; dayIndex++) { 6887 dayDate = this.dayDates[dayIndex].clone(); // TODO: better API for this? 6888 dayRange = { 6889 start: dayDate.clone().time(this.minTime), 6890 end: dayDate.clone().time(this.maxTime) 6891 }; 6892 seg = intersectRanges(range, dayRange); // both will be ambig timezone 6893 if (seg) { 6894 seg.dayIndex = dayIndex; 6895 segs.push(seg); 6896 } 6897 } 6898 6899 return segs; 6900 }, 6901 6902 6903 /* Coordinates 6904 ------------------------------------------------------------------------------------------------------------------*/ 6905 6906 6907 updateSize: function(isResize) { // NOT a standard Grid method 6908 this.slatCoordCache.build(); 6909 6910 if (isResize) { 6911 this.updateSegVerticals( 6912 [].concat(this.fgSegs || [], this.bgSegs || [], this.businessSegs || []) 6913 ); 6914 } 6915 }, 6916 6917 6918 getTotalSlatHeight: function() {
6919 return this.slatContainerEl.outerHeight(); 6920 }, 6921 6922 6923 // Computes the top coordinate, relative to the bounds of the grid, of the given date. 6924 // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight. 6925 computeDateTop: function(date, startOfDayDate) { 6926 return this.computeTimeTop( 6927 moment.duration( 6928 date - startOfDayDate.clone().stripTime() 6929 ) 6930 ); 6931 }, 6932 6933 6934 // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration). 6935 computeTimeTop: function(time) { 6936 var len = this.slatEls.length; 6937 var slatCoverage = (time - this.minTime) / this.slotDuration; // floating-point value of # of slots covered 6938 var slatIndex; 6939 var slatRemainder; 6940 6941 // compute a floating-point number for how many slats should be progressed through. 6942 // from 0 to number of slats (inclusive) 6943 // constrained because minTime/maxTime might be customized. 6944 slatCoverage = Math.max(0, slatCoverage); 6945 slatCoverage = Math.min(len, slatCoverage); 6946 6947 // an integer index of the furthest whole slat 6948 // from 0 to number slats (*exclusive*, so len-1) 6949 slatIndex = Math.floor(slatCoverage); 6950 slatIndex = Math.min(slatIndex, len - 1); 6951 6952 // how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition. 6953 // could be 1.0 if slatCoverage is covering *all* the slots 6954 slatRemainder = slatCoverage - slatIndex; 6955
6956 return this.slatCoordCache.getTopPosition(slatIndex) + 6957 this.slatCoordCache.getHeight(slatIndex) * slatRemainder; 6958 }, 6959 6960 6961 6962 /* Event Drag Visualization 6963 ------------------------------------------------------------------------------------------------------------------*/ 6964 6965 6966 // Renders a visual indication of an event being dragged over the specified date(s). 6967 // A returned value of `true` signals that a mock "helper" event has been rendered. 6968 renderDrag: function(eventLocation, seg) { 6969 6970 if (seg) { // if there is event information for this drag, render a helper event 6971 6972 // returns mock event elements 6973 // signal that a helper has been rendered 6974 return this.renderEventLocationHelper(eventLocation, seg); 6975 } 6976 else { 6977 // otherwise, just render a highlight 6978 this.renderHighlight(this.eventToSpan(eventLocation)); 6979 } 6980 }, 6981 6982 6983 // Unrenders any visual indication of an event being dragged 6984 unrenderDrag: function() { 6985 this.unrenderHelper(); 6986 this.unrenderHighlight(); 6987 }, 6988 6989 6990 /* Event Resize Visualization 6991 ------------------------------------------------------------------------------------------------------------------*/ 6992 6993 6994 // Renders a visual indication of an event being resized 6995 renderEventResize: function(eventLocation, seg) { 6996 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements 6997 }, 6998 6999 7000 // Unrenders any visual indication of an event being resized 7001 unrenderEventResize: function() { 7002 this.unrenderHelper(); 7003 }, 7004 7005 7006 /* Event Helper 7007 ------------------------------------------------------------------------------------------------------------------*/ 7008 7009 7010 // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag) 7011 renderHelper: function(event, sourceSeg) { 7012 return this.renderHelperSegs(this.eventToSegs(event), sourceSeg); // returns mock event elements 7013 }, 7014 7015 7016 // Unrenders any mock helper event 7017 unrenderHelper: function() { 7018 this.unrenderHelperSegs(); 7019 }, 7020 7021 7022 /* Business Hours 7023 ------------------------------------------------------------------------------------------------------------------*/ 7024 7025 7026 renderBusinessHours: function() { 7027 this.renderBusinessSegs( 7028 this.buildBusinessHourSegs() 7029 ); 7030 }, 7031 7032 7033 unrenderBusinessHours: function() { 7034 this.unrenderBusinessSegs(); 7035 }, 7036 7037 7038 /* Now Indicator 7039 ------------------------------------------------------------------------------------------------------------------*/ 7040 7041 7042 getNowIndicatorUnit: function() { 7043 return 'minute'; // will refresh on the minute 7044 }, 7045 7046 7047 renderNowIndicator: function(date) { 7048 // seg system might be overkill, but it handles scenario where line needs to be rendered 7049 // more than once because of columns with the same date (resources columns for example) 7050 var segs = this.spanToSegs({ start: date, end: date }); 7051 var top = this.computeDateTop(date, date); 7052 var nodes = []; 7053 var i; 7054 7055 // render lines within the columns 7056 for (i = 0; i < segs.length; i++) { 7057 nodes.push($('<div class="fc-now-indicator fc-now-indicator-line"></div>') 7058 .css('top', top) 7059 .appendTo(this.colContainerEls.eq(segs[i].col))[0]); 7060 } 7061 7062 // render an arrow over the axis 7063 if (segs.length > 0) { // is the current time in view? 7064 nodes.push($('<div class="fc-now-indicator fc-now-indicator-arrow"></div>') 7065 .css('top', top) 7066 .appendTo(this.el.find('.fc-content-skeleton'))[0]); 7067 } 7068 7069 this.nowIndicatorEls = $(nodes); 7070 }, 7071 7072 7073 unrenderNowIndicator: function() { 7074 if (this.nowIndicatorEls) { 7075 this.nowIndicatorEls.remove(); 7076 this.nowIndicatorEls = null; 7077 } 7078 }, 7079 7080 7081 /* Selection 7082 ------------------------------------------------------------------------------------------------------------------*/ 7083 7084 7085 // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight. 7086 renderSelection: function(span) { 7087 if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered 7088 7089 // normally acceps an eventLocation, span has a start/end, which is good enough 7090 this.renderEventLocationHelper(span); 7091 } 7092 else { 7093 this.renderHighlight(span); 7094 } 7095 }, 7096 7097 7098 // Unrenders any visual indication of a selection 7099 unrenderSelection: function() { 7100 this.unrenderHelper(); 7101 this.unrenderHighlight(); 7102 }, 7103 7104 7105 /* Highlight 7106 ------------------------------------------------------------------------------------------------------------------*/ 7107 7108 7109 renderHighlight: function(span) { 7110 this.renderHighlightSegs(this.spanToSegs(span)); 7111 }, 7112 7113 7114 unrenderHighlight: function() { 7115 this.unrenderHighlightSegs(); 7116 } 7117 7118}); 7119 7120;; 7121 7122/* Methods for rendering SEGMENTS, pieces of content that live on the view 7123 ( this file is no longer just for events ) 7124----------------------------------------------------------------------------------------------------------------------*/ 7125 7126TimeGrid.mixin({ 7127 7128 colContainerEls: null, // containers for each column 7129 7130 // inner-containers for each column where different types of segs live 7131 fgContainerEls: null, 7132 bgContainerEls: null, 7133 helperContainerEls: null, 7134 highlightContainerEls: null, 7135 businessContainerEls: null, 7136 7137 // arrays of different types of displayed segments 7138 fgSegs: null, 7139 bgSegs: null, 7140 helperSegs: null, 7141 highlightSegs: null,
7142 businessSegs: null, 7143 7144 7145 // Renders the DOM that the view's content will live in 7146 renderContentSkeleton: function() { 7147 var cellHtml = ''; 7148 var i; 7149 var skeletonEl; 7150 7151 for (i = 0; i < this.colCnt; i++) { 7152 cellHtml += 7153 '<td>' + 7154 '<div class="fc-content-col">' + 7155 '<div class="fc-event-container fc-helper-container"></div>' + 7156 '<div class="fc-event-container"></div>' + 7157 '<div class="fc-highlight-container"></div>' + 7158 '<div class="fc-bgevent-container"></div>' + 7159 '<div class="fc-business-container"></div>' + 7160 '</div>' + 7161 '</td>'; 7162 } 7163 7164 skeletonEl = $( 7165 '<div class="fc-content-skeleton">' + 7166 '<table>' + 7167 '<tr>' + cellHtml + '</tr>' + 7168 '</table>' + 7169 '</div>' 7170 ); 7171 7172 this.colContainerEls = skeletonEl.find('.fc-content-col'); 7173 this.helperContainerEls = skeletonEl.find('.fc-helper-container'); 7174 this.fgContainerEls = skeletonEl.find('.fc-event-container:not(.fc-helper-container)'); 7175 this.bgContainerEls = skeletonEl.find('.fc-bgevent-container'); 7176 this.highlightContainerEls = skeletonEl.find('.fc-highlight-container'); 7177 this.businessContainerEls = skeletonEl.find('.fc-business-container'); 7178 7179 this.bookendCells(skeletonEl.find('tr')); // TODO: do this on string level 7180 this.el.append(skeletonEl); 7181 }, 7182 7183 7184 /* Foreground Events 7185 ------------------------------------------------------------------------------------------------------------------*/ 7186 7187 7188 renderFgSegs: function(segs) { 7189 segs = this.renderFgSegsIntoContainers(segs, this.fgContainerEls); 7190 this.fgSegs = segs; 7191 return segs; // needed for Grid::renderEvents 7192 }, 7193 7194 7195 unrenderFgSegs: function() { 7196 this.unrenderNamedSegs('fgSegs'); 7197 }, 7198 7199 7200 /* Foreground Helper Events 7201 ------------------------------------------------------------------------------------------------------------------*/ 7202 7203 7204 renderHelperSegs: function(segs, sourceSeg) { 7205 var helperEls = []; 7206 var i, seg; 7207 var sourceEl; 7208 7209 segs = this.renderFgSegsIntoContainers(segs, this.helperContainerEls); 7210 7211 // Try to make the segment that is in the same row as sourceSeg look the same 7212 for (i = 0; i < segs.length; i++) { 7213 seg = segs[i]; 7214 if (sourceSeg && sourceSeg.col === seg.col) { 7215 sourceEl = sourceSeg.el; 7216 seg.el.css({ 7217 left: sourceEl.css('left'), 7218 right: sourceEl.css('right'), 7219 'margin-left': sourceEl.css('margin-left'), 7220 'margin-right': sourceEl.css('margin-right') 7221 }); 7222 } 7223 helperEls.push(seg.el[0]); 7224 } 7225 7226 this.helperSegs = segs; 7227 7228 return $(helperEls); // must return rendered helpers 7229 }, 7230 7231 7232 unrenderHelperSegs: function() { 7233 this.unrenderNamedSegs('helperSegs'); 7234 }, 7235 7236 7237 /* Background Events 7238 ------------------------------------------------------------------------------------------------------------------*/ 7239 7240 7241 renderBgSegs: function(segs) { 7242 segs = this.renderFillSegEls('bgEvent', segs); // TODO: old fill system 7243 this.updateSegVerticals(segs); 7244 this.attachSegsByCol(this.groupSegsByCol(segs), this.bgContainerEls); 7245 this.bgSegs = segs; 7246 return segs; // needed for Grid::renderEvents 7247 }, 7248 7249 7250 unrenderBgSegs: function() { 7251 this.unrenderNamedSegs('bgSegs'); 7252 }, 7253 7254 7255 /* Highlight 7256 ------------------------------------------------------------------------------------------------------------------*/ 7257 7258 7259 renderHighlightSegs: function(segs) { 7260 segs = this.renderFillSegEls('highlight', segs); // TODO: old fill system 7261 this.updateSegVerticals(segs); 7262 this.attachSegsByCol(this.groupSegsByCol(segs), this.highlightContainerEls); 7263 this.highlightSegs = segs; 7264 }, 7265 7266 7267 unrenderHighlightSegs: function() { 7268 this.unrenderNamedSegs('highlightSegs'); 7269 }, 7270 7271 7272 /* Business Hours 7273 ------------------------------------------------------------------------------------------------------------------*/ 7274 7275 7276 renderBusinessSegs: function(segs) { 7277 segs = this.renderFillSegEls('businessHours', segs); // TODO: old fill system 7278 this.updateSegVerticals(segs); 7279 this.attachSegsByCol(this.groupSegsByCol(segs), this.businessContainerEls);
7280 this.businessSegs = segs; 7281 }, 7282 7283 7284 unrenderBusinessSegs: function() { 7285 this.unrenderNamedSegs('businessSegs'); 7286 }, 7287 7288 7289 /* Seg Rendering Utils 7290 ------------------------------------------------------------------------------------------------------------------*/ 7291 7292 7293 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col 7294 groupSegsByCol: function(segs) { 7295 var segsByCol = []; 7296 var i; 7297 7298 for (i = 0; i < this.colCnt; i++) { 7299 segsByCol.push([]); 7300 } 7301 7302 for (i = 0; i < segs.length; i++) { 7303 segsByCol[segs[i].col].push(segs[i]); 7304 } 7305 7306 return segsByCol; 7307 }, 7308 7309 7310 // Given segments grouped by column, insert the segments' elements into a parallel array of container 7311 // elements, each living within a column. 7312 attachSegsByCol: function(segsByCol, containerEls) { 7313 var col; 7314 var segs; 7315 var i; 7316 7317 for (col = 0; col < this.colCnt; col++) { // iterate each column grouping 7318 segs = segsByCol[col]; 7319 7320 for (i = 0; i < segs.length; i++) { 7321 containerEls.eq(col).append(segs[i].el); 7322 } 7323 } 7324 }, 7325 7326 7327 // Given the name of a property of `this` object, assumed to be an array of segments, 7328 // loops through each segment and removes from DOM. Will null-out the property afterwards. 7329 unrenderNamedSegs: function(propName) { 7330 var segs = this[propName]; 7331 var i; 7332 7333 if (segs) { 7334 for (i = 0; i < segs.length; i++) { 7335 segs[i].el.remove(); 7336 } 7337 this[propName] = null; 7338 } 7339 }, 7340 7341 7342 7343 /* Foreground Event Rendering Utils 7344 ------------------------------------------------------------------------------------------------------------------*/ 7345 7346 7347 // Given an array of foreground segments, render a DOM element for each, computes position, 7348 // and attaches to the column inner-container elements. 7349 renderFgSegsIntoContainers: function(segs, containerEls) { 7350 var segsByCol; 7351 var col; 7352 7353 segs = this.renderFgSegEls(segs); // will call fgSegHtml 7354 segsByCol = this.groupSegsByCol(segs); 7355 7356 for (col = 0; col < this.colCnt; col++) { 7357 this.updateFgSegCoords(segsByCol[col]); 7358 } 7359 7360 this.attachSegsByCol(segsByCol, containerEls); 7361 7362 return segs; 7363 }, 7364 7365 7366 // Renders the HTML for a single event segment's default rendering 7367 fgSegHtml: function(seg, disableResizing) { 7368 var view = this.view; 7369 var event = seg.event; 7370 var isDraggable = view.isEventDraggable(event); 7371 var isResizableFromStart = !disableResizing && seg.isStart && view.isEventResizableFromStart(event); 7372 var isResizableFromEnd = !disableResizing && seg.isEnd && view.isEventResizableFromEnd(event); 7373 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); 7374 var skinCss = cssToStr(this.getSegSkinCss(seg)); 7375 var timeText; 7376 var fullTimeText; // more verbose time text. for the print stylesheet 7377 var startTimeText; // just the start time text 7378 7379 classes.unshift('fc-time-grid-event', 'fc-v-event'); 7380 7381 if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day... 7382 // Don't display time text on segments that run entirely through a day. 7383 // That would appear as midnight-midnight and would look dumb. 7384 // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am) 7385 if (seg.isStart || seg.isEnd) { 7386 timeText = this.getEventTimeText(seg); 7387 fullTimeText = this.getEventTimeText(seg, 'LT'); 7388 startTimeText = this.getEventTimeText(seg, null, false); // displayEnd=false 7389 } 7390 } else { 7391 // Display the normal time text for the *event's* times 7392 timeText = this.getEventTimeText(event); 7393 fullTimeText = this.getEventTimeText(event, 'LT'); 7394 startTimeText = this.getEventTimeText(event, null, false); // displayEnd=false 7395 } 7396 7397 return '<a class="' + classes.join(' ') + '"' + 7398 (event.url ? 7399 ' href="' + htmlEscape(event.url) + '"' : 7400 '' 7401 ) + 7402 (skinCss ? 7403 ' style="' + skinCss + '"' : 7404 '' 7405 ) + 7406 '>' + 7407 '<div class="fc-content">' + 7408 (timeText ? 7409 '<div class="fc-time"' + 7410 ' data-start="' + htmlEscape(startTimeText) + '"' + 7411 ' data-full="' + htmlEscape(fullTimeText) + '"' + 7412 '>' + 7413 '<span>' + htmlEscape(timeText) + '</span>' + 7414 '</div>' : 7415 '' 7416 ) + 7417 (event.title ? 7418 '<div class="fc-title">' + 7419 htmlEscape(event.title) + 7420 '</div>' : 7421 '' 7422 ) + 7423 '</div>' + 7424 '<div class="fc-bg"/>' + 7425 /* TODO: write CSS for this 7426 (isResizableFromStart ? 7427 '<div class="fc-resizer fc-start-resizer" />' : 7428 '' 7429 ) + 7430 */ 7431 (isResizableFromEnd ? 7432 '<div class="fc-resizer fc-end-resizer" />' : 7433 '' 7434 ) + 7435 '</a>'; 7436 }, 7437 7438 7439 /* Seg Position Utils 7440 ------------------------------------------------------------------------------------------------------------------*/ 7441 7442
7443 // Refreshes the CSS top/bottom coordinates for each segment element. 7444 // Works when called after initial render, after a window resize/zoom for example. 7445 updateSegVerticals: function(segs) { 7446 this.computeSegVerticals(segs); 7447 this.assignSegVerticals(segs); 7448 }, 7449 7450 7451 // For each segment in an array, computes and assigns its top and bottom properties 7452 computeSegVerticals: function(segs) { 7453 var i, seg; 7454 7455 for (i = 0; i < segs.length; i++) { 7456 seg = segs[i]; 7457 seg.top = this.computeDateTop(seg.start, seg.start); 7458 seg.bottom = this.computeDateTop(seg.end, seg.start); 7459 } 7460 }, 7461 7462 7463 // Given segments that already have their top/bottom properties computed, applies those values to 7464 // the segments' elements. 7465 assignSegVerticals: function(segs) { 7466 var i, seg; 7467 7468 for (i = 0; i < segs.length; i++) { 7469 seg = segs[i]; 7470 seg.el.css(this.generateSegVerticalCss(seg)); 7471 } 7472 }, 7473 7474 7475 // Generates an object with CSS properties for the top/bottom coordinates of a segment element 7476 generateSegVerticalCss: function(seg) { 7477 return { 7478 top: seg.top, 7479 bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container 7480 }; 7481 }, 7482 7483 7484 /* Foreground Event Positioning Utils 7485 ------------------------------------------------------------------------------------------------------------------*/ 7486 7487 7488 // Given segments that are assumed to all live in the *same column*, 7489 // compute their verical/horizontal coordinates and assign to their elements. 7490 updateFgSegCoords: function(segs) { 7491 this.computeSegVerticals(segs); // horizontals relies on this 7492 this.computeFgSegHorizontals(segs); // compute horizontal coordinates, z-index's, and reorder the array 7493 this.assignSegVerticals(segs); 7494 this.assignFgSegHorizontals(segs); 7495 }, 7496 7497 7498 // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each. 7499 // NOTE: Also reorders the given array by date! 7500 computeFgSegHorizontals: function(segs) { 7501 var levels; 7502 var level0; 7503 var i; 7504 7505 this.sortEventSegs(segs); // order by certain criteria 7506 levels = buildSlotSegLevels(segs); 7507 computeForwardSlotSegs(levels); 7508 7509 if ((level0 = levels[0])) { 7510 7511 for (i = 0; i < level0.length; i++) { 7512 computeSlotSegPressures(level0[i]); 7513 } 7514 7515 for (i = 0; i < level0.length; i++) { 7516 this.computeFgSegForwardBack(level0[i], 0, 0); 7517 } 7518 } 7519 }, 7520 7521 7522 // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range 7523 // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and 7524 // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left. 7525 // 7526 // The segment might be part of a "series", which means consecutive segments with the same pressure 7527 // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of 7528 // segments behind this one in the current series, and `seriesBackwardCoord` is the starting 7529 // coordinate of the first segment in the series. 7530 computeFgSegForwardBack: function(seg, seriesBackwardPressure, seriesBackwardCoord) { 7531 var forwardSegs = seg.forwardSegs; 7532 var i; 7533 7534 if (seg.forwardCoord === undefined) { // not already computed 7535 7536 if (!forwardSegs.length) { 7537 7538 // if there are no forward segments, this segment should butt up against the edge 7539 seg.forwardCoord = 1; 7540 } 7541 else { 7542 7543 // sort highest pressure first 7544 this.sortForwardSegs(forwardSegs); 7545 7546 // this segment's forwardCoord will be calculated from the backwardCoord of the 7547 // highest-pressure forward segment. 7548 this.computeFgSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord); 7549 seg.forwardCoord = forwardSegs[0].backwardCoord; 7550 } 7551 7552 // calculate the backwardCoord from the forwardCoord. consider the series 7553 seg.backwardCoord = seg.forwardCoord - 7554 (seg.forwardCoord - seriesBackwardCoord) / // available width for series 7555 (seriesBackwardPressure + 1); // # of segments in the series 7556 7557 // use this segment's coordinates to computed the coordinates of the less-pressurized 7558 // forward segments 7559 for (i=0; i<forwardSegs.length; i++) { 7560 this.computeFgSegForwardBack(forwardSegs[i], 0, seg.forwardCoord); 7561 } 7562 } 7563 }, 7564 7565
7566 sortForwardSegs: function(forwardSegs) { 7567 forwardSegs.sort(proxy(this, 'compareForwardSegs')); 7568 }, 7569 7570 7571 // A cmp function for determining which forward segment to rely on more when computing coordinates. 7572 compareForwardSegs: function(seg1, seg2) { 7573 // put higher-pressure first 7574 return seg2.forwardPressure - seg1.forwardPressure || 7575 // put segments that are closer to initial edge first (and favor ones with no coords yet) 7576 (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) || 7577 // do normal sorting... 7578 this.compareEventSegs(seg1, seg2); 7579 }, 7580 7581 7582 // Given foreground event segments that have already had their position coordinates computed, 7583 // assigns position-related CSS values to their elements. 7584 assignFgSegHorizontals: function(segs) { 7585 var i, seg; 7586 7587 for (i = 0; i < segs.length; i++) { 7588 seg = segs[i]; 7589 seg.el.css(this.generateFgSegHorizontalCss(seg)); 7590 7591 // if the height is short, add a className for alternate styling 7592 if (seg.bottom - seg.top < 30) { 7593 seg.el.addClass('fc-short'); 7594 } 7595 } 7596 }, 7597 7598 7599 // Generates an object with CSS properties/values that should be applied to an event segment element. 7600 // Contains important positioning-related properties that should be applied to any event element, customized or not. 7601 generateFgSegHorizontalCss: function(seg) { 7602 var shouldOverlap = this.view.opt('slotEventOverlap'); 7603 var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point 7604 var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point 7605 var props = this.generateSegVerticalCss(seg); // get top/bottom first 7606 var left; // amount of space from left edge, a fraction of the total width 7607 var right; // amount of space from right edge, a fraction of the total width 7608 7609 if (shouldOverlap) { 7610 // double the width, but don't go beyond the maximum forward coordinate (1.0) 7611 forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2); 7612 } 7613 7614 if (this.isRTL) { 7615 left = 1 - forwardCoord; 7616 right = backwardCoord; 7617 } 7618 else { 7619 left = backwardCoord; 7620 right = 1 - forwardCoord; 7621 } 7622 7623 props.zIndex = seg.level + 1; // convert from 0-base to 1-based 7624 props.left = left * 100 + '%'; 7625 props.right = right * 100 + '%'; 7626 7627 if (shouldOverlap && seg.forwardPressure) { 7628 // add padding to the edge so that forward stacked events don't cover the resizer's icon 7629 props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width 7630 } 7631 7632 return props; 7633 } 7634 7635}); 7636 7637 7638// Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is 7639// left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date. 7640function buildSlotSegLevels(segs) { 7641 var levels = []; 7642 var i, seg; 7643 var j; 7644 7645 for (i=0; i<segs.length; i++) { 7646 seg = segs[i]; 7647 7648 // go through all the levels and stop on the first level where there are no collisions 7649 for (j=0; j<levels.length; j++) { 7650 if (!computeSlotSegCollisions(seg, levels[j]).length) { 7651 break; 7652 } 7653 } 7654 7655 seg.level = j; 7656 7657 (levels[j] || (levels[j] = [])).push(seg); 7658 } 7659 7660 return levels; 7661} 7662 7663 7664// For every segment, figure out the other segments that are in subsequent 7665// levels that also occupy the same vertical space. Accumulate in seg.forwardSegs 7666function computeForwardSlotSegs(levels) { 7667 var i, level; 7668 var j, seg; 7669 var k; 7670 7671 for (i=0; i<levels.length; i++) { 7672 level = levels[i]; 7673 7674 for (j=0; j<level.length; j++) { 7675 seg = level[j]; 7676 7677 seg.forwardSegs = []; 7678 for (k=i+1; k<levels.length; k++) { 7679 computeSlotSegCollisions(seg, levels[k], seg.forwardSegs); 7680 } 7681 } 7682 } 7683} 7684 7685 7686// Figure out which path forward (via seg.forwardSegs) results in the longest path until 7687// the furthest edge is reached. The number of segments in this path will be seg.forwardPressure 7688function computeSlotSegPressures(seg) { 7689 var forwardSegs = seg.forwardSegs; 7690 var forwardPressure = 0; 7691 var i, forwardSeg; 7692 7693 if (seg.forwardPressure === undefined) { // not already computed 7694 7695 for (i=0; i<forwardSegs.length; i++) { 7696 forwardSeg = forwardSegs[i]; 7697 7698 // figure out the child's maximum forward path 7699 computeSlotSegPressures(forwardSeg); 7700 7701 // either use the existing maximum, or use the child's forward pressure 7702 // plus one (for the forwardSeg itself) 7703 forwardPressure = Math.max( 7704 forwardPressure, 7705 1 + forwardSeg.forwardPressure 7706 ); 7707 } 7708 7709 seg.forwardPressure = forwardPressure; 7710 } 7711} 7712 7713 7714// Find all the segments in `otherSegs` that vertically collide with `seg`. 7715// Append into an optionally-supplied `results` array and return. 7716function computeSlotSegCollisions(seg, otherSegs, results) { 7717 results = results || []; 7718 7719 for (var i=0; i<otherSegs.length; i++) { 7720 if (isSlotSegCollision(seg, otherSegs[i])) { 7721 results.push(otherSegs[i]); 7722 } 7723 } 7724 7725 return results; 7726} 7727 7728 7729// Do these segments occupy the same vertical space? 7730function isSlotSegCollision(seg1, seg2) { 7731 return seg1.bottom > seg2.top && seg1.top < seg2.bottom; 7732} 7733 7734;; 7735 7736/* An abstract class from which other views inherit from 7737----------------------------------------------------------------------------------------------------------------------*/ 7738 7739var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, { 7740 7741 type: null, // subclass' view name (string) 7742 name: null, // deprecated. use `type` instead 7743 title: null, // the text that will be displayed in the header's title 7744 7745 calendar: null, // owner Calendar object 7746 options: null, // hash containing all options. already merged with view-specific-options 7747 el: null, // the view's containing element. set by Calendar 7748 7749 displaying: null, // a promise representing the state of rendering. null if no render requested 7750 isSkeletonRendered: false, 7751 isEventsRendered: false, 7752 7753 // range the view is actually displaying (moments) 7754 start: null, 7755 end: null, // exclusive 7756 7757 // range the view is formally responsible for (moments) 7758 // may be different from start/end. for example, a month view might have 1st-31st, excluding padded dates 7759 intervalStart: null, 7760 intervalEnd: null, // exclusive 7761 intervalDuration: null, 7762 intervalUnit: null, // name of largest unit being displayed, like "month" or "week" 7763 7764 isRTL: false, 7765 isSelected: false, // boolean whether a range of time is user-selected or not 7766 selectedEvent: null, 7767 7768 eventOrderSpecs: null, // criteria for ordering events when they have same date/time 7769 7770 // classNames styled by jqui themes 7771 widgetHeaderClass: null, 7772 widgetContentClass: null, 7773 highlightStateClass: null, 7774 7775 // for date utils, computed from options 7776 nextDayThreshold: null,
7777 isHiddenDayHash: null, 7778 7779 // now indicator 7780 isNowIndicatorRendered: null, 7781 initialNowDate: null, // result first getNow call 7782 initialNowQueriedMs: null, // ms time the getNow was called 7783 nowIndicatorTimeoutID: null, // for refresh timing of now indicator 7784 nowIndicatorIntervalID: null, // " 7785 7786 7787 constructor: function(calendar, type, options, intervalDuration) { 7788 7789 this.calendar = calendar; 7790 this.type = this.name = type; // .name is deprecated 7791 this.options = options; 7792 this.intervalDuration = intervalDuration || moment.duration(1, 'day'); 7793 7794 this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold')); 7795 this.initThemingProps(); 7796 this.initHiddenDays(); 7797 this.isRTL = this.opt('isRTL'); 7798 7799 this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder')); 7800 7801 this.initialize(); 7802 }, 7803 7804 7805 // A good place for subclasses to initialize member variables 7806 initialize: function() { 7807 // subclasses can implement 7808 }, 7809 7810 7811 // Retrieves an option with the given name 7812 opt: function(name) { 7813 return this.options[name]; 7814 }, 7815 7816 7817 // Triggers handlers that are view-related. Modifies args before passing to calendar. 7818 trigger: function(name, thisObj) { // arguments beyond thisObj are passed along 7819 var calendar = this.calendar; 7820 7821 return calendar.trigger.apply( 7822 calendar, 7823 [name, thisObj || this].concat( 7824 Array.prototype.slice.call(arguments, 2), // arguments beyond thisObj 7825 [ this ] // always make the last argument a reference to the view. TODO: deprecate 7826 ) 7827 ); 7828 }, 7829 7830 7831 /* Dates 7832 ------------------------------------------------------------------------------------------------------------------*/ 7833 7834 7835 // Updates all internal dates to center around the given current unzoned date. 7836 setDate: function(date) { 7837 this.setRange(this.computeRange(date)); 7838 }, 7839 7840 7841 // Updates all internal dates for displaying the given unzoned range. 7842 setRange: function(range) { 7843 $.extend(this, range); // assigns every property to this object's member variables 7844 this.updateTitle(); 7845 }, 7846 7847 7848 // Given a single current unzoned date, produce information about what range to display. 7849 // Subclasses can override. Must return all properties. 7850 computeRange: function(date) { 7851 var intervalUnit = computeIntervalUnit(this.intervalDuration); 7852 var intervalStart = date.clone().startOf(intervalUnit); 7853 var intervalEnd = intervalStart.clone().add(this.intervalDuration); 7854 var start, end; 7855 7856 // normalize the range's time-ambiguity 7857 if (/year|month|week|day/.test(intervalUnit)) { // whole-days? 7858 intervalStart.stripTime(); 7859 intervalEnd.stripTime(); 7860 } 7861 else { // needs to have a time? 7862 if (!intervalStart.hasTime()) { 7863 intervalStart = this.calendar.time(0); // give 00:00 time 7864 } 7865 if (!intervalEnd.hasTime()) { 7866 intervalEnd = this.calendar.time(0); // give 00:00 time 7867 } 7868 } 7869 7870 start = intervalStart.clone(); 7871 start = this.skipHiddenDays(start); 7872 end = intervalEnd.clone(); 7873 end = this.skipHiddenDays(end, -1, true); // exclusively move backwards 7874 7875 return { 7876 intervalUnit: intervalUnit, 7877 intervalStart: intervalStart, 7878 intervalEnd: intervalEnd, 7879 start: start, 7880 end: end 7881 }; 7882 }, 7883 7884 7885 // Computes the new date when the user hits the prev button, given the current date 7886 computePrevDate: function(date) { 7887 return this.massageCurrentDate( 7888 date.clone().startOf(this.intervalUnit).subtract(this.intervalDuration), -1 7889 ); 7890 }, 7891 7892 7893 // Computes the new date when the user hits the next button, given the current date 7894 computeNextDate: function(date) { 7895 return this.massageCurrentDate( 7896 date.clone().startOf(this.intervalUnit).add(this.intervalDuration) 7897 ); 7898 }, 7899 7900 7901 // Given an arbitrarily calculated current date of the calendar, returns a date that is ensured to be completely 7902 // visible. `direction` is optional and indicates which direction the current date was being 7903 // incremented or decremented (1 or -1). 7904 massageCurrentDate: function(date, direction) { 7905 if (this.intervalDuration.as('days') <= 1) { // if the view displays a single day or smaller
7906 if (this.isHiddenDay(date)) { 7907 date = this.skipHiddenDays(date, direction); 7908 date.startOf('day'); 7909 } 7910 } 7911 7912 return date; 7913 }, 7914 7915 7916 /* Title and Date Formatting 7917 ------------------------------------------------------------------------------------------------------------------*/ 7918 7919 7920 // Sets the view's title property to the most updated computed value 7921 updateTitle: function() { 7922 this.title = this.computeTitle(); 7923 }, 7924 7925 7926 // Computes what the title at the top of the calendar should be for this view 7927 computeTitle: function() { 7928 return this.formatRange( 7929 { 7930 // in case intervalStart/End has a time, make sure timezone is correct 7931 start: this.calendar.applyTimezone(this.intervalStart), 7932 end: this.calendar.applyTimezone(this.intervalEnd) 7933 }, 7934 this.opt('titleFormat') || this.computeTitleFormat(), 7935 this.opt('titleRangeSeparator') 7936 ); 7937 }, 7938 7939 7940 // Generates the format string that should be used to generate the title for the current date range. 7941 // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`. 7942 computeTitleFormat: function() { 7943 if (this.intervalUnit == 'year') { 7944 return 'YYYY'; 7945 } 7946 else if (this.intervalUnit == 'month') { 7947 return this.opt('monthYearFormat'); // like "September 2014" 7948 } 7949 else if (this.intervalDuration.as('days') > 1) { 7950 return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014" 7951 } 7952 else { 7953 return 'LL'; // one day. longer, like "September 9 2014" 7954 } 7955 }, 7956 7957 7958 // Utility for formatting a range. Accepts a range object, formatting string, and optional separator. 7959 // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account. 7960 // The timezones of the dates within `range` will be respected. 7961 formatRange: function(range, formatStr, separator) { 7962 var end = range.end; 7963 7964 if (!end.hasTime()) { // all-day? 7965 end = end.clone().subtract(1); // convert to inclusive. last ms of previous day 7966 } 7967 7968 return formatRange(range.start, end, formatStr, separator, this.opt('isRTL')); 7969 }, 7970 7971 7972 getAllDayHtml: function() { 7973 return this.opt('allDayHtml') || htmlEscape(this.opt('allDayText')); 7974 }, 7975 7976 7977 /* Navigation 7978 ------------------------------------------------------------------------------------------------------------------*/ 7979 7980 7981 // Generates HTML for an anchor to another view into the calendar. 7982 // Will either generate an <a> tag or a non-clickable <span> tag, depending on enabled settings. 7983 // `gotoOptions` can either be a moment input, or an object with the form: 7984 // { date, type, forceOff } 7985 // `type` is a view-type like "day" or "week". default value is "day". 7986 // `attrs` and `innerHtml` are use to generate the rest of the HTML tag. 7987 buildGotoAnchorHtml: function(gotoOptions, attrs, innerHtml) { 7988 var date, type, forceOff; 7989 var finalOptions; 7990 7991 if ($.isPlainObject(gotoOptions)) { 7992 date = gotoOptions.date; 7993 type = gotoOptions.type; 7994 forceOff = gotoOptions.forceOff; 7995 } 7996 else { 7997 date = gotoOptions; // a single moment input 7998 } 7999 date = FC.moment(date); // if a string, parse it 8000 8001 finalOptions = { // for serialization into the link 8002 date: date.format('YYYY-MM-DD'), 8003 type: type || 'day' 8004 }; 8005 8006 if (typeof attrs === 'string') { 8007 innerHtml = attrs; 8008 attrs = null; 8009 } 8010 8011 attrs = attrs ? ' ' + attrsToStr(attrs) : ''; // will have a leading space 8012 innerHtml = innerHtml || ''; 8013 8014 if (!forceOff && this.opt('navLinks')) { 8015 return '<a' + attrs + 8016 ' data-goto="' + htmlEscape(JSON.stringify(finalOptions)) + '">' + 8017 innerHtml + 8018 '</a>'; 8019 } 8020 else { 8021 return '<span' + attrs + '>' + 8022 innerHtml + 8023 '</span>'; 8024 } 8025 }, 8026 8027 8028 /* Rendering 8029 ------------------------------------------------------------------------------------------------------------------*/ 8030 8031 8032 // Sets the container element that the view should render inside of. 8033 // Does other DOM-related initializations. 8034 setElement: function(el) { 8035 this.el = el; 8036 this.bindGlobalHandlers(); 8037 }, 8038 8039 8040 // Removes the view's container element from the DOM, clearing any content beforehand. 8041 // Undoes any other DOM-related attachments. 8042 removeElement: function() { 8043 this.clear(); // clears all content 8044 8045 // clean up the skeleton 8046 if (this.isSkeletonRendered) { 8047 this.unrenderSkeleton(); 8048 this.isSkeletonRendered = false; 8049 } 8050 8051 this.unbindGlobalHandlers(); 8052 8053 this.el.remove(); 8054 8055 // NOTE: don't null-out this.el in case the View was destroyed within an API callback. 8056 // We don't null-out the View's other jQuery element references upon destroy, 8057 // so we shouldn't kill this.el either. 8058 }, 8059 8060 8061 // Does everything necessary to display the view centered around the given unzoned date. 8062 // Does every type of rendering EXCEPT rendering events. 8063 // Is asychronous and returns a promise. 8064 display: function(date, explicitScrollState) { 8065 var _this = this; 8066 var prevScrollState = null; 8067 8068 if (explicitScrollState != null && this.displaying) { // don't need prevScrollState if explicitScrollState 8069 prevScrollState = this.queryScroll(); 8070 } 8071 8072 this.calendar.freezeContentHeight(); 8073 8074 return syncThen(this.clear(), function() { // clear the content first 8075 return ( 8076 _this.displaying = 8077 syncThen(_this.displayView(date), function() { // displayView might return a promise 8078 8079 // caller of display() wants a specific scroll state? 8080 if (explicitScrollState != null) { 8081 // we make an assumption that this is NOT the initial render, 8082 // and thus don't need forceScroll (is inconveniently asynchronous) 8083 _this.setScroll(explicitScrollState); 8084 } 8085 else { 8086 _this.forceScroll(_this.computeInitialScroll(prevScrollState)); 8087 } 8088 8089 _this.calendar.unfreezeContentHeight();
8090 _this.triggerRender(); 8091 }) 8092 ); 8093 }); 8094 }, 8095 8096 8097 // Does everything necessary to clear the content of the view. 8098 // Clears dates and events. Does not clear the skeleton. 8099 // Is asychronous and returns a promise. 8100 clear: function() { 8101 var _this = this; 8102 var displaying = this.displaying; 8103 8104 if (displaying) { // previously displayed, or in the process of being displayed? 8105 return syncThen(displaying, function() { // wait for the display to finish 8106 _this.displaying = null; 8107 _this.clearEvents(); 8108 return _this.clearView(); // might return a promise. chain it 8109 }); 8110 } 8111 else { 8112 return $.when(); // an immediately-resolved promise 8113 } 8114 }, 8115 8116 8117 // Displays the view's non-event content, such as date-related content or anything required by events. 8118 // Renders the view's non-content skeleton if necessary. 8119 // Can be asynchronous and return a promise. 8120 displayView: function(date) { 8121 if (!this.isSkeletonRendered) { 8122 this.renderSkeleton(); 8123 this.isSkeletonRendered = true; 8124 } 8125 if (date) { 8126 this.setDate(date); 8127 } 8128 if (this.render) { 8129 this.render(); // TODO: deprecate 8130 } 8131 this.renderDates(); 8132 this.updateSize(); 8133 this.renderBusinessHours(); // might need coordinates, so should go after updateSize() 8134 this.startNowIndicator(); 8135 }, 8136 8137 8138 // Unrenders the view content that was rendered in displayView. 8139 // Can be asynchronous and return a promise. 8140 clearView: function() { 8141 this.unselect(); 8142 this.stopNowIndicator(); 8143 this.triggerUnrender(); 8144 this.unrenderBusinessHours(); 8145 this.unrenderDates(); 8146 if (this.destroy) { 8147 this.destroy(); // TODO: deprecate 8148 } 8149 }, 8150 8151 8152 // Renders the basic structure of the view before any content is rendered 8153 renderSkeleton: function() { 8154 // subclasses should implement 8155 }, 8156 8157 8158 // Unrenders the basic structure of the view 8159 unrenderSkeleton: function() { 8160 // subclasses should implement 8161 }, 8162 8163 8164 // Renders the view's date-related content. 8165 // Assumes setRange has already been called and the skeleton has already been rendered. 8166 renderDates: function() { 8167 // subclasses should implement 8168 }, 8169 8170 8171 // Unrenders the view's date-related content 8172 unrenderDates: function() { 8173 // subclasses should override 8174 }, 8175 8176 8177 // Signals that the view's content has been rendered
8178 triggerRender: function() { 8179 this.trigger('viewRender', this, this, this.el); 8180 }, 8181 8182 8183 // Signals that the view's content is about to be unrendered 8184 triggerUnrender: function() { 8185 this.trigger('viewDestroy', this, this, this.el); 8186 }, 8187 8188 8189 // Binds DOM handlers to elements that reside outside the view container, such as the document 8190 bindGlobalHandlers: function() { 8191 this.listenTo($(document), 'mousedown', this.handleDocumentMousedown); 8192 this.listenTo($(document), 'touchstart', this.processUnselect); 8193 }, 8194 8195 8196 // Unbinds DOM handlers from elements that reside outside the view container 8197 unbindGlobalHandlers: function() { 8198 this.stopListeningTo($(document)); 8199 }, 8200 8201 8202 // Initializes internal variables related to theming 8203 initThemingProps: function() { 8204 var tm = this.opt('theme') ? 'ui' : 'fc'; 8205 8206 this.widgetHeaderClass = tm + '-widget-header'; 8207 this.widgetContentClass = tm + '-widget-content'; 8208 this.highlightStateClass = tm + '-state-highlight'; 8209 }, 8210 8211 8212 /* Business Hours 8213 ------------------------------------------------------------------------------------------------------------------*/ 8214 8215 8216 // Renders business-hours onto the view. Assumes updateSize has already been called. 8217 renderBusinessHours: function() { 8218 // subclasses should implement 8219 }, 8220 8221 8222 // Unrenders previously-rendered business-hours 8223 unrenderBusinessHours: function() { 8224 // subclasses should implement 8225 }, 8226 8227 8228 /* Now Indicator 8229 ------------------------------------------------------------------------------------------------------------------*/ 8230 8231 8232 // Immediately render the current time indicator and begins re-rendering it at an interval, 8233 // which is defined by this.getNowIndicatorUnit(). 8234 // TODO: somehow do this for the current whole day's background too 8235 startNowIndicator: function() { 8236 var _this = this; 8237 var unit; 8238 var update; 8239 var delay; // ms wait value 8240 8241 if (this.opt('nowIndicator')) { 8242 unit = this.getNowIndicatorUnit(); 8243 if (unit) { 8244 update = proxy(this, 'updateNowIndicator'); // bind to `this` 8245 8246 this.initialNowDate = this.calendar.getNow(); 8247 this.initialNowQueriedMs = +new Date(); 8248 this.renderNowIndicator(this.initialNowDate); 8249 this.isNowIndicatorRendered = true; 8250 8251 // wait until the beginning of the next interval 8252 delay = this.initialNowDate.clone().startOf(unit).add(1, unit) - this.initialNowDate; 8253 this.nowIndicatorTimeoutID = setTimeout(function() { 8254 _this.nowIndicatorTimeoutID = null; 8255 update();
8256 delay = +moment.duration(1, unit); 8257 delay = Math.max(100, delay); // prevent too frequent 8258 _this.nowIndicatorIntervalID = setInterval(update, delay); // update every interval 8259 }, delay); 8260 } 8261 } 8262 }, 8263 8264 8265 // rerenders the now indicator, computing the new current time from the amount of time that has passed 8266 // since the initial getNow call. 8267 updateNowIndicator: function() { 8268 if (this.isNowIndicatorRendered) { 8269 this.unrenderNowIndicator(); 8270 this.renderNowIndicator( 8271 this.initialNowDate.clone().add(new Date() - this.initialNowQueriedMs) // add ms 8272 ); 8273 } 8274 }, 8275 8276 8277 // Immediately unrenders the view's current time indicator and stops any re-rendering timers. 8278 // Won't cause side effects if indicator isn't rendered. 8279 stopNowIndicator: function() { 8280 if (this.isNowIndicatorRendered) { 8281 8282 if (this.nowIndicatorTimeoutID) { 8283 clearTimeout(this.nowIndicatorTimeoutID); 8284 this.nowIndicatorTimeoutID = null; 8285 } 8286 if (this.nowIndicatorIntervalID) { 8287 clearTimeout(this.nowIndicatorIntervalID); 8288 this.nowIndicatorIntervalID = null; 8289 } 8290 8291 this.unrenderNowIndicator(); 8292 this.isNowIndicatorRendered = false; 8293 } 8294 }, 8295 8296 8297 // Returns a string unit, like 'second' or 'minute' that defined how often the current time indicator 8298 // should be refreshed. If something falsy is returned, no time indicator is rendered at all. 8299 getNowIndicatorUnit: function() { 8300 // subclasses should implement 8301 }, 8302 8303 8304 // Renders a current time indicator at the given datetime 8305 renderNowIndicator: function(date) { 8306 // subclasses should implement 8307 }, 8308 8309 8310 // Undoes the rendering actions from renderNowIndicator 8311 unrenderNowIndicator: function() { 8312 // subclasses should implement 8313 }, 8314 8315 8316 /* Dimensions 8317 ------------------------------------------------------------------------------------------------------------------*/ 8318 8319 8320 // Refreshes anything dependant upon sizing of the container element of the grid 8321 updateSize: function(isResize) { 8322 var scrollState; 8323 8324 if (isResize) { 8325 scrollState = this.queryScroll(); 8326 } 8327 8328 this.updateHeight(isResize); 8329 this.updateWidth(isResize); 8330 this.updateNowIndicator(); 8331 8332 if (isResize) { 8333 this.setScroll(scrollState); 8334 } 8335 }, 8336 8337 8338 // Refreshes the horizontal dimensions of the calendar 8339 updateWidth: function(isResize) { 8340 // subclasses should implement 8341 }, 8342 8343 8344 // Refreshes the vertical dimensions of the calendar 8345 updateHeight: function(isResize) { 8346 var calendar = this.calendar; // we poll the calendar for height information 8347 8348 this.setHeight( 8349 calendar.getSuggestedViewHeight(), 8350 calendar.isHeightAuto() 8351 ); 8352 }, 8353 8354 8355 // Updates the vertical dimensions of the calendar to the specified height. 8356 // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height. 8357 setHeight: function(height, isAuto) { 8358 // subclasses should implement 8359 }, 8360 8361 8362 /* Scroller 8363 ------------------------------------------------------------------------------------------------------------------*/ 8364 8365 8366 // Computes the initial pre-configured scroll state prior to allowing the user to change it. 8367 // Given the scroll state from the previous rendering. If first time rendering, given null. 8368 computeInitialScroll: function(previousScrollState) { 8369 return 0; 8370 }, 8371 8372 8373 // Retrieves the view's current natural scroll state. Can return an arbitrary format. 8374 queryScroll: function() { 8375 // subclasses must implement 8376 }, 8377 8378 8379 // Sets the view's scroll state. Will accept the same format computeInitialScroll and queryScroll produce. 8380 setScroll: function(scrollState) { 8381 // subclasses must implement 8382 }, 8383 8384 8385 // Sets the scroll state, making sure to overcome any predefined scroll value the browser has in mind 8386 forceScroll: function(scrollState) { 8387 var _this = this; 8388 8389 this.setScroll(scrollState); 8390 setTimeout(function() { 8391 _this.setScroll(scrollState); 8392 }, 0); 8393 }, 8394 8395 8396 /* Event Elements / Segments 8397 ------------------------------------------------------------------------------------------------------------------*/ 8398 8399 8400 // Does everything necessary to display the given events onto the current view 8401 displayEvents: function(events) { 8402 var scrollState = this.queryScroll(); 8403 8404 this.clearEvents(); 8405 this.renderEvents(events); 8406 this.isEventsRendered = true; 8407 this.setScroll(scrollState); 8408 this.triggerEventRender(); 8409 }, 8410 8411 8412 // Does everything necessary to clear the view's currently-rendered events 8413 clearEvents: function() { 8414 var scrollState; 8415 8416 if (this.isEventsRendered) { 8417 8418 // TODO: optimize: if we know this is part of a displayEvents call, don't queryScroll/setScroll 8419 scrollState = this.queryScroll(); 8420 8421 this.triggerEventUnrender(); 8422 if (this.destroyEvents) { 8423 this.destroyEvents(); // TODO: deprecate 8424 } 8425 this.unrenderEvents(); 8426 this.setScroll(scrollState); 8427 this.isEventsRendered = false; 8428 } 8429 }, 8430 8431 8432 // Renders the events onto the view. 8433 renderEvents: function(events) { 8434 // subclasses should implement 8435 }, 8436 8437 8438 // Removes event elements from the view. 8439 unrenderEvents: function() { 8440 // subclasses should implement 8441 }, 8442 8443 8444 // Signals that all events have been rendered 8445 triggerEventRender: function() { 8446 this.renderedEventSegEach(function(seg) { 8447 this.trigger('eventAfterRender', seg.event, seg.event, seg.el); 8448 }); 8449 this.trigger('eventAfterAllRender'); 8450 }, 8451 8452 8453 // Signals that all event elements are about to be removed 8454 triggerEventUnrender: function() { 8455 this.renderedEventSegEach(function(seg) { 8456 this.trigger('eventDestroy', seg.event, seg.event, seg.el); 8457 }); 8458 }, 8459 8460 8461 // Given an event and the default element used for rendering, returns the element that should actually be used. 8462 // Basically runs events and elements through the eventRender hook. 8463 resolveEventEl: function(event, el) { 8464 var custom = this.trigger('eventRender', event, event, el); 8465 8466 if (custom === false) { // means don't render at all 8467 el = null; 8468 } 8469 else if (custom && custom !== true) { 8470 el = $(custom); 8471 } 8472 8473 return el; 8474 }, 8475 8476 8477 // Hides all rendered event segments linked to the given event 8478 showEvent: function(event) { 8479 this.renderedEventSegEach(function(seg) { 8480 seg.el.css('visibility', ''); 8481 }, event); 8482 }, 8483 8484 8485 // Shows all rendered event segments linked to the given event 8486 hideEvent: function(event) { 8487 this.renderedEventSegEach(function(seg) { 8488 seg.el.css('visibility', 'hidden'); 8489 }, event); 8490 }, 8491 8492 8493 // Iterates through event segments that have been rendered (have an el). Goes through all by default. 8494 // If the optional `event` argument is specified, only iterates through segments linked to that event. 8495 // The `this` value of the callback function will be the view. 8496 renderedEventSegEach: function(func, event) { 8497 var segs = this.getEventSegs(); 8498 var i; 8499 8500 for (i = 0; i < segs.length; i++) { 8501 if (!event || segs[i].event._id === event._id) { 8502 if (segs[i].el) { 8503 func.call(this, segs[i]); 8504 } 8505 } 8506 } 8507 }, 8508 8509 8510 // Retrieves all the rendered segment objects for the view 8511 getEventSegs: function() { 8512 // subclasses must implement 8513 return []; 8514 }, 8515 8516 8517 /* Event Drag-n-Drop 8518 ------------------------------------------------------------------------------------------------------------------*/ 8519 8520 8521 // Computes if the given event is allowed to be dragged by the user 8522 isEventDraggable: function(event) { 8523 return this.isEventStartEditable(event); 8524 }, 8525 8526 8527 isEventStartEditable: function(event) { 8528 return firstDefined( 8529 event.startEditable, 8530 (event.source || {}).startEditable, 8531 this.opt('eventStartEditable'), 8532 this.isEventGenerallyEditable(event) 8533 ); 8534 }, 8535 8536 8537 isEventGenerallyEditable: function(event) { 8538 return firstDefined( 8539 event.editable, 8540 (event.source || {}).editable, 8541 this.opt('editable') 8542 ); 8543 }, 8544 8545 8546 // Must be called when an event in the view is dropped onto new location. 8547 // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event. 8548 reportEventDrop: function(event, dropLocation, largeUnit, el, ev) { 8549 var calendar = this.calendar; 8550 var mutateResult = calendar.mutateEvent(event, dropLocation, largeUnit);
8551 var undoFunc = function() { 8552 mutateResult.undo(); 8553 calendar.reportEventChange(); 8554 }; 8555 8556 this.triggerEventDrop(event, mutateResult.dateDelta, undoFunc, el, ev); 8557 calendar.reportEventChange(); // will rerender events 8558 }, 8559 8560 8561 // Triggers event-drop handlers that have subscribed via the API 8562 triggerEventDrop: function(event, dateDelta, undoFunc, el, ev) { 8563 this.trigger('eventDrop', el[0], event, dateDelta, undoFunc, ev, {}); // {} = jqui dummy 8564 }, 8565 8566 8567 /* External Element Drag-n-Drop 8568 ------------------------------------------------------------------------------------------------------------------*/ 8569 8570 8571 // Must be called when an external element, via jQuery UI, has been dropped onto the calendar. 8572 // `meta` is the parsed data that has been embedded into the dragging event. 8573 // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event. 8574 reportExternalDrop: function(meta, dropLocation, el, ev, ui) { 8575 var eventProps = meta.eventProps; 8576 var eventInput; 8577 var event; 8578 8579 // Try to build an event object and render it. TODO: decouple the two 8580 if (eventProps) { 8581 eventInput = $.extend({}, eventProps, dropLocation); 8582 event = this.calendar.renderEvent(eventInput, meta.stick)[0]; // renderEvent returns an array 8583 } 8584 8585 this.triggerExternalDrop(event, dropLocation, el, ev, ui); 8586 }, 8587 8588 8589 // Triggers external-drop handlers that have subscribed via the API 8590 triggerExternalDrop: function(event, dropLocation, el, ev, ui) { 8591 8592 // trigger 'drop' regardless of whether element represents an event 8593 this.trigger('drop', el[0], dropLocation.start, ev, ui); 8594 8595 if (event) { 8596 this.trigger('eventReceive', null, event); // signal an external event landed 8597 } 8598 }, 8599 8600 8601 /* Drag-n-Drop Rendering (for both events and external elements) 8602 ------------------------------------------------------------------------------------------------------------------*/ 8603 8604 8605 // Renders a visual indication of a event or external-element drag over the given drop zone. 8606 // If an external-element, seg will be `null`. 8607 // Must return elements used for any mock events. 8608 renderDrag: function(dropLocation, seg) { 8609 // subclasses must implement 8610 }, 8611 8612 8613 // Unrenders a visual indication of an event or external-element being dragged. 8614 unrenderDrag: function() { 8615 // subclasses must implement 8616 }, 8617 8618 8619 /* Event Resizing 8620 ------------------------------------------------------------------------------------------------------------------*/ 8621 8622 8623 // Computes if the given event is allowed to be resized from its starting edge 8624 isEventResizableFromStart: function(event) { 8625 return this.opt('eventResizableFromStart') && this.isEventResizable(event); 8626 }, 8627 8628 8629 // Computes if the given event is allowed to be resized from its ending edge 8630 isEventResizableFromEnd: function(event) { 8631 return this.isEventResizable(event); 8632 }, 8633 8634 8635 // Computes if the given event is allowed to be resized by the user at all 8636 isEventResizable: function(event) { 8637 var source = event.source || {}; 8638 8639 return firstDefined( 8640 event.durationEditable, 8641 source.durationEditable, 8642 this.opt('eventDurationEditable'), 8643 event.editable, 8644 source.editable, 8645 this.opt('editable') 8646 ); 8647 }, 8648 8649 8650 // Must be called when an event in the view has been resized to a new length 8651 reportEventResize: function(event, resizeLocation, largeUnit, el, ev) { 8652 var calendar = this.calendar; 8653 var mutateResult = calendar.mutateEvent(event, resizeLocation, largeUnit); 8654 var undoFunc = function() { 8655 mutateResult.undo(); 8656 calendar.reportEventChange(); 8657 }; 8658 8659 this.triggerEventResize(event, mutateResult.durationDelta, undoFunc, el, ev); 8660 calendar.reportEventChange(); // will rerender events 8661 }, 8662 8663 8664 // Triggers event-resize handlers that have subscribed via the API 8665 triggerEventResize: function(event, durationDelta, undoFunc, el, ev) { 8666 this.trigger('eventResize', el[0], event, durationDelta, undoFunc, ev, {}); // {} = jqui dummy 8667 }, 8668 8669 8670 /* Selection (time range) 8671 ------------------------------------------------------------------------------------------------------------------*/ 8672 8673 8674 // Selects a date span on the view. `start` and `end` are both Moments. 8675 // `ev` is the native mouse event that begin the interaction. 8676 select: function(span, ev) { 8677 this.unselect(ev); 8678 this.renderSelection(span); 8679 this.reportSelection(span, ev); 8680 }, 8681 8682 8683 // Renders a visual indication of the selection 8684 renderSelection: function(span) { 8685 // subclasses should implement 8686 }, 8687 8688 8689 // Called when a new selection is made. Updates internal state and triggers handlers. 8690 reportSelection: function(span, ev) { 8691 this.isSelected = true; 8692 this.triggerSelect(span, ev); 8693 }, 8694 8695 8696 // Triggers handlers to 'select' 8697 triggerSelect: function(span, ev) { 8698 this.trigger( 8699 'select', 8700 null, 8701 this.calendar.applyTimezone(span.start), // convert to calendar's tz for external API 8702 this.calendar.applyTimezone(span.end), // " 8703 ev 8704 ); 8705 }, 8706 8707 8708 // Undoes a selection. updates in the internal state and triggers handlers. 8709 // `ev` is the native mouse event that began the interaction. 8710 unselect: function(ev) { 8711 if (this.isSelected) { 8712 this.isSelected = false; 8713 if (this.destroySelection) { 8714 this.destroySelection(); // TODO: deprecate 8715 } 8716 this.unrenderSelection(); 8717 this.trigger('unselect', null, ev); 8718 } 8719 }, 8720 8721 8722 // Unrenders a visual indication of selection 8723 unrenderSelection: function() { 8724 // subclasses should implement 8725 }, 8726 8727 8728 /* Event Selection 8729 ------------------------------------------------------------------------------------------------------------------*/ 8730 8731 8732 selectEvent: function(event) { 8733 if (!this.selectedEvent || this.selectedEvent !== event) { 8734 this.unselectEvent(); 8735 this.renderedEventSegEach(function(seg) { 8736 seg.el.addClass('fc-selected'); 8737 }, event); 8738 this.selectedEvent = event; 8739 } 8740 }, 8741 8742 8743 unselectEvent: function() { 8744 if (this.selectedEvent) { 8745 this.renderedEventSegEach(function(seg) { 8746 seg.el.removeClass('fc-selected'); 8747 }, this.selectedEvent); 8748 this.selectedEvent = null; 8749 } 8750 }, 8751 8752 8753 isEventSelected: function(event) { 8754 // event references might change on refetchEvents(), while selectedEvent doesn't, 8755 // so compare IDs 8756 return this.selectedEvent && this.selectedEvent._id === event._id; 8757 }, 8758 8759 8760 /* Mouse / Touch Unselecting (time range & event unselection) 8761 ------------------------------------------------------------------------------------------------------------------*/ 8762 // TODO: move consistently to down/start or up/end? 8763 // TODO: don't kill previous selection if touch scrolling 8764 8765 8766 handleDocumentMousedown: function(ev) { 8767 if (isPrimaryMouseButton(ev)) { 8768 this.processUnselect(ev); 8769 } 8770 }, 8771 8772 8773 processUnselect: function(ev) { 8774 this.processRangeUnselect(ev); 8775 this.processEventUnselect(ev); 8776 }, 8777 8778 8779 processRangeUnselect: function(ev) { 8780 var ignore; 8781
8782 // is there a time-range selection? 8783 if (this.isSelected && this.opt('unselectAuto')) { 8784 // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element 8785 ignore = this.opt('unselectCancel'); 8786 if (!ignore || !$(ev.target).closest(ignore).length) { 8787 this.unselect(ev); 8788 } 8789 } 8790 }, 8791 8792 8793 processEventUnselect: function(ev) { 8794 if (this.selectedEvent) { 8795 if (!$(ev.target).closest('.fc-selected').length) { 8796 this.unselectEvent(); 8797 } 8798 } 8799 }, 8800 8801 8802 /* Day Click 8803 ------------------------------------------------------------------------------------------------------------------*/ 8804 8805 8806 // Triggers handlers to 'dayClick' 8807 // Span has start/end of the clicked area. Only the start is useful. 8808 triggerDayClick: function(span, dayEl, ev) { 8809 this.trigger( 8810 'dayClick', 8811 dayEl, 8812 this.calendar.applyTimezone(span.start), // convert to calendar's timezone for external API 8813 ev 8814 ); 8815 }, 8816 8817 8818 /* Date Utils 8819 ------------------------------------------------------------------------------------------------------------------*/ 8820 8821 8822 // Initializes internal variables related to calculating hidden days-of-week 8823 initHiddenDays: function() { 8824 var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden 8825 var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool) 8826 var dayCnt = 0; 8827 var i; 8828 8829 if (this.opt('weekends') === false) { 8830 hiddenDays.push(0, 6); // 0=sunday, 6=saturday 8831 } 8832 8833 for (i = 0; i < 7; i++) { 8834 if ( 8835 !(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1) 8836 ) { 8837 dayCnt++; 8838 } 8839 } 8840 8841 if (!dayCnt) { 8842 throw 'invalid hiddenDays'; // all days were hidden? bad. 8843 } 8844 8845 this.isHiddenDayHash = isHiddenDayHash; 8846 }, 8847 8848 8849 // Is the current day hidden? 8850 // `day` is a day-of-week index (0-6), or a Moment 8851 isHiddenDay: function(day) { 8852 if (moment.isMoment(day)) { 8853 day = day.day(); 8854 } 8855 return this.isHiddenDayHash[day]; 8856 }, 8857 8858 8859 // Incrementing the current day until it is no longer a hidden day, returning a copy. 8860 // If the initial value of `date` is not a hidden day, don't do anything. 8861 // Pass `isExclusive` as `true` if you are dealing with an end date. 8862 // `inc` defaults to `1` (increment one day forward each time) 8863 skipHiddenDays: function(date, inc, isExclusive) { 8864 var out = date.clone(); 8865 inc = inc || 1; 8866 while ( 8867 this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7] 8868 ) { 8869 out.add(inc, 'days'); 8870 } 8871 return out; 8872 }, 8873 8874 8875 // Returns the date range of the full days the given range visually appears to occupy. 8876 // Returns a new range object. 8877 computeDayRange: function(range) { 8878 var startDay = range.start.clone().stripTime(); // the beginning of the day the range starts 8879 var end = range.end; 8880 var endDay = null; 8881 var endTimeMS; 8882 8883 if (end) { 8884 endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends 8885 endTimeMS = +end.time(); // # of milliseconds into `endDay` 8886 8887 // If the end time is actually inclusively part of the next day and is equal to or 8888 // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`. 8889 // Otherwise, leaving it as inclusive will cause it to exclude `endDay`. 8890 if (endTimeMS && endTimeMS >= this.nextDayThreshold) { 8891 endDay.add(1, 'days'); 8892 } 8893 } 8894 8895 // If no end was specified, or if it is within `startDay` but not past nextDayThreshold, 8896 // assign the default duration of one day. 8897 if (!end || endDay <= startDay) { 8898 endDay = startDay.clone().add(1, 'days'); 8899 } 8900 8901 return { start: startDay, end: endDay }; 8902 }, 8903 8904 8905 // Does the given event visually appear to occupy more than one day? 8906 isMultiDayEvent: function(event) { 8907 var range = this.computeDayRange(event); // event is range-ish 8908 8909 return range.end.diff(range.start, 'days') > 1; 8910 } 8911 8912}); 8913 8914;; 8915 8916/* 8917Embodies a div that has potential scrollbars 8918*/ 8919var Scroller = FC.Scroller = Class.extend({ 8920 8921 el: null, // the guaranteed outer element 8922 scrollEl: null, // the element with the scrollbars 8923 overflowX: null, 8924 overflowY: null, 8925 8926 8927 constructor: function(options) { 8928 options = options || {}; 8929 this.overflowX = options.overflowX || options.overflow || 'auto'; 8930 this.overflowY = options.overflowY || options.overflow || 'auto'; 8931 }, 8932 8933 8934 render: function() { 8935 this.el = this.renderEl(); 8936 this.applyOverflow(); 8937 }, 8938 8939 8940 renderEl: function() {
8941 return (this.scrollEl = $('<div class="fc-scroller"></div>')); 8942 }, 8943 8944 8945 // sets to natural height, unlocks overflow 8946 clear: function() { 8947 this.setHeight('auto'); 8948 this.applyOverflow(); 8949 }, 8950 8951 8952 destroy: function() { 8953 this.el.remove(); 8954 }, 8955 8956 8957 // Overflow 8958 // ----------------------------------------------------------------------------------------------------------------- 8959 8960 8961 applyOverflow: function() { 8962 this.scrollEl.css({ 8963 'overflow-x': this.overflowX, 8964 'overflow-y': this.overflowY 8965 }); 8966 }, 8967 8968 8969 // Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'. 8970 // Useful for preserving scrollbar widths regardless of future resizes. 8971 // Can pass in scrollbarWidths for optimization. 8972 lockOverflow: function(scrollbarWidths) { 8973 var overflowX = this.overflowX; 8974 var overflowY = this.overflowY; 8975 8976 scrollbarWidths = scrollbarWidths || this.getScrollbarWidths(); 8977 8978 if (overflowX === 'auto') { 8979 overflowX = ( 8980 scrollbarWidths.top || scrollbarWidths.bottom || // horizontal scrollbars? 8981 // OR scrolling pane with massless scrollbars? 8982 this.scrollEl[0].scrollWidth - 1 > this.scrollEl[0].clientWidth 8983 // subtract 1 because of IE off-by-one issue 8984 ) ? 'scroll' : 'hidden'; 8985 } 8986 8987 if (overflowY === 'auto') { 8988 overflowY = ( 8989 scrollbarWidths.left || scrollbarWidths.right || // vertical scrollbars? 8990 // OR scrolling pane with massless scrollbars? 8991 this.scrollEl[0].scrollHeight - 1 > this.scrollEl[0].clientHeight 8992 // subtract 1 because of IE off-by-one issue 8993 ) ? 'scroll' : 'hidden'; 8994 } 8995 8996 this.scrollEl.css({ 'overflow-x': overflowX, 'overflow-y': overflowY }); 8997 }, 8998 8999 9000 // Getters / Setters 9001 // ----------------------------------------------------------------------------------------------------------------- 9002 9003 9004 setHeight: function(height) { 9005 this.scrollEl.height(height); 9006 }, 9007 9008 9009 getScrollTop: function() { 9010 return this.scrollEl.scrollTop(); 9011 }, 9012 9013 9014 setScrollTop: function(top) { 9015 this.scrollEl.scrollTop(top); 9016 }, 9017 9018 9019 getClientWidth: function() { 9020 return this.scrollEl[0].clientWidth; 9021 }, 9022 9023 9024 getClientHeight: function() { 9025 return this.scrollEl[0].clientHeight; 9026 }, 9027 9028 9029 getScrollbarWidths: function() { 9030 return getScrollbarWidths(this.scrollEl); 9031 } 9032 9033}); 9034 9035;; 9036 9037var Calendar = FC.Calendar = Class.extend({ 9038 9039 dirDefaults: null, // option defaults related to LTR or RTL 9040 localeDefaults: null, // option defaults related to current locale 9041 overrides: null, // option overrides given to the fullCalendar constructor 9042 dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides. 9043 options: null, // all defaults combined with overrides 9044 viewSpecCache: null, // cache of view definitions 9045 view: null, // current View object 9046 header: null, 9047 loadingLevel: 0, // number of simultaneous loading tasks 9048 9049 9050 // a lot of this class' OOP logic is scoped within this constructor function, 9051 // but in the future, write individual methods on the prototype. 9052 constructor: Calendar_constructor, 9053 9054 9055 // Subclasses can override this for initialization logic after the constructor has been called 9056 initialize: function() { 9057 }, 9058 9059 9060 // Computes the flattened options hash for the calendar and assigns to `this.options`. 9061 // Assumes this.overrides and this.dynamicOverrides have already been initialized. 9062 populateOptionsHash: function() { 9063 var locale, localeDefaults; 9064 var isRTL, dirDefaults; 9065 9066 locale = firstDefined( // explicit locale option given? 9067 this.dynamicOverrides.locale, 9068 this.overrides.locale 9069 ); 9070 localeDefaults = localeOptionHash[locale]; 9071 if (!localeDefaults) { // explicit locale option not given or invalid? 9072 locale = Calendar.defaults.locale; 9073 localeDefaults = localeOptionHash[locale] || {}; 9074 } 9075 9076 isRTL = firstDefined( // based on options computed so far, is direction RTL? 9077 this.dynamicOverrides.isRTL, 9078 this.overrides.isRTL, 9079 localeDefaults.isRTL, 9080 Calendar.defaults.isRTL 9081 ); 9082 dirDefaults = isRTL ? Calendar.rtlDefaults : {}; 9083 9084 this.dirDefaults = dirDefaults; 9085 this.localeDefaults = localeDefaults; 9086 this.options = mergeOptions([ // merge defaults and overrides. lowest to highest precedence 9087 Calendar.defaults, // global defaults 9088 dirDefaults, 9089 localeDefaults, 9090 this.overrides, 9091 this.dynamicOverrides 9092 ]); 9093 populateInstanceComputableOptions(this.options); // fill in gaps with computed options 9094 }, 9095 9096 9097 // Gets information about how to create a view. Will use a cache. 9098 getViewSpec: function(viewType) { 9099 var cache = this.viewSpecCache; 9100 9101 return cache[viewType] || (cache[viewType] = this.buildViewSpec(viewType)); 9102 }, 9103 9104 9105 // Given a duration singular unit, like "week" or "day", finds a matching view spec. 9106 // Preference is given to views that have corresponding buttons. 9107 getUnitViewSpec: function(unit) { 9108 var viewTypes; 9109 var i; 9110 var spec; 9111 9112 if ($.inArray(unit, intervalUnits) != -1) { 9113 9114 // put views that have buttons first. there will be duplicates, but oh well 9115 viewTypes = this.header.getViewsWithButtons(); 9116 $.each(FC.views, function(viewType) { // all views 9117 viewTypes.push(viewType); 9118 }); 9119 9120 for (i = 0; i < viewTypes.length; i++) { 9121 spec = this.getViewSpec(viewTypes[i]); 9122 if (spec) { 9123 if (spec.singleUnit == unit) { 9124 return spec; 9125 } 9126 } 9127 } 9128 } 9129 }, 9130 9131 9132 // Builds an object with information on how to create a given view 9133 buildViewSpec: function(requestedViewType) { 9134 var viewOverrides = this.overrides.views || {}; 9135 var specChain = []; // for the view. lowest to highest priority 9136 var defaultsChain = []; // for the view. lowest to highest priority 9137 var overridesChain = []; // for the view. lowest to highest priority 9138 var viewType = requestedViewType; 9139 var spec; // for the view 9140 var overrides; // for the view 9141 var duration; 9142 var unit; 9143 9144 // iterate from the specific view definition to a more general one until we hit an actual View class 9145 while (viewType) { 9146 spec = fcViews[viewType];
9147 overrides = viewOverrides[viewType]; 9148 viewType = null; // clear. might repopulate for another iteration 9149 9150 if (typeof spec === 'function') { // TODO: deprecate 9151 spec = { 'class': spec }; 9152 } 9153 9154 if (spec) { 9155 specChain.unshift(spec); 9156 defaultsChain.unshift(spec.defaults || {}); 9157 duration = duration || spec.duration; 9158 viewType = viewType || spec.type; 9159 } 9160 9161 if (overrides) { 9162 overridesChain.unshift(overrides); // view-specific option hashes have options at zero-level 9163 duration = duration || overrides.duration; 9164 viewType = viewType || overrides.type; 9165 } 9166 } 9167 9168 spec = mergeProps(specChain); 9169 spec.type = requestedViewType; 9170 if (!spec['class']) { 9171 return false; 9172 } 9173 9174 if (duration) { 9175 duration = moment.duration(duration); 9176 if (duration.valueOf()) { // valid? 9177 spec.duration = duration; 9178 unit = computeIntervalUnit(duration); 9179 9180 // view is a single-unit duration, like "week" or "day" 9181 // incorporate options for this. lowest priority 9182 if (duration.as(unit) === 1) { 9183 spec.singleUnit = unit; 9184 overridesChain.unshift(viewOverrides[unit] || {}); 9185 } 9186 } 9187 } 9188 9189 spec.defaults = mergeOptions(defaultsChain); 9190 spec.overrides = mergeOptions(overridesChain); 9191 9192 this.buildViewSpecOptions(spec); 9193 this.buildViewSpecButtonText(spec, requestedViewType); 9194 9195 return spec; 9196 }, 9197 9198 9199 // Builds and assigns a view spec's options object from its already-assigned defaults and overrides 9200 buildViewSpecOptions: function(spec) { 9201 spec.options = mergeOptions([ // lowest to highest priority 9202 Calendar.defaults, // global defaults 9203 spec.defaults, // view's defaults (from ViewSubclass.defaults) 9204 this.dirDefaults, 9205 this.localeDefaults, // locale and dir take precedence over view's defaults! 9206 this.overrides, // calendar's overrides (options given to constructor) 9207 spec.overrides, // view's overrides (view-specific options) 9208 this.dynamicOverrides // dynamically set via setter. highest precedence 9209 ]); 9210 populateInstanceComputableOptions(spec.options); 9211 }, 9212 9213 9214 // Computes and assigns a view spec's buttonText-related options 9215 buildViewSpecButtonText: function(spec, requestedViewType) { 9216 9217 // given an options object with a possible `buttonText` hash, lookup the buttonText for the 9218 // requested view, falling back to a generic unit entry like "week" or "day" 9219 function queryButtonText(options) { 9220 var buttonText = options.buttonText || {}; 9221 return buttonText[requestedViewType] || 9222 // view can decide to look up a certain key 9223 (spec.buttonTextKey ? buttonText[spec.buttonTextKey] : null) || 9224 // a key like "month" 9225 (spec.singleUnit ? buttonText[spec.singleUnit] : null); 9226 } 9227 9228 // highest to lowest priority 9229 spec.buttonTextOverride = 9230 queryButtonText(this.dynamicOverrides) || 9231 queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence 9232 spec.overrides.buttonText; // `buttonText` for view-specific options is a string 9233 9234 // highest to lowest priority. mirrors buildViewSpecOptions 9235 spec.buttonTextDefault = 9236 queryButtonText(this.localeDefaults) || 9237 queryButtonText(this.dirDefaults) || 9238 spec.defaults.buttonText || // a single string. from ViewSubclass.defaults 9239 queryButtonText(Calendar.defaults) || 9240 (spec.duration ? this.humanizeDuration(spec.duration) : null) || // like "3 days" 9241 requestedViewType; // fall back to given view name 9242 }, 9243 9244 9245 // Given a view name for a custom view or a standard view, creates a ready-to-go View object 9246 instantiateView: function(viewType) { 9247 var spec = this.getViewSpec(viewType); 9248 9249 return new spec['class'](this, viewType, spec.options, spec.duration); 9250 }, 9251 9252 9253 // Returns a boolean about whether the view is okay to instantiate at some point 9254 isValidViewType: function(viewType) { 9255 return Boolean(this.getViewSpec(viewType)); 9256 }, 9257 9258 9259 // Should be called when any type of async data fetching begins 9260 pushLoading: function() { 9261 if (!(this.loadingLevel++)) { 9262 this.trigger('loading', null, true, this.view); 9263 } 9264 }, 9265 9266 9267 // Should be called when any type of async data fetching completes 9268 popLoading: function() { 9269 if (!(--this.loadingLevel)) { 9270 this.trigger('loading', null, false, this.view); 9271 } 9272 }, 9273 9274 9275 // Given arguments to the select method in the API, returns a span (unzoned start/end and other info) 9276 buildSelectSpan: function(zonedStartInput, zonedEndInput) { 9277 var start = this.moment(zonedStartInput).stripZone(); 9278 var end; 9279 9280 if (zonedEndInput) { 9281 end = this.moment(zonedEndInput).stripZone(); 9282 } 9283 else if (start.hasTime()) { 9284 end = start.clone().add(this.defaultTimedEventDuration); 9285 } 9286 else { 9287 end = start.clone().add(this.defaultAllDayEventDuration); 9288 } 9289 9290 return { start: start, end: end }; 9291 } 9292 9293}); 9294 9295 9296Calendar.mixin(EmitterMixin); 9297 9298 9299function Calendar_constructor(element, overrides) { 9300 var t = this; 9301 9302 9303 // Exports 9304 // ----------------------------------------------------------------------------------- 9305 9306 t.render = render; 9307 t.destroy = destroy; 9308 t.refetchEvents = refetchEvents; 9309 t.refetchEventSources = refetchEventSources; 9310 t.reportEvents = reportEvents; 9311 t.reportEventChange = reportEventChange; 9312 t.rerenderEvents = renderEvents; // `renderEvents` serves as a rerender. an API method 9313 t.changeView = renderView; // `renderView` will switch to another view 9314 t.select = select; 9315 t.unselect = unselect; 9316 t.prev = prev; 9317 t.next = next; 9318 t.prevYear = prevYear; 9319 t.nextYear = nextYear; 9320 t.today = today; 9321 t.gotoDate = gotoDate; 9322 t.incrementDate = incrementDate; 9323 t.zoomTo = zoomTo; 9324 t.getDate = getDate; 9325 t.getCalendar = getCalendar; 9326 t.getView = getView; 9327 t.option = option; // getter/setter method 9328 t.trigger = trigger; 9329 9330 9331 // Options 9332 // ----------------------------------------------------------------------------------- 9333 9334 t.dynamicOverrides = {}; 9335 t.viewSpecCache = {}; 9336 t.optionHandlers = {}; // for Calendar.options.js 9337 t.overrides = $.extend({}, overrides); // make a copy 9338 9339 t.populateOptionsHash(); // sets this.options 9340 9341 9342 9343 // Locale-data Internals 9344 // ----------------------------------------------------------------------------------- 9345 // Apply overrides to the current locale's data 9346 9347 var localeData; 9348
9349 // Called immediately, and when any of the options change. 9350 // Happens before any internal objects rebuild or rerender, because this is very core. 9351 t.bindOptions([ 9352 'locale', 'monthNames', 'monthNamesShort', 'dayNames', 'dayNamesShort', 'firstDay', 'weekNumberCalculation' 9353 ], function(locale, monthNames, monthNamesShort, dayNames, dayNamesShort, firstDay, weekNumberCalculation) { 9354 9355 // normalize 9356 if (weekNumberCalculation === 'iso') { 9357 weekNumberCalculation = 'ISO'; // normalize 9358 } 9359 9360 localeData = createObject( // make a cheap copy 9361 getMomentLocaleData(locale) // will fall back to en 9362 ); 9363 9364 if (monthNames) { 9365 localeData._months = monthNames; 9366 } 9367 if (monthNamesShort) { 9368 localeData._monthsShort = monthNamesShort; 9369 } 9370 if (dayNames) { 9371 localeData._weekdays = dayNames; 9372 } 9373 if (dayNamesShort) { 9374 localeData._weekdaysShort = dayNamesShort; 9375 } 9376 9377 if (firstDay == null && weekNumberCalculation === 'ISO') { 9378 firstDay = 1; 9379 } 9380 if (firstDay != null) { 9381 var _week = createObject(localeData._week); // _week: { dow: # } 9382 _week.dow = firstDay; 9383 localeData._week = _week; 9384 } 9385 9386 if ( // whitelist certain kinds of input 9387 weekNumberCalculation === 'ISO' || 9388 weekNumberCalculation === 'local' || 9389 typeof weekNumberCalculation === 'function' 9390 ) { 9391 localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it 9392 } 9393 9394 // If the internal current date object already exists, move to new locale. 9395 // We do NOT need to do this technique for event dates, because this happens when converting to "segments". 9396 if (date) { 9397 localizeMoment(date); // sets to localeData 9398 } 9399 }); 9400 9401 9402 // Calendar-specific Date Utilities 9403 // ----------------------------------------------------------------------------------- 9404 9405 9406 t.defaultAllDayEventDuration = moment.duration(t.options.defaultAllDayEventDuration); 9407 t.defaultTimedEventDuration = moment.duration(t.options.defaultTimedEventDuration); 9408 9409 9410 // Builds a moment using the settings of the current calendar: timezone and locale. 9411 // Accepts anything the vanilla moment() constructor accepts. 9412 t.moment = function() { 9413 var mom; 9414 9415 if (t.options.timezone === 'local') { 9416 mom = FC.moment.apply(null, arguments); 9417 9418 // Force the moment to be local, because FC.moment doesn't guarantee it. 9419 if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone 9420 mom.local(); 9421 } 9422 } 9423 else if (t.options.timezone === 'UTC') { 9424 mom = FC.moment.utc.apply(null, arguments); // process as UTC 9425 } 9426 else { 9427 mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone 9428 } 9429 9430 localizeMoment(mom); 9431 9432 return mom; 9433 }; 9434 9435 9436 // Updates the given moment's locale settings to the current calendar locale settings. 9437 function localizeMoment(mom) { 9438 mom._locale = localeData; 9439 } 9440 t.localizeMoment = localizeMoment; 9441 9442 9443 // Returns a boolean about whether or not the calendar knows how to calculate 9444 // the timezone offset of arbitrary dates in the current timezone. 9445 t.getIsAmbigTimezone = function() { 9446 return t.options.timezone !== 'local' && t.options.timezone !== 'UTC'; 9447 }; 9448 9449 9450 // Returns a copy of the given date in the current timezone. Has no effect on dates without times. 9451 t.applyTimezone = function(date) { 9452 if (!date.hasTime()) { 9453 return date.clone(); 9454 } 9455 9456 var zonedDate = t.moment(date.toArray()); 9457 var timeAdjust = date.time() - zonedDate.time(); 9458 var adjustedZonedDate; 9459 9460 // Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396) 9461 if (timeAdjust) { // is the time result different than expected? 9462 adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds 9463 if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now? 9464 zonedDate = adjustedZonedDate; 9465 } 9466 } 9467 9468 return zonedDate; 9469 }; 9470 9471 9472 // Returns a moment for the current date, as defined by the client's computer or from the `now` option. 9473 // Will return an moment with an ambiguous timezone. 9474 t.getNow = function() { 9475 var now = t.options.now; 9476 if (typeof now === 'function') { 9477 now = now(); 9478 } 9479 return t.moment(now).stripZone(); 9480 }; 9481 9482 9483 // Get an event's normalized end date. If not present, calculate it from the defaults. 9484 t.getEventEnd = function(event) {
9485 if (event.end) { 9486 return event.end.clone(); 9487 } 9488 else { 9489 return t.getDefaultEventEnd(event.allDay, event.start); 9490 } 9491 }; 9492 9493 9494 // Given an event's allDay status and start date, return what its fallback end date should be. 9495 // TODO: rename to computeDefaultEventEnd 9496 t.getDefaultEventEnd = function(allDay, zonedStart) { 9497 var end = zonedStart.clone(); 9498 9499 if (allDay) { 9500 end.stripTime().add(t.defaultAllDayEventDuration); 9501 } 9502 else { 9503 end.add(t.defaultTimedEventDuration); 9504 } 9505 9506 if (t.getIsAmbigTimezone()) { 9507 end.stripZone(); // we don't know what the tzo should be 9508 } 9509 9510 return end; 9511 }; 9512 9513 9514 // Produces a human-readable string for the given duration. 9515 // Side-effect: changes the locale of the given duration. 9516 t.humanizeDuration = function(duration) { 9517 return duration.locale(t.options.locale).humanize(); 9518 }; 9519 9520 9521 9522 // Imports 9523 // ----------------------------------------------------------------------------------- 9524 9525 9526 EventManager.call(t); 9527 var isFetchNeeded = t.isFetchNeeded; 9528 var fetchEvents = t.fetchEvents; 9529 var fetchEventSources = t.fetchEventSources; 9530 9531 9532 9533 // Locals 9534 // ----------------------------------------------------------------------------------- 9535 9536 9537 var _element = element[0]; 9538 var header; 9539 var content; 9540 var tm; // for making theme classes 9541 var currentView; // NOTE: keep this in sync with this.view 9542 var viewsByType = {}; // holds all instantiated view instances, current or not 9543 var suggestedViewHeight; 9544 var windowResizeProxy; // wraps the windowResize function 9545 var ignoreWindowResize = 0; 9546 var events = []; 9547 var date; // unzoned 9548 9549 9550 9551 // Main Rendering 9552 // ----------------------------------------------------------------------------------- 9553 9554 9555 // compute the initial ambig-timezone date 9556 if (t.options.defaultDate != null) { 9557 date = t.moment(t.options.defaultDate).stripZone(); 9558 } 9559 else { 9560 date = t.getNow(); // getNow already returns unzoned 9561 } 9562 9563 9564 function render() { 9565 if (!content) { 9566 initialRender(); 9567 } 9568 else if (elementVisible()) { 9569 // mainly for the public API 9570 calcSize(); 9571 renderView(); 9572 } 9573 } 9574 9575 9576 function initialRender() { 9577 element.addClass('fc'); 9578 9579 // event delegation for nav links 9580 element.on('click.fc', 'a[data-goto]', function(ev) { 9581 var anchorEl = $(this); 9582 var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON 9583 var date = t.moment(gotoOptions.date); 9584 var viewType = gotoOptions.type; 9585 9586 // property like "navLinkDayClick". might be a string or a function 9587 var customAction = currentView.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click'); 9588 9589 if (typeof customAction === 'function') { 9590 customAction(date, ev); 9591 } 9592 else { 9593 if (typeof customAction === 'string') { 9594 viewType = customAction; 9595 } 9596 zoomTo(date, viewType); 9597 } 9598 }); 9599 9600 // called immediately, and upon option change 9601 t.bindOption('theme', function(theme) { 9602 tm = theme ? 'ui' : 'fc'; // affects a larger scope 9603 element.toggleClass('ui-widget', theme); 9604 element.toggleClass('fc-unthemed', !theme); 9605 }); 9606 9607 // called immediately, and upon option change. 9608 // HACK: locale often affects isRTL, so we explicitly listen to that too. 9609 t.bindOptions([ 'isRTL', 'locale' ], function(isRTL) { 9610 element.toggleClass('fc-ltr', !isRTL); 9611 element.toggleClass('fc-rtl', isRTL); 9612 }); 9613 9614 content = $("<div class='fc-view-container'/>").prependTo(element); 9615 9616 header = t.header = new Header(t); 9617 renderHeader(); 9618 9619 renderView(t.options.defaultView); 9620 9621 if (t.options.handleWindowResize) { 9622 windowResizeProxy = debounce(windowResize, t.options.windowResizeDelay); // prevents rapid calls 9623 $(window).resize(windowResizeProxy); 9624 } 9625 } 9626 9627 9628 // can be called repeatedly and Header will rerender 9629 function renderHeader() { 9630 header.render(); 9631 if (header.el) { 9632 element.prepend(header.el); 9633 } 9634 } 9635 9636 9637 function destroy() { 9638 9639 if (currentView) { 9640 currentView.removeElement(); 9641 9642 // NOTE: don't null-out currentView/t.view in case API methods are called after destroy. 9643 // It is still the "current" view, just not rendered. 9644 } 9645 9646 header.removeElement(); 9647 content.remove(); 9648 element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget'); 9649 9650 element.off('.fc'); // unbind nav link handlers 9651 9652 if (windowResizeProxy) { 9653 $(window).unbind('resize', windowResizeProxy); 9654 } 9655 } 9656 9657 9658 function elementVisible() { 9659 return element.is(':visible'); 9660 } 9661 9662 9663 9664 // View Rendering 9665 // ----------------------------------------------------------------------------------- 9666 9667 9668 // Renders a view because of a date change, view-type change, or for the first time. 9669 // If not given a viewType, keep the current view but render different dates. 9670 // Accepts an optional scroll state to restore to. 9671 function renderView(viewType, explicitScrollState) { 9672 ignoreWindowResize++; 9673 9674 // if viewType is changing, remove the old view's rendering 9675 if (currentView && viewType && currentView.type !== viewType) { 9676 freezeContentHeight(); // prevent a scroll jump when view element is removed 9677 clearView(); 9678 } 9679 9680 // if viewType changed, or the view was never created, create a fresh view 9681 if (!currentView && viewType) { 9682 currentView = t.view = 9683 viewsByType[viewType] || 9684 (viewsByType[viewType] = t.instantiateView(viewType)); 9685 9686 currentView.setElement( 9687 $("<div class='fc-view fc-" + viewType + "-view' />").appendTo(content) 9688 ); 9689 header.activateButton(viewType); 9690 } 9691 9692 if (currentView) { 9693 9694 // in case the view should render a period of time that is completely hidden 9695 date = currentView.massageCurrentDate(date); 9696 9697 // render or rerender the view 9698 if ( 9699 !currentView.displaying || 9700 !( // NOT within interval range signals an implicit date window change 9701 date >= currentView.intervalStart && 9702 date < currentView.intervalEnd 9703 ) 9704 ) { 9705 if (elementVisible()) { 9706 9707 currentView.display(date, explicitScrollState); // will call freezeContentHeight 9708 unfreezeContentHeight(); // immediately unfreeze regardless of whether display is async 9709 9710 // need to do this after View::render, so dates are calculated 9711 updateHeaderTitle(); 9712 updateTodayButton(); 9713 9714 getAndRenderEvents(); 9715 } 9716 } 9717 } 9718 9719 unfreezeContentHeight(); // undo any lone freezeContentHeight calls 9720 ignoreWindowResize--; 9721 } 9722 9723 9724 // Unrenders the current view and reflects this change in the Header. 9725 // Unregsiters the `currentView`, but does not remove from viewByType hash. 9726 function clearView() { 9727 header.deactivateButton(currentView.type); 9728 currentView.removeElement(); 9729 currentView = t.view = null; 9730 } 9731 9732 9733 // Destroys the view, including the view object. Then, re-instantiates it and renders it. 9734 // Maintains the same scroll state. 9735 // TODO: maintain any other user-manipulated state. 9736 function reinitView() { 9737 ignoreWindowResize++; 9738 freezeContentHeight(); 9739 9740 var viewType = currentView.type; 9741 var scrollState = currentView.queryScroll(); 9742 clearView(); 9743 renderView(viewType, scrollState); 9744 9745 unfreezeContentHeight(); 9746 ignoreWindowResize--; 9747 } 9748 9749
9750 9751 // Resizing 9752 // ----------------------------------------------------------------------------------- 9753 9754 9755 t.getSuggestedViewHeight = function() { 9756 if (suggestedViewHeight === undefined) { 9757 calcSize(); 9758 } 9759 return suggestedViewHeight; 9760 }; 9761 9762 9763 t.isHeightAuto = function() { 9764 return t.options.contentHeight === 'auto' || t.options.height === 'auto'; 9765 }; 9766 9767 9768 function updateSize(shouldRecalc) { 9769 if (elementVisible()) { 9770 9771 if (shouldRecalc) { 9772 _calcSize(); 9773 } 9774 9775 ignoreWindowResize++; 9776 currentView.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto() 9777 ignoreWindowResize--; 9778 9779 return true; // signal success 9780 } 9781 } 9782 9783 9784 function calcSize() { 9785 if (elementVisible()) { 9786 _calcSize(); 9787 } 9788 } 9789 9790 9791 function _calcSize() { // assumes elementVisible 9792 var contentHeightInput = t.options.contentHeight; 9793 var heightInput = t.options.height; 9794 9795 if (typeof contentHeightInput === 'number') { // exists and not 'auto' 9796 suggestedViewHeight = contentHeightInput; 9797 } 9798 else if (typeof contentHeightInput === 'function') { // exists and is a function 9799 suggestedViewHeight = contentHeightInput(); 9800 } 9801 else if (typeof heightInput === 'number') { // exists and not 'auto' 9802 suggestedViewHeight = heightInput - queryHeaderHeight(); 9803 } 9804 else if (typeof heightInput === 'function') { // exists and is a function 9805 suggestedViewHeight = heightInput() - queryHeaderHeight(); 9806 } 9807 else if (heightInput === 'parent') { // set to height of parent element 9808 suggestedViewHeight = element.parent().height() - queryHeaderHeight(); 9809 } 9810 else { 9811 suggestedViewHeight = Math.round(content.width() / Math.max(t.options.aspectRatio, .5)); 9812 } 9813 } 9814 9815 9816 function queryHeaderHeight() { 9817 return header.el ? header.el.outerHeight(true) : 0; // includes margin 9818 } 9819 9820 9821 function windowResize(ev) { 9822 if ( 9823 !ignoreWindowResize && 9824 ev.target === window && // so we don't process jqui "resize" events that have bubbled up 9825 currentView.start // view has already been rendered 9826 ) { 9827 if (updateSize(true)) { 9828 currentView.trigger('windowResize', _element); 9829 } 9830 } 9831 } 9832 9833 9834 9835 /* Event Fetching/Rendering 9836 -----------------------------------------------------------------------------*/ 9837 // TODO: going forward, most of this stuff should be directly handled by the view 9838 9839 9840 function refetchEvents() { // can be called as an API method 9841 fetchAndRenderEvents(); 9842 } 9843 9844 9845 // TODO: move this into EventManager? 9846 function refetchEventSources(matchInputs) { 9847 fetchEventSources(t.getEventSourcesByMatchArray(matchInputs)); 9848 } 9849 9850 9851 function renderEvents() { // destroys old events if previously rendered 9852 if (elementVisible()) { 9853 freezeContentHeight(); 9854 currentView.displayEvents(events); 9855 unfreezeContentHeight(); 9856 } 9857 } 9858 9859 9860 function getAndRenderEvents() { 9861 if (!t.options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) { 9862 fetchAndRenderEvents(); 9863 } 9864 else { 9865 renderEvents(); 9866 } 9867 } 9868 9869 9870 function fetchAndRenderEvents() { 9871 fetchEvents(currentView.start, currentView.end); 9872 // ... will call reportEvents 9873 // ... which will call renderEvents 9874 } 9875 9876 9877 // called when event data arrives 9878 function reportEvents(_events) { 9879 events = _events; 9880 renderEvents(); 9881 } 9882 9883 9884 // called when a single event's data has been changed 9885 function reportEventChange() { 9886 renderEvents(); 9887 } 9888 9889 9890 9891 /* Header Updating 9892 -----------------------------------------------------------------------------*/ 9893 9894 9895 function updateHeaderTitle() { 9896 header.updateTitle(currentView.title); 9897 } 9898 9899 9900 function updateTodayButton() { 9901 var now = t.getNow(); 9902 9903 if (now >= currentView.intervalStart && now < currentView.intervalEnd) { 9904 header.disableButton('today'); 9905 } 9906 else { 9907 header.enableButton('today'); 9908 } 9909 } 9910 9911 9912 9913 /* Selection 9914 -----------------------------------------------------------------------------*/ 9915 9916 9917 // this public method receives start/end dates in any format, with any timezone 9918 function select(zonedStartInput, zonedEndInput) { 9919 currentView.select( 9920 t.buildSelectSpan.apply(t, arguments) 9921 ); 9922 } 9923 9924 9925 function unselect() { // safe to be called before renderView 9926 if (currentView) { 9927 currentView.unselect(); 9928 } 9929 } 9930 9931 9932 9933 /* Date 9934 -----------------------------------------------------------------------------*/ 9935 9936 9937 function prev() { 9938 date = currentView.computePrevDate(date); 9939 renderView(); 9940 } 9941 9942 9943 function next() { 9944 date = currentView.computeNextDate(date); 9945 renderView(); 9946 } 9947 9948 9949 function prevYear() { 9950 date.add(-1, 'years'); 9951 renderView(); 9952 } 9953 9954 9955 function nextYear() { 9956 date.add(1, 'years'); 9957 renderView(); 9958 } 9959 9960 9961 function today() { 9962 date = t.getNow(); 9963 renderView(); 9964 } 9965 9966 9967 function gotoDate(zonedDateInput) { 9968 date = t.moment(zonedDateInput).stripZone(); 9969 renderView(); 9970 } 9971 9972 9973 function incrementDate(delta) { 9974 date.add(moment.duration(delta)); 9975 renderView(); 9976 } 9977 9978
9979 // Forces navigation to a view for the given date. 9980 // `viewType` can be a specific view name or a generic one like "week" or "day". 9981 function zoomTo(newDate, viewType) { 9982 var spec; 9983 9984 viewType = viewType || 'day'; // day is default zoom 9985 spec = t.getViewSpec(viewType) || t.getUnitViewSpec(viewType); 9986 9987 date = newDate.clone(); 9988 renderView(spec ? spec.type : null); 9989 } 9990 9991 9992 // for external API 9993 function getDate() { 9994 return t.applyTimezone(date); // infuse the calendar's timezone 9995 } 9996 9997 9998 9999 /* Height "Freezing" 10000 -----------------------------------------------------------------------------*/ 10001 // TODO: move this into the view 10002 10003 t.freezeContentHeight = freezeContentHeight; 10004 t.unfreezeContentHeight = unfreezeContentHeight; 10005 10006 10007 function freezeContentHeight() { 10008 content.css({ 10009 width: '100%', 10010 height: content.height(), 10011 overflow: 'hidden' 10012 }); 10013 } 10014 10015 10016 function unfreezeContentHeight() { 10017 content.css({ 10018 width: '', 10019 height: '', 10020 overflow: '' 10021 }); 10022 } 10023 10024 10025 10026 /* Misc 10027 -----------------------------------------------------------------------------*/ 10028 10029 10030 function getCalendar() { 10031 return t; 10032 } 10033 10034 10035 function getView() { 10036 return currentView; 10037 } 10038 10039 10040 function option(name, value) { 10041 var newOptionHash; 10042 10043 if (typeof name === 'string') { 10044 if (value === undefined) { // getter 10045 return t.options[name]; 10046 } 10047 else { // setter for individual option 10048 newOptionHash = {}; 10049 newOptionHash[name] = value; 10050 setOptions(newOptionHash); 10051 } 10052 } 10053 else if (typeof name === 'object') { // compound setter with object input 10054 setOptions(name); 10055 } 10056 } 10057 10058 10059 function setOptions(newOptionHash) { 10060 var optionCnt = 0; 10061 var optionName; 10062 10063 for (optionName in newOptionHash) { 10064 t.dynamicOverrides[optionName] = newOptionHash[optionName]; 10065 } 10066 10067 t.viewSpecCache = {}; // the dynamic override invalidates the options in this cache, so just clear it 10068 t.populateOptionsHash(); // this.options needs to be recomputed after the dynamic override 10069 10070 // trigger handlers after this.options has been updated 10071 for (optionName in newOptionHash) { 10072 t.triggerOptionHandlers(optionName); // recall bindOption/bindOptions 10073 optionCnt++; 10074 } 10075 10076 // special-case handling of single option change. 10077 // if only one option change, `optionName` will be its name. 10078 if (optionCnt === 1) { 10079 if (optionName === 'height' || optionName === 'contentHeight' || optionName === 'aspectRatio') { 10080 updateSize(true); // true = allow recalculation of height 10081 return; 10082 } 10083 else if (optionName === 'defaultDate') { 10084 return; // can't change date this way. use gotoDate instead 10085 } 10086 else if (optionName === 'businessHours') { 10087 if (currentView) { 10088 currentView.unrenderBusinessHours(); 10089 currentView.renderBusinessHours(); 10090 } 10091 return; 10092 } 10093 else if (optionName === 'timezone') { 10094 t.rezoneArrayEventSources(); 10095 refetchEvents(); 10096 return; 10097 } 10098 } 10099 10100 // catch-all. rerender the header and rebuild/rerender the current view 10101 renderHeader(); 10102 viewsByType = {}; // even non-current views will be affected by this option change. do before rerender 10103 reinitView(); 10104 } 10105 10106 10107 function trigger(name, thisObj) { // overrides the Emitter's trigger method :( 10108 var args = Array.prototype.slice.call(arguments, 2); 10109 10110 thisObj = thisObj || _element; 10111 this.triggerWith(name, thisObj, args); // Emitter's method 10112 10113 if (t.options[name]) { 10114 return t.options[name].apply(thisObj, args); 10115 } 10116 } 10117 10118 t.initialize(); 10119} 10120 10121;; 10122/* 10123Options binding/triggering system. 10124*/ 10125Calendar.mixin({ 10126 10127 // A map of option names to arrays of handler objects. Initialized to {} in Calendar. 10128 // Format for a handler object: 10129 // { 10130 // func // callback function to be called upon change 10131 // names // option names whose values should be given to func 10132 // } 10133 optionHandlers: null, 10134 10135 // Calls handlerFunc immediately, and when the given option has changed. 10136 // handlerFunc will be given the option value. 10137 bindOption: function(optionName, handlerFunc) { 10138 this.bindOptions([ optionName ], handlerFunc); 10139 }, 10140 10141 // Calls handlerFunc immediately, and when any of the given options change. 10142 // handlerFunc will be given each option value as ordered function arguments. 10143 bindOptions: function(optionNames, handlerFunc) { 10144 var handlerObj = { func: handlerFunc, names: optionNames }; 10145 var i; 10146 10147 for (i = 0; i < optionNames.length; i++) { 10148 this.registerOptionHandlerObj(optionNames[i], handlerObj); 10149 } 10150 10151 this.triggerOptionHandlerObj(handlerObj); 10152 }, 10153 10154 // Puts the given handler object into the internal hash 10155 registerOptionHandlerObj: function(optionName, handlerObj) { 10156 (this.optionHandlers[optionName] || (this.optionHandlers[optionName] = [])) 10157 .push(handlerObj); 10158 }, 10159 10160 // Reports that the given option has changed, and calls all appropriate handlers. 10161 triggerOptionHandlers: function(optionName) { 10162 var handlerObjs = this.optionHandlers[optionName] || []; 10163 var i; 10164 10165 for (i = 0; i < handlerObjs.length; i++) { 10166 this.triggerOptionHandlerObj(handlerObjs[i]); 10167 } 10168 }, 10169 10170 // Calls the callback for a specific handler object, passing in the appropriate arguments. 10171 triggerOptionHandlerObj: function(handlerObj) { 10172 var optionNames = handlerObj.names; 10173 var optionValues = []; 10174 var i; 10175 10176 for (i = 0; i < optionNames.length; i++) { 10177 optionValues.push(this.options[optionNames[i]]); 10178 } 10179 10180 handlerObj.func.apply(this, optionValues); // maintain the Calendar's `this` context 10181 } 10182 10183}); 10184 10185;; 10186 10187Calendar.defaults = { 10188 10189 titleRangeSeparator: ' \u2013 ', // en dash 10190 monthYearFormat: 'MMMM YYYY', // required for en. other locales rely on datepicker computable option 10191 10192 defaultTimedEventDuration: '00:00:00', 10193 defaultAllDayEventDuration: { days: 1 }, 10194 forceEventDuration: false, 10195 nextDayThreshold: '09:00:00', // 9am 10196 10197 // display 10198 defaultView: 'month', 10199 aspectRatio: 1.35, 10200 header: { 10201 left: 'title',
10202 center: '', 10203 right: 'today prev,next' 10204 }, 10205 weekends: true, 10206 weekNumbers: false, 10207 10208 weekNumberTitle: 'W', 10209 weekNumberCalculation: 'local', 10210 10211 //editable: false, 10212 10213 //nowIndicator: false, 10214 10215 scrollTime: '06:00:00', 10216 10217 // event ajax 10218 lazyFetching: true, 10219 startParam: 'start', 10220 endParam: 'end', 10221 timezoneParam: 'timezone', 10222 10223 timezone: false, 10224 10225 allDayDefault: false, 10226 10227 // locale 10228 isRTL: false, 10229 buttonText: { 10230 prev: "prev", 10231 next: "next", 10232 prevYear: "prev year", 10233 nextYear: "next year", 10234 year: 'year', // TODO: locale files need to specify this 10235 today: 'today', 10236 month: 'month', 10237 week: 'week', 10238 day: 'day' 10239 }, 10240 10241 buttonIcons: { 10242 prev: 'left-single-arrow', 10243 next: 'right-single-arrow', 10244 prevYear: 'left-double-arrow', 10245 nextYear: 'right-double-arrow' 10246 }, 10247 10248 allDayText: 'all-day', 10249 10250 // jquery-ui theming 10251 theme: false, 10252 themeButtonIcons: { 10253 prev: 'circle-triangle-w', 10254 next: 'circle-triangle-e', 10255 prevYear: 'seek-prev', 10256 nextYear: 'seek-next' 10257 }, 10258 10259 //eventResizableFromStart: false, 10260 dragOpacity: .75, 10261 dragRevertDuration: 500, 10262 dragScroll: true, 10263 10264 //selectable: false, 10265 unselectAuto: true, 10266 10267 dropAccept: '*', 10268 10269 eventOrder: 'title', 10270 10271 eventLimit: false, 10272 eventLimitText: 'more', 10273 eventLimitClick: 'popover', 10274 dayPopoverFormat: 'LL', 10275 10276 handleWindowResize: true, 10277 windowResizeDelay: 100, // milliseconds before an updateSize happens 10278 10279 longPressDelay: 1000 10280 10281}; 10282 10283 10284Calendar.englishDefaults = { // used by locale.js 10285 dayPopoverFormat: 'dddd, MMMM D' 10286}; 10287 10288 10289Calendar.rtlDefaults = { // right-to-left defaults 10290 header: { // TODO: smarter solution (first/center/last ?) 10291 left: 'next,prev today', 10292 center: '', 10293 right: 'title' 10294 }, 10295 buttonIcons: { 10296 prev: 'right-single-arrow', 10297 next: 'left-single-arrow', 10298 prevYear: 'right-double-arrow', 10299 nextYear: 'left-double-arrow' 10300 }, 10301 themeButtonIcons: { 10302 prev: 'circle-triangle-e', 10303 next: 'circle-triangle-w', 10304 nextYear: 'seek-prev', 10305 prevYear: 'seek-next' 10306 } 10307}; 10308 10309;; 10310 10311var localeOptionHash = FC.locales = {}; // initialize and expose 10312 10313 10314// TODO: document the structure and ordering of a FullCalendar locale file 10315 10316 10317// Initialize jQuery UI datepicker translations while using some of the translations 10318// Will set this as the default locales for datepicker. 10319FC.datepickerLocale = function(localeCode, dpLocaleCode, dpOptions) { 10320 10321 // get the FullCalendar internal option hash for this locale. create if necessary 10322 var fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {}); 10323 10324 // transfer some simple options from datepicker to fc 10325 fcOptions.isRTL = dpOptions.isRTL; 10326 fcOptions.weekNumberTitle = dpOptions.weekHeader; 10327 10328 // compute some more complex options from datepicker 10329 $.each(dpComputableOptions, function(name, func) { 10330 fcOptions[name] = func(dpOptions); 10331 }); 10332 10333 // is jQuery UI Datepicker is on the page? 10334 if ($.datepicker) { 10335 10336 // Register the locale data. 10337 // FullCalendar and MomentJS use locale codes like "pt-br" but Datepicker 10338 // does it like "pt-BR" or if it doesn't have the locale, maybe just "pt". 10339 // Make an alias so the locale can be referenced either way. 10340 $.datepicker.regional[dpLocaleCode] = 10341 $.datepicker.regional[localeCode] = // alias 10342 dpOptions; 10343 10344 // Alias 'en' to the default locale data. Do this every time. 10345 $.datepicker.regional.en = $.datepicker.regional['']; 10346 10347 // Set as Datepicker's global defaults. 10348 $.datepicker.setDefaults(dpOptions); 10349 } 10350}; 10351 10352 10353// Sets FullCalendar-specific translations. Will set the locales as the global default. 10354FC.locale = function(localeCode, newFcOptions) { 10355 var fcOptions; 10356 var momOptions; 10357 10358 // get the FullCalendar internal option hash for this locale. create if necessary 10359 fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {}); 10360 10361 // provided new options for this locales? merge them in 10362 if (newFcOptions) { 10363 fcOptions = localeOptionHash[localeCode] = mergeOptions([ fcOptions, newFcOptions ]); 10364 } 10365 10366 // compute locale options that weren't defined. 10367 // always do this. newFcOptions can be undefined when initializing from i18n file, 10368 // so no way to tell if this is an initialization or a default-setting. 10369 momOptions = getMomentLocaleData(localeCode); // will fall back to en 10370 $.each(momComputableOptions, function(name, func) { 10371 if (fcOptions[name] == null) { 10372 fcOptions[name] = func(momOptions, fcOptions); 10373 } 10374 }); 10375 10376 // set it as the default locale for FullCalendar 10377 Calendar.defaults.locale = localeCode; 10378}; 10379 10380 10381// NOTE: can't guarantee any of these computations will run because not every locale has datepicker
10382// configs, so make sure there are English fallbacks for these in the defaults file. 10383var dpComputableOptions = { 10384 10385 buttonText: function(dpOptions) { 10386 return { 10387 // the translations sometimes wrongly contain HTML entities 10388 prev: stripHtmlEntities(dpOptions.prevText), 10389 next: stripHtmlEntities(dpOptions.nextText), 10390 today: stripHtmlEntities(dpOptions.currentText) 10391 }; 10392 }, 10393 10394 // Produces format strings like "MMMM YYYY" -> "September 2014" 10395 monthYearFormat: function(dpOptions) { 10396 return dpOptions.showMonthAfterYear ? 10397 'YYYY[' + dpOptions.yearSuffix + '] MMMM' : 10398 'MMMM YYYY[' + dpOptions.yearSuffix + ']'; 10399 } 10400 10401}; 10402 10403var momComputableOptions = { 10404 10405 // Produces format strings like "ddd M/D" -> "Fri 9/15" 10406 dayOfMonthFormat: function(momOptions, fcOptions) { 10407 var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY" 10408 10409 // strip the year off the edge, as well as other misc non-whitespace chars 10410 format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, ''); 10411 10412 if (fcOptions.isRTL) { 10413 format += ' ddd'; // for RTL, add day-of-week to end 10414 } 10415 else { 10416 format = 'ddd ' + format; // for LTR, add day-of-week to beginning 10417 } 10418 return format; 10419 }, 10420 10421 // Produces format strings like "h:mma" -> "6:00pm" 10422 mediumTimeFormat: function(momOptions) { // can't be called `timeFormat` because collides with option 10423 return momOptions.longDateFormat('LT') 10424 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand 10425 }, 10426 10427 // Produces format strings like "h(:mm)a" -> "6pm" / "6:30pm" 10428 smallTimeFormat: function(momOptions) { 10429 return momOptions.longDateFormat('LT') 10430 .replace(':mm', '(:mm)') 10431 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales 10432 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand 10433 }, 10434 10435 // Produces format strings like "h(:mm)t" -> "6p" / "6:30p" 10436 extraSmallTimeFormat: function(momOptions) { 10437 return momOptions.longDateFormat('LT') 10438 .replace(':mm', '(:mm)') 10439 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales 10440 .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand 10441 }, 10442 10443 // Produces format strings like "ha" / "H" -> "6pm" / "18" 10444 hourFormat: function(momOptions) { 10445 return momOptions.longDateFormat('LT') 10446 .replace(':mm', '') 10447 .replace(/(\Wmm)$/, '') // like above, but for foreign locales 10448 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand 10449 }, 10450 10451 // Produces format strings like "h:mm" -> "6:30" (with no AM/PM) 10452 noMeridiemTimeFormat: function(momOptions) { 10453 return momOptions.longDateFormat('LT') 10454 .replace(/\s*a$/i, ''); // remove trailing AM/PM 10455 } 10456 10457}; 10458 10459 10460// options that should be computed off live calendar options (considers override options) 10461// TODO: best place for this? related to locale? 10462// TODO: flipping text based on isRTL is a bad idea because the CSS `direction` might want to handle it 10463var instanceComputableOptions = { 10464 10465 // Produces format strings for results like "Mo 16" 10466 smallDayDateFormat: function(options) { 10467 return options.isRTL ? 10468 'D dd' : 10469 'dd D'; 10470 }, 10471 10472 // Produces format strings for results like "Wk 5" 10473 weekFormat: function(options) { 10474 return options.isRTL ? 10475 'w[ ' + options.weekNumberTitle + ']' : 10476 '[' + options.weekNumberTitle + ' ]w'; 10477 }, 10478 10479 // Produces format strings for results like "Wk5" 10480 smallWeekFormat: function(options) { 10481 return options.isRTL ? 10482 'w[' + options.weekNumberTitle + ']' : 10483 '[' + options.weekNumberTitle + ']w'; 10484 } 10485 10486}; 10487 10488function populateInstanceComputableOptions(options) { 10489 $.each(instanceComputableOptions, function(name, func) { 10490 if (options[name] == null) { 10491 options[name] = func(options); 10492 } 10493 }); 10494} 10495 10496 10497// Returns moment's internal locale data. If doesn't exist, returns English. 10498function getMomentLocaleData(localeCode) { 10499 return moment.localeData(localeCode) || moment.localeData('en'); 10500} 10501 10502 10503// Initialize English by forcing computation of moment-derived options. 10504// Also, sets it as the default. 10505FC.locale('en', Calendar.englishDefaults); 10506 10507;; 10508 10509/* Top toolbar area with buttons and title 10510----------------------------------------------------------------------------------------------------------------------*/ 10511// TODO: rename all header-related things to "toolbar" 10512 10513function Header(calendar) { 10514 var t = this; 10515 10516 // exports 10517 t.render = render; 10518 t.removeElement = removeElement; 10519 t.updateTitle = updateTitle; 10520 t.activateButton = activateButton; 10521 t.deactivateButton = deactivateButton; 10522 t.disableButton = disableButton; 10523 t.enableButton = enableButton; 10524 t.getViewsWithButtons = getViewsWithButtons; 10525 t.el = null; // mirrors local `el` 10526 10527 // locals 10528 var el; 10529 var viewsWithButtons = []; 10530 var tm; 10531 10532 10533 // can be called repeatedly and will rerender 10534 function render() { 10535 var options = calendar.options; 10536 var sections = options.header; 10537 10538 tm = options.theme ? 'ui' : 'fc'; 10539 10540 if (sections) { 10541 if (!el) { 10542 el = this.el = $("<div class='fc-toolbar'/>"); 10543 } 10544 else { 10545 el.empty(); 10546 } 10547 el.append(renderSection('left')) 10548 .append(renderSection('right')) 10549 .append(renderSection('center')) 10550 .append('<div class="fc-clear"/>'); 10551 } 10552 else { 10553 removeElement(); 10554 } 10555 } 10556 10557 10558 function removeElement() { 10559 if (el) { 10560 el.remove(); 10561 el = t.el = null; 10562 } 10563 } 10564 10565
10566 function renderSection(position) { 10567 var sectionEl = $('<div class="fc-' + position + '"/>'); 10568 var options = calendar.options; 10569 var buttonStr = options.header[position]; 10570 10571 if (buttonStr) { 10572 $.each(buttonStr.split(' '), function(i) { 10573 var groupChildren = $(); 10574 var isOnlyButtons = true; 10575 var groupEl; 10576 10577 $.each(this.split(','), function(j, buttonName) { 10578 var customButtonProps; 10579 var viewSpec; 10580 var buttonClick; 10581 var overrideText; // text explicitly set by calendar's constructor options. overcomes icons 10582 var defaultText; 10583 var themeIcon; 10584 var normalIcon; 10585 var innerHtml; 10586 var classes; 10587 var button; // the element 10588 10589 if (buttonName == 'title') { 10590 groupChildren = groupChildren.add($('<h2> </h2>')); // we always want it to take up height 10591 isOnlyButtons = false; 10592 } 10593 else { 10594 if ((customButtonProps = (options.customButtons || {})[buttonName])) { 10595 buttonClick = function(ev) { 10596 if (customButtonProps.click) { 10597 customButtonProps.click.call(button[0], ev); 10598 } 10599 }; 10600 overrideText = ''; // icons will override text 10601 defaultText = customButtonProps.text; 10602 } 10603 else if ((viewSpec = calendar.getViewSpec(buttonName))) { 10604 buttonClick = function() { 10605 calendar.changeView(buttonName); 10606 }; 10607 viewsWithButtons.push(buttonName); 10608 overrideText = viewSpec.buttonTextOverride; 10609 defaultText = viewSpec.buttonTextDefault; 10610 } 10611 else if (calendar[buttonName]) { // a calendar method 10612 buttonClick = function() { 10613 calendar[buttonName](); 10614 }; 10615 overrideText = (calendar.overrides.buttonText || {})[buttonName]; 10616 defaultText = options.buttonText[buttonName]; // everything else is considered default 10617 } 10618 10619 if (buttonClick) { 10620 10621 themeIcon = 10622 customButtonProps ? 10623 customButtonProps.themeIcon : 10624 options.themeButtonIcons[buttonName]; 10625 10626 normalIcon = 10627 customButtonProps ? 10628 customButtonProps.icon : 10629 options.buttonIcons[buttonName]; 10630 10631 if (overrideText) { 10632 innerHtml = htmlEscape(overrideText); 10633 } 10634 else if (themeIcon && options.theme) { 10635 innerHtml = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>"; 10636 } 10637 else if (normalIcon && !options.theme) { 10638 innerHtml = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>"; 10639 } 10640 else { 10641 innerHtml = htmlEscape(defaultText); 10642 } 10643 10644 classes = [ 10645 'fc-' + buttonName + '-button', 10646 tm + '-button', 10647 tm + '-state-default' 10648 ]; 10649 10650 button = $( // type="button" so that it doesn't submit a form 10651 '<button type="button" class="' + classes.join(' ') + '">' + 10652 innerHtml + 10653 '</button>' 10654 ) 10655 .click(function(ev) { 10656 // don't process clicks for disabled buttons 10657 if (!button.hasClass(tm + '-state-disabled')) { 10658 10659 buttonClick(ev); 10660 10661 // after the click action, if the button becomes the "active" tab, or disabled, 10662 // it should never have a hover class, so remove it now. 10663 if ( 10664 button.hasClass(tm + '-state-active') || 10665 button.hasClass(tm + '-state-disabled') 10666 ) { 10667 button.removeClass(tm + '-state-hover'); 10668 } 10669 } 10670 }) 10671 .mousedown(function() { 10672 // the *down* effect (mouse pressed in). 10673 // only on buttons that are not the "active" tab, or disabled 10674 button 10675 .not('.' + tm + '-state-active') 10676 .not('.' + tm + '-state-disabled') 10677 .addClass(tm + '-state-down'); 10678 }) 10679 .mouseup(function() { 10680 // undo the *down* effect 10681 button.removeClass(tm + '-state-down'); 10682 }) 10683 .hover( 10684 function() { 10685 // the *hover* effect. 10686 // only on buttons that are not the "active" tab, or disabled 10687 button 10688 .not('.' + tm + '-state-active') 10689 .not('.' + tm + '-state-disabled') 10690 .addClass(tm + '-state-hover'); 10691 }, 10692 function() {
10693 // undo the *hover* effect 10694 button 10695 .removeClass(tm + '-state-hover') 10696 .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup 10697 } 10698 ); 10699 10700 groupChildren = groupChildren.add(button); 10701 } 10702 } 10703 }); 10704 10705 if (isOnlyButtons) { 10706 groupChildren 10707 .first().addClass(tm + '-corner-left').end() 10708 .last().addClass(tm + '-corner-right').end(); 10709 } 10710 10711 if (groupChildren.length > 1) { 10712 groupEl = $('<div/>'); 10713 if (isOnlyButtons) { 10714 groupEl.addClass('fc-button-group'); 10715 } 10716 groupEl.append(groupChildren); 10717 sectionEl.append(groupEl); 10718 } 10719 else { 10720 sectionEl.append(groupChildren); // 1 or 0 children 10721 } 10722 }); 10723 } 10724 10725 return sectionEl; 10726 } 10727 10728 10729 function updateTitle(text) { 10730 if (el) { 10731 el.find('h2').text(text); 10732 } 10733 } 10734 10735 10736 function activateButton(buttonName) { 10737 if (el) { 10738 el.find('.fc-' + buttonName + '-button') 10739 .addClass(tm + '-state-active'); 10740 } 10741 } 10742 10743 10744 function deactivateButton(buttonName) { 10745 if (el) { 10746 el.find('.fc-' + buttonName + '-button') 10747 .removeClass(tm + '-state-active'); 10748 } 10749 } 10750 10751 10752 function disableButton(buttonName) { 10753 if (el) { 10754 el.find('.fc-' + buttonName + '-button') 10755 .prop('disabled', true) 10756 .addClass(tm + '-state-disabled'); 10757 } 10758 } 10759 10760 10761 function enableButton(buttonName) { 10762 if (el) { 10763 el.find('.fc-' + buttonName + '-button') 10764 .prop('disabled', false) 10765 .removeClass(tm + '-state-disabled'); 10766 } 10767 } 10768 10769 10770 function getViewsWithButtons() { 10771 return viewsWithButtons; 10772 } 10773 10774} 10775 10776;; 10777 10778FC.sourceNormalizers = []; 10779FC.sourceFetchers = []; 10780 10781var ajaxDefaults = { 10782 dataType: 'json', 10783 cache: false 10784}; 10785 10786var eventGUID = 1; 10787 10788 10789function EventManager() { // assumed to be a calendar 10790 var t = this; 10791 10792 10793 // exports 10794 t.isFetchNeeded = isFetchNeeded; 10795 t.fetchEvents = fetchEvents; 10796 t.fetchEventSources = fetchEventSources; 10797 t.getEventSources = getEventSources; 10798 t.getEventSourceById = getEventSourceById; 10799 t.getEventSourcesByMatchArray = getEventSourcesByMatchArray; 10800 t.getEventSourcesByMatch = getEventSourcesByMatch; 10801 t.addEventSource = addEventSource; 10802 t.removeEventSource = removeEventSource; 10803 t.removeEventSources = removeEventSources; 10804 t.updateEvent = updateEvent; 10805 t.renderEvent = renderEvent; 10806 t.removeEvents = removeEvents; 10807 t.clientEvents = clientEvents; 10808 t.mutateEvent = mutateEvent; 10809 t.normalizeEventDates = normalizeEventDates; 10810 t.normalizeEventTimes = normalizeEventTimes; 10811 10812 10813 // imports 10814 var reportEvents = t.reportEvents; 10815 10816 10817 // locals 10818 var stickySource = { events: [] }; 10819 var sources = [ stickySource ]; 10820 var rangeStart, rangeEnd; 10821 var pendingSourceCnt = 0; // outstanding fetch requests, max one per source 10822 var cache = []; // holds events that have already been expanded 10823 10824 10825 $.each( 10826 (t.options.events ? [ t.options.events ] : []).concat(t.options.eventSources || []),
10827 function(i, sourceInput) { 10828 var source = buildEventSource(sourceInput); 10829 if (source) { 10830 sources.push(source); 10831 } 10832 } 10833 ); 10834 10835 10836 10837 /* Fetching 10838 -----------------------------------------------------------------------------*/ 10839 10840 10841 // start and end are assumed to be unzoned 10842 function isFetchNeeded(start, end) { 10843 return !rangeStart || // nothing has been fetched yet? 10844 start < rangeStart || end > rangeEnd; // is part of the new range outside of the old range? 10845 } 10846 10847 10848 function fetchEvents(start, end) { 10849 rangeStart = start; 10850 rangeEnd = end; 10851 fetchEventSources(sources, 'reset'); 10852 } 10853 10854 10855 // expects an array of event source objects (the originals, not copies) 10856 // `specialFetchType` is an optimization parameter that affects purging of the event cache. 10857 function fetchEventSources(specificSources, specialFetchType) { 10858 var i, source; 10859 10860 if (specialFetchType === 'reset') { 10861 cache = []; 10862 } 10863 else if (specialFetchType !== 'add') { 10864 cache = excludeEventsBySources(cache, specificSources); 10865 } 10866 10867 for (i = 0; i < specificSources.length; i++) { 10868 source = specificSources[i]; 10869 10870 // already-pending sources have already been accounted for in pendingSourceCnt 10871 if (source._status !== 'pending') { 10872 pendingSourceCnt++; 10873 } 10874 10875 source._fetchId = (source._fetchId || 0) + 1; 10876 source._status = 'pending'; 10877 } 10878 10879 for (i = 0; i < specificSources.length; i++) { 10880 source = specificSources[i]; 10881 10882 tryFetchEventSource(source, source._fetchId); 10883 } 10884 } 10885 10886 10887 // fetches an event source and processes its result ONLY if it is still the current fetch. 10888 // caller is responsible for incrementing pendingSourceCnt first. 10889 function tryFetchEventSource(source, fetchId) { 10890 _fetchEventSource(source, function(eventInputs) { 10891 var isArraySource = $.isArray(source.events); 10892 var i, eventInput; 10893 var abstractEvent; 10894 10895 if ( 10896 // is this the source's most recent fetch? 10897 // if not, rely on an upcoming fetch of this source to decrement pendingSourceCnt 10898 fetchId === source._fetchId && 10899 // event source no longer valid? 10900 source._status !== 'rejected' 10901 ) { 10902 source._status = 'resolved'; 10903 10904 if (eventInputs) { 10905 for (i = 0; i < eventInputs.length; i++) { 10906 eventInput = eventInputs[i]; 10907 10908 if (isArraySource) { // array sources have already been convert to Event Objects 10909 abstractEvent = eventInput; 10910 } 10911 else { 10912 abstractEvent = buildEventFromInput(eventInput, source); 10913 } 10914 10915 if (abstractEvent) { // not false (an invalid event) 10916 cache.push.apply( 10917 cache, 10918 expandEvent(abstractEvent) // add individual expanded events to the cache 10919 ); 10920 } 10921 } 10922 } 10923 10924 decrementPendingSourceCnt(); 10925 } 10926 }); 10927 } 10928 10929 10930 function rejectEventSource(source) { 10931 var wasPending = source._status === 'pending'; 10932 10933 source._status = 'rejected'; 10934 10935 if (wasPending) { 10936 decrementPendingSourceCnt(); 10937 } 10938 } 10939 10940 10941 function decrementPendingSourceCnt() { 10942 pendingSourceCnt--; 10943 if (!pendingSourceCnt) { 10944 reportEvents(cache); 10945 } 10946 } 10947 10948 10949 function _fetchEventSource(source, callback) { 10950 var i; 10951 var fetchers = FC.sourceFetchers; 10952 var res; 10953 10954 for (i=0; i<fetchers.length; i++) { 10955 res = fetchers[i].call( 10956 t, // this, the Calendar object 10957 source, 10958 rangeStart.clone(), 10959 rangeEnd.clone(), 10960 t.options.timezone, 10961 callback 10962 ); 10963 10964 if (res === true) { 10965 // the fetcher is in charge. made its own async request 10966 return; 10967 } 10968 else if (typeof res == 'object') { 10969 // the fetcher returned a new source. process it 10970 _fetchEventSource(res, callback); 10971 return; 10972 } 10973 } 10974 10975 var events = source.events; 10976 if (events) { 10977 if ($.isFunction(events)) { 10978 t.pushLoading(); 10979 events.call( 10980 t, // this, the Calendar object 10981 rangeStart.clone(), 10982 rangeEnd.clone(), 10983 t.options.timezone, 10984 function(events) { 10985 callback(events); 10986 t.popLoading(); 10987 } 10988 ); 10989 } 10990 else if ($.isArray(events)) { 10991 callback(events); 10992 } 10993 else { 10994 callback(); 10995 } 10996 }else{ 10997 var url = source.url; 10998 if (url) { 10999 var success = source.success; 11000 var error = source.error; 11001 var complete = source.complete; 11002 11003 // retrieve any outbound GET/POST $.ajax data from the options 11004 var customData; 11005 if ($.isFunction(source.data)) { 11006 // supplied as a function that returns a key/value object 11007 customData = source.data(); 11008 } 11009 else { 11010 // supplied as a straight key/value object 11011 customData = source.data; 11012 } 11013 11014 // use a copy of the custom data so we can modify the parameters 11015 // and not affect the passed-in object. 11016 var data = $.extend({}, customData || {}); 11017 11018 var startParam = firstDefined(source.startParam, t.options.startParam); 11019 var endParam = firstDefined(source.endParam, t.options.endParam); 11020 var timezoneParam = firstDefined(source.timezoneParam, t.options.timezoneParam); 11021 11022 if (startParam) { 11023 data[startParam] = rangeStart.format(); 11024 } 11025 if (endParam) { 11026 data[endParam] = rangeEnd.format(); 11027 } 11028 if (t.options.timezone && t.options.timezone != 'local') { 11029 data[timezoneParam] = t.options.timezone; 11030 } 11031 11032 t.pushLoading(); 11033 $.ajax($.extend({}, ajaxDefaults, source, { 11034 data: data, 11035 success: function(events) { 11036 events = events || []; 11037 var res = applyAll(success, this, arguments); 11038 if ($.isArray(res)) { 11039 events = res; 11040 } 11041 callback(events); 11042 }, 11043 error: function() { 11044 applyAll(error, this, arguments); 11045 callback(); 11046 }, 11047 complete: function() { 11048 applyAll(complete, this, arguments); 11049 t.popLoading(); 11050 } 11051 })); 11052 }else{ 11053 callback(); 11054 } 11055 } 11056 } 11057 11058 11059 11060 /* Sources 11061 -----------------------------------------------------------------------------*/ 11062 11063 11064 function addEventSource(sourceInput) { 11065 var source = buildEventSource(sourceInput); 11066 if (source) { 11067 sources.push(source); 11068 fetchEventSources([ source ], 'add'); // will eventually call reportEvents 11069 } 11070 } 11071 11072 11073 function buildEventSource(sourceInput) { // will return undefined if invalid source 11074 var normalizers = FC.sourceNormalizers; 11075 var source; 11076 var i; 11077 11078 if ($.isFunction(sourceInput) || $.isArray(sourceInput)) {
11079 source = { events: sourceInput }; 11080 } 11081 else if (typeof sourceInput === 'string') { 11082 source = { url: sourceInput }; 11083 } 11084 else if (typeof sourceInput === 'object') { 11085 source = $.extend({}, sourceInput); // shallow copy 11086 } 11087 11088 if (source) { 11089 11090 // TODO: repeat code, same code for event classNames 11091 if (source.className) { 11092 if (typeof source.className === 'string') { 11093 source.className = source.className.split(/\s+/); 11094 } 11095 // otherwise, assumed to be an array 11096 } 11097 else { 11098 source.className = []; 11099 } 11100 11101 // for array sources, we convert to standard Event Objects up front 11102 if ($.isArray(source.events)) { 11103 source.origArray = source.events; // for removeEventSource 11104 source.events = $.map(source.events, function(eventInput) { 11105 return buildEventFromInput(eventInput, source); 11106 }); 11107 } 11108 11109 for (i=0; i<normalizers.length; i++) { 11110 normalizers[i].call(t, source); 11111 } 11112 11113 return source; 11114 } 11115 } 11116 11117 11118 function removeEventSource(matchInput) { 11119 removeSpecificEventSources( 11120 getEventSourcesByMatch(matchInput) 11121 ); 11122 } 11123 11124 11125 // if called with no arguments, removes all. 11126 function removeEventSources(matchInputs) { 11127 if (matchInputs == null) { 11128 removeSpecificEventSources(sources, true); // isAll=true 11129 } 11130 else { 11131 removeSpecificEventSources( 11132 getEventSourcesByMatchArray(matchInputs) 11133 ); 11134 } 11135 } 11136 11137 11138 function removeSpecificEventSources(targetSources, isAll) { 11139 var i; 11140 11141 // cancel pending requests 11142 for (i = 0; i < targetSources.length; i++) { 11143 rejectEventSource(targetSources[i]); 11144 } 11145 11146 if (isAll) { // an optimization 11147 sources = []; 11148 cache = []; 11149 } 11150 else { 11151 // remove from persisted source list 11152 sources = $.grep(sources, function(source) { 11153 for (i = 0; i < targetSources.length; i++) { 11154 if (source === targetSources[i]) { 11155 return false; // exclude 11156 } 11157 } 11158 return true; // include 11159 }); 11160 11161 cache = excludeEventsBySources(cache, targetSources); 11162 } 11163 11164 reportEvents(cache); 11165 } 11166 11167 11168 function getEventSources() { 11169 return sources.slice(1); // returns a shallow copy of sources with stickySource removed 11170 } 11171 11172 11173 function getEventSourceById(id) { 11174 return $.grep(sources, function(source) { 11175 return source.id && source.id === id; 11176 })[0]; 11177 } 11178 11179 11180 // like getEventSourcesByMatch, but accepts multple match criteria (like multiple IDs) 11181 function getEventSourcesByMatchArray(matchInputs) { 11182 11183 // coerce into an array 11184 if (!matchInputs) { 11185 matchInputs = []; 11186 } 11187 else if (!$.isArray(matchInputs)) { 11188 matchInputs = [ matchInputs ]; 11189 } 11190 11191 var matchingSources = []; 11192 var i; 11193 11194 // resolve raw inputs to real event source objects 11195 for (i = 0; i < matchInputs.length; i++) { 11196 matchingSources.push.apply( // append 11197 matchingSources, 11198 getEventSourcesByMatch(matchInputs[i]) 11199 ); 11200 } 11201 11202 return matchingSources; 11203 } 11204 11205 11206 // matchInput can either by a real event source object, an ID, or the function/URL for the source. 11207 // returns an array of matching source objects. 11208 function getEventSourcesByMatch(matchInput) { 11209 var i, source; 11210 11211 // given an proper event source object 11212 for (i = 0; i < sources.length; i++) { 11213 source = sources[i]; 11214 if (source === matchInput) { 11215 return [ source ]; 11216 } 11217 } 11218 11219 // an ID match 11220 source = getEventSourceById(matchInput); 11221 if (source) { 11222 return [ source ]; 11223 } 11224 11225 return $.grep(sources, function(source) { 11226 return isSourcesEquivalent(matchInput, source); 11227 }); 11228 } 11229 11230 11231 function isSourcesEquivalent(source1, source2) { 11232 return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2); 11233 } 11234 11235 11236 function getSourcePrimitive(source) { 11237 return ( 11238 (typeof source === 'object') ? // a normalized event source? 11239 (source.origArray || source.googleCalendarId || source.url || source.events) : // get the primitive 11240 null 11241 ) || 11242 source; // the given argument *is* the primitive 11243 } 11244 11245 11246 // util 11247 // returns a filtered array without events that are part of any of the given sources 11248 function excludeEventsBySources(specificEvents, specificSources) { 11249 return $.grep(specificEvents, function(event) { 11250 for (var i = 0; i < specificSources.length; i++) { 11251 if (event.source === specificSources[i]) { 11252 return false; // exclude 11253 } 11254 } 11255 return true; // keep 11256 }); 11257 } 11258 11259 11260 11261 /* Manipulation 11262 -----------------------------------------------------------------------------*/ 11263 11264 11265 // Only ever called from the externally-facing API 11266 function updateEvent(event) { 11267 11268 // massage start/end values, even if date string values 11269 event.start = t.moment(event.start);
11270 if (event.end) { 11271 event.end = t.moment(event.end); 11272 } 11273 else { 11274 event.end = null; 11275 } 11276 11277 mutateEvent(event, getMiscEventProps(event)); // will handle start/end/allDay normalization 11278 reportEvents(cache); // reports event modifications (so we can redraw) 11279 } 11280 11281 11282 // Returns a hash of misc event properties that should be copied over to related events. 11283 function getMiscEventProps(event) { 11284 var props = {}; 11285 11286 $.each(event, function(name, val) { 11287 if (isMiscEventPropName(name)) { 11288 if (val !== undefined && isAtomic(val)) { // a defined non-object 11289 props[name] = val; 11290 } 11291 } 11292 }); 11293 11294 return props; 11295 } 11296 11297 // non-date-related, non-id-related, non-secret 11298 function isMiscEventPropName(name) { 11299 return !/^_|^(id|allDay|start|end)$/.test(name); 11300 } 11301 11302 11303 // returns the expanded events that were created 11304 function renderEvent(eventInput, stick) { 11305 var abstractEvent = buildEventFromInput(eventInput); 11306 var events; 11307 var i, event; 11308 11309 if (abstractEvent) { // not false (a valid input) 11310 events = expandEvent(abstractEvent); 11311 11312 for (i = 0; i < events.length; i++) { 11313 event = events[i]; 11314 11315 if (!event.source) { 11316 if (stick) { 11317 stickySource.events.push(event); 11318 event.source = stickySource; 11319 } 11320 cache.push(event); 11321 } 11322 } 11323 11324 reportEvents(cache); 11325 11326 return events; 11327 } 11328 11329 return []; 11330 } 11331 11332 11333 function removeEvents(filter) { 11334 var eventID; 11335 var i; 11336 11337 if (filter == null) { // null or undefined. remove all events 11338 filter = function() { return true; }; // will always match 11339 } 11340 else if (!$.isFunction(filter)) { // an event ID 11341 eventID = filter + ''; 11342 filter = function(event) { 11343 return event._id == eventID; 11344 }; 11345 } 11346 11347 // Purge event(s) from our local cache 11348 cache = $.grep(cache, filter, true); // inverse=true 11349 11350 // Remove events from array sources. 11351 // This works because they have been converted to official Event Objects up front. 11352 // (and as a result, event._id has been calculated). 11353 for (i=0; i<sources.length; i++) { 11354 if ($.isArray(sources[i].events)) { 11355 sources[i].events = $.grep(sources[i].events, filter, true); 11356 } 11357 } 11358 11359 reportEvents(cache); 11360 } 11361 11362 11363 function clientEvents(filter) { 11364 if ($.isFunction(filter)) { 11365 return $.grep(cache, filter); 11366 } 11367 else if (filter != null) { // not null, not undefined. an event ID 11368 filter += ''; 11369 return $.grep(cache, function(e) { 11370 return e._id == filter; 11371 }); 11372 } 11373 return cache; // else, return all 11374 } 11375 11376 11377 // Makes sure all array event sources have their internal event objects 11378 // converted over to the Calendar's current timezone. 11379 t.rezoneArrayEventSources = function() { 11380 var i; 11381 var events; 11382 var j; 11383 11384 for (i = 0; i < sources.length; i++) { 11385 events = sources[i].events; 11386 if ($.isArray(events)) { 11387 11388 for (j = 0; j < events.length; j++) { 11389 rezoneEventDates(events[j]); 11390 } 11391 } 11392 } 11393 }; 11394 11395 function rezoneEventDates(event) { 11396 event.start = t.moment(event.start); 11397 if (event.end) { 11398 event.end = t.moment(event.end); 11399 } 11400 backupEventDates(event); 11401 } 11402 11403 11404 /* Event Normalization 11405 -----------------------------------------------------------------------------*/ 11406 11407 11408 // Given a raw object with key/value properties, returns an "abstract" Event object. 11409 // An "abstract" event is an event that, if recurring, will not have been expanded yet. 11410 // Will return `false` when input is invalid. 11411 // `source` is optional 11412 function buildEventFromInput(input, source) { 11413 var out = {}; 11414 var start, end; 11415 var allDay; 11416 11417 if (t.options.eventDataTransform) { 11418 input = t.options.eventDataTransform(input); 11419 } 11420 if (source && source.eventDataTransform) { 11421 input = source.eventDataTransform(input); 11422 } 11423 11424 // Copy all properties over to the resulting object. 11425 // The special-case properties will be copied over afterwards. 11426 $.extend(out, input); 11427 11428 if (source) { 11429 out.source = source; 11430 } 11431 11432 out._id = input._id || (input.id === undefined ? '_fc' + eventGUID++ : input.id + ''); 11433 11434 if (input.className) { 11435 if (typeof input.className == 'string') { 11436 out.className = input.className.split(/\s+/); 11437 } 11438 else { // assumed to be an array 11439 out.className = input.className; 11440 } 11441 } 11442 else { 11443 out.className = []; 11444 } 11445 11446 start = input.start || input.date; // "date" is an alias for "start" 11447 end = input.end; 11448 11449 // parse as a time (Duration) if applicable
11450 if (isTimeString(start)) { 11451 start = moment.duration(start); 11452 } 11453 if (isTimeString(end)) { 11454 end = moment.duration(end); 11455 } 11456 11457 if (input.dow || moment.isDuration(start) || moment.isDuration(end)) { 11458 11459 // the event is "abstract" (recurring) so don't calculate exact start/end dates just yet 11460 out.start = start ? moment.duration(start) : null; // will be a Duration or null 11461 out.end = end ? moment.duration(end) : null; // will be a Duration or null 11462 out._recurring = true; // our internal marker 11463 } 11464 else { 11465 11466 if (start) { 11467 start = t.moment(start); 11468 if (!start.isValid()) { 11469 return false; 11470 } 11471 } 11472 11473 if (end) { 11474 end = t.moment(end); 11475 if (!end.isValid()) { 11476 end = null; // let defaults take over 11477 } 11478 } 11479 11480 allDay = input.allDay; 11481 if (allDay === undefined) { // still undefined? fallback to default 11482 allDay = firstDefined( 11483 source ? source.allDayDefault : undefined, 11484 t.options.allDayDefault 11485 ); 11486 // still undefined? normalizeEventDates will calculate it 11487 } 11488 11489 assignDatesToEvent(start, end, allDay, out); 11490 } 11491 11492 t.normalizeEvent(out); // hook for external use. a prototype method 11493 11494 return out; 11495 } 11496 t.buildEventFromInput = buildEventFromInput; 11497 11498 11499 // Normalizes and assigns the given dates to the given partially-formed event object. 11500 // NOTE: mutates the given start/end moments. does not make a copy. 11501 function assignDatesToEvent(start, end, allDay, event) { 11502 event.start = start; 11503 event.end = end; 11504 event.allDay = allDay; 11505 normalizeEventDates(event); 11506 backupEventDates(event); 11507 } 11508 11509 11510 // Ensures proper values for allDay/start/end. Accepts an Event object, or a plain object with event-ish properties. 11511 // NOTE: Will modify the given object. 11512 function normalizeEventDates(eventProps) { 11513 11514 normalizeEventTimes(eventProps); 11515 11516 if (eventProps.end && !eventProps.end.isAfter(eventProps.start)) { 11517 eventProps.end = null; 11518 } 11519 11520 if (!eventProps.end) { 11521 if (t.options.forceEventDuration) { 11522 eventProps.end = t.getDefaultEventEnd(eventProps.allDay, eventProps.start); 11523 } 11524 else { 11525 eventProps.end = null; 11526 } 11527 } 11528 } 11529 11530 11531 // Ensures the allDay property exists and the timeliness of the start/end dates are consistent 11532 function normalizeEventTimes(eventProps) { 11533 if (eventProps.allDay == null) { 11534 eventProps.allDay = !(eventProps.start.hasTime() || (eventProps.end && eventProps.end.hasTime())); 11535 } 11536 11537 if (eventProps.allDay) { 11538 eventProps.start.stripTime(); 11539 if (eventProps.end) { 11540 // TODO: consider nextDayThreshold here? If so, will require a lot of testing and adjustment 11541 eventProps.end.stripTime(); 11542 } 11543 } 11544 else { 11545 if (!eventProps.start.hasTime()) { 11546 eventProps.start = t.applyTimezone(eventProps.start.time(0)); // will assign a 00:00 time 11547 } 11548 if (eventProps.end && !eventProps.end.hasTime()) { 11549 eventProps.end = t.applyTimezone(eventProps.end.time(0)); // will assign a 00:00 time 11550 } 11551 } 11552 } 11553 11554 11555 // If the given event is a recurring event, break it down into an array of individual instances. 11556 // If not a recurring event, return an array with the single original event. 11557 // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array. 11558 // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours). 11559 function expandEvent(abstractEvent, _rangeStart, _rangeEnd) { 11560 var events = []; 11561 var dowHash; 11562 var dow; 11563 var i; 11564 var date; 11565 var startTime, endTime; 11566 var start, end; 11567 var event; 11568 11569 _rangeStart = _rangeStart || rangeStart; 11570 _rangeEnd = _rangeEnd || rangeEnd; 11571 11572 if (abstractEvent) { 11573 if (abstractEvent._recurring) { 11574 11575 // make a boolean hash as to whether the event occurs on each day-of-week 11576 if ((dow = abstractEvent.dow)) { 11577 dowHash = {}; 11578 for (i = 0; i < dow.length; i++) { 11579 dowHash[dow[i]] = true; 11580 } 11581 } 11582 11583 // iterate through every day in the current range 11584 date = _rangeStart.clone().stripTime(); // holds the date of the current day 11585 while (date.isBefore(_rangeEnd)) { 11586 11587 if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week 11588 11589 startTime = abstractEvent.start; // the stored start and end properties are times (Durations) 11590 endTime = abstractEvent.end; // " 11591 start = date.clone(); 11592 end = null; 11593 11594 if (startTime) { 11595 start = start.time(startTime); 11596 } 11597 if (endTime) { 11598 end = date.clone().time(endTime); 11599 } 11600 11601 event = $.extend({}, abstractEvent); // make a copy of the original 11602 assignDatesToEvent( 11603 start, end, 11604 !startTime && !endTime, // allDay? 11605 event 11606 ); 11607 events.push(event); 11608 } 11609 11610 date.add(1, 'days'); 11611 } 11612 } 11613 else { 11614 events.push(abstractEvent); // return the original event. will be a one-item array 11615 } 11616 } 11617 11618 return events; 11619 } 11620 t.expandEvent = expandEvent; 11621 11622 11623 11624 /* Event Modification Math 11625 -----------------------------------------------------------------------------------------*/ 11626 11627 11628 // Modifies an event and all related events by applying the given properties. 11629 // Special date-diffing logic is used for manipulation of dates. 11630 // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end. 11631 // All date comparisons are done against the event's pristine _start and _end dates. 11632 // Returns an object with delta information and a function to undo all operations. 11633 // For making computations in a granularity greater than day/time, specify largeUnit. 11634 // NOTE: The given `newProps` might be mutated for normalization purposes. 11635 function mutateEvent(event, newProps, largeUnit) { 11636 var miscProps = {}; 11637 var oldProps; 11638 var clearEnd; 11639 var startDelta; 11640 var endDelta; 11641 var durationDelta;
11642 var undoFunc; 11643 11644 // diffs the dates in the appropriate way, returning a duration 11645 function diffDates(date1, date0) { // date1 - date0 11646 if (largeUnit) { 11647 return diffByUnit(date1, date0, largeUnit); 11648 } 11649 else if (newProps.allDay) { 11650 return diffDay(date1, date0); 11651 } 11652 else { 11653 return diffDayTime(date1, date0); 11654 } 11655 } 11656 11657 newProps = newProps || {}; 11658 11659 // normalize new date-related properties 11660 if (!newProps.start) { 11661 newProps.start = event.start.clone(); 11662 } 11663 if (newProps.end === undefined) { 11664 newProps.end = event.end ? event.end.clone() : null; 11665 } 11666 if (newProps.allDay == null) { // is null or undefined? 11667 newProps.allDay = event.allDay; 11668 } 11669 normalizeEventDates(newProps); 11670 11671 // create normalized versions of the original props to compare against 11672 // need a real end value, for diffing 11673 oldProps = { 11674 start: event._start.clone(), 11675 end: event._end ? event._end.clone() : t.getDefaultEventEnd(event._allDay, event._start), 11676 allDay: newProps.allDay // normalize the dates in the same regard as the new properties 11677 }; 11678 normalizeEventDates(oldProps); 11679 11680 // need to clear the end date if explicitly changed to null 11681 clearEnd = event._end !== null && newProps.end === null; 11682 11683 // compute the delta for moving the start date 11684 startDelta = diffDates(newProps.start, oldProps.start); 11685 11686 // compute the delta for moving the end date 11687 if (newProps.end) { 11688 endDelta = diffDates(newProps.end, oldProps.end); 11689 durationDelta = endDelta.subtract(startDelta); 11690 } 11691 else { 11692 durationDelta = null; 11693 } 11694 11695 // gather all non-date-related properties 11696 $.each(newProps, function(name, val) { 11697 if (isMiscEventPropName(name)) { 11698 if (val !== undefined) { 11699 miscProps[name] = val; 11700 } 11701 } 11702 }); 11703 11704 // apply the operations to the event and all related events 11705 undoFunc = mutateEvents( 11706 clientEvents(event._id), // get events with this ID 11707 clearEnd, 11708 newProps.allDay, 11709 startDelta, 11710 durationDelta, 11711 miscProps 11712 ); 11713 11714 return { 11715 dateDelta: startDelta, 11716 durationDelta: durationDelta, 11717 undo: undoFunc 11718 }; 11719 } 11720 11721 11722 // Modifies an array of events in the following ways (operations are in order): 11723 // - clear the event's `end` 11724 // - convert the event to allDay 11725 // - add `dateDelta` to the start and end 11726 // - add `durationDelta` to the event's duration 11727 // - assign `miscProps` to the event 11728 // 11729 // Returns a function that can be called to undo all the operations. 11730 // 11731 // TODO: don't use so many closures. possible memory issues when lots of events with same ID. 11732 // 11733 function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) { 11734 var isAmbigTimezone = t.getIsAmbigTimezone(); 11735 var undoFunctions = []; 11736 11737 // normalize zero-length deltas to be null 11738 if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; } 11739 if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; } 11740 11741 $.each(events, function(i, event) { 11742 var oldProps; 11743 var newProps; 11744 11745 // build an object holding all the old values, both date-related and misc. 11746 // for the undo function. 11747 oldProps = { 11748 start: event.start.clone(), 11749 end: event.end ? event.end.clone() : null, 11750 allDay: event.allDay 11751 }; 11752 $.each(miscProps, function(name) { 11753 oldProps[name] = event[name]; 11754 }); 11755 11756 // new date-related properties. work off the original date snapshot. 11757 // ok to use references because they will be thrown away when backupEventDates is called. 11758 newProps = { 11759 start: event._start, 11760 end: event._end, 11761 allDay: allDay // normalize the dates in the same regard as the new properties 11762 }; 11763 normalizeEventDates(newProps); // massages start/end/allDay 11764 11765 // strip or ensure the end date 11766 if (clearEnd) { 11767 newProps.end = null; 11768 } 11769 else if (durationDelta && !newProps.end) { // the duration translation requires an end date 11770 newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start); 11771 } 11772 11773 if (dateDelta) { 11774 newProps.start.add(dateDelta); 11775 if (newProps.end) { 11776 newProps.end.add(dateDelta); 11777 } 11778 } 11779 11780 if (durationDelta) { 11781 newProps.end.add(durationDelta); // end already ensured above 11782 } 11783 11784 // if the dates have changed, and we know it is impossible to re
11784compute the 11785 // timezone offsets, strip the zone. 11786 if ( 11787 isAmbigTimezone && 11788 !newProps.allDay && 11789 (dateDelta || durationDelta) 11790 ) { 11791 newProps.start.stripZone(); 11792 if (newProps.end) { 11793 newProps.end.stripZone(); 11794 } 11795 } 11796 11797 $.extend(event, miscProps, newProps); // copy over misc props, then date-related props 11798 backupEventDates(event); // regenerate internal _start/_end/_allDay 11799 11800 undoFunctions.push(function() { 11801 $.extend(event, oldProps); 11802 backupEventDates(event); // regenerate internal _start/_end/_allDay 11803 }); 11804 }); 11805 11806 return function() { 11807 for (var i = 0; i < undoFunctions.length; i++) { 11808 undoFunctions[i](); 11809 } 11810 }; 11811 } 11812 11813 11814 t.getEventCache = function() { 11815 return cache; 11816 }; 11817 11818} 11819 11820 11821// hook for external libs to manipulate event properties upon creation. 11822// should manipulate the event in-place. 11823Calendar.prototype.normalizeEvent = function(event) { 11824}; 11825 11826 11827// Does the given span (start, end, and other location information) 11828// fully contain the other? 11829Calendar.prototype.spanContainsSpan = function(outerSpan, innerSpan) { 11830 var eventStart = outerSpan.start.clone().stripZone(); 11831 var eventEnd = this.getEventEnd(outerSpan).stripZone(); 11832 11833 return innerSpan.start >= eventStart && innerSpan.end <= eventEnd; 11834}; 11835 11836 11837// Returns a list of events that the given event should be compared against when being considered for a move to 11838// the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar. 11839Calendar.prototype.getPeerEvents = function(span, event) { 11840 var cache = this.getEventCache(); 11841 var peerEvents = []; 11842 var i, otherEvent; 11843 11844 for (i = 0; i < cache.length; i++) { 11845 otherEvent = cache[i]; 11846 if ( 11847 !event || 11848 event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events 11849 ) { 11850 peerEvents.push(otherEvent); 11851 } 11852 } 11853 11854 return peerEvents; 11855}; 11856 11857 11858// updates the "backup" properties, which are preserved in order to compute diffs later on. 11859function backupEventDates(event) { 11860 event._allDay = event.allDay; 11861 event._start = event.start.clone(); 11862 event._end = event.end ? event.end.clone() : null; 11863} 11864 11865 11866/* Overlapping / Constraining 11867-----------------------------------------------------------------------------------------*/ 11868 11869 11870// Determines if the given event can be relocated to the given span (unzoned start/end with other misc data) 11871Calendar.prototype.isEventSpanAllowed = function(span, event) { 11872 var source = event.source || {}; 11873 11874 var constraint = firstDefined( 11875 event.constraint, 11876 source.constraint, 11877 this.options.eventConstraint 11878 ); 11879 11880 var overlap = firstDefined( 11881 event.overlap, 11882 source.overlap, 11883 this.options.eventOverlap 11884 ); 11885 11886 return this.isSpanAllowed(span, constraint, overlap, event) && 11887 (!this.options.eventAllow || this.options.eventAllow(span, event) !== false); 11888}; 11889 11890 11891// Determines if an external event can be relocated to the given span (unzoned start/end with other misc data) 11892Calendar.prototype.isExternalSpanAllowed = function(eventSpan, eventLocation, eventProps) { 11893 var eventInput; 11894 var event; 11895 11896 // note: very similar logic is in View's reportExternalDrop 11897 if (eventProps) { 11898 eventInput = $.extend({}, eventProps, eventLocation); 11899 event = this.expandEvent( 11900 this.buildEventFromInput(eventInput) 11901 )[0]; 11902 } 11903 11904 if (event) { 11905 return this.isEventSpanAllowed(eventSpan, event); 11906 } 11907 else { // treat it as a selection 11908 11909 return this.isSelectionSpanAllowed(eventSpan); 11910 } 11911}; 11912 11913 11914// Determines the given span (unzoned start/end with other misc data) can be selected. 11915Calendar.prototype.isSelectionSpanAllowed = function(span) { 11916 return this.isSpanAllowed(span, this.options.selectConstraint, this.options.selectOverlap) && 11917 (!this.options.selectAllow || this.options.selectAllow(span) !== false); 11918}; 11919 11920 11921// Returns true if the given span (caused by an event drop/resize or a selection) is allowed to exist 11922// according to the constraint/overlap settings. 11923// `event` is not required if checking a selection. 11924Calendar.prototype.isSpanAllowed = function(span, constraint, overlap, event) { 11925 var constraintEvents; 11926 var anyContainment; 11927 var peerEvents; 11928 var i, peerEvent; 11929 var peerOverlap; 11930 11931 // the range must be fully contained by at least one of produced constraint events 11932 if (constraint != null) { 11933 11934 // not treated as an event! intermediate data structure 11935 // TODO: use ranges in the future 11936 constraintEvents = this.constraintToEvents(constraint); 11937 if (constraintEvents) { // not invalid 11938 11939 anyContainment = false; 11940 for (i = 0; i < constraintEvents.length; i++) { 11941 if (this.spanContainsSpan(constraintEvents[i], span)) { 11942 anyContainment = true; 11943 break; 11944 } 11945 } 11946 11947 if (!anyContainment) { 11948 return false; 11949 } 11950 } 11951 } 11952 11953 peerEvents = this.getPeerEvents(span, event); 11954 11955 for (i = 0; i < peerEvents.length; i++) { 11956 peerEvent = peerEvents[i]; 11957 11958 // there needs to be an actual intersection before disallowing anything 11959 if (this.eventIntersectsRange(peerEvent, span)) { 11960 11961 // evaluate overlap for the given range and short-circuit if necessary 11962 if (overlap === false) { 11963 return false; 11964 } 11965 // if the event's overlap is a test function, pass the peer event in question as the first param 11966 else if (typeof overlap === 'function' && !overlap(peerEvent, event)) { 11967 return false; 11968 } 11969 11970 // if we are computing if the given range is allowable for an event, consider the other event's 11971 // EventObject-specific or Source-specific `overlap` property 11972 if (event) { 11973 peerOverlap = firstDefined( 11974 peerEvent.overlap,
11975 (peerEvent.source || {}).overlap 11976 // we already considered the global `eventOverlap` 11977 ); 11978 if (peerOverlap === false) { 11979 return false; 11980 } 11981 // if the peer event's overlap is a test function, pass the subject event as the first param 11982 if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) { 11983 return false; 11984 } 11985 } 11986 } 11987 } 11988 11989 return true; 11990}; 11991 11992 11993// Given an event input from the API, produces an array of event objects. Possible event inputs: 11994// 'businessHours' 11995// An event ID (number or string) 11996// An object with specific start/end dates or a recurring event (like what businessHours accepts) 11997Calendar.prototype.constraintToEvents = function(constraintInput) { 11998 11999 if (constraintInput === 'businessHours') { 12000 return this.getCurrentBusinessHourEvents(); 12001 } 12002 12003 if (typeof constraintInput === 'object') { 12004 if (constraintInput.start != null) { // needs to be event-like input 12005 return this.expandEvent(this.buildEventFromInput(constraintInput)); 12006 } 12007 else { 12008 return null; // invalid 12009 } 12010 } 12011 12012 return this.clientEvents(constraintInput); // probably an ID 12013}; 12014 12015 12016// Does the event's date range intersect with the given range? 12017// start/end already assumed to have stripped zones :( 12018Calendar.prototype.eventIntersectsRange = function(event, range) { 12019 var eventStart = event.start.clone().stripZone(); 12020 var eventEnd = this.getEventEnd(event).stripZone(); 12021 12022 return range.start < eventEnd && range.end > eventStart; 12023}; 12024 12025 12026/* Business Hours 12027-----------------------------------------------------------------------------------------*/ 12028 12029var BUSINESS_HOUR_EVENT_DEFAULTS = { 12030 id: '_fcBusinessHours', // will relate events from different calls to expandEvent 12031 start: '09:00', 12032 end: '17:00', 12033 dow: [ 1, 2, 3, 4, 5 ], // monday - friday 12034 rendering: 'inverse-background' 12035 // classNames are defined in businessHoursSegClasses 12036}; 12037 12038// Return events objects for business hours within the current view. 12039// Abuse of our event system :( 12040Calendar.prototype.getCurrentBusinessHourEvents = function(wholeDay) { 12041 return this.computeBusinessHourEvents(wholeDay, this.options.businessHours); 12042}; 12043 12044// Given a raw input value from options, return events objects for business hours within the current view. 12045Calendar.prototype.computeBusinessHourEvents = function(wholeDay, input) { 12046 if (input === true) { 12047 return this.expandBusinessHourEvents(wholeDay, [ {} ]); 12048 } 12049 else if ($.isPlainObject(input)) { 12050 return this.expandBusinessHourEvents(wholeDay, [ input ]); 12051 } 12052 else if ($.isArray(input)) { 12053 return this.expandBusinessHourEvents(wholeDay, input, true); 12054 } 12055 else { 12056 return []; 12057 } 12058}; 12059 12060// inputs expected to be an array of objects. 12061// if ignoreNoDow is true, will ignore entries that don't specify a day-of-week (dow) key. 12062Calendar.prototype.expandBusinessHourEvents = function(wholeDay, inputs, ignoreNoDow) { 12063 var view = this.getView(); 12064 var events = []; 12065 var i, input; 12066 12067 for (i = 0; i < inputs.length; i++) { 12068 input = inputs[i]; 12069 12070 if (ignoreNoDow && !input.dow) { 12071 continue; 12072 } 12073 12074 // give defaults. will make a copy 12075 input = $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, input); 12076 12077 // if a whole-day series is requested, clear the start/end times 12078 if (wholeDay) { 12079 input.start = null; 12080 input.end = null; 12081 } 12082 12083 events.push.apply(events, // append 12084 this.expandEvent( 12085 this.buildEventFromInput(input), 12086 view.start, 12087 view.end 12088 ) 12089 ); 12090 } 12091 12092 return events; 12093}; 12094 12095;; 12096 12097/* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells. 12098----------------------------------------------------------------------------------------------------------------------*/ 12099// It is a manager for a DayGrid subcomponent, which does most of the heavy lifting. 12100// It is responsible for managing width/height. 12101 12102var BasicView = FC.BasicView = View.extend({ 12103 12104 scroller: null, 12105 12106 dayGridClass: DayGrid, // class the dayGrid will be instantiated from (overridable by subclasses) 12107 dayGrid: null, // the main subcomponent that does most of the heavy lifting 12108 12109 dayNumbersVisible: false, // display day numbers on each day cell? 12110 colWeekNumbersVisible: false, // display week numbers along the side? 12111 cellWeekNumbersVisible: false, // display week numbers in day cell? 12112 12113 weekNumberWidth: null, // width of all the week-number cells running down the side 12114 12115 headContainerEl: null, // div that hold's the dayGrid's rendered date header 12116 headRowEl: null, // the fake row element of the day-of-week header 12117 12118 12119 initialize: function() { 12120 this.dayGrid = this.instantiateDayGrid(); 12121 12122 this.scroller = new Scroller({ 12123 overflowX: 'hidden', 12124 overflowY: 'auto' 12125 }); 12126 }, 12127 12128 12129 // Generates the DayGrid object this view needs. Draws from this.dayGridClass 12130 instantiateDayGrid: function() { 12131 // generate a subclass on the fly with BasicView-specific behavior 12132 // TODO: cache this subclass 12133 var subclass = this.dayGridClass.extend(basicDayGridMethods); 12134 12135 return new subclass(this); 12136 }, 12137 12138 12139 // Sets the display range and computes all necessary dates 12140 setRange: function(range) { 12141 View.prototype.setRange.call(this, range); // call the super-method 12142 12143 this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange 12144 this.dayGrid.setRange(range); 12145 }, 12146 12147 12148 // Compute the value to feed into setRange. Overrides superclass. 12149 computeRange: function(date) { 12150 var range = View.prototype.computeRange.call(this, date); // get value from the super-method 12151 12152 // year and month views should be aligned with weeks. this is already done for week 12153 if (/year|month/.test(range.intervalUnit)) { 12154 range.start.startOf('week'); 12155 range.start = this.skipHiddenDays(range.start); 12156 12157 // make end-of-week if not already 12158 if (range.end.weekday()) { 12159 range.end.add(1, 'week').startOf('week'); 12160 range.end = this.skipHiddenDays(range.end, -1, true); // exclusively move backwards 12161 } 12162 } 12163 12164 return range; 12165 }, 12166 12167 12168 // Renders the view into `this.el`, which should already be assigned 12169 renderDates: function() { 12170 12171 this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible 12172 if (this.opt('weekNumbers')) { 12173 if (this.opt('weekNumbersWithinDays')) { 12174 this.cellWeekNumbersVisible = true; 12175 this.colWeekNumbersVisible = false; 12176 } 12177 else { 12178 this.cellWeekNumbersVisible = false; 12179 this.colWeekNumbersVisible = true; 12180 }; 12181 } 12182 this.dayGrid.numbersVisible = this.dayNumbersVisible || 12183 this.cellWeekNumbersVisible || this.colWeekNumbersVisible; 12184 12185 this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml()); 12186 this.renderHead(); 12187 12188 this.scroller.render(); 12189 var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container');
12190 var dayGridEl = $('<div class="fc-day-grid" />').appendTo(dayGridContainerEl); 12191 this.el.find('.fc-body > tr > td').append(dayGridContainerEl); 12192 12193 this.dayGrid.setElement(dayGridEl); 12194 this.dayGrid.renderDates(this.hasRigidRows()); 12195 }, 12196 12197 12198 // render the day-of-week headers 12199 renderHead: function() { 12200 this.headContainerEl = 12201 this.el.find('.fc-head-container') 12202 .html(this.dayGrid.renderHeadHtml()); 12203 this.headRowEl = this.headContainerEl.find('.fc-row'); 12204 }, 12205 12206 12207 // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, 12208 // always completely kill the dayGrid's rendering. 12209 unrenderDates: function() { 12210 this.dayGrid.unrenderDates(); 12211 this.dayGrid.removeElement(); 12212 this.scroller.destroy(); 12213 }, 12214 12215 12216 renderBusinessHours: function() { 12217 this.dayGrid.renderBusinessHours(); 12218 }, 12219 12220 12221 unrenderBusinessHours: function() { 12222 this.dayGrid.unrenderBusinessHours(); 12223 }, 12224 12225 12226 // Builds the HTML skeleton for the view. 12227 // The day-grid component will render inside of a container defined by this HTML. 12228 renderSkeletonHtml: function() { 12229 return '' + 12230 '<table>' + 12231 '<thead class="fc-head">' + 12232 '<tr>' + 12233 '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' + 12234 '</tr>' + 12235 '</thead>' + 12236 '<tbody class="fc-body">' + 12237 '<tr>' + 12238 '<td class="' + this.widgetContentClass + '"></td>' + 12239 '</tr>' + 12240 '</tbody>' + 12241 '</table>'; 12242 }, 12243 12244 12245 // Generates an HTML attribute string for setting the width of the week number column, if it is known 12246 weekNumberStyleAttr: function() { 12247 if (this.weekNumberWidth !== null) { 12248 return 'style="width:' + this.weekNumberWidth + 'px"'; 12249 } 12250 return ''; 12251 }, 12252 12253 12254 // Determines whether each row should have a constant height 12255 hasRigidRows: function() { 12256 var eventLimit = this.opt('eventLimit'); 12257 return eventLimit && typeof eventLimit !== 'number'; 12258 }, 12259 12260 12261 /* Dimensions 12262 ------------------------------------------------------------------------------------------------------------------*/ 12263 12264 12265 // Refreshes the horizontal dimensions of the view 12266 updateWidth: function() { 12267 if (this.colWeekNumbersVisible) { 12268 // Make sure all week number cells running down the side have the same width. 12269 // Record the width for cells created later. 12270 this.weekNumberWidth = matchCellWidths( 12271 this.el.find('.fc-week-number') 12272 ); 12273 } 12274 }, 12275 12276 12277 // Adjusts the vertical dimensions of the view to the specified values 12278 setHeight: function(totalHeight, isAuto) { 12279 var eventLimit = this.opt('eventLimit'); 12280 var scrollerHeight; 12281 var scrollbarWidths; 12282 12283 // reset all heights to be natural 12284 this.scroller.clear(); 12285 uncompensateScroll(this.headRowEl); 12286 12287 this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed 12288 12289 // is the event limit a constant level number? 12290 if (eventLimit && typeof eventLimit === 'number') { 12291 this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after 12292 } 12293 12294 // distribute the height to the rows 12295 // (totalHeight is a "recommended" value if isAuto) 12296 scrollerHeight = this.computeScrollerHeight(totalHeight); 12297 this.setGridHeight(scrollerHeight, isAuto); 12298 12299 // is the event limit dynamically calculated? 12300 if (eventLimit && typeof eventLimit !== 'number') { 12301 this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set 12302 } 12303 12304 if (!isAuto) { // should we force dimensions of the scroll container? 12305 12306 this.scroller.setHeight(scrollerHeight); 12307 scrollbarWidths = this.scroller.getScrollbarWidths(); 12308 12309 if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars? 12310 12311 compensateScroll(this.headRowEl, scrollbarWidths); 12312 12313 // doing the scrollbar compensation might have created text overflow which created more height. redo 12314 scrollerHeight = this.computeScrollerHeight(totalHeight); 12315 this.scroller.setHeight(scrollerHeight); 12316 } 12317 12318 // guarantees the same scrollbar widths 12319 this.scroller.lockOverflow(scrollbarWidths); 12320 } 12321 }, 12322 12323
12324 // given a desired total height of the view, returns what the height of the scroller should be 12325 computeScrollerHeight: function(totalHeight) { 12326 return totalHeight - 12327 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller 12328 }, 12329 12330 12331 // Sets the height of just the DayGrid component in this view 12332 setGridHeight: function(height, isAuto) { 12333 if (isAuto) { 12334 undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding 12335 } 12336 else { 12337 distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows 12338 } 12339 }, 12340 12341 12342 /* Scroll 12343 ------------------------------------------------------------------------------------------------------------------*/ 12344 12345 12346 queryScroll: function() { 12347 return this.scroller.getScrollTop(); 12348 }, 12349 12350 12351 setScroll: function(top) { 12352 this.scroller.setScrollTop(top); 12353 }, 12354 12355 12356 /* Hit Areas 12357 ------------------------------------------------------------------------------------------------------------------*/ 12358 // forward all hit-related method calls to dayGrid 12359 12360 12361 prepareHits: function() { 12362 this.dayGrid.prepareHits(); 12363 }, 12364 12365 12366 releaseHits: function() { 12367 this.dayGrid.releaseHits(); 12368 }, 12369 12370 12371 queryHit: function(left, top) { 12372 return this.dayGrid.queryHit(left, top); 12373 }, 12374 12375 12376 getHitSpan: function(hit) { 12377 return this.dayGrid.getHitSpan(hit); 12378 }, 12379 12380 12381 getHitEl: function(hit) { 12382 return this.dayGrid.getHitEl(hit); 12383 }, 12384 12385 12386 /* Events 12387 ------------------------------------------------------------------------------------------------------------------*/ 12388 12389 12390 // Renders the given events onto the view and populates the segments array 12391 renderEvents: function(events) { 12392 this.dayGrid.renderEvents(events); 12393 12394 this.updateHeight(); // must compensate for events that overflow the row 12395 }, 12396 12397 12398 // Retrieves all segment objects that are rendered in the view 12399 getEventSegs: function() { 12400 return this.dayGrid.getEventSegs(); 12401 }, 12402 12403 12404 // Unrenders all event elements and clears internal segment data 12405 unrenderEvents: function() { 12406 this.dayGrid.unrenderEvents(); 12407 12408 // we DON'T need to call updateHeight() because 12409 // a renderEvents() call always happens after this, which will eventually call updateHeight() 12410 }, 12411 12412 12413 /* Dragging (for both events and external elements) 12414 ------------------------------------------------------------------------------------------------------------------*/ 12415 12416 12417 // A returned value of `true` signals that a mock "helper" event has been rendered. 12418 renderDrag: function(dropLocation, seg) { 12419 return this.dayGrid.renderDrag(dropLocation, seg); 12420 }, 12421 12422 12423 unrenderDrag: function() { 12424 this.dayGrid.unrenderDrag(); 12425 }, 12426 12427 12428 /* Selection 12429 ------------------------------------------------------------------------------------------------------------------*/ 12430 12431 12432 // Renders a visual indication of a selection 12433 renderSelection: function(span) { 12434 this.dayGrid.renderSelection(span); 12435 }, 12436 12437 12438 // Unrenders a visual indications of a selection 12439 unrenderSelection: function() { 12440 this.dayGrid.unrenderSelection(); 12441 } 12442 12443}); 12444 12445 12446// Methods that will customize the rendering behavior of the BasicView's dayGrid 12447var basicDayGridMethods = { 12448 12449 12450 // Generates the HTML that will go before the day-of week header cells 12451 renderHeadIntroHtml: function() { 12452 var view = this.view; 12453 12454 if (view.colWeekNumbersVisible) { 12455 return '' + 12456 '<th class="fc-week-number ' + view.widgetHeaderClass + '" ' + view.weekNumberStyleAttr() + '>' + 12457 '<span>' + // needed for matchCellWidths 12458 htmlEscape(view.opt('weekNumberTitle')) + 12459 '</span>' + 12460 '</th>'; 12461 } 12462 12463 return ''; 12464 }, 12465 12466 12467 // Generates the HTML that will go before content-skeleton cells that display the day/week numbers 12468 renderNumberIntroHtml: function(row) { 12469 var view = this.view; 12470 var weekStart = this.getCellDate(row, 0); 12471 12472 if (view.colWeekNumbersVisible) { 12473 return '' + 12474 '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '>' + 12475 view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths 12476 { date: weekStart, type: 'week', forceOff: this.colCnt === 1 }, 12477 weekStart.format('w') // inner HTML 12478 ) + 12479 '</td>'; 12480 } 12481 12482 return ''; 12483 }, 12484 12485 12486 // Generates the HTML that goes before the day bg cells for each day-row 12487 renderBgIntroHtml: function() { 12488 var view = this.view; 12489 12490 if (view.colWeekNumbersVisible) { 12491 return '<td class="fc-week-number ' + view.widgetContentClass + '" ' + 12492 view.weekNumberStyleAttr() + '></td>'; 12493 } 12494 12495 return ''; 12496 }, 12497 12498 12499 // Generates the HTML that goes before every other type of row generated by DayGrid. 12500 // Affects helper-skeleton and highlight-skeleton rows. 12501 renderIntroHtml: function() { 12502 var view = this.view; 12503 12504 if (view.colWeekNumbersVisible) { 12505 return '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '></td>'; 12506 } 12507 12508 return ''; 12509 } 12510 12511}; 12512 12513;; 12514 12515/* A month view with day cells running in rows (one-per-week) and columns 12516----------------------------------------------------------------------------------------------------------------------*/ 12517 12518var MonthView = FC.MonthView = BasicView.extend({ 12519 12520 // Produces information about what range to display 12521 computeRange: function(date) { 12522 var range = BasicView.prototype.computeRange.call(this, date); // get value from super-method 12523 var rowCnt; 12524 12525 // ensure 6 weeks 12526 if (this.isFixedWeeks()) {
12527 rowCnt = Math.ceil(range.end.diff(range.start, 'weeks', true)); // could be partial weeks due to hiddenDays 12528 range.end.add(6 - rowCnt, 'weeks'); 12529 } 12530 12531 return range; 12532 }, 12533 12534 12535 // Overrides the default BasicView behavior to have special multi-week auto-height logic 12536 setGridHeight: function(height, isAuto) { 12537 12538 // if auto, make the height of each row the height that it would be if there were 6 weeks 12539 if (isAuto) { 12540 height *= this.rowCnt / 6; 12541 } 12542 12543 distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows 12544 }, 12545 12546 12547 isFixedWeeks: function() { 12548 return this.opt('fixedWeekCount'); 12549 } 12550 12551}); 12552 12553;; 12554 12555fcViews.basic = { 12556 'class': BasicView 12557}; 12558 12559fcViews.basicDay = { 12560 type: 'basic', 12561 duration: { days: 1 } 12562}; 12563 12564fcViews.basicWeek = { 12565 type: 'basic', 12566 duration: { weeks: 1 } 12567}; 12568 12569fcViews.month = { 12570 'class': MonthView, 12571 duration: { months: 1 }, // important for prev/next 12572 defaults: { 12573 fixedWeekCount: true 12574 } 12575}; 12576;; 12577 12578/* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically. 12579----------------------------------------------------------------------------------------------------------------------*/ 12580// Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on). 12581// Responsible for managing width/height. 12582 12583var AgendaView = FC.AgendaView = View.extend({ 12584 12585 scroller: null, 12586 12587 timeGridClass: TimeGrid, // class used to instantiate the timeGrid. subclasses can override 12588 timeGrid: null, // the main time-grid subcomponent of this view 12589 12590 dayGridClass: DayGrid, // class used to instantiate the dayGrid. subclasses can override 12591 dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null 12592 12593 axisWidth: null, // the width of the time axis running down the side 12594 12595 headContainerEl: null, // div that hold's the timeGrid's rendered date header 12596 noScrollRowEls: null, // set of fake row elements that must compensate when scroller has scrollbars 12597 12598 // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath 12599 bottomRuleEl: null, 12600 12601 12602 initialize: function() { 12603 this.timeGrid = this.instantiateTimeGrid(); 12604 12605 if (this.opt('allDaySlot')) { // should we display the "all-day" area? 12606 this.dayGrid = this.instantiateDayGrid(); // the all-day subcomponent of this view 12607 } 12608 12609 this.scroller = new Scroller({ 12610 overflowX: 'hidden', 12611 overflowY: 'auto' 12612 }); 12613 }, 12614 12615 12616 // Instantiates the TimeGrid object this view needs. Draws from this.timeGridClass 12617 instantiateTimeGrid: function() { 12618 var subclass = this.timeGridClass.extend(agendaTimeGridMethods); 12619 12620 return new subclass(this); 12621 }, 12622 12623 12624 // Instantiates the DayGrid object this view might need. Draws from this.dayGridClass 12625 instantiateDayGrid: function() { 12626 var subclass = this.dayGridClass.extend(agendaDayGridMethods); 12627 12628 return new subclass(this); 12629 }, 12630 12631 12632 /* Rendering 12633 ------------------------------------------------------------------------------------------------------------------*/ 12634 12635 12636 // Sets the display range and computes all necessary dates 12637 setRange: function(range) { 12638 View.prototype.setRange.call(this, range); // call the super-method 12639 12640 this.timeGrid.setRange(range); 12641 if (this.dayGrid) { 12642 this.dayGrid.setRange(range); 12643 } 12644 }, 12645 12646 12647 // Renders the view into `this.el`, which has already been assigned 12648 renderDates: function() { 12649 12650 this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml()); 12651 this.renderHead(); 12652 12653 this.scroller.render(); 12654 var timeGridWrapEl = this.scroller.el.addClass('fc-time-grid-container'); 12655 var timeGridEl = $('<div class="fc-time-grid" />').appendTo(timeGridWrapEl); 12656 this.el.find('.fc-body > tr > td').append(timeGridWrapEl); 12657 12658 this.timeGrid.setElement(timeGridEl); 12659 this.timeGrid.renderDates(); 12660 12661 // the <hr> that sometimes displays under the time-grid 12662 this.bottomRuleEl = $('<hr class="fc-divider ' + this.widgetHeaderClass + '"/>') 12663 .appendTo(this.timeGrid.el); // inject it into the time-grid 12664 12665 if (this.dayGrid) { 12666 this.dayGrid.setElement(this.el.find('.fc-day-grid')); 12667 this.dayGrid.renderDates();
12668 12669 // have the day-grid extend it's coordinate area over the <hr> dividing the two grids 12670 this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight(); 12671 } 12672 12673 this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller 12674 }, 12675 12676 12677 // render the day-of-week headers 12678 renderHead: function() { 12679 this.headContainerEl = 12680 this.el.find('.fc-head-container') 12681 .html(this.timeGrid.renderHeadHtml()); 12682 }, 12683 12684 12685 // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, 12686 // always completely kill each grid's rendering. 12687 unrenderDates: function() { 12688 this.timeGrid.unrenderDates(); 12689 this.timeGrid.removeElement(); 12690 12691 if (this.dayGrid) { 12692 this.dayGrid.unrenderDates(); 12693 this.dayGrid.removeElement(); 12694 } 12695 12696 this.scroller.destroy(); 12697 }, 12698 12699 12700 // Builds the HTML skeleton for the view. 12701 // The day-grid and time-grid components will render inside containers defined by this HTML. 12702 renderSkeletonHtml: function() { 12703 return '' + 12704 '<table>' + 12705 '<thead class="fc-head">' + 12706 '<tr>' + 12707 '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' + 12708 '</tr>' + 12709 '</thead>' + 12710 '<tbody class="fc-body">' + 12711 '<tr>' + 12712 '<td class="' + this.widgetContentClass + '">' + 12713 (this.dayGrid ? 12714 '<div class="fc-day-grid"/>' + 12715 '<hr class="fc-divider ' + this.widgetHeaderClass + '"/>' : 12716 '' 12717 ) + 12718 '</td>' + 12719 '</tr>' + 12720 '</tbody>' + 12721 '</table>'; 12722 }, 12723 12724 12725 // Generates an HTML attribute string for setting the width of the axis, if it is known 12726 axisStyleAttr: function() { 12727 if (this.axisWidth !== null) { 12728 return 'style="width:' + this.axisWidth + 'px"'; 12729 } 12730 return ''; 12731 }, 12732 12733 12734 /* Business Hours 12735 ------------------------------------------------------------------------------------------------------------------*/ 12736 12737 12738 renderBusinessHours: function() { 12739 this.timeGrid.renderBusinessHours(); 12740 12741 if (this.dayGrid) { 12742 this.dayGrid.renderBusinessHours(); 12743 } 12744 }, 12745 12746 12747 unrenderBusinessHours: function() { 12748 this.timeGrid.unrenderBusinessHours(); 12749 12750 if (this.dayGrid) { 12751 this.dayGrid.unrenderBusinessHours(); 12752 } 12753 }, 12754 12755 12756 /* Now Indicator 12757 ------------------------------------------------------------------------------------------------------------------*/ 12758 12759 12760 getNowIndicatorUnit: function() { 12761 return this.timeGrid.getNowIndicatorUnit(); 12762 }, 12763 12764 12765 renderNowIndicator: function(date) { 12766 this.timeGrid.renderNowIndicator(date); 12767 }, 12768 12769 12770 unrenderNowIndicator: function() { 12771 this.timeGrid.unrenderNowIndicator(); 12772 }, 12773 12774 12775 /* Dimensions 12776 ------------------------------------------------------------------------------------------------------------------*/ 12777 12778 12779 updateSize: function(isResize) { 12780 this.timeGrid.updateSize(isResize); 12781 12782 View.prototype.updateSize.call(this, isResize); // call the super-method 12783 }, 12784 12785 12786 // Refreshes the horizontal dimensions of the view 12787 updateWidth: function() { 12788 // make all axis cells line up, and record the width so newly created axis cells will have it 12789 this.axisWidth = matchCellWidths(this.el.find('.fc-axis')); 12790 }, 12791 12792 12793 // Adjusts the vertical dimensions of the view to the specified values 12794 setHeight: function(totalHeight, isAuto) { 12795 var eventLimit; 12796 var scrollerHeight; 12797 var scrollbarWidths; 12798 12799 // reset all dimensions back to the original state 12800 this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary 12801 this.scroller.clear(); // sets height to 'auto' and clears overflow 12802 uncompensateScroll(this.noScrollRowEls); 12803 12804 // limit number of events in the all-day area 12805 if (this.dayGrid) { 12806 this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed 12807 12808 eventLimit = this.opt('eventLimit'); 12809 if (eventLimit && typeof eventLimit !== 'number') { 12810 eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number 12811 } 12812 if (eventLimit) { 12813 this.dayGrid.limitRows(eventLimit); 12814 } 12815 } 12816 12817 if (!isAuto) { // should we force dimensions of the scroll container? 12818 12819 scrollerHeight = this.computeScrollerHeight(totalHeight); 12820 this.scroller.setHeight(scrollerHeight);
12821 scrollbarWidths = this.scroller.getScrollbarWidths(); 12822 12823 if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars? 12824 12825 // make the all-day and header rows lines up 12826 compensateScroll(this.noScrollRowEls, scrollbarWidths); 12827 12828 // the scrollbar compensation might have changed text flow, which might affect height, so recalculate 12829 // and reapply the desired height to the scroller. 12830 scrollerHeight = this.computeScrollerHeight(totalHeight); 12831 this.scroller.setHeight(scrollerHeight); 12832 } 12833 12834 // guarantees the same scrollbar widths 12835 this.scroller.lockOverflow(scrollbarWidths); 12836 12837 // if there's any space below the slats, show the horizontal rule. 12838 // this won't cause any new overflow, because lockOverflow already called. 12839 if (this.timeGrid.getTotalSlatHeight() < scrollerHeight) { 12840 this.bottomRuleEl.show(); 12841 } 12842 } 12843 }, 12844 12845 12846 // given a desired total height of the view, returns what the height of the scroller should be 12847 computeScrollerHeight: function(totalHeight) { 12848 return totalHeight - 12849 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller 12850 }, 12851 12852 12853 /* Scroll 12854 ------------------------------------------------------------------------------------------------------------------*/ 12855 12856 12857 // Computes the initial pre-configured scroll state prior to allowing the user to change it 12858 computeInitialScroll: function() { 12859 var scrollTime = moment.duration(this.opt('scrollTime')); 12860 var top = this.timeGrid.computeTimeTop(scrollTime); 12861 12862 // zoom can give weird floating-point values. rather scroll a little bit further 12863 top = Math.ceil(top); 12864 12865 if (top) { 12866 top++; // to overcome top border that slots beyond the first have. looks better 12867 } 12868 12869 return top; 12870 }, 12871 12872 12873 queryScroll: function() { 12874 return this.scroller.getScrollTop(); 12875 }, 12876 12877 12878 setScroll: function(top) { 12879 this.scroller.setScrollTop(top); 12880 }, 12881 12882 12883 /* Hit Areas 12884 ------------------------------------------------------------------------------------------------------------------*/ 12885 // forward all hit-related method calls to the grids (dayGrid might not be defined) 12886 12887 12888 prepareHits: function() { 12889 this.timeGrid.prepareHits(); 12890 if (this.dayGrid) { 12891 this.dayGrid.prepareHits(); 12892 } 12893 }, 12894 12895 12896 releaseHits: function() { 12897 this.timeGrid.releaseHits(); 12898 if (this.dayGrid) { 12899 this.dayGrid.releaseHits(); 12900 } 12901 }, 12902 12903 12904 queryHit: function(left, top) { 12905 var hit = this.timeGrid.queryHit(left, top); 12906 12907 if (!hit && this.dayGrid) { 12908 hit = this.dayGrid.queryHit(left, top); 12909 } 12910 12911 return hit; 12912 }, 12913 12914 12915 getHitSpan: function(hit) { 12916 // TODO: hit.component is set as a hack to identify where the hit came from 12917 return hit.component.getHitSpan(hit); 12918 }, 12919 12920 12921 getHitEl: function(hit) { 12922 // TODO: hit.component is set as a hack to identify where the hit came from 12923 return hit.component.getHitEl(hit); 12924 }, 12925 12926 12927 /* Events 12928 ------------------------------------------------------------------------------------------------------------------*/ 12929 12930 12931 // Renders events onto the view and populates the View's segment array 12932 renderEvents: function(events) { 12933 var dayEvents = []; 12934 var timedEvents = []; 12935 var daySegs = []; 12936 var timedSegs; 12937 var i; 12938 12939 // separate the events into all-day and timed 12940 for (i = 0; i < events.length; i++) { 12941 if (events[i].allDay) { 12942 dayEvents.push(events[i]); 12943 } 12944 else { 12945 timedEvents.push(events[i]); 12946 } 12947 } 12948 12949 // render the events in the subcomponents 12950 timedSegs = this.timeGrid.renderEvents(timedEvents); 12951 if (this.dayGrid) { 12952 daySegs = this.dayGrid.renderEvents(dayEvents); 12953 } 12954 12955 // the all-day area is flexible and might have a lot of events, so shift the height 12956 this.updateHeight(); 12957 }, 12958 12959 12960 // Retrieves all segment objects that are rendered in the view 12961 getEventSegs: function() { 12962 return this.timeGrid.getEventSegs().concat( 12963 this.dayGrid ? this.dayGrid.getEventSegs() : [] 12964 ); 12965 }, 12966 12967 12968 // Unrenders all event elements and clears internal segment data 12969 unrenderEvents: function() { 12970 12971 // unrender the events in the subcomponents 12972 this.timeGrid.unrenderEvents(); 12973 if (this.dayGrid) { 12974 this.dayGrid.unrenderEvents(); 12975 } 12976 12977 // we DON'T need to call updateHeight() because 12978 // a renderEvents() call always happens after this, which will eventually call updateHeight() 12979 }, 12980 12981 12982 /* Dragging (for events and external elements) 12983 ------------------------------------------------------------------------------------------------------------------*/ 12984 12985 12986 // A returned value of `true` signals that a mock "helper" event has been rendered. 12987 renderDrag: function(dropLocation, seg) { 12988 if (dropLocation.start.hasTime()) { 12989 return this.timeGrid.renderDrag(dropLocation, seg); 12990 } 12991 else if (this.dayGrid) { 12992 return this.dayGrid.renderDrag(dropLocation, seg); 12993 } 12994 }, 12995 12996 12997 unrenderDrag: function() { 12998 this.timeGrid.unrenderDrag(); 12999 if (this.dayGrid) { 13000 this.dayGrid.unrenderDrag(); 13001 } 13002 }, 13003 13004 13005 /* Selection 13006 ------------------------------------------------------------------------------------------------------------------*/ 13007 13008 13009 // Renders a visual indication of a selection 13010 renderSelection: function(span) {
13011 if (span.start.hasTime() || span.end.hasTime()) { 13012 this.timeGrid.renderSelection(span); 13013 } 13014 else if (this.dayGrid) { 13015 this.dayGrid.renderSelection(span); 13016 } 13017 }, 13018 13019 13020 // Unrenders a visual indications of a selection 13021 unrenderSelection: function() { 13022 this.timeGrid.unrenderSelection(); 13023 if (this.dayGrid) { 13024 this.dayGrid.unrenderSelection(); 13025 } 13026 } 13027 13028}); 13029 13030 13031// Methods that will customize the rendering behavior of the AgendaView's timeGrid 13032// TODO: move into TimeGrid 13033var agendaTimeGridMethods = { 13034 13035 13036 // Generates the HTML that will go before the day-of week header cells 13037 renderHeadIntroHtml: function() { 13038 var view = this.view; 13039 var weekText; 13040 13041 if (view.opt('weekNumbers')) { 13042 weekText = this.start.format(view.opt('smallWeekFormat')); 13043 13044 return '' + 13045 '<th class="fc-axis fc-week-number ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '>' + 13046 view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths 13047 { date: this.start, type: 'week', forceOff: this.colCnt > 1 }, 13048 htmlEscape(weekText) // inner HTML 13049 ) + 13050 '</th>'; 13051 } 13052 else { 13053 return '<th class="fc-axis ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '></th>'; 13054 } 13055 }, 13056 13057 13058 // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column. 13059 renderBgIntroHtml: function() { 13060 var view = this.view; 13061 13062 return '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '></td>'; 13063 }, 13064 13065 13066 // Generates the HTML that goes before all other types of cells. 13067 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. 13068 renderIntroHtml: function() { 13069 var view = this.view; 13070 13071 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>'; 13072 } 13073 13074}; 13075 13076 13077// Methods that will customize the rendering behavior of the AgendaView's dayGrid 13078var agendaDayGridMethods = { 13079 13080 13081 // Generates the HTML that goes before the all-day cells 13082 renderBgIntroHtml: function() { 13083 var view = this.view; 13084 13085 return '' + 13086 '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' + 13087 '<span>' + // needed for matchCellWidths 13088 view.getAllDayHtml() + 13089 '</span>' + 13090 '</td>'; 13091 }, 13092 13093 13094 // Generates the HTML that goes before all other types of cells. 13095 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. 13096 renderIntroHtml: function() { 13097 var view = this.view; 13098 13099 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>'; 13100 } 13101 13102}; 13103 13104;; 13105 13106var AGENDA_ALL_DAY_EVENT_LIMIT = 5; 13107 13108// potential nice values for the slot-duration and interval-duration 13109// from largest to smallest 13110var AGENDA_STOCK_SUB_DURATIONS = [ 13111 { hours: 1 }, 13112 { minutes: 30 }, 13113 { minutes: 15 }, 13114 { seconds: 30 }, 13115 { seconds: 15 } 13116]; 13117 13118fcViews.agenda = { 13119 'class': AgendaView, 13120 defaults: { 13121 allDaySlot: true, 13122 slotDuration: '00:30:00', 13123 minTime: '00:00:00', 13124 maxTime: '24:00:00', 13125 slotEventOverlap: true // a bad name. confused with overlap/constraint system 13126 } 13127}; 13128 13129fcViews.agendaDay = { 13130 type: 'agenda', 13131 duration: { days: 1 } 13132}; 13133 13134fcViews.agendaWeek = { 13135 type: 'agenda', 13136 duration: { weeks: 1 } 13137}; 13138;; 13139 13140/* 13141Responsible for the scroller, and forwarding event-related actions into the "grid" 13142*/ 13143var ListView = View.extend({ 13144 13145 grid: null, 13146 scroller: null, 13147 13148 initialize: function() { 13149 this.grid = new ListViewGrid(this); 13150 this.scroller = new Scroller({ 13151 overflowX: 'hidden', 13152 overflowY: 'auto' 13153 }); 13154 }, 13155 13156 setRange: function(range) { 13157 View.prototype.setRange.call(this, range); // super 13158 13159 this.grid.setRange(range); // needs to process range-related options 13160 }, 13161 13162 renderSkeleton: function() { 13163 this.el.addClass( 13164 'fc-list-view ' + 13165 this.widgetContentClass 13166 ); 13167 13168 this.scroller.render(); 13169 this.scroller.el.appendTo(this.el); 13170 13171 this.grid.setElement(this.scroller.scrollEl); 13172 }, 13173 13174 unrenderSkeleton: function() { 13175 this.scroller.destroy(); // will remove the Grid too 13176 }, 13177 13178 setHeight: function(totalHeight, isAuto) { 13179 this.scroller.setHeight(this.computeScrollerHeight(totalHeight)); 13180 }, 13181 13182 computeScrollerHeight: function(totalHeight) { 13183 return totalHeight -
13184 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller 13185 }, 13186 13187 renderEvents: function(events) { 13188 this.grid.renderEvents(events); 13189 }, 13190 13191 unrenderEvents: function() { 13192 this.grid.unrenderEvents(); 13193 }, 13194 13195 isEventResizable: function(event) { 13196 return false; 13197 }, 13198 13199 isEventDraggable: function(event) { 13200 return false; 13201 } 13202 13203}); 13204 13205/* 13206Responsible for event rendering and user-interaction. 13207Its "el" is the inner-content of the above view's scroller. 13208*/ 13209var ListViewGrid = Grid.extend({ 13210 13211 segSelector: '.fc-list-item', // which elements accept event actions 13212 hasDayInteractions: false, // no day selection or day clicking 13213 13214 // slices by day 13215 spanToSegs: function(span) { 13216 var view = this.view; 13217 var dayStart = view.start.clone(); 13218 var dayEnd; 13219 var seg; 13220 var segs = []; 13221 13222 while (dayStart < view.end) { 13223 dayEnd = dayStart.clone().add(1, 'day'); 13224 seg = intersectRanges(span, { 13225 start: dayStart, 13226 end: dayEnd 13227 }); 13228 if (seg) { 13229 segs.push(seg); 13230 } 13231 dayStart = dayEnd; 13232 } 13233 13234 return segs; 13235 }, 13236 13237 // like "4:00am" 13238 computeEventTimeFormat: function() { 13239 return this.view.opt('mediumTimeFormat'); 13240 }, 13241 13242 // for events with a url, the whole <tr> should be clickable, 13243 // but it's impossible to wrap with an <a> tag. simulate this. 13244 handleSegClick: function(seg, ev) { 13245 var url; 13246 13247 Grid.prototype.handleSegClick.apply(this, arguments); // super. might prevent the default action 13248 13249 // not clicking on or within an <a> with an href 13250 if (!$(ev.target).closest('a[href]').length) { 13251 url = seg.event.url; 13252 if (url && !ev.isDefaultPrevented()) { // jsEvent not cancelled in handler 13253 window.location.href = url; // simulate link click 13254 } 13255 } 13256 }, 13257 13258 // returns list of foreground segs that were actually rendered 13259 renderFgSegs: function(segs) { 13260 segs = this.renderFgSegEls(segs); // might filter away hidden events 13261 13262 if (!segs.length) { 13263 this.renderEmptyMessage(); 13264 return segs; 13265 } 13266 else { 13267 return this.renderSegList(segs); 13268 } 13269 }, 13270 13271 renderEmptyMessage: function() { 13272 this.el.html( 13273 '<div class="fc-list-empty-wrap2">' + // TODO: try less wraps 13274 '<div class="fc-list-empty-wrap1">' + 13275 '<div class="fc-list-empty">' + 13276 htmlEscape(this.view.opt('noEventsMessage')) + 13277 '</div>' + 13278 '</div>' + 13279 '</div>' 13280 ); 13281 }, 13282 13283 // render the event segments in the view. returns the mutated array. 13284 renderSegList: function(segs) { 13285 var tableEl = $('<table class="fc-list-table"><tbody/></table>'); 13286 var tbodyEl = tableEl.find('tbody'); 13287 var i, seg; 13288 var dayDate; 13289 13290 this.sortEventSegs(segs); 13291 13292 for (i = 0; i < segs.length; i++) { 13293 seg = segs[i]; 13294 13295 // append a day header 13296 if (!dayDate || !seg.start.isSame(dayDate, 'day')) { 13297 dayDate = seg.start.clone().stripTime(); 13298 tbodyEl.append(this.dayHeaderHtml(dayDate)); 13299 } 13300 13301 tbodyEl.append(seg.el); // append event row 13302 } 13303 13304 this.el.empty().append(tableEl); 13305 13306 return segs; // return the sorted list 13307 }, 13308 13309 // generates the HTML for the day headers that live amongst the event rows 13310 dayHeaderHtml: function(dayDate) { 13311 var view = this.view; 13312 var mainFormat = view.opt('listDayFormat'); 13313 var altFormat = view.opt('listDayAltFormat'); 13314 13315 return '<tr class="fc-list-heading" data-date="' + dayDate.format('YYYY-MM-DD') + '">' + 13316 '<td class="' + view.widgetHeaderClass + '" colspan="3">' + 13317 (mainFormat ? 13318 view.buildGotoAnchorHtml( 13319 dayDate, 13320 { 'class': 'fc-list-heading-main' }, 13321 htmlEscape(dayDate.format(mainFormat)) // inner HTML 13322 ) : 13323 '') + 13324 (altFormat ? 13325 view.buildGotoAnchorHtml( 13326 dayDate, 13327 { 'class': 'fc-list-heading-alt' }, 13328 htmlEscape(dayDate.format(altFormat)) // inner HTML 13329 ) : 13330 '') + 13331 '</td>' + 13332 '</tr>'; 13333 }, 13334 13335 // generates the HTML for a single event row 13336 fgSegHtml: function(seg) { 13337 var view = this.view; 13338 var classes = [ 'fc-list-item' ].concat(this.getSegCustomClasses(seg)); 13339 var bgColor = this.getSegBackgroundColor(seg); 13340 var event = seg.event; 13341 var url = event.url; 13342 var timeHtml; 13343 13344 if (!seg.start.hasTime()) { 13345 if (this.displayEventTime) { 13346 timeHtml = view.getAllDayHtml(); 13347 } 13348 } 13349 else { 13350 timeHtml = htmlEscape(this.getEventTimeText(event)); // might return empty 13351 } 13352 13353 if (url) { 13354 classes.push('fc-has-url'); 13355 } 13356 13357 return '<tr class="' + classes.join(' ') + '">' + 13358 (timeHtml ? 13359 '<td class="fc-list-item-time ' + view.widgetContentClass + '">' + 13360 timeHtml + 13361 '</td>' : 13362 '') + 13363 '<td class="fc-list-item-marker ' + view.widgetContentClass + '">' + 13364 '<span class="fc-event-dot"' + 13365 (bgColor ? 13366 ' style="background-color:' + bgColor + '"' : 13367 '') + 13368 '></span>' + 13369 '</td>' + 13370 '<td class="fc-list-item-title ' + view.widgetContentClass + '">' + 13371 '<a' + (url ? ' href="' + htmlEscape(url) + '"' : '') + '>
13371' + 13372 htmlEscape(seg.event.title) + 13373 '</a>' + 13374 '</td>' + 13375 '</tr>'; 13376 } 13377 13378}); 13379 13380;; 13381 13382fcViews.list = { 13383 'class': ListView, 13384 buttonTextKey: 'list', // what to lookup in locale files 13385 defaults: { 13386 buttonText: 'list', // text to display for English 13387 listTime: true, // show the time column? 13388 listDayFormat: 'LL', // like "January 1, 2016" 13389 noEventsMessage: 'No events to display' 13390 } 13391}; 13392 13393fcViews.listDay = { 13394 type: 'list', 13395 duration: { days: 1 }, 13396 defaults: { 13397 listDayFormat: 'dddd' // day-of-week is all we need. full date is probably in header 13398 } 13399}; 13400 13401fcViews.listWeek = { 13402 type: 'list', 13403 duration: { weeks: 1 }, 13404 defaults: { 13405 listDayFormat: 'dddd', // day-of-week is more important 13406 listDayAltFormat: 'LL' 13407 } 13408}; 13409 13410fcViews.listMonth = { 13411 type: 'list', 13412 duration: { month: 1 }, 13413 defaults: { 13414 listDayAltFormat: 'dddd' // day-of-week is nice-to-have 13415 } 13416}; 13417 13418fcViews.listYear = { 13419 type: 'list', 13420 duration: { year: 1 }, 13421 defaults: { 13422 listDayAltFormat: 'dddd' // day-of-week is nice-to-have 13423 } 13424}; 13425 13426;; 13427 13428return FC; // export for Node/CommonJS 13429});
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.