You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

13109 lines
493 KiB

2 years ago
  1. /*!
  2. * FullCalendar v3.1.0
  3. * Docs & License: http://fullcalendar.io/
  4. * (c) 2016 Adam Shaw
  5. */
  6. (function (factory) {
  7. if (typeof define === 'function' && define.amd) {
  8. define(['jquery', 'moment'], factory);
  9. }
  10. else if (typeof exports === 'object') { // Node/CommonJS
  11. module.exports = factory(require('jquery'), require('moment'));
  12. }
  13. else {
  14. factory(jQuery, moment);
  15. }
  16. })(function ($, moment) {
  17. ;;
  18. var FC = $.fullCalendar = {
  19. version: "3.1.0",
  20. internalApiVersion: 7
  21. };
  22. var fcViews = FC.views = {};
  23. $.fn.fullCalendar = function (options) {
  24. var args = Array.prototype.slice.call(arguments, 1); // for a possible method call
  25. var res = this; // what this function will return (this jQuery object by default)
  26. this.each(function (i, _element) { // loop each DOM element involved
  27. var element = $(_element);
  28. var calendar = element.data('fullCalendar'); // get the existing calendar object (if any)
  29. var singleRes; // the returned value of this single method call
  30. // a method call
  31. if (typeof options === 'string') {
  32. if (calendar && $.isFunction(calendar[options])) {
  33. singleRes = calendar[options].apply(calendar, args);
  34. if (!i) {
  35. res = singleRes; // record the first method call result
  36. }
  37. if (options === 'destroy') { // for the destroy method, must remove Calendar object data
  38. element.removeData('fullCalendar');
  39. }
  40. }
  41. }
  42. // a new calendar initialization
  43. else if (!calendar) { // don't initialize twice
  44. calendar = new Calendar(element, options);
  45. element.data('fullCalendar', calendar);
  46. calendar.render();
  47. }
  48. });
  49. return res;
  50. };
  51. var complexOptions = [ // names of options that are objects whose properties should be combined
  52. 'header',
  53. 'footer',
  54. 'buttonText',
  55. 'buttonIcons',
  56. 'themeButtonIcons'
  57. ];
  58. // Merges an array of option objects into a single object
  59. function mergeOptions(optionObjs) {
  60. return mergeProps(optionObjs, complexOptions);
  61. }
  62. ;;
  63. // exports
  64. FC.intersectRanges = intersectRanges;
  65. FC.applyAll = applyAll;
  66. FC.debounce = debounce;
  67. FC.isInt = isInt;
  68. FC.htmlEscape = htmlEscape;
  69. FC.cssToStr = cssToStr;
  70. FC.proxy = proxy;
  71. FC.capitaliseFirstLetter = capitaliseFirstLetter;
  72. /* FullCalendar-specific DOM Utilities
  73. ----------------------------------------------------------------------------------------------------------------------*/
  74. // Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left
  75. // and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that.
  76. function compensateScroll(rowEls, scrollbarWidths) {
  77. if (scrollbarWidths.left) {
  78. rowEls.css({
  79. 'border-left-width': 1,
  80. 'margin-left': scrollbarWidths.left - 1
  81. });
  82. }
  83. if (scrollbarWidths.right) {
  84. rowEls.css({
  85. 'border-right-width': 1,
  86. 'margin-right': scrollbarWidths.right - 1
  87. });
  88. }
  89. }
  90. // Undoes compensateScroll and restores all borders/margins
  91. function uncompensateScroll(rowEls) {
  92. rowEls.css({
  93. 'margin-left': '',
  94. 'margin-right': '',
  95. 'border-left-width': '',
  96. 'border-right-width': ''
  97. });
  98. }
  99. // Make the mouse cursor express that an event is not allowed in the current area
  100. function disableCursor() {
  101. $('body').addClass('fc-not-allowed');
  102. }
  103. // Returns the mouse cursor to its original look
  104. function enableCursor() {
  105. $('body').removeClass('fc-not-allowed');
  106. }
  107. // Given a total available height to fill, have `els` (essentially child rows) expand to accomodate.
  108. // By default, all elements that are shorter than the recommended height are expanded uniformly, not considering
  109. // any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and
  110. // reduces the available height.
  111. function distributeHeight(els, availableHeight, shouldRedistribute) {
  112. // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions,
  113. // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars.
  114. var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element
  115. var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE*
  116. var flexEls = []; // elements that are allowed to expand. array of DOM nodes
  117. var flexOffsets = []; // amount of vertical space it takes up
  118. var flexHeights = []; // actual css height
  119. var usedHeight = 0;
  120. undistributeHeight(els); // give all elements their natural height
  121. // find elements that are below the recommended height (expandable).
  122. // important to query for heights in a single first pass (to avoid reflow oscillation).
  123. els.each(function (i, el) {
  124. var minOffset = i === els.length - 1 ? minOffset2 : minOffset1;
  125. var naturalOffset = $(el).outerHeight(true);
  126. if (naturalOffset < minOffset) {
  127. flexEls.push(el);
  128. flexOffsets.push(naturalOffset);
  129. flexHeights.push($(el).height());
  130. }
  131. else {
  132. // this element stretches past recommended height (non-expandable). mark the space as occupied.
  133. usedHeight += naturalOffset;
  134. }
  135. });
  136. // readjust the recommended height to only consider the height available to non-maxed-out rows.
  137. if (shouldRedistribute) {
  138. availableHeight -= usedHeight;
  139. minOffset1 = Math.floor(availableHeight / flexEls.length);
  140. minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE*
  141. }
  142. // assign heights to all expandable elements
  143. $(flexEls).each(function (i, el) {
  144. var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1;
  145. var naturalOffset = flexOffsets[i];
  146. var naturalHeight = flexHeights[i];
  147. var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding
  148. if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things
  149. $(el).height(newHeight);
  150. }
  151. });
  152. }
  153. // Undoes distrubuteHeight, restoring all els to their natural height
  154. function undistributeHeight(els) {
  155. els.height('');
  156. }
  157. // Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the
  158. // cells to be that width.
  159. // PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline
  160. function matchCellWidths(els) {
  161. var maxInnerWidth = 0;
  162. els.find('> *').each(function (i, innerEl) {
  163. var innerWidth = $(innerEl).outerWidth();
  164. if (innerWidth > maxInnerWidth) {
  165. maxInnerWidth = innerWidth;
  166. }
  167. });
  168. maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance
  169. els.width(maxInnerWidth);
  170. return maxInnerWidth;
  171. }
  172. // Given one element that resides inside another,
  173. // Subtracts the height of the inner element from the outer element.
  174. function subtractInnerElHeight(outerEl, innerEl) {
  175. var both = outerEl.add(innerEl);
  176. var diff;
  177. // effin' IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked
  178. both.css({
  179. position: 'relative', // cause a reflow, which will force fresh dimension recalculation
  180. left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll
  181. });
  182. diff = outerEl.outerHeight() - innerEl.outerHeight(); // grab the dimensions
  183. both.css({ position: '', left: '' }); // undo hack
  184. return diff;
  185. }
  186. /* Element Geom Utilities
  187. ----------------------------------------------------------------------------------------------------------------------*/
  188. FC.getOuterRect = getOuterRect;
  189. FC.getClientRect = getClientRect;
  190. FC.getContentRect = getContentRect;
  191. FC.getScrollbarWidths = getScrollbarWidths;
  192. // borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51
  193. function getScrollParent(el) {
  194. var position = el.css('position'),
  195. scrollParent = el.parents().filter(function () {
  196. var parent = $(this);
  197. return (/(auto|scroll)/).test(
  198. parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x')
  199. );
  200. }).eq(0);
  201. return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent;
  202. }
  203. // Queries the outer bounding area of a jQuery element.
  204. // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
  205. // Origin is optional.
  206. function getOuterRect(el, origin) {
  207. var offset = el.offset();
  208. var left = offset.left - (origin ? origin.left : 0);
  209. var top = offset.top - (origin ? origin.top : 0);
  210. return {
  211. left: left,
  212. right: left + el.outerWidth(),
  213. top: top,
  214. bottom: top + el.outerHeight()
  215. };
  216. }
  217. // Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding.
  218. // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
  219. // Origin is optional.
  220. // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser.
  221. function getClientRect(el, origin) {
  222. var offset = el.offset();
  223. var scrollbarWidths = getScrollbarWidths(el);
  224. var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left - (origin ? origin.left : 0);
  225. var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top - (origin ? origin.top : 0);
  226. return {
  227. left: left,
  228. right: left + el[0].clientWidth, // clientWidth includes padding but NOT scrollbars
  229. top: top,
  230. bottom: top + el[0].clientHeight // clientHeight includes padding but NOT scrollbars
  231. };
  232. }
  233. // Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars.
  234. // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
  235. // Origin is optional.
  236. function getContentRect(el, origin) {
  237. var offset = el.offset(); // just outside of border, margin not included
  238. var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left') -
  239. (origin ? origin.left : 0);
  240. var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top') -
  241. (origin ? origin.top : 0);
  242. return {
  243. left: left,
  244. right: left + el.width(),
  245. top: top,
  246. bottom: top + el.height()
  247. };
  248. }
  249. // Returns the computed left/right/top/bottom scrollbar widths for the given jQuery element.
  250. // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser.
  251. function getScrollbarWidths(el) {
  252. var leftRightWidth = el.innerWidth() - el[0].clientWidth; // the paddings cancel out, leaving the scrollbars
  253. var widths = {
  254. left: 0,
  255. right: 0,
  256. top: 0,
  257. bottom: el.innerHeight() - el[0].clientHeight // the paddings cancel out, leaving the bottom scrollbar
  258. };
  259. if (getIsLeftRtlScrollbars() && el.css('direction') == 'rtl') { // is the scrollbar on the left side?
  260. widths.left = leftRightWidth;
  261. }
  262. else {
  263. widths.right = leftRightWidth;
  264. }
  265. return widths;
  266. }
  267. // Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side
  268. var _isLeftRtlScrollbars = null;
  269. function getIsLeftRtlScrollbars() { // responsible for caching the computation
  270. if (_isLeftRtlScrollbars === null) {
  271. _isLeftRtlScrollbars = computeIsLeftRtlScrollbars();
  272. }
  273. return _isLeftRtlScrollbars;
  274. }
  275. function computeIsLeftRtlScrollbars() { // creates an offscreen test element, then removes it
  276. var el = $('<div><div/></div>')
  277. .css({
  278. position: 'absolute',
  279. top: -1000,
  280. left: 0,
  281. border: 0,
  282. padding: 0,
  283. overflow: 'scroll',
  284. direction: 'rtl'
  285. })
  286. .appendTo('body');
  287. var innerEl = el.children();
  288. var res = innerEl.offset().left > el.offset().left; // is the inner div shifted to accommodate a left scrollbar?
  289. el.remove();
  290. return res;
  291. }
  292. // Retrieves a jQuery element's computed CSS value as a floating-point number.
  293. // If the queried value is non-numeric (ex: IE can return "medium" for border width), will just return zero.
  294. function getCssFloat(el, prop) {
  295. return parseFloat(el.css(prop)) || 0;
  296. }
  297. /* Mouse / Touch Utilities
  298. ----------------------------------------------------------------------------------------------------------------------*/
  299. FC.preventDefault = preventDefault;
  300. // Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac)
  301. function isPrimaryMouseButton(ev) {
  302. return ev.which == 1 && !ev.ctrlKey;
  303. }
  304. function getEvX(ev) {
  305. if (ev.pageX !== undefined) {
  306. return ev.pageX;
  307. }
  308. var touches = ev.originalEvent.touches;
  309. if (touches) {
  310. return touches[0].pageX;
  311. }
  312. }
  313. function getEvY(ev) {
  314. if (ev.pageY !== undefined) {
  315. return ev.pageY;
  316. }
  317. var touches = ev.originalEvent.touches;
  318. if (touches) {
  319. return touches[0].pageY;
  320. }
  321. }
  322. function getEvIsTouch(ev) {
  323. return /^touch/.test(ev.type);
  324. }
  325. function preventSelection(el) {
  326. el.addClass('fc-unselectable')
  327. .on('selectstart', preventDefault);
  328. }
  329. // Stops a mouse/touch event from doing it's native browser action
  330. function preventDefault(ev) {
  331. ev.preventDefault();
  332. }
  333. // attach a handler to get called when ANY scroll action happens on the page.
  334. // this was impossible to do with normal on/off because 'scroll' doesn't bubble.
  335. // http://stackoverflow.com/a/32954565/96342
  336. // returns `true` on success.
  337. function bindAnyScroll(handler) {
  338. if (window.addEventListener) {
  339. window.addEventListener('scroll', handler, true); // useCapture=true
  340. return true;
  341. }
  342. return false;
  343. }
  344. // undoes bindAnyScroll. must pass in the original function.
  345. // returns `true` on success.
  346. function unbindAnyScroll(handler) {
  347. if (window.removeEventListener) {
  348. window.removeEventListener('scroll', handler, true); // useCapture=true
  349. return true;
  350. }
  351. return false;
  352. }
  353. /* General Geometry Utils
  354. ----------------------------------------------------------------------------------------------------------------------*/
  355. FC.intersectRects = intersectRects;
  356. // Returns a new rectangle that is the intersection of the two rectangles. If they don't intersect, returns false
  357. function intersectRects(rect1, rect2) {
  358. var res = {
  359. left: Math.max(rect1.left, rect2.left),
  360. right: Math.min(rect1.right, rect2.right),
  361. top: Math.max(rect1.top, rect2.top),
  362. bottom: Math.min(rect1.bottom, rect2.bottom)
  363. };
  364. if (res.left < res.right && res.top < res.bottom) {
  365. return res;
  366. }
  367. return false;
  368. }
  369. // Returns a new point that will have been moved to reside within the given rectangle
  370. function constrainPoint(point, rect) {
  371. return {
  372. left: Math.min(Math.max(point.left, rect.left), rect.right),
  373. top: Math.min(Math.max(point.top, rect.top), rect.bottom)
  374. };
  375. }
  376. // Returns a point that is the center of the given rectangle
  377. function getRectCenter(rect) {
  378. return {
  379. left: (rect.left + rect.right) / 2,
  380. top: (rect.top + rect.bottom) / 2
  381. };
  382. }
  383. // Subtracts point2's coordinates from point1's coordinates, returning a delta
  384. function diffPoints(point1, point2) {
  385. return {
  386. left: point1.left - point2.left,
  387. top: point1.top - point2.top
  388. };
  389. }
  390. /* Object Ordering by Field
  391. ----------------------------------------------------------------------------------------------------------------------*/
  392. FC.parseFieldSpecs = parseFieldSpecs;
  393. FC.compareByFieldSpecs = compareByFieldSpecs;
  394. FC.compareByFieldSpec = compareByFieldSpec;
  395. FC.flexibleCompare = flexibleCompare;
  396. function parseFieldSpecs(input) {
  397. var specs = [];
  398. var tokens = [];
  399. var i, token;
  400. if (typeof input === 'string') {
  401. tokens = input.split(/\s*,\s*/);
  402. }
  403. else if (typeof input === 'function') {
  404. tokens = [input];
  405. }
  406. else if ($.isArray(input)) {
  407. tokens = input;
  408. }
  409. for (i = 0; i < tokens.length; i++) {
  410. token = tokens[i];
  411. if (typeof token === 'string') {
  412. specs.push(
  413. token.charAt(0) == '-' ?
  414. { field: token.substring(1), order: -1 } :
  415. { field: token, order: 1 }
  416. );
  417. }
  418. else if (typeof token === 'function') {
  419. specs.push({ func: token });
  420. }
  421. }
  422. return specs;
  423. }
  424. function compareByFieldSpecs(obj1, obj2, fieldSpecs) {
  425. var i;
  426. var cmp;
  427. for (i = 0; i < fieldSpecs.length; i++) {
  428. cmp = compareByFieldSpec(obj1, obj2, fieldSpecs[i]);
  429. if (cmp) {
  430. return cmp;
  431. }
  432. }
  433. return 0;
  434. }
  435. function compareByFieldSpec(obj1, obj2, fieldSpec) {
  436. if (fieldSpec.func) {
  437. return fieldSpec.func(obj1, obj2);
  438. }
  439. return flexibleCompare(obj1[fieldSpec.field], obj2[fieldSpec.field]) *
  440. (fieldSpec.order || 1);
  441. }
  442. function flexibleCompare(a, b) {
  443. if (!a && !b) {
  444. return 0;
  445. }
  446. if (b == null) {
  447. return -1;
  448. }
  449. if (a == null) {
  450. return 1;
  451. }
  452. if ($.type(a) === 'string' || $.type(b) === 'string') {
  453. return String(a).localeCompare(String(b));
  454. }
  455. return a - b;
  456. }
  457. /* FullCalendar-specific Misc Utilities
  458. ----------------------------------------------------------------------------------------------------------------------*/
  459. // Computes the intersection of the two ranges. Will return fresh date clones in a range.
  460. // Returns undefined if no intersection.
  461. // Expects all dates to be normalized to the same timezone beforehand.
  462. // TODO: move to date section?
  463. function intersectRanges(subjectRange, constraintRange) {
  464. var subjectStart = subjectRange.start;
  465. var subjectEnd = subjectRange.end;
  466. var constraintStart = constraintRange.start;
  467. var constraintEnd = constraintRange.end;
  468. var segStart, segEnd;
  469. var isStart, isEnd;
  470. if (subjectEnd > constraintStart && subjectStart < constraintEnd) { // in bounds at all?
  471. if (subjectStart >= constraintStart) {
  472. segStart = subjectStart.clone();
  473. isStart = true;
  474. }
  475. else {
  476. segStart = constraintStart.clone();
  477. isStart = false;
  478. }
  479. if (subjectEnd <= constraintEnd) {
  480. segEnd = subjectEnd.clone();
  481. isEnd = true;
  482. }
  483. else {
  484. segEnd = constraintEnd.clone();
  485. isEnd = false;
  486. }
  487. return {
  488. start: segStart,
  489. end: segEnd,
  490. isStart: isStart,
  491. isEnd: isEnd
  492. };
  493. }
  494. }
  495. /* Date Utilities
  496. ----------------------------------------------------------------------------------------------------------------------*/
  497. FC.computeIntervalUnit = computeIntervalUnit;
  498. FC.divideRangeByDuration = divideRangeByDuration;
  499. FC.divideDurationByDuration = divideDurationByDuration;
  500. FC.multiplyDuration = multiplyDuration;
  501. FC.durationHasTime = durationHasTime;
  502. var dayIDs = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
  503. var intervalUnits = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond'];
  504. // Diffs the two moments into a Duration where full-days are recorded first, then the remaining time.
  505. // Moments will have their timezones normalized.
  506. function diffDayTime(a, b) {
  507. return moment.duration({
  508. days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'),
  509. ms: a.time() - b.time() // time-of-day from day start. disregards timezone
  510. });
  511. }
  512. // Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations.
  513. function diffDay(a, b) {
  514. return moment.duration({
  515. days: a.clone().stripTime().diff(b.clone().stripTime(), 'days')
  516. });
  517. }
  518. // Diffs two moments, producing a duration, made of a whole-unit-increment of the given unit. Uses rounding.
  519. function diffByUnit(a, b, unit) {
  520. return moment.duration(
  521. Math.round(a.diff(b, unit, true)), // returnFloat=true
  522. unit
  523. );
  524. }
  525. // Computes the unit name of the largest whole-unit period of time.
  526. // For example, 48 hours will be "days" whereas 49 hours will be "hours".
  527. // Accepts start/end, a range object, or an original duration object.
  528. function computeIntervalUnit(start, end) {
  529. var i, unit;
  530. var val;
  531. for (i = 0; i < intervalUnits.length; i++) {
  532. unit = intervalUnits[i];
  533. val = computeRangeAs(unit, start, end);
  534. if (val >= 1 && isInt(val)) {
  535. break;
  536. }
  537. }
  538. return unit; // will be "milliseconds" if nothing else matches
  539. }
  540. // Computes the number of units (like "hours") in the given range.
  541. // Range can be a {start,end} object, separate start/end args, or a Duration.
  542. // Results are based on Moment's .as() and .diff() methods, so results can depend on internal handling
  543. // of month-diffing logic (which tends to vary from version to version).
  544. function computeRangeAs(unit, start, end) {
  545. if (end != null) { // given start, end
  546. return end.diff(start, unit, true);
  547. }
  548. else if (moment.isDuration(start)) { // given duration
  549. return start.as(unit);
  550. }
  551. else { // given { start, end } range object
  552. return start.end.diff(start.start, unit, true);
  553. }
  554. }
  555. // Intelligently divides a range (specified by a start/end params) by a duration
  556. function divideRangeByDuration(start, end, dur) {
  557. var months;
  558. if (durationHasTime(dur)) {
  559. return (end - start) / dur;
  560. }
  561. months = dur.asMonths();
  562. if (Math.abs(months) >= 1 && isInt(months)) {
  563. return end.diff(start, 'months', true) / months;
  564. }
  565. return end.diff(start, 'days', true) / dur.asDays();
  566. }
  567. // Intelligently divides one duration by another
  568. function divideDurationByDuration(dur1, dur2) {
  569. var months1, months2;
  570. if (durationHasTime(dur1) || durationHasTime(dur2)) {
  571. return dur1 / dur2;
  572. }
  573. months1 = dur1.asMonths();
  574. months2 = dur2.asMonths();
  575. if (
  576. Math.abs(months1) >= 1 && isInt(months1) &&
  577. Math.abs(months2) >= 1 && isInt(months2)
  578. ) {
  579. return months1 / months2;
  580. }
  581. return dur1.asDays() / dur2.asDays();
  582. }
  583. // Intelligently multiplies a duration by a number
  584. function multiplyDuration(dur, n) {
  585. var months;
  586. if (durationHasTime(dur)) {
  587. return moment.duration(dur * n);
  588. }
  589. months = dur.asMonths();
  590. if (Math.abs(months) >= 1 && isInt(months)) {
  591. return moment.duration({ months: months * n });
  592. }
  593. return moment.duration({ days: dur.asDays() * n });
  594. }
  595. // Returns a boolean about whether the given duration has any time parts (hours/minutes/seconds/ms)
  596. function durationHasTime(dur) {
  597. return Boolean(dur.hours() || dur.minutes() || dur.seconds() || dur.milliseconds());
  598. }
  599. function isNativeDate(input) {
  600. return Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date;
  601. }
  602. // Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00"
  603. function isTimeString(str) {
  604. return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str);
  605. }
  606. /* Logging and Debug
  607. ----------------------------------------------------------------------------------------------------------------------*/
  608. FC.log = function () {
  609. var console = window.console;
  610. if (console && console.log) {
  611. return console.log.apply(console, arguments);
  612. }
  613. };
  614. FC.warn = function () {
  615. var console = window.console;
  616. if (console && console.warn) {
  617. return console.warn.apply(console, arguments);
  618. }
  619. else {
  620. return FC.log.apply(FC, arguments);
  621. }
  622. };
  623. /* General Utilities
  624. ----------------------------------------------------------------------------------------------------------------------*/
  625. var hasOwnPropMethod = {}.hasOwnProperty;
  626. // Merges an array of objects into a single object.
  627. // The second argument allows for an array of property names who's object values will be merged together.
  628. function mergeProps(propObjs, complexProps) {
  629. var dest = {};
  630. var i, name;
  631. var complexObjs;
  632. var j, val;
  633. var props;
  634. if (complexProps) {
  635. for (i = 0; i < complexProps.length; i++) {
  636. name = complexProps[i];
  637. complexObjs = [];
  638. // collect the trailing object values, stopping when a non-object is discovered
  639. for (j = propObjs.length - 1; j >= 0; j--) {
  640. val = propObjs[j][name];
  641. if (typeof val === 'object') {
  642. complexObjs.unshift(val);
  643. }
  644. else if (val !== undefined) {
  645. dest[name] = val; // if there were no objects, this value will be used
  646. break;
  647. }
  648. }
  649. // if the trailing values were objects, use the merged value
  650. if (complexObjs.length) {
  651. dest[name] = mergeProps(complexObjs);
  652. }
  653. }
  654. }
  655. // copy values into the destination, going from last to first
  656. for (i = propObjs.length - 1; i >= 0; i--) {
  657. props = propObjs[i];
  658. for (name in props) {
  659. if (!(name in dest)) { // if already assigned by previous props or complex props, don't reassign
  660. dest[name] = props[name];
  661. }
  662. }
  663. }
  664. return dest;
  665. }
  666. // Create an object that has the given prototype. Just like Object.create
  667. function createObject(proto) {
  668. var f = function () { };
  669. f.prototype = proto;
  670. return new f();
  671. }
  672. FC.createObject = createObject;
  673. function copyOwnProps(src, dest) {
  674. for (var name in src) {
  675. if (hasOwnProp(src, name)) {
  676. dest[name] = src[name];
  677. }
  678. }
  679. }
  680. function hasOwnProp(obj, name) {
  681. return hasOwnPropMethod.call(obj, name);
  682. }
  683. // Is the given value a non-object non-function value?
  684. function isAtomic(val) {
  685. return /undefined|null|boolean|number|string/.test($.type(val));
  686. }
  687. function applyAll(functions, thisObj, args) {
  688. if ($.isFunction(functions)) {
  689. functions = [functions];
  690. }
  691. if (functions) {
  692. var i;
  693. var ret;
  694. for (i = 0; i < functions.length; i++) {
  695. ret = functions[i].apply(thisObj, args) || ret;
  696. }
  697. return ret;
  698. }
  699. }
  700. function firstDefined() {
  701. for (var i = 0; i < arguments.length; i++) {
  702. if (arguments[i] !== undefined) {
  703. return arguments[i];
  704. }
  705. }
  706. }
  707. function htmlEscape(s) {
  708. return (s + '').replace(/&/g, '&amp;')
  709. .replace(/</g, '&lt;')
  710. .replace(/>/g, '&gt;')
  711. .replace(/'/g, '&#039;')
  712. .replace(/"/g, '&quot;')
  713. .replace(/\n/g, '<br />');
  714. }
  715. function stripHtmlEntities(text) {
  716. return text.replace(/&.*?;/g, '');
  717. }
  718. // Given a hash of CSS properties, returns a string of CSS.
  719. // Uses property names as-is (no camel-case conversion). Will not make statements for null/undefined values.
  720. function cssToStr(cssProps) {
  721. var statements = [];
  722. $.each(cssProps, function (name, val) {
  723. if (val != null) {
  724. statements.push(name + ':' + val);
  725. }
  726. });
  727. return statements.join(';');
  728. }
  729. // Given an object hash of HTML attribute names to values,
  730. // generates a string that can be injected between < > in HTML
  731. function attrsToStr(attrs) {
  732. var parts = [];
  733. $.each(attrs, function (name, val) {
  734. if (val != null) {
  735. parts.push(name + '="' + htmlEscape(val) + '"');
  736. }
  737. });
  738. return parts.join(' ');
  739. }
  740. function capitaliseFirstLetter(str) {
  741. return str.charAt(0).toUpperCase() + str.slice(1);
  742. }
  743. function compareNumbers(a, b) { // for .sort()
  744. return a - b;
  745. }
  746. function isInt(n) {
  747. return n % 1 === 0;
  748. }
  749. // Returns a method bound to the given object context.
  750. // Just like one of the jQuery.proxy signatures, but without the undesired behavior of treating the same method with
  751. // different contexts as identical when binding/unbinding events.
  752. function proxy(obj, methodName) {
  753. var method = obj[methodName];
  754. return function () {
  755. return method.apply(obj, arguments);
  756. };
  757. }
  758. // Returns a function, that, as long as it continues to be invoked, will not
  759. // be triggered. The function will be called after it stops being called for
  760. // N milliseconds. If `immediate` is passed, trigger the function on the
  761. // leading edge, instead of the trailing.
  762. // https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714
  763. function debounce(func, wait, immediate) {
  764. var timeout, args, context, timestamp, result;
  765. var later = function () {
  766. var last = +new Date() - timestamp;
  767. if (last < wait) {
  768. timeout = setTimeout(later, wait - last);
  769. }
  770. else {
  771. timeout = null;
  772. if (!immediate) {
  773. result = func.apply(context, args);
  774. context = args = null;
  775. }
  776. }
  777. };
  778. return function () {
  779. context = this;
  780. args = arguments;
  781. timestamp = +new Date();
  782. var callNow = immediate && !timeout;
  783. if (!timeout) {
  784. timeout = setTimeout(later, wait);
  785. }
  786. if (callNow) {
  787. result = func.apply(context, args);
  788. context = args = null;
  789. }
  790. return result;
  791. };
  792. }
  793. ;;
  794. /*
  795. GENERAL NOTE on moments throughout the *entire rest* of the codebase:
  796. All moments are assumed to be ambiguously-zoned unless otherwise noted,
  797. with the NOTABLE EXCEOPTION of start/end dates that live on *Event Objects*.
  798. Ambiguously-TIMED moments are assumed to be ambiguously-zoned by nature.
  799. */
  800. var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/;
  801. var ambigTimeOrZoneRegex =
  802. /^\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+)?)?)?)?)?$/;
  803. var newMomentProto = moment.fn; // where we will attach our new methods
  804. var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods
  805. // tell momentjs to transfer these properties upon clone
  806. var momentProperties = moment.momentProperties;
  807. momentProperties.push('_fullCalendar');
  808. momentProperties.push('_ambigTime');
  809. momentProperties.push('_ambigZone');
  810. // Creating
  811. // -------------------------------------------------------------------------------------------------
  812. // Creates a new moment, similar to the vanilla moment(...) constructor, but with
  813. // extra features (ambiguous time, enhanced formatting). When given an existing moment,
  814. // it will function as a clone (and retain the zone of the moment). Anything else will
  815. // result in a moment in the local zone.
  816. FC.moment = function () {
  817. return makeMoment(arguments);
  818. };
  819. // Sames as FC.moment, but forces the resulting moment to be in the UTC timezone.
  820. FC.moment.utc = function () {
  821. var mom = makeMoment(arguments, true);
  822. // Force it into UTC because makeMoment doesn't guarantee it
  823. // (if given a pre-existing moment for example)
  824. if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone
  825. mom.utc();
  826. }
  827. return mom;
  828. };
  829. // Same as FC.moment, but when given an ISO8601 string, the timezone offset is preserved.
  830. // ISO8601 strings with no timezone offset will become ambiguously zoned.
  831. FC.moment.parseZone = function () {
  832. return makeMoment(arguments, true, true);
  833. };
  834. // Builds an enhanced moment from args. When given an existing moment, it clones. When given a
  835. // native Date, or called with no arguments (the current time), the resulting moment will be local.
  836. // Anything else needs to be "parsed" (a string or an array), and will be affected by:
  837. // parseAsUTC - if there is no zone information, should we parse the input in UTC?
  838. // parseZone - if there is zone information, should we force the zone of the moment?
  839. function makeMoment(args, parseAsUTC, parseZone) {
  840. var input = args[0];
  841. var isSingleString = args.length == 1 && typeof input === 'string';
  842. var isAmbigTime;
  843. var isAmbigZone;
  844. var ambigMatch;
  845. var mom;
  846. if (moment.isMoment(input) || isNativeDate(input) || input === undefined) {
  847. mom = moment.apply(null, args);
  848. }
  849. else { // "parsing" is required
  850. isAmbigTime = false;
  851. isAmbigZone = false;
  852. if (isSingleString) {
  853. if (ambigDateOfMonthRegex.test(input)) {
  854. // accept strings like '2014-05', but convert to the first of the month
  855. input += '-01';
  856. args = [input]; // for when we pass it on to moment's constructor
  857. isAmbigTime = true;
  858. isAmbigZone = true;
  859. }
  860. else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) {
  861. isAmbigTime = !ambigMatch[5]; // no time part?
  862. isAmbigZone = true;
  863. }
  864. }
  865. else if ($.isArray(input)) {
  866. // arrays have no timezone information, so assume ambiguous zone
  867. isAmbigZone = true;
  868. }
  869. // otherwise, probably a string with a format
  870. if (parseAsUTC || isAmbigTime) {
  871. mom = moment.utc.apply(moment, args);
  872. }
  873. else {
  874. mom = moment.apply(null, args);
  875. }
  876. if (isAmbigTime) {
  877. mom._ambigTime = true;
  878. mom._ambigZone = true; // ambiguous time always means ambiguous zone
  879. }
  880. else if (parseZone) { // let's record the inputted zone somehow
  881. if (isAmbigZone) {
  882. mom._ambigZone = true;
  883. }
  884. else if (isSingleString) {
  885. mom.utcOffset(input); // if not a valid zone, will assign UTC
  886. }
  887. }
  888. }
  889. mom._fullCalendar = true; // flag for extended functionality
  890. return mom;
  891. }
  892. // Week Number
  893. // -------------------------------------------------------------------------------------------------
  894. // Returns the week number, considering the locale's custom week number calcuation
  895. // `weeks` is an alias for `week`
  896. newMomentProto.week = newMomentProto.weeks = function (input) {
  897. var weekCalc = this._locale._fullCalendar_weekCalc;
  898. if (input == null && typeof weekCalc === 'function') { // custom function only works for getter
  899. return weekCalc(this);
  900. }
  901. else if (weekCalc === 'ISO') {
  902. return oldMomentProto.isoWeek.apply(this, arguments); // ISO getter/setter
  903. }
  904. return oldMomentProto.week.apply(this, arguments); // local getter/setter
  905. };
  906. // Time-of-day
  907. // -------------------------------------------------------------------------------------------------
  908. // GETTER
  909. // Returns a Duration with the hours/minutes/seconds/ms values of the moment.
  910. // If the moment has an ambiguous time, a duration of 00:00 will be returned.
  911. //
  912. // SETTER
  913. // You can supply a Duration, a Moment, or a Duration-like argument.
  914. // When setting the time, and the moment has an ambiguous time, it then becomes unambiguous.
  915. newMomentProto.time = function (time) {
  916. // Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar.
  917. // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins.
  918. if (!this._fullCalendar) {
  919. return oldMomentProto.time.apply(this, arguments);
  920. }
  921. if (time == null) { // getter
  922. return moment.duration({
  923. hours: this.hours(),
  924. minutes: this.minutes(),
  925. seconds: this.seconds(),
  926. milliseconds: this.milliseconds()
  927. });
  928. }
  929. else { // setter
  930. this._ambigTime = false; // mark that the moment now has a time
  931. if (!moment.isDuration(time) && !moment.isMoment(time)) {
  932. time = moment.duration(time);
  933. }
  934. // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day).
  935. // Only for Duration times, not Moment times.
  936. var dayHours = 0;
  937. if (moment.isDuration(time)) {
  938. dayHours = Math.floor(time.asDays()) * 24;
  939. }
  940. // We need to set the individual fields.
  941. // Can't use startOf('day') then add duration. In case of DST at start of day.
  942. return this.hours(dayHours + time.hours())
  943. .minutes(time.minutes())
  944. .seconds(time.seconds())
  945. .milliseconds(time.milliseconds());
  946. }
  947. };
  948. // Converts the moment to UTC, stripping out its time-of-day and timezone offset,
  949. // but preserving its YMD. A moment with a stripped time will display no time
  950. // nor timezone offset when .format() is called.
  951. newMomentProto.stripTime = function () {
  952. if (!this._ambigTime) {
  953. this.utc(true); // keepLocalTime=true (for keeping *date* value)
  954. // set time to zero
  955. this.set({
  956. hours: 0,
  957. minutes: 0,
  958. seconds: 0,
  959. ms: 0
  960. });
  961. // Mark the time as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(),
  962. // which clears all ambig flags.
  963. this._ambigTime = true;
  964. this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset
  965. }
  966. return this; // for chaining
  967. };
  968. // Returns if the moment has a non-ambiguous time (boolean)
  969. newMomentProto.hasTime = function () {
  970. return !this._ambigTime;
  971. };
  972. // Timezone
  973. // -------------------------------------------------------------------------------------------------
  974. // Converts the moment to UTC, stripping out its timezone offset, but preserving its
  975. // YMD and time-of-day. A moment with a stripped timezone offset will display no
  976. // timezone offset when .format() is called.
  977. newMomentProto.stripZone = function () {
  978. var wasAmbigTime;
  979. if (!this._ambigZone) {
  980. wasAmbigTime = this._ambigTime;
  981. this.utc(true); // keepLocalTime=true (for keeping date and time values)
  982. // the above call to .utc()/.utcOffset() unfortunately might clear the ambig flags, so restore
  983. this._ambigTime = wasAmbigTime || false;
  984. // Mark the zone as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(),
  985. // which clears the ambig flags.
  986. this._ambigZone = true;
  987. }
  988. return this; // for chaining
  989. };
  990. // Returns of the moment has a non-ambiguous timezone offset (boolean)
  991. newMomentProto.hasZone = function () {
  992. return !this._ambigZone;
  993. };
  994. // implicitly marks a zone
  995. newMomentProto.local = function (keepLocalTime) {
  996. // for when converting from ambiguously-zoned to local,
  997. // keep the time values when converting from UTC -> local
  998. oldMomentProto.local.call(this, this._ambigZone || keepLocalTime);
  999. // ensure non-ambiguous
  1000. // this probably already happened via local() -> utcOffset(), but don't rely on Moment's internals
  1001. this._ambigTime = false;
  1002. this._ambigZone = false;
  1003. return this; // for chaining
  1004. };
  1005. // implicitly marks a zone
  1006. newMomentProto.utc = function (keepLocalTime) {
  1007. oldMomentProto.utc.call(this, keepLocalTime);
  1008. // ensure non-ambiguous
  1009. // this probably already happened via utc() -> utcOffset(), but don't rely on Moment's internals
  1010. this._ambigTime = false;
  1011. this._ambigZone = false;
  1012. return this;
  1013. };
  1014. // implicitly marks a zone (will probably get called upon .utc() and .local())
  1015. newMomentProto.utcOffset = function (tzo) {
  1016. if (tzo != null) { // setter
  1017. // these assignments needs to happen before the original zone method is called.
  1018. // I forget why, something to do with a browser crash.
  1019. this._ambigTime = false;
  1020. this._ambigZone = false;
  1021. }
  1022. return oldMomentProto.utcOffset.apply(this, arguments);
  1023. };
  1024. // Formatting
  1025. // -------------------------------------------------------------------------------------------------
  1026. newMomentProto.format = function () {
  1027. if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided?
  1028. return formatDate(this, arguments[0]); // our extended formatting
  1029. }
  1030. if (this._ambigTime) {
  1031. return oldMomentFormat(this, 'YYYY-MM-DD');
  1032. }
  1033. if (this._ambigZone) {
  1034. return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss');
  1035. }
  1036. return oldMomentProto.format.apply(this, arguments);
  1037. };
  1038. newMomentProto.toISOString = function () {
  1039. if (this._ambigTime) {
  1040. return oldMomentFormat(this, 'YYYY-MM-DD');
  1041. }
  1042. if (this._ambigZone) {
  1043. return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss');
  1044. }
  1045. return oldMomentProto.toISOString.apply(this, arguments);
  1046. };
  1047. ;;
  1048. // Single Date Formatting
  1049. // -------------------------------------------------------------------------------------------------
  1050. // call this if you want Moment's original format method to be used
  1051. function oldMomentFormat(mom, formatStr) {
  1052. return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js
  1053. }
  1054. // Formats `date` with a Moment formatting string, but allow our non-zero areas and
  1055. // additional token.
  1056. function formatDate(date, formatStr) {
  1057. return formatDateWithChunks(date, getFormatStringChunks(formatStr));
  1058. }
  1059. function formatDateWithChunks(date, chunks) {
  1060. var s = '';
  1061. var i;
  1062. for (i = 0; i < chunks.length; i++) {
  1063. s += formatDateWithChunk(date, chunks[i]);
  1064. }
  1065. return s;
  1066. }
  1067. // addition formatting tokens we want recognized
  1068. var tokenOverrides = {
  1069. t: function (date) { // "a" or "p"
  1070. return oldMomentFormat(date, 'a').charAt(0);
  1071. },
  1072. T: function (date) { // "A" or "P"
  1073. return oldMomentFormat(date, 'A').charAt(0);
  1074. }
  1075. };
  1076. function formatDateWithChunk(date, chunk) {
  1077. var token;
  1078. var maybeStr;
  1079. if (typeof chunk === 'string') { // a literal string
  1080. return chunk;
  1081. }
  1082. else if ((token = chunk.token)) { // a token, like "YYYY"
  1083. if (tokenOverrides[token]) {
  1084. return tokenOverrides[token](date); // use our custom token
  1085. }
  1086. return oldMomentFormat(date, token);
  1087. }
  1088. else if (chunk.maybe) { // a grouping of other chunks that must be non-zero
  1089. maybeStr = formatDateWithChunks(date, chunk.maybe);
  1090. if (maybeStr.match(/[1-9]/)) {
  1091. return maybeStr;
  1092. }
  1093. }
  1094. return '';
  1095. }
  1096. // Date Range Formatting
  1097. // -------------------------------------------------------------------------------------------------
  1098. // TODO: make it work with timezone offset
  1099. // Using a formatting string meant for a single date, generate a range string, like
  1100. // "Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ.
  1101. // If the dates are the same as far as the format string is concerned, just return a single
  1102. // rendering of one date, without any separator.
  1103. function formatRange(date1, date2, formatStr, separator, isRTL) {
  1104. var localeData;
  1105. date1 = FC.moment.parseZone(date1);
  1106. date2 = FC.moment.parseZone(date2);
  1107. localeData = date1.localeData();
  1108. // Expand localized format strings, like "LL" -> "MMMM D YYYY"
  1109. formatStr = localeData.longDateFormat(formatStr) || formatStr;
  1110. // BTW, this is not important for `formatDate` because it is impossible to put custom tokens
  1111. // or non-zero areas in Moment's localized format strings.
  1112. separator = separator || ' - ';
  1113. return formatRangeWithChunks(
  1114. date1,
  1115. date2,
  1116. getFormatStringChunks(formatStr),
  1117. separator,
  1118. isRTL
  1119. );
  1120. }
  1121. FC.formatRange = formatRange; // expose
  1122. function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) {
  1123. var unzonedDate1 = date1.clone().stripZone(); // for formatSimilarChunk
  1124. var unzonedDate2 = date2.clone().stripZone(); // "
  1125. var chunkStr; // the rendering of the chunk
  1126. var leftI;
  1127. var leftStr = '';
  1128. var rightI;
  1129. var rightStr = '';
  1130. var middleI;
  1131. var middleStr1 = '';
  1132. var middleStr2 = '';
  1133. var middleStr = '';
  1134. // Start at the leftmost side of the formatting string and continue until you hit a token
  1135. // that is not the same between dates.
  1136. for (leftI = 0; leftI < chunks.length; leftI++) {
  1137. chunkStr = formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunks[leftI]);
  1138. if (chunkStr === false) {
  1139. break;
  1140. }
  1141. leftStr += chunkStr;
  1142. }
  1143. // Similarly, start at the rightmost side of the formatting string and move left
  1144. for (rightI = chunks.length - 1; rightI > leftI; rightI--) {
  1145. chunkStr = formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunks[rightI]);
  1146. if (chunkStr === false) {
  1147. break;
  1148. }
  1149. rightStr = chunkStr + rightStr;
  1150. }
  1151. // The area in the middle is different for both of the dates.
  1152. // Collect them distinctly so we can jam them together later.
  1153. for (middleI = leftI; middleI <= rightI; middleI++) {
  1154. middleStr1 += formatDateWithChunk(date1, chunks[middleI]);
  1155. middleStr2 += formatDateWithChunk(date2, chunks[middleI]);
  1156. }
  1157. if (middleStr1 || middleStr2) {
  1158. if (isRTL) {
  1159. middleStr = middleStr2 + separator + middleStr1;
  1160. }
  1161. else {
  1162. middleStr = middleStr1 + separator + middleStr2;
  1163. }
  1164. }
  1165. return leftStr + middleStr + rightStr;
  1166. }
  1167. var similarUnitMap = {
  1168. Y: 'year',
  1169. M: 'month',
  1170. D: 'day', // day of month
  1171. d: 'day', // day of week
  1172. // prevents a separator between anything time-related...
  1173. A: 'second', // AM/PM
  1174. a: 'second', // am/pm
  1175. T: 'second', // A/P
  1176. t: 'second', // a/p
  1177. H: 'second', // hour (24)
  1178. h: 'second', // hour (12)
  1179. m: 'second', // minute
  1180. s: 'second' // second
  1181. };
  1182. // TODO: week maybe?
  1183. // Given a formatting chunk, and given that both dates are similar in the regard the
  1184. // formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`.
  1185. function formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunk) {
  1186. var token;
  1187. var unit;
  1188. if (typeof chunk === 'string') { // a literal string
  1189. return chunk;
  1190. }
  1191. else if ((token = chunk.token)) {
  1192. unit = similarUnitMap[token.charAt(0)];
  1193. // are the dates the same for this unit of measurement?
  1194. // use the unzoned dates for this calculation because unreliable when near DST (bug #2396)
  1195. if (unit && unzonedDate1.isSame(unzonedDate2, unit)) {
  1196. return oldMomentFormat(date1, token); // would be the same if we used `date2`
  1197. // BTW, don't support custom tokens
  1198. }
  1199. }
  1200. return false; // the chunk is NOT the same for the two dates
  1201. // BTW, don't support splitting on non-zero areas
  1202. }
  1203. // Chunking Utils
  1204. // -------------------------------------------------------------------------------------------------
  1205. var formatStringChunkCache = {};
  1206. function getFormatStringChunks(formatStr) {
  1207. if (formatStr in formatStringChunkCache) {
  1208. return formatStringChunkCache[formatStr];
  1209. }
  1210. return (formatStringChunkCache[formatStr] = chunkFormatString(formatStr));
  1211. }
  1212. // Break the formatting string into an array of chunks
  1213. function chunkFormatString(formatStr) {
  1214. var chunks = [];
  1215. var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g; // TODO: more descrimination
  1216. var match;
  1217. while ((match = chunker.exec(formatStr))) {
  1218. if (match[1]) { // a literal string inside [ ... ]
  1219. chunks.push(match[1]);
  1220. }
  1221. else if (match[2]) { // non-zero formatting inside ( ... )
  1222. chunks.push({ maybe: chunkFormatString(match[2]) });
  1223. }
  1224. else if (match[3]) { // a formatting token
  1225. chunks.push({ token: match[3] });
  1226. }
  1227. else if (match[5]) { // an unenclosed literal string
  1228. chunks.push(match[5]);
  1229. }
  1230. }
  1231. return chunks;
  1232. }
  1233. // Misc Utils
  1234. // -------------------------------------------------------------------------------------------------
  1235. // granularity only goes up until day
  1236. // TODO: unify with similarUnitMap
  1237. var tokenGranularities = {
  1238. Y: { value: 1, unit: 'year' },
  1239. M: { value: 2, unit: 'month' },
  1240. W: { value: 3, unit: 'week' },
  1241. w: { value: 3, unit: 'week' },
  1242. D: { value: 4, unit: 'day' }, // day of month
  1243. d: { value: 4, unit: 'day' } // day of week
  1244. };
  1245. // returns a unit string, either 'year', 'month', 'day', or null
  1246. // for the most granular formatting token in the string.
  1247. FC.queryMostGranularFormatUnit = function (formatStr) {
  1248. var chunks = getFormatStringChunks(formatStr);
  1249. var i, chunk;
  1250. var candidate;
  1251. var best;
  1252. for (i = 0; i < chunks.length; i++) {
  1253. chunk = chunks[i];
  1254. if (chunk.token) {
  1255. candidate = tokenGranularities[chunk.token.charAt(0)];
  1256. if (candidate) {
  1257. if (!best || candidate.value > best.value) {
  1258. best = candidate;
  1259. }
  1260. }
  1261. }
  1262. }
  1263. if (best) {
  1264. return best.unit;
  1265. }
  1266. return null;
  1267. };
  1268. ;;
  1269. FC.Class = Class; // export
  1270. // Class that all other classes will inherit from
  1271. function Class() { }
  1272. // Called on a class to create a subclass.
  1273. // Last argument contains instance methods. Any argument before the last are considered mixins.
  1274. Class.extend = function () {
  1275. var len = arguments.length;
  1276. var i;
  1277. var members;
  1278. for (i = 0; i < len; i++) {
  1279. members = arguments[i];
  1280. if (i < len - 1) { // not the last argument?
  1281. mixIntoClass(this, members);
  1282. }
  1283. }
  1284. return extendClass(this, members || {}); // members will be undefined if no arguments
  1285. };
  1286. // Adds new member variables/methods to the class's prototype.
  1287. // Can be called with another class, or a plain object hash containing new members.
  1288. Class.mixin = function (members) {
  1289. mixIntoClass(this, members);
  1290. };
  1291. function extendClass(superClass, members) {
  1292. var subClass;
  1293. // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist
  1294. if (hasOwnProp(members, 'constructor')) {
  1295. subClass = members.constructor;
  1296. }
  1297. if (typeof subClass !== 'function') {
  1298. subClass = members.constructor = function () {
  1299. superClass.apply(this, arguments);
  1300. };
  1301. }
  1302. // build the base prototype for the subclass, which is an new object chained to the superclass's prototype
  1303. subClass.prototype = createObject(superClass.prototype);
  1304. // copy each member variable/method onto the the subclass's prototype
  1305. copyOwnProps(members, subClass.prototype);
  1306. // copy over all class variables/methods to the subclass, such as `extend` and `mixin`
  1307. copyOwnProps(superClass, subClass);
  1308. return subClass;
  1309. }
  1310. function mixIntoClass(theClass, members) {
  1311. copyOwnProps(members, theClass.prototype);
  1312. }
  1313. ;;
  1314. /*
  1315. Wrap jQuery's Deferred Promise object to be slightly more Promise/A+ compliant.
  1316. With the added non-standard feature of synchronously executing handlers on resolved promises,
  1317. which doesn't always happen otherwise (esp with nested .then handlers!?),
  1318. so, this makes things a lot easier, esp because jQuery 3 changed the synchronicity for Deferred objects.
  1319. TODO: write tests and more comments
  1320. */
  1321. function Promise(executor) {
  1322. var deferred = $.Deferred();
  1323. var promise = deferred.promise();
  1324. if (typeof executor === 'function') {
  1325. executor(
  1326. function (value) { // resolve
  1327. if (Promise.immediate) {
  1328. promise._value = value;
  1329. }
  1330. deferred.resolve(value);
  1331. },
  1332. function () { // reject
  1333. deferred.reject();
  1334. }
  1335. );
  1336. }
  1337. if (Promise.immediate) {
  1338. var origThen = promise.then;
  1339. promise.then = function (onFulfilled, onRejected) {
  1340. var state = promise.state();
  1341. if (state === 'resolved') {
  1342. if (typeof onFulfilled === 'function') {
  1343. return Promise.resolve(onFulfilled(promise._value));
  1344. }
  1345. }
  1346. else if (state === 'rejected') {
  1347. if (typeof onRejected === 'function') {
  1348. onRejected();
  1349. return promise; // already rejected
  1350. }
  1351. }
  1352. return origThen.call(promise, onFulfilled, onRejected);
  1353. };
  1354. }
  1355. return promise; // instanceof Promise will break :( TODO: make Promise a real class
  1356. }
  1357. FC.Promise = Promise;
  1358. Promise.immediate = true;
  1359. Promise.resolve = function (value) {
  1360. if (value && typeof value.resolve === 'function') {
  1361. return value.promise();
  1362. }
  1363. if (value && typeof value.then === 'function') {
  1364. return value;
  1365. }
  1366. else {
  1367. var deferred = $.Deferred().resolve(value);
  1368. var promise = deferred.promise();
  1369. if (Promise.immediate) {
  1370. var origThen = promise.then;
  1371. promise._value = value;
  1372. promise.then = function (onFulfilled, onRejected) {
  1373. if (typeof onFulfilled === 'function') {
  1374. return Promise.resolve(onFulfilled(value));
  1375. }
  1376. return origThen.call(promise, onFulfilled, onRejected);
  1377. };
  1378. }
  1379. return promise;
  1380. }
  1381. };
  1382. Promise.reject = function () {
  1383. return $.Deferred().reject().promise();
  1384. };
  1385. Promise.all = function (inputs) {
  1386. var hasAllValues = false;
  1387. var values;
  1388. var i, input;
  1389. if (Promise.immediate) {
  1390. hasAllValues = true;
  1391. values = [];
  1392. for (i = 0; i < inputs.length; i++) {
  1393. input = inputs[i];
  1394. if (input && typeof input.state === 'function' && input.state() === 'resolved' && ('_value' in input)) {
  1395. values.push(input._value);
  1396. }
  1397. else if (input && typeof input.then === 'function') {
  1398. hasAllValues = false;
  1399. break;
  1400. }
  1401. else {
  1402. values.push(input);
  1403. }
  1404. }
  1405. }
  1406. if (hasAllValues) {
  1407. return Promise.resolve(values);
  1408. }
  1409. else {
  1410. return $.when.apply($.when, inputs).then(function () {
  1411. return $.when($.makeArray(arguments));
  1412. });
  1413. }
  1414. };
  1415. ;;
  1416. // TODO: write tests and clean up code
  1417. function TaskQueue(debounceWait) {
  1418. var q = []; // array of runFuncs
  1419. function addTask(taskFunc) {
  1420. return new Promise(function (resolve) {
  1421. // should run this function when it's taskFunc's turn to run.
  1422. // responsible for popping itself off the queue.
  1423. var runFunc = function () {
  1424. Promise.resolve(taskFunc()) // result might be async, coerce to promise
  1425. .then(resolve) // resolve TaskQueue::push's promise, for the caller. will receive result of taskFunc.
  1426. .then(function () {
  1427. q.shift(); // pop itself off
  1428. // run the next task, if any
  1429. if (q.length) {
  1430. q[0]();
  1431. }
  1432. });
  1433. };
  1434. // always put the task at the end of the queue, BEFORE running the task
  1435. q.push(runFunc);
  1436. // if it's the only task in the queue, run immediately
  1437. if (q.length === 1) {
  1438. runFunc();
  1439. }
  1440. });
  1441. }
  1442. this.add = // potentially debounce, for the public method
  1443. typeof debounceWait === 'number' ?
  1444. debounce(addTask, debounceWait) :
  1445. addTask; // if not a number (null/undefined/false), no debounce at all
  1446. this.addQuickly = addTask; // guaranteed no debounce
  1447. }
  1448. FC.TaskQueue = TaskQueue;
  1449. /*
  1450. q = new TaskQueue();
  1451. function work(i) {
  1452. return q.push(function() {
  1453. trigger();
  1454. console.log('work' + i);
  1455. });
  1456. }
  1457. var cnt = 0;
  1458. function trigger() {
  1459. if (cnt < 5) {
  1460. cnt++;
  1461. work(cnt);
  1462. }
  1463. }
  1464. work(9);
  1465. */
  1466. ;;
  1467. var EmitterMixin = FC.EmitterMixin = {
  1468. // jQuery-ification via $(this) allows a non-DOM object to have
  1469. // the same event handling capabilities (including namespaces).
  1470. on: function (types, handler) {
  1471. $(this).on(types, this._prepareIntercept(handler));
  1472. return this; // for chaining
  1473. },
  1474. one: function (types, handler) {
  1475. $(this).one(types, this._prepareIntercept(handler));
  1476. return this; // for chaining
  1477. },
  1478. _prepareIntercept: function (handler) {
  1479. // handlers are always called with an "event" object as their first param.
  1480. // sneak the `this` context and arguments into the extra parameter object
  1481. // and forward them on to the original handler.
  1482. var intercept = function (ev, extra) {
  1483. return handler.apply(
  1484. extra.context || this,
  1485. extra.args || []
  1486. );
  1487. };
  1488. // mimick jQuery's internal "proxy" system (risky, I know)
  1489. // causing all functions with the same .guid to appear to be the same.
  1490. // https://github.com/jquery/jquery/blob/2.2.4/src/core.js#L448
  1491. // this is needed for calling .off with the original non-intercept handler.
  1492. if (!handler.guid) {
  1493. handler.guid = $.guid++;
  1494. }
  1495. intercept.guid = handler.guid;
  1496. return intercept;
  1497. },
  1498. off: function (types, handler) {
  1499. $(this).off(types, handler);
  1500. return this; // for chaining
  1501. },
  1502. trigger: function (types) {
  1503. var args = Array.prototype.slice.call(arguments, 1); // arguments after the first
  1504. // pass in "extra" info to the intercept
  1505. $(this).triggerHandler(types, { args: args });
  1506. return this; // for chaining
  1507. },
  1508. triggerWith: function (types, context, args) {
  1509. // `triggerHandler` is less reliant on the DOM compared to `trigger`.
  1510. // pass in "extra" info to the intercept.
  1511. $(this).triggerHandler(types, { context: context, args: args });
  1512. return this; // for chaining
  1513. }
  1514. };
  1515. ;;
  1516. /*
  1517. Utility methods for easily listening to events on another object,
  1518. and more importantly, easily unlistening from them.
  1519. */
  1520. var ListenerMixin = FC.ListenerMixin = (function () {
  1521. var guid = 0;
  1522. var ListenerMixin = {
  1523. listenerId: null,
  1524. /*
  1525. Given an `other` object that has on/off methods, bind the given `callback` to an event by the given name.
  1526. The `callback` will be called with the `this` context of the object that .listenTo is being called on.
  1527. Can be called:
  1528. .listenTo(other, eventName, callback)
  1529. OR
  1530. .listenTo(other, {
  1531. eventName1: callback1,
  1532. eventName2: callback2
  1533. })
  1534. */
  1535. listenTo: function (other, arg, callback) {
  1536. if (typeof arg === 'object') { // given dictionary of callbacks
  1537. for (var eventName in arg) {
  1538. if (arg.hasOwnProperty(eventName)) {
  1539. this.listenTo(other, eventName, arg[eventName]);
  1540. }
  1541. }
  1542. }
  1543. else if (typeof arg === 'string') {
  1544. other.on(
  1545. arg + '.' + this.getListenerNamespace(), // use event namespacing to identify this object
  1546. $.proxy(callback, this) // always use `this` context
  1547. // the usually-undesired jQuery guid behavior doesn't matter,
  1548. // because we always unbind via namespace
  1549. );
  1550. }
  1551. },
  1552. /*
  1553. Causes the current object to stop listening to events on the `other` object.
  1554. `eventName` is optional. If omitted, will stop listening to ALL events on `other`.
  1555. */
  1556. stopListeningTo: function (other, eventName) {
  1557. other.off((eventName || '') + '.' + this.getListenerNamespace());
  1558. },
  1559. /*
  1560. Returns a string, unique to this object, to be used for event namespacing
  1561. */
  1562. getListenerNamespace: function () {
  1563. if (this.listenerId == null) {
  1564. this.listenerId = guid++;
  1565. }
  1566. return '_listener' + this.listenerId;
  1567. }
  1568. };
  1569. return ListenerMixin;
  1570. })();
  1571. ;;
  1572. // simple class for toggle a `isIgnoringMouse` flag on delay
  1573. // initMouseIgnoring must first be called, with a millisecond delay setting.
  1574. var MouseIgnorerMixin = {
  1575. isIgnoringMouse: false, // bool
  1576. delayUnignoreMouse: null, // method
  1577. initMouseIgnoring: function (delay) {
  1578. this.delayUnignoreMouse = debounce(proxy(this, 'unignoreMouse'), delay || 1000);
  1579. },
  1580. // temporarily ignore mouse actions on segments
  1581. tempIgnoreMouse: function () {
  1582. this.isIgnoringMouse = true;
  1583. this.delayUnignoreMouse();
  1584. },
  1585. // delayUnignoreMouse eventually calls this
  1586. unignoreMouse: function () {
  1587. this.isIgnoringMouse = false;
  1588. }
  1589. };
  1590. ;;
  1591. /* A rectangular panel that is absolutely positioned over other content
  1592. ------------------------------------------------------------------------------------------------------------------------
  1593. Options:
  1594. - className (string)
  1595. - content (HTML string or jQuery element set)
  1596. - parentEl
  1597. - top
  1598. - left
  1599. - right (the x coord of where the right edge should be. not a "CSS" right)
  1600. - autoHide (boolean)
  1601. - show (callback)
  1602. - hide (callback)
  1603. */
  1604. var Popover = Class.extend(ListenerMixin, {
  1605. isHidden: true,
  1606. options: null,
  1607. el: null, // the container element for the popover. generated by this object
  1608. margin: 10, // the space required between the popover and the edges of the scroll container
  1609. constructor: function (options) {
  1610. this.options = options || {};
  1611. },
  1612. // Shows the popover on the specified position. Renders it if not already
  1613. show: function () {
  1614. if (this.isHidden) {
  1615. if (!this.el) {
  1616. this.render();
  1617. }
  1618. this.el.show();
  1619. this.position();
  1620. this.isHidden = false;
  1621. this.trigger('show');
  1622. }
  1623. },
  1624. // Hides the popover, through CSS, but does not remove it from the DOM
  1625. hide: function () {
  1626. if (!this.isHidden) {
  1627. this.el.hide();
  1628. this.isHidden = true;
  1629. this.trigger('hide');
  1630. }
  1631. },
  1632. // Creates `this.el` and renders content inside of it
  1633. render: function () {
  1634. var _this = this;
  1635. var options = this.options;
  1636. this.el = $('<div class="fc-popover"/>')
  1637. .addClass(options.className || '')
  1638. .css({
  1639. // position initially to the top left to avoid creating scrollbars
  1640. top: 0,
  1641. left: 0
  1642. })
  1643. .append(options.content)
  1644. .appendTo(options.parentEl);
  1645. // when a click happens on anything inside with a 'fc-close' className, hide the popover
  1646. this.el.on('click', '.fc-close', function () {
  1647. _this.hide();
  1648. });
  1649. if (options.autoHide) {
  1650. this.listenTo($(document), 'mousedown', this.documentMousedown);
  1651. }
  1652. },
  1653. // Triggered when the user clicks *anywhere* in the document, for the autoHide feature
  1654. documentMousedown: function (ev) {
  1655. // only hide the popover if the click happened outside the popover
  1656. if (this.el && !$(ev.target).closest(this.el).length) {
  1657. this.hide();
  1658. }
  1659. },
  1660. // Hides and unregisters any handlers
  1661. removeElement: function () {
  1662. this.hide();
  1663. if (this.el) {
  1664. this.el.remove();
  1665. this.el = null;
  1666. }
  1667. this.stopListeningTo($(document), 'mousedown');
  1668. },
  1669. // Positions the popover optimally, using the top/left/right options
  1670. position: function () {
  1671. var options = this.options;
  1672. var origin = this.el.offsetParent().offset();
  1673. var width = this.el.outerWidth();
  1674. var height = this.el.outerHeight();
  1675. var windowEl = $(window);
  1676. var viewportEl = getScrollParent(this.el);
  1677. var viewportTop;
  1678. var viewportLeft;
  1679. var viewportOffset;
  1680. var top; // the "position" (not "offset") values for the popover
  1681. var left; //
  1682. // compute top and left
  1683. top = options.top || 0;
  1684. if (options.left !== undefined) {
  1685. left = options.left;
  1686. }
  1687. else if (options.right !== undefined) {
  1688. left = options.right - width; // derive the left value from the right value
  1689. }
  1690. else {
  1691. left = 0;
  1692. }
  1693. if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result
  1694. viewportEl = windowEl;
  1695. viewportTop = 0; // the window is always at the top left
  1696. viewportLeft = 0; // (and .offset() won't work if called here)
  1697. }
  1698. else {
  1699. viewportOffset = viewportEl.offset();
  1700. viewportTop = viewportOffset.top;
  1701. viewportLeft = viewportOffset.left;
  1702. }
  1703. // if the window is scrolled, it causes the visible area to be further down
  1704. viewportTop += windowEl.scrollTop();
  1705. viewportLeft += windowEl.scrollLeft();
  1706. // constrain to the view port. if constrained by two edges, give precedence to top/left
  1707. if (options.viewportConstrain !== false) {
  1708. top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin);
  1709. top = Math.max(top, viewportTop + this.margin);
  1710. left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin);
  1711. left = Math.max(left, viewportLeft + this.margin);
  1712. }
  1713. this.el.css({
  1714. top: top - origin.top,
  1715. left: left - origin.left
  1716. });
  1717. },
  1718. // Triggers a callback. Calls a function in the option hash of the same name.
  1719. // Arguments beyond the first `name` are forwarded on.
  1720. // TODO: better code reuse for this. Repeat code
  1721. trigger: function (name) {
  1722. if (this.options[name]) {
  1723. this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
  1724. }
  1725. }
  1726. });
  1727. ;;
  1728. /*
  1729. A cache for the left/right/top/bottom/width/height values for one or more elements.
  1730. Works with both offset (from topleft document) and position (from offsetParent).
  1731. options:
  1732. - els
  1733. - isHorizontal
  1734. - isVertical
  1735. */
  1736. var CoordCache = FC.CoordCache = Class.extend({
  1737. els: null, // jQuery set (assumed to be siblings)
  1738. forcedOffsetParentEl: null, // options can override the natural offsetParent
  1739. origin: null, // {left,top} position of offsetParent of els
  1740. boundingRect: null, // constrain cordinates to this rectangle. {left,right,top,bottom} or null
  1741. isHorizontal: false, // whether to query for left/right/width
  1742. isVertical: false, // whether to query for top/bottom/height
  1743. // arrays of coordinates (offsets from topleft of document)
  1744. lefts: null,
  1745. rights: null,
  1746. tops: null,
  1747. bottoms: null,
  1748. constructor: function (options) {
  1749. this.els = $(options.els);
  1750. this.isHorizontal = options.isHorizontal;
  1751. this.isVertical = options.isVertical;
  1752. this.forcedOffsetParentEl = options.offsetParent ? $(options.offsetParent) : null;
  1753. },
  1754. // Queries the els for coordinates and stores them.
  1755. // Call this method before using and of the get* methods below.
  1756. build: function () {
  1757. var offsetParentEl = this.forcedOffsetParentEl;
  1758. if (!offsetParentEl && this.els.length > 0) {
  1759. offsetParentEl = this.els.eq(0).offsetParent();
  1760. }
  1761. this.origin = offsetParentEl ?
  1762. offsetParentEl.offset() :
  1763. null;
  1764. this.boundingRect = this.queryBoundingRect();
  1765. if (this.isHorizontal) {
  1766. this.buildElHorizontals();
  1767. }
  1768. if (this.isVertical) {
  1769. this.buildElVerticals();
  1770. }
  1771. },
  1772. // Destroys all internal data about coordinates, freeing memory
  1773. clear: function () {
  1774. this.origin = null;
  1775. this.boundingRect = null;
  1776. this.lefts = null;
  1777. this.rights = null;
  1778. this.tops = null;
  1779. this.bottoms = null;
  1780. },
  1781. // When called, if coord caches aren't built, builds them
  1782. ensureBuilt: function () {
  1783. if (!this.origin) {
  1784. this.build();
  1785. }
  1786. },
  1787. // Populates the left/right internal coordinate arrays
  1788. buildElHorizontals: function () {
  1789. var lefts = [];
  1790. var rights = [];
  1791. this.els.each(function (i, node) {
  1792. var el = $(node);
  1793. var left = el.offset().left;
  1794. var width = el.outerWidth();
  1795. lefts.push(left);
  1796. rights.push(left + width);
  1797. });
  1798. this.lefts = lefts;
  1799. this.rights = rights;
  1800. },
  1801. // Populates the top/bottom internal coordinate arrays
  1802. buildElVerticals: function () {
  1803. var tops = [];
  1804. var bottoms = [];
  1805. this.els.each(function (i, node) {
  1806. var el = $(node);
  1807. var top = el.offset().top;
  1808. var height = el.outerHeight();
  1809. tops.push(top);
  1810. bottoms.push(top + height);
  1811. });
  1812. this.tops = tops;
  1813. this.bottoms = bottoms;
  1814. },
  1815. // Given a left offset (from document left), returns the index of the el that it horizontally intersects.
  1816. // If no intersection is made, returns undefined.
  1817. getHorizontalIndex: function (leftOffset) {
  1818. this.ensureBuilt();
  1819. var lefts = this.lefts;
  1820. var rights = this.rights;
  1821. var len = lefts.length;
  1822. var i;
  1823. for (i = 0; i < len; i++) {
  1824. if (leftOffset >= lefts[i] && leftOffset < rights[i]) {
  1825. return i;
  1826. }
  1827. }
  1828. },
  1829. // Given a top offset (from document top), returns the index of the el that it vertically intersects.
  1830. // If no intersection is made, returns undefined.
  1831. getVerticalIndex: function (topOffset) {
  1832. this.ensureBuilt();
  1833. var tops = this.tops;
  1834. var bottoms = this.bottoms;
  1835. var len = tops.length;
  1836. var i;
  1837. for (i = 0; i < len; i++) {
  1838. if (topOffset >= tops[i] && topOffset < bottoms[i]) {
  1839. return i;
  1840. }
  1841. }
  1842. },
  1843. // Gets the left offset (from document left) of the element at the given index
  1844. getLeftOffset: function (leftIndex) {
  1845. this.ensureBuilt();
  1846. return this.lefts[leftIndex];
  1847. },
  1848. // Gets the left position (from offsetParent left) of the element at the given index
  1849. getLeftPosition: function (leftIndex) {
  1850. this.ensureBuilt();
  1851. return this.lefts[leftIndex] - this.origin.left;
  1852. },
  1853. // Gets the right offset (from document left) of the element at the given index.
  1854. // This value is NOT relative to the document's right edge, like the CSS concept of "right" would be.
  1855. getRightOffset: function (leftIndex) {
  1856. this.ensureBuilt();
  1857. return this.rights[leftIndex];
  1858. },
  1859. // Gets the right position (from offsetParent left) of the element at the given index.
  1860. // This value is NOT relative to the offsetParent's right edge, like the CSS concept of "right" would be.
  1861. getRightPosition: function (leftIndex) {
  1862. this.ensureBuilt();
  1863. return this.rights[leftIndex] - this.origin.left;
  1864. },
  1865. // Gets the width of the element at the given index
  1866. getWidth: function (leftIndex) {
  1867. this.ensureBuilt();
  1868. return this.rights[leftIndex] - this.lefts[leftIndex];
  1869. },
  1870. // Gets the top offset (from document top) of the element at the given index
  1871. getTopOffset: function (topIndex) {
  1872. this.ensureBuilt();
  1873. return this.tops[topIndex];
  1874. },
  1875. // Gets the top position (from offsetParent top) of the element at the given position
  1876. getTopPosition: function (topIndex) {
  1877. this.ensureBuilt();
  1878. return this.tops[topIndex] - this.origin.top;
  1879. },
  1880. // Gets the bottom offset (from the document top) of the element at the given index.
  1881. // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be.
  1882. getBottomOffset: function (topIndex) {
  1883. this.ensureBuilt();
  1884. return this.bottoms[topIndex];
  1885. },
  1886. // Gets the bottom position (from the offsetParent top) of the element at the given index.
  1887. // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be.
  1888. getBottomPosition: function (topIndex) {
  1889. this.ensureBuilt();
  1890. return this.bottoms[topIndex] - this.origin.top;
  1891. },
  1892. // Gets the height of the element at the given index
  1893. getHeight: function (topIndex) {
  1894. this.ensureBuilt();
  1895. return this.bottoms[topIndex] - this.tops[topIndex];
  1896. },
  1897. // Bounding Rect
  1898. // TODO: decouple this from CoordCache
  1899. // Compute and return what the elements' bounding rectangle is, from the user's perspective.
  1900. // Right now, only returns a rectangle if constrained by an overflow:scroll element.
  1901. // Returns null if there are no elements
  1902. queryBoundingRect: function () {
  1903. var scrollParentEl;
  1904. if (this.els.length > 0) {
  1905. scrollParentEl = getScrollParent(this.els.eq(0));
  1906. if (!scrollParentEl.is(document)) {
  1907. return getClientRect(scrollParentEl);
  1908. }
  1909. }
  1910. return null;
  1911. },
  1912. isPointInBounds: function (leftOffset, topOffset) {
  1913. return this.isLeftInBounds(leftOffset) && this.isTopInBounds(topOffset);
  1914. },
  1915. isLeftInBounds: function (leftOffset) {
  1916. return !this.boundingRect || (leftOffset >= this.boundingRect.left && leftOffset < this.boundingRect.right);
  1917. },
  1918. isTopInBounds: function (topOffset) {
  1919. return !this.boundingRect || (topOffset >= this.boundingRect.top && topOffset < this.boundingRect.bottom);
  1920. }
  1921. });
  1922. ;;
  1923. /* Tracks a drag's mouse movement, firing various handlers
  1924. ----------------------------------------------------------------------------------------------------------------------*/
  1925. // TODO: use Emitter
  1926. var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMixin, {
  1927. options: null,
  1928. subjectEl: null,
  1929. // coordinates of the initial mousedown
  1930. originX: null,
  1931. originY: null,
  1932. // the wrapping element that scrolls, or MIGHT scroll if there's overflow.
  1933. // TODO: do this for wrappers that have overflow:hidden as well.
  1934. scrollEl: null,
  1935. isInteracting: false,
  1936. isDistanceSurpassed: false,
  1937. isDelayEnded: false,
  1938. isDragging: false,
  1939. isTouch: false,
  1940. delay: null,
  1941. delayTimeoutId: null,
  1942. minDistance: null,
  1943. handleTouchScrollProxy: null, // calls handleTouchScroll, always bound to `this`
  1944. constructor: function (options) {
  1945. this.options = options || {};
  1946. this.handleTouchScrollProxy = proxy(this, 'handleTouchScroll');
  1947. this.initMouseIgnoring(500);
  1948. },
  1949. // Interaction (high-level)
  1950. // -----------------------------------------------------------------------------------------------------------------
  1951. startInteraction: function (ev, extraOptions) {
  1952. var isTouch = getEvIsTouch(ev);
  1953. if (ev.type === 'mousedown') {
  1954. if (this.isIgnoringMouse) {
  1955. return;
  1956. }
  1957. else if (!isPrimaryMouseButton(ev)) {
  1958. return;
  1959. }
  1960. else {
  1961. ev.preventDefault(); // prevents native selection in most browsers
  1962. }
  1963. }
  1964. if (!this.isInteracting) {
  1965. // process options
  1966. extraOptions = extraOptions || {};
  1967. this.delay = firstDefined(extraOptions.delay, this.options.delay, 0);
  1968. this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0);
  1969. this.subjectEl = this.options.subjectEl;
  1970. this.isInteracting = true;
  1971. this.isTouch = isTouch;
  1972. this.isDelayEnded = false;
  1973. this.isDistanceSurpassed = false;
  1974. this.originX = getEvX(ev);
  1975. this.originY = getEvY(ev);
  1976. this.scrollEl = getScrollParent($(ev.target));
  1977. this.bindHandlers();
  1978. this.initAutoScroll();
  1979. this.handleInteractionStart(ev);
  1980. this.startDelay(ev);
  1981. if (!this.minDistance) {
  1982. this.handleDistanceSurpassed(ev);
  1983. }
  1984. }
  1985. },
  1986. handleInteractionStart: function (ev) {
  1987. this.trigger('interactionStart', ev);
  1988. },
  1989. endInteraction: function (ev, isCancelled) {
  1990. if (this.isInteracting) {
  1991. this.endDrag(ev);
  1992. if (this.delayTimeoutId) {
  1993. clearTimeout(this.delayTimeoutId);
  1994. this.delayTimeoutId = null;
  1995. }
  1996. this.destroyAutoScroll();
  1997. this.unbindHandlers();
  1998. this.isInteracting = false;
  1999. this.handleInteractionEnd(ev, isCancelled);
  2000. // a touchstart+touchend on the same element will result in the following addition simulated events:
  2001. // mouseover + mouseout + click
  2002. // let's ignore these bogus events
  2003. if (this.isTouch) {
  2004. this.tempIgnoreMouse();
  2005. }
  2006. }
  2007. },
  2008. handleInteractionEnd: function (ev, isCancelled) {
  2009. this.trigger('interactionEnd', ev, isCancelled || false);
  2010. },
  2011. // Binding To DOM
  2012. // -----------------------------------------------------------------------------------------------------------------
  2013. bindHandlers: function () {
  2014. var _this = this;
  2015. var touchStartIgnores = 1;
  2016. if (this.isTouch) {
  2017. this.listenTo($(document), {
  2018. touchmove: this.handleTouchMove,
  2019. touchend: this.endInteraction,
  2020. touchcancel: this.endInteraction,
  2021. // Sometimes touchend doesn't fire
  2022. // (can't figure out why. touchcancel doesn't fire either. has to do with scrolling?)
  2023. // If another touchstart happens, we know it's bogus, so cancel the drag.
  2024. // touchend will continue to be broken until user does a shorttap/scroll, but this is best we can do.
  2025. touchstart: function (ev) {
  2026. if (touchStartIgnores) { // bindHandlers is called from within a touchstart,
  2027. touchStartIgnores--; // and we don't want this to fire immediately, so ignore.
  2028. }
  2029. else {
  2030. _this.endInteraction(ev, true); // isCancelled=true
  2031. }
  2032. }
  2033. });
  2034. // listen to ALL scroll actions on the page
  2035. if (
  2036. !bindAnyScroll(this.handleTouchScrollProxy) && // hopefully this works and short-circuits the rest
  2037. this.scrollEl // otherwise, attach a single handler to this
  2038. ) {
  2039. this.listenTo(this.scrollEl, 'scroll', this.handleTouchScroll);
  2040. }
  2041. }
  2042. else {
  2043. this.listenTo($(document), {
  2044. mousemove: this.handleMouseMove,
  2045. mouseup: this.endInteraction
  2046. });
  2047. }
  2048. this.listenTo($(document), {
  2049. selectstart: preventDefault, // don't allow selection while dragging
  2050. contextmenu: preventDefault // long taps would open menu on Chrome dev tools
  2051. });
  2052. },
  2053. unbindHandlers: function () {
  2054. this.stopListeningTo($(document));
  2055. // unbind scroll listening
  2056. unbindAnyScroll(this.handleTouchScrollProxy);
  2057. if (this.scrollEl) {
  2058. this.stopListeningTo(this.scrollEl, 'scroll');
  2059. }
  2060. },
  2061. // Drag (high-level)
  2062. // -----------------------------------------------------------------------------------------------------------------
  2063. // extraOptions ignored if drag already started
  2064. startDrag: function (ev, extraOptions) {
  2065. this.startInteraction(ev, extraOptions); // ensure interaction began
  2066. if (!this.isDragging) {
  2067. this.isDragging = true;
  2068. this.handleDragStart(ev);
  2069. }
  2070. },
  2071. handleDragStart: function (ev) {
  2072. this.trigger('dragStart', ev);
  2073. },
  2074. handleMove: function (ev) {
  2075. var dx = getEvX(ev) - this.originX;
  2076. var dy = getEvY(ev) - this.originY;
  2077. var minDistance = this.minDistance;
  2078. var distanceSq; // current distance from the origin, squared
  2079. if (!this.isDistanceSurpassed) {
  2080. distanceSq = dx * dx + dy * dy;
  2081. if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem
  2082. this.handleDistanceSurpassed(ev);
  2083. }
  2084. }
  2085. if (this.isDragging) {
  2086. this.handleDrag(dx, dy, ev);
  2087. }
  2088. },
  2089. // Called while the mouse is being moved and when we know a legitimate drag is taking place
  2090. handleDrag: function (dx, dy, ev) {
  2091. this.trigger('drag', dx, dy, ev);
  2092. this.updateAutoScroll(ev); // will possibly cause scrolling
  2093. },
  2094. endDrag: function (ev) {
  2095. if (this.isDragging) {
  2096. this.isDragging = false;
  2097. this.handleDragEnd(ev);
  2098. }
  2099. },
  2100. handleDragEnd: function (ev) {
  2101. this.trigger('dragEnd', ev);
  2102. },
  2103. // Delay
  2104. // -----------------------------------------------------------------------------------------------------------------
  2105. startDelay: function (initialEv) {
  2106. var _this = this;
  2107. if (this.delay) {
  2108. this.delayTimeoutId = setTimeout(function () {
  2109. _this.handleDelayEnd(initialEv);
  2110. }, this.delay);
  2111. }
  2112. else {
  2113. this.handleDelayEnd(initialEv);
  2114. }
  2115. },
  2116. handleDelayEnd: function (initialEv) {
  2117. this.isDelayEnded = true;
  2118. if (this.isDistanceSurpassed) {
  2119. this.startDrag(initialEv);
  2120. }
  2121. },
  2122. // Distance
  2123. // -----------------------------------------------------------------------------------------------------------------
  2124. handleDistanceSurpassed: function (ev) {
  2125. this.isDistanceSurpassed = true;
  2126. if (this.isDelayEnded) {
  2127. this.startDrag(ev);
  2128. }
  2129. },
  2130. // Mouse / Touch
  2131. // -----------------------------------------------------------------------------------------------------------------
  2132. handleTouchMove: function (ev) {
  2133. // prevent inertia and touchmove-scrolling while dragging
  2134. if (this.isDragging) {
  2135. ev.preventDefault();
  2136. }
  2137. this.handleMove(ev);
  2138. },
  2139. handleMouseMove: function (ev) {
  2140. this.handleMove(ev);
  2141. },
  2142. // Scrolling (unrelated to auto-scroll)
  2143. // -----------------------------------------------------------------------------------------------------------------
  2144. handleTouchScroll: function (ev) {
  2145. // if the drag is being initiated by touch, but a scroll happens before
  2146. // the drag-initiating delay is over, cancel the drag
  2147. if (!this.isDragging) {
  2148. this.endInteraction(ev, true); // isCancelled=true
  2149. }
  2150. },
  2151. // Utils
  2152. // -----------------------------------------------------------------------------------------------------------------
  2153. // Triggers a callback. Calls a function in the option hash of the same name.
  2154. // Arguments beyond the first `name` are forwarded on.
  2155. trigger: function (name) {
  2156. if (this.options[name]) {
  2157. this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
  2158. }
  2159. // makes _methods callable by event name. TODO: kill this
  2160. if (this['_' + name]) {
  2161. this['_' + name].apply(this, Array.prototype.slice.call(arguments, 1));
  2162. }
  2163. }
  2164. });
  2165. ;;
  2166. /*
  2167. this.scrollEl is set in DragListener
  2168. */
  2169. DragListener.mixin({
  2170. isAutoScroll: false,
  2171. scrollBounds: null, // { top, bottom, left, right }
  2172. scrollTopVel: null, // pixels per second
  2173. scrollLeftVel: null, // pixels per second
  2174. scrollIntervalId: null, // ID of setTimeout for scrolling animation loop
  2175. // defaults
  2176. scrollSensitivity: 30, // pixels from edge for scrolling to start
  2177. scrollSpeed: 200, // pixels per second, at maximum speed
  2178. scrollIntervalMs: 50, // millisecond wait between scroll increment
  2179. initAutoScroll: function () {
  2180. var scrollEl = this.scrollEl;
  2181. this.isAutoScroll =
  2182. this.options.scroll &&
  2183. scrollEl &&
  2184. !scrollEl.is(window) &&
  2185. !scrollEl.is(document);
  2186. if (this.isAutoScroll) {
  2187. // debounce makes sure rapid calls don't happen
  2188. this.listenTo(scrollEl, 'scroll', debounce(this.handleDebouncedScroll, 100));
  2189. }
  2190. },
  2191. destroyAutoScroll: function () {
  2192. this.endAutoScroll(); // kill any animation loop
  2193. // remove the scroll handler if there is a scrollEl
  2194. if (this.isAutoScroll) {
  2195. this.stopListeningTo(this.scrollEl, 'scroll'); // will probably get removed by unbindHandlers too :(
  2196. }
  2197. },
  2198. // Computes and stores the bounding rectangle of scrollEl
  2199. computeScrollBounds: function () {
  2200. if (this.isAutoScroll) {
  2201. this.scrollBounds = getOuterRect(this.scrollEl);
  2202. // TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars
  2203. }
  2204. },
  2205. // Called when the dragging is in progress and scrolling should be updated
  2206. updateAutoScroll: function (ev) {
  2207. var sensitivity = this.scrollSensitivity;
  2208. var bounds = this.scrollBounds;
  2209. var topCloseness, bottomCloseness;
  2210. var leftCloseness, rightCloseness;
  2211. var topVel = 0;
  2212. var leftVel = 0;
  2213. if (bounds) { // only scroll if scrollEl exists
  2214. // compute closeness to edges. valid range is from 0.0 - 1.0
  2215. topCloseness = (sensitivity - (getEvY(ev) - bounds.top)) / sensitivity;
  2216. bottomCloseness = (sensitivity - (bounds.bottom - getEvY(ev))) / sensitivity;
  2217. leftCloseness = (sensitivity - (getEvX(ev) - bounds.left)) / sensitivity;
  2218. rightCloseness = (sensitivity - (bounds.right - getEvX(ev))) / sensitivity;
  2219. // translate vertical closeness into velocity.
  2220. // mouse must be completely in bounds for velocity to happen.
  2221. if (topCloseness >= 0 && topCloseness <= 1) {
  2222. topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up
  2223. }
  2224. else if (bottomCloseness >= 0 && bottomCloseness <= 1) {
  2225. topVel = bottomCloseness * this.scrollSpeed;
  2226. }
  2227. // translate horizontal closeness into velocity
  2228. if (leftCloseness >= 0 && leftCloseness <= 1) {
  2229. leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left
  2230. }
  2231. else if (rightCloseness >= 0 && rightCloseness <= 1) {
  2232. leftVel = rightCloseness * this.scrollSpeed;
  2233. }
  2234. }
  2235. this.setScrollVel(topVel, leftVel);
  2236. },
  2237. // Sets the speed-of-scrolling for the scrollEl
  2238. setScrollVel: function (topVel, leftVel) {
  2239. this.scrollTopVel = topVel;
  2240. this.scrollLeftVel = leftVel;
  2241. this.constrainScrollVel(); // massages into realistic values
  2242. // if there is non-zero velocity, and an animation loop hasn't already started, then START
  2243. if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) {
  2244. this.scrollIntervalId = setInterval(
  2245. proxy(this, 'scrollIntervalFunc'), // scope to `this`
  2246. this.scrollIntervalMs
  2247. );
  2248. }
  2249. },
  2250. // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way
  2251. constrainScrollVel: function () {
  2252. var el = this.scrollEl;
  2253. if (this.scrollTopVel < 0) { // scrolling up?
  2254. if (el.scrollTop() <= 0) { // already scrolled all the way up?
  2255. this.scrollTopVel = 0;
  2256. }
  2257. }
  2258. else if (this.scrollTopVel > 0) { // scrolling down?
  2259. if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down?
  2260. this.scrollTopVel = 0;
  2261. }
  2262. }
  2263. if (this.scrollLeftVel < 0) { // scrolling left?
  2264. if (el.scrollLeft() <= 0) { // already scrolled all the left?
  2265. this.scrollLeftVel = 0;
  2266. }
  2267. }
  2268. else if (this.scrollLeftVel > 0) { // scrolling right?
  2269. if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right?
  2270. this.scrollLeftVel = 0;
  2271. }
  2272. }
  2273. },
  2274. // This function gets called during every iteration of the scrolling animation loop
  2275. scrollIntervalFunc: function () {
  2276. var el = this.scrollEl;
  2277. var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by
  2278. // change the value of scrollEl's scroll
  2279. if (this.scrollTopVel) {
  2280. el.scrollTop(el.scrollTop() + this.scrollTopVel * frac);
  2281. }
  2282. if (this.scrollLeftVel) {
  2283. el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac);
  2284. }
  2285. this.constrainScrollVel(); // since the scroll values changed, recompute the velocities
  2286. // if scrolled all the way, which causes the vels to be zero, stop the animation loop
  2287. if (!this.scrollTopVel && !this.scrollLeftVel) {
  2288. this.endAutoScroll();
  2289. }
  2290. },
  2291. // Kills any existing scrolling animation loop
  2292. endAutoScroll: function () {
  2293. if (this.scrollIntervalId) {
  2294. clearInterval(this.scrollIntervalId);
  2295. this.scrollIntervalId = null;
  2296. this.handleScrollEnd();
  2297. }
  2298. },
  2299. // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce)
  2300. handleDebouncedScroll: function () {
  2301. // recompute all coordinates, but *only* if this is *not* part of our scrolling animation
  2302. if (!this.scrollIntervalId) {
  2303. this.handleScrollEnd();
  2304. }
  2305. },
  2306. // Called when scrolling has stopped, whether through auto scroll, or the user scrolling
  2307. handleScrollEnd: function () {
  2308. }
  2309. });
  2310. ;;
  2311. /* Tracks mouse movements over a component and raises events about which hit the mouse is over.
  2312. ------------------------------------------------------------------------------------------------------------------------
  2313. options:
  2314. - subjectEl
  2315. - subjectCenter
  2316. */
  2317. var HitDragListener = DragListener.extend({
  2318. component: null, // converts coordinates to hits
  2319. // methods: prepareHits, releaseHits, queryHit
  2320. origHit: null, // the hit the mouse was over when listening started
  2321. hit: null, // the hit the mouse is over
  2322. coordAdjust: null, // delta that will be added to the mouse coordinates when computing collisions
  2323. constructor: function (component, options) {
  2324. DragListener.call(this, options); // call the super-constructor
  2325. this.component = component;
  2326. },
  2327. // Called when drag listening starts (but a real drag has not necessarily began).
  2328. // ev might be undefined if dragging was started manually.
  2329. handleInteractionStart: function (ev) {
  2330. var subjectEl = this.subjectEl;
  2331. var subjectRect;
  2332. var origPoint;
  2333. var point;
  2334. this.computeCoords();
  2335. if (ev) {
  2336. origPoint = { left: getEvX(ev), top: getEvY(ev) };
  2337. point = origPoint;
  2338. // constrain the point to bounds of the element being dragged
  2339. if (subjectEl) {
  2340. subjectRect = getOuterRect(subjectEl); // used for centering as well
  2341. point = constrainPoint(point, subjectRect);
  2342. }
  2343. this.origHit = this.queryHit(point.left, point.top);
  2344. // treat the center of the subject as the collision point?
  2345. if (subjectEl && this.options.subjectCenter) {
  2346. // only consider the area the subject overlaps the hit. best for large subjects.
  2347. // TODO: skip this if hit didn't supply left/right/top/bottom
  2348. if (this.origHit) {
  2349. subjectRect = intersectRects(this.origHit, subjectRect) ||
  2350. subjectRect; // in case there is no intersection
  2351. }
  2352. point = getRectCenter(subjectRect);
  2353. }
  2354. this.coordAdjust = diffPoints(point, origPoint); // point - origPoint
  2355. }
  2356. else {
  2357. this.origHit = null;
  2358. this.coordAdjust = null;
  2359. }
  2360. // call the super-method. do it after origHit has been computed
  2361. DragListener.prototype.handleInteractionStart.apply(this, arguments);
  2362. },
  2363. // Recomputes the drag-critical positions of elements
  2364. computeCoords: function () {
  2365. this.component.prepareHits();
  2366. this.computeScrollBounds(); // why is this here??????
  2367. },
  2368. // Called when the actual drag has started
  2369. handleDragStart: function (ev) {
  2370. var hit;
  2371. DragListener.prototype.handleDragStart.apply(this, arguments); // call the super-method
  2372. // might be different from this.origHit if the min-distance is large
  2373. hit = this.queryHit(getEvX(ev), getEvY(ev));
  2374. // report the initial hit the mouse is over
  2375. // especially important if no min-distance and drag starts immediately
  2376. if (hit) {
  2377. this.handleHitOver(hit);
  2378. }
  2379. },
  2380. // Called when the drag moves
  2381. handleDrag: function (dx, dy, ev) {
  2382. var hit;
  2383. DragListener.prototype.handleDrag.apply(this, arguments); // call the super-method
  2384. hit = this.queryHit(getEvX(ev), getEvY(ev));
  2385. if (!isHitsEqual(hit, this.hit)) { // a different hit than before?
  2386. if (this.hit) {
  2387. this.handleHitOut();
  2388. }
  2389. if (hit) {
  2390. this.handleHitOver(hit);
  2391. }
  2392. }
  2393. },
  2394. // Called when dragging has been stopped
  2395. handleDragEnd: function () {
  2396. this.handleHitDone();
  2397. DragListener.prototype.handleDragEnd.apply(this, arguments); // call the super-method
  2398. },
  2399. // Called when a the mouse has just moved over a new hit
  2400. handleHitOver: function (hit) {
  2401. var isOrig = isHitsEqual(hit, this.origHit);
  2402. this.hit = hit;
  2403. this.trigger('hitOver', this.hit, isOrig, this.origHit);
  2404. },
  2405. // Called when the mouse has just moved out of a hit
  2406. handleHitOut: function () {
  2407. if (this.hit) {
  2408. this.trigger('hitOut', this.hit);
  2409. this.handleHitDone();
  2410. this.hit = null;
  2411. }
  2412. },
  2413. // Called after a hitOut. Also called before a dragStop
  2414. handleHitDone: function () {
  2415. if (this.hit) {
  2416. this.trigger('hitDone', this.hit);
  2417. }
  2418. },
  2419. // Called when the interaction ends, whether there was a real drag or not
  2420. handleInteractionEnd: function () {
  2421. DragListener.prototype.handleInteractionEnd.apply(this, arguments); // call the super-method
  2422. this.origHit = null;
  2423. this.hit = null;
  2424. this.component.releaseHits();
  2425. },
  2426. // Called when scrolling has stopped, whether through auto scroll, or the user scrolling
  2427. handleScrollEnd: function () {
  2428. DragListener.prototype.handleScrollEnd.apply(this, arguments); // call the super-method
  2429. this.computeCoords(); // hits' absolute positions will be in new places. recompute
  2430. },
  2431. // Gets the hit underneath the coordinates for the given mouse event
  2432. queryHit: function (left, top) {
  2433. if (this.coordAdjust) {
  2434. left += this.coordAdjust.left;
  2435. top += this.coordAdjust.top;
  2436. }
  2437. return this.component.queryHit(left, top);
  2438. }
  2439. });
  2440. // Returns `true` if the hits are identically equal. `false` otherwise. Must be from the same component.
  2441. // Two null values will be considered equal, as two "out of the component" states are the same.
  2442. function isHitsEqual(hit0, hit1) {
  2443. if (!hit0 && !hit1) {
  2444. return true;
  2445. }
  2446. if (hit0 && hit1) {
  2447. return hit0.component === hit1.component &&
  2448. isHitPropsWithin(hit0, hit1) &&
  2449. isHitPropsWithin(hit1, hit0); // ensures all props are identical
  2450. }
  2451. return false;
  2452. }
  2453. // Returns true if all of subHit's non-standard properties are within superHit
  2454. function isHitPropsWithin(subHit, superHit) {
  2455. for (var propName in subHit) {
  2456. if (!/^(component|left|right|top|bottom)$/.test(propName)) {
  2457. if (subHit[propName] !== superHit[propName]) {
  2458. return false;
  2459. }
  2460. }
  2461. }
  2462. return true;
  2463. }
  2464. ;;
  2465. /* Creates a clone of an element and lets it track the mouse as it moves
  2466. ----------------------------------------------------------------------------------------------------------------------*/
  2467. var MouseFollower = Class.extend(ListenerMixin, {
  2468. options: null,
  2469. sourceEl: null, // the element that will be cloned and made to look like it is dragging
  2470. el: null, // the clone of `sourceEl` that will track the mouse
  2471. parentEl: null, // the element that `el` (the clone) will be attached to
  2472. // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl
  2473. top0: null,
  2474. left0: null,
  2475. // the absolute coordinates of the initiating touch/mouse action
  2476. y0: null,
  2477. x0: null,
  2478. // the number of pixels the mouse has moved from its initial position
  2479. topDelta: null,
  2480. leftDelta: null,
  2481. isFollowing: false,
  2482. isHidden: false,
  2483. isAnimating: false, // doing the revert animation?
  2484. constructor: function (sourceEl, options) {
  2485. this.options = options = options || {};
  2486. this.sourceEl = sourceEl;
  2487. this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent
  2488. },
  2489. // Causes the element to start following the mouse
  2490. start: function (ev) {
  2491. if (!this.isFollowing) {
  2492. this.isFollowing = true;
  2493. this.y0 = getEvY(ev);
  2494. this.x0 = getEvX(ev);
  2495. this.topDelta = 0;
  2496. this.leftDelta = 0;
  2497. if (!this.isHidden) {
  2498. this.updatePosition();
  2499. }
  2500. if (getEvIsTouch(ev)) {
  2501. this.listenTo($(document), 'touchmove', this.handleMove);
  2502. }
  2503. else {
  2504. this.listenTo($(document), 'mousemove', this.handleMove);
  2505. }
  2506. }
  2507. },
  2508. // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position.
  2509. // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately.
  2510. stop: function (shouldRevert, callback) {
  2511. var _this = this;
  2512. var revertDuration = this.options.revertDuration;
  2513. function complete() { // might be called by .animate(), which might change `this` context
  2514. _this.isAnimating = false;
  2515. _this.removeElement();
  2516. _this.top0 = _this.left0 = null; // reset state for future updatePosition calls
  2517. if (callback) {
  2518. callback();
  2519. }
  2520. }
  2521. if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time
  2522. this.isFollowing = false;
  2523. this.stopListeningTo($(document));
  2524. if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation?
  2525. this.isAnimating = true;
  2526. this.el.animate({
  2527. top: this.top0,
  2528. left: this.left0
  2529. }, {
  2530. duration: revertDuration,
  2531. complete: complete
  2532. });
  2533. }
  2534. else {
  2535. complete();
  2536. }
  2537. }
  2538. },
  2539. // Gets the tracking element. Create it if necessary
  2540. getEl: function () {
  2541. var el = this.el;
  2542. if (!el) {
  2543. el = this.el = this.sourceEl.clone()
  2544. .addClass(this.options.additionalClass || '')
  2545. .css({
  2546. position: 'absolute',
  2547. visibility: '', // in case original element was hidden (commonly through hideEvents())
  2548. display: this.isHidden ? 'none' : '', // for when initially hidden
  2549. margin: 0,
  2550. right: 'auto', // erase and set width instead
  2551. bottom: 'auto', // erase and set height instead
  2552. width: this.sourceEl.width(), // explicit height in case there was a 'right' value
  2553. height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value
  2554. opacity: this.options.opacity || '',
  2555. zIndex: this.options.zIndex
  2556. });
  2557. // we don't want long taps or any mouse interaction causing selection/menus.
  2558. // would use preventSelection(), but that prevents selectstart, causing problems.
  2559. el.addClass('fc-unselectable');
  2560. el.appendTo(this.parentEl);
  2561. }
  2562. return el;
  2563. },
  2564. // Removes the tracking element if it has already been created
  2565. removeElement: function () {
  2566. if (this.el) {
  2567. this.el.remove();
  2568. this.el = null;
  2569. }
  2570. },
  2571. // Update the CSS position of the tracking element
  2572. updatePosition: function () {
  2573. var sourceOffset;
  2574. var origin;
  2575. this.getEl(); // ensure this.el
  2576. // make sure origin info was computed
  2577. if (this.top0 === null) {
  2578. sourceOffset = this.sourceEl.offset();
  2579. origin = this.el.offsetParent().offset();
  2580. this.top0 = sourceOffset.top - origin.top;
  2581. this.left0 = sourceOffset.left - origin.left;
  2582. }
  2583. this.el.css({
  2584. top: this.top0 + this.topDelta,
  2585. left: this.left0 + this.leftDelta
  2586. });
  2587. },
  2588. // Gets called when the user moves the mouse
  2589. handleMove: function (ev) {
  2590. this.topDelta = getEvY(ev) - this.y0;
  2591. this.leftDelta = getEvX(ev) - this.x0;
  2592. if (!this.isHidden) {
  2593. this.updatePosition();
  2594. }
  2595. },
  2596. // Temporarily makes the tracking element invisible. Can be called before following starts
  2597. hide: function () {
  2598. if (!this.isHidden) {
  2599. this.isHidden = true;
  2600. if (this.el) {
  2601. this.el.hide();
  2602. }
  2603. }
  2604. },
  2605. // Show the tracking element after it has been temporarily hidden
  2606. show: function () {
  2607. if (this.isHidden) {
  2608. this.isHidden = false;
  2609. this.updatePosition();
  2610. this.getEl().show();
  2611. }
  2612. }
  2613. });
  2614. ;;
  2615. /* An abstract class comprised of a "grid" of areas that each represent a specific datetime
  2616. ----------------------------------------------------------------------------------------------------------------------*/
  2617. var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, {
  2618. // self-config, overridable by subclasses
  2619. hasDayInteractions: true, // can user click/select ranges of time?
  2620. view: null, // a View object
  2621. isRTL: null, // shortcut to the view's isRTL option
  2622. start: null,
  2623. end: null,
  2624. el: null, // the containing element
  2625. elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name.
  2626. // derived from options
  2627. eventTimeFormat: null,
  2628. displayEventTime: null,
  2629. displayEventEnd: null,
  2630. minResizeDuration: null, // TODO: hack. set by subclasses. minumum event resize duration
  2631. // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity
  2632. // of the date areas. if not defined, assumes to be day and time granularity.
  2633. // TODO: port isTimeScale into same system?
  2634. largeUnit: null,
  2635. dayDragListener: null,
  2636. segDragListener: null,
  2637. segResizeListener: null,
  2638. externalDragListener: null,
  2639. constructor: function (view) {
  2640. this.view = view;
  2641. this.isRTL = view.opt('isRTL');
  2642. this.elsByFill = {};
  2643. this.dayDragListener = this.buildDayDragListener();
  2644. this.initMouseIgnoring();
  2645. },
  2646. /* Options
  2647. ------------------------------------------------------------------------------------------------------------------*/
  2648. // Generates the format string used for event time text, if not explicitly defined by 'timeFormat'
  2649. computeEventTimeFormat: function () {
  2650. return this.view.opt('smallTimeFormat');
  2651. },
  2652. // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventTime'.
  2653. // Only applies to non-all-day events.
  2654. computeDisplayEventTime: function () {
  2655. return true;
  2656. },
  2657. // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd'
  2658. computeDisplayEventEnd: function () {
  2659. return true;
  2660. },
  2661. /* Dates
  2662. ------------------------------------------------------------------------------------------------------------------*/
  2663. // Tells the grid about what period of time to display.
  2664. // Any date-related internal data should be generated.
  2665. setRange: function (range) {
  2666. this.start = range.start.clone();
  2667. this.end = range.end.clone();
  2668. this.rangeUpdated();
  2669. this.processRangeOptions();
  2670. },
  2671. // Called when internal variables that rely on the range should be updated
  2672. rangeUpdated: function () {
  2673. },
  2674. // Updates values that rely on options and also relate to range
  2675. processRangeOptions: function () {
  2676. var view = this.view;
  2677. var displayEventTime;
  2678. var displayEventEnd;
  2679. this.eventTimeFormat =
  2680. view.opt('eventTimeFormat') ||
  2681. view.opt('timeFormat') || // deprecated
  2682. this.computeEventTimeFormat();
  2683. displayEventTime = view.opt('displayEventTime');
  2684. if (displayEventTime == null) {
  2685. displayEventTime = this.computeDisplayEventTime(); // might be based off of range
  2686. }
  2687. displayEventEnd = view.opt('displayEventEnd');
  2688. if (displayEventEnd == null) {
  2689. displayEventEnd = this.computeDisplayEventEnd(); // might be based off of range
  2690. }
  2691. this.displayEventTime = displayEventTime;
  2692. this.displayEventEnd = displayEventEnd;
  2693. },
  2694. // Converts a span (has unzoned start/end and any other grid-specific location information)
  2695. // into an array of segments (pieces of events whose format is decided by the grid).
  2696. spanToSegs: function (span) {
  2697. // subclasses must implement
  2698. },
  2699. // Diffs the two dates, returning a duration, based on granularity of the grid
  2700. // TODO: port isTimeScale into this system?
  2701. diffDates: function (a, b) {
  2702. if (this.largeUnit) {
  2703. return diffByUnit(a, b, this.largeUnit);
  2704. }
  2705. else {
  2706. return diffDayTime(a, b);
  2707. }
  2708. },
  2709. /* Hit Area
  2710. ------------------------------------------------------------------------------------------------------------------*/
  2711. // Called before one or more queryHit calls might happen. Should prepare any cached coordinates for queryHit
  2712. prepareHits: function () {
  2713. },
  2714. // Called when queryHit calls have subsided. Good place to clear any coordinate caches.
  2715. releaseHits: function () {
  2716. },
  2717. // Given coordinates from the topleft of the document, return data about the date-related area underneath.
  2718. // Can return an object with arbitrary properties (although top/right/left/bottom are encouraged).
  2719. // Must have a `grid` property, a reference to this current grid. TODO: avoid this
  2720. // The returned object will be processed by getHitSpan and getHitEl.
  2721. queryHit: function (leftOffset, topOffset) {
  2722. },
  2723. // Given position-level information about a date-related area within the grid,
  2724. // should return an object with at least a start/end date. Can provide other information as well.
  2725. getHitSpan: function (hit) {
  2726. },
  2727. // Given position-level information about a date-related area within the grid,
  2728. // should return a jQuery element that best represents it. passed to dayClick callback.
  2729. getHitEl: function (hit) {
  2730. },
  2731. /* Rendering
  2732. ------------------------------------------------------------------------------------------------------------------*/
  2733. // Sets the container element that the grid should render inside of.
  2734. // Does other DOM-related initializations.
  2735. setElement: function (el) {
  2736. this.el = el;
  2737. if (this.hasDayInteractions) {
  2738. preventSelection(el);
  2739. this.bindDayHandler('touchstart', this.dayTouchStart);
  2740. this.bindDayHandler('mousedown', this.dayMousedown);
  2741. }
  2742. // attach event-element-related handlers. in Grid.events
  2743. // same garbage collection note as above.
  2744. this.bindSegHandlers();
  2745. this.bindGlobalHandlers();
  2746. },
  2747. bindDayHandler: function (name, handler) {
  2748. var _this = this;
  2749. // attach a handler to the grid's root element.
  2750. // jQuery will take care of unregistering them when removeElement gets called.
  2751. this.el.on(name, function (ev) {
  2752. if (
  2753. !$(ev.target).is(
  2754. _this.segSelector + ',' + // directly on an event element
  2755. _this.segSelector + ' *,' + // within an event element
  2756. '.fc-more,' + // a "more.." link
  2757. 'a[data-goto]' // a clickable nav link
  2758. )
  2759. ) {
  2760. return handler.call(_this, ev);
  2761. }
  2762. });
  2763. },
  2764. // Removes the grid's container element from the DOM. Undoes any other DOM-related attachments.
  2765. // DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View
  2766. removeElement: function () {
  2767. this.unbindGlobalHandlers();
  2768. this.clearDragListeners();
  2769. this.el.remove();
  2770. // NOTE: we don't null-out this.el for the same reasons we don't do it within View::removeElement
  2771. },
  2772. // Renders the basic structure of grid view before any content is rendered
  2773. renderSkeleton: function () {
  2774. // subclasses should implement
  2775. },
  2776. // Renders the grid's date-related content (like areas that represent days/times).
  2777. // Assumes setRange has already been called and the skeleton has already been rendered.
  2778. renderDates: function () {
  2779. // subclasses should implement
  2780. },
  2781. // Unrenders the grid's date-related content
  2782. unrenderDates: function () {
  2783. // subclasses should implement
  2784. },
  2785. /* Handlers
  2786. ------------------------------------------------------------------------------------------------------------------*/
  2787. // Binds DOM handlers to elements that reside outside the grid, such as the document
  2788. bindGlobalHandlers: function () {
  2789. this.listenTo($(document), {
  2790. dragstart: this.externalDragStart, // jqui
  2791. sortstart: this.externalDragStart // jqui
  2792. });
  2793. },
  2794. // Unbinds DOM handlers from elements that reside outside the grid
  2795. unbindGlobalHandlers: function () {
  2796. this.stopListeningTo($(document));
  2797. },
  2798. // Process a mousedown on an element that represents a day. For day clicking and selecting.
  2799. dayMousedown: function (ev) {
  2800. if (!this.isIgnoringMouse) {
  2801. this.dayDragListener.startInteraction(ev, {
  2802. //distance: 5, // needs more work if we want dayClick to fire correctly
  2803. });
  2804. }
  2805. },
  2806. dayTouchStart: function (ev) {
  2807. var view = this.view;
  2808. var selectLongPressDelay = view.opt('selectLongPressDelay');
  2809. // HACK to prevent a user's clickaway for unselecting a range or an event
  2810. // from causing a dayClick.
  2811. if (view.isSelected || view.selectedEvent) {
  2812. this.tempIgnoreMouse();
  2813. }
  2814. if (selectLongPressDelay == null) {
  2815. selectLongPressDelay = view.opt('longPressDelay'); // fallback
  2816. }
  2817. this.dayDragListener.startInteraction(ev, {
  2818. delay: selectLongPressDelay
  2819. });
  2820. },
  2821. // Creates a listener that tracks the user's drag across day elements.
  2822. // For day clicking and selecting.
  2823. buildDayDragListener: function () {
  2824. var _this = this;
  2825. var view = this.view;
  2826. var isSelectable = view.opt('selectable');
  2827. var dayClickHit; // null if invalid dayClick
  2828. var selectionSpan; // null if invalid selection
  2829. // this listener tracks a mousedown on a day element, and a subsequent drag.
  2830. // if the drag ends on the same day, it is a 'dayClick'.
  2831. // if 'selectable' is enabled, this listener also detects selections.
  2832. var dragListener = new HitDragListener(this, {
  2833. scroll: view.opt('dragScroll'),
  2834. interactionStart: function () {
  2835. dayClickHit = dragListener.origHit; // for dayClick, where no dragging happens
  2836. selectionSpan = null;
  2837. },
  2838. dragStart: function () {
  2839. view.unselect(); // since we could be rendering a new selection, we want to clear any old one
  2840. },
  2841. hitOver: function (hit, isOrig, origHit) {
  2842. if (origHit) { // click needs to have started on a hit
  2843. // if user dragged to another cell at any point, it can no longer be a dayClick
  2844. if (!isOrig) {
  2845. dayClickHit = null;
  2846. }
  2847. if (isSelectable) {
  2848. selectionSpan = _this.computeSelection(
  2849. _this.getHitSpan(origHit),
  2850. _this.getHitSpan(hit)
  2851. );
  2852. if (selectionSpan) {
  2853. _this.renderSelection(selectionSpan);
  2854. }
  2855. else if (selectionSpan === false) {
  2856. disableCursor();
  2857. }
  2858. }
  2859. }
  2860. },
  2861. hitOut: function () { // called before mouse moves to a different hit OR moved out of all hits
  2862. dayClickHit = null;
  2863. selectionSpan = null;
  2864. _this.unrenderSelection();
  2865. },
  2866. hitDone: function () { // called after a hitOut OR before a dragEnd
  2867. enableCursor();
  2868. },
  2869. interactionEnd: function (ev, isCancelled) {
  2870. if (!isCancelled) {
  2871. if (
  2872. dayClickHit &&
  2873. !_this.isIgnoringMouse // see hack in dayTouchStart
  2874. ) {
  2875. view.triggerDayClick(
  2876. _this.getHitSpan(dayClickHit),
  2877. _this.getHitEl(dayClickHit),
  2878. ev
  2879. );
  2880. }
  2881. if (selectionSpan) {
  2882. // the selection will already have been rendered. just report it
  2883. view.reportSelection(selectionSpan, ev);
  2884. }
  2885. }
  2886. }
  2887. });
  2888. return dragListener;
  2889. },
  2890. // Kills all in-progress dragging.
  2891. // Useful for when public API methods that result in re-rendering are invoked during a drag.
  2892. // Also useful for when touch devices misbehave and don't fire their touchend.
  2893. clearDragListeners: function () {
  2894. this.dayDragListener.endInteraction();
  2895. if (this.segDragListener) {
  2896. this.segDragListener.endInteraction(); // will clear this.segDragListener
  2897. }
  2898. if (this.segResizeListener) {
  2899. this.segResizeListener.endInteraction(); // will clear this.segResizeListener
  2900. }
  2901. if (this.externalDragListener) {
  2902. this.externalDragListener.endInteraction(); // will clear this.externalDragListener
  2903. }
  2904. },
  2905. /* Event Helper
  2906. ------------------------------------------------------------------------------------------------------------------*/
  2907. // TODO: should probably move this to Grid.events, like we did event dragging / resizing
  2908. // Renders a mock event at the given event location, which contains zoned start/end properties.
  2909. // Returns all mock event elements.
  2910. renderEventLocationHelper: function (eventLocation, sourceSeg) {
  2911. var fakeEvent = this.fabricateHelperEvent(eventLocation, sourceSeg);
  2912. return this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering
  2913. },
  2914. // Builds a fake event given zoned event date properties and a segment is should be inspired from.
  2915. // The range's end can be null, in which case the mock event that is rendered will have a null end time.
  2916. // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging.
  2917. fabricateHelperEvent: function (eventLocation, sourceSeg) {
  2918. var fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible
  2919. fakeEvent.start = eventLocation.start.clone();
  2920. fakeEvent.end = eventLocation.end ? eventLocation.end.clone() : null;
  2921. fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventDates
  2922. this.view.calendar.normalizeEventDates(fakeEvent);
  2923. // this extra className will be useful for differentiating real events from mock events in CSS
  2924. fakeEvent.className = (fakeEvent.className || []).concat('fc-helper');
  2925. // if something external is being dragged in, don't render a resizer
  2926. if (!sourceSeg) {
  2927. fakeEvent.editable = false;
  2928. }
  2929. return fakeEvent;
  2930. },
  2931. // Renders a mock event. Given zoned event date properties.
  2932. // Must return all mock event elements.
  2933. renderHelper: function (eventLocation, sourceSeg) {
  2934. // subclasses must implement
  2935. },
  2936. // Unrenders a mock event
  2937. unrenderHelper: function () {
  2938. // subclasses must implement
  2939. },
  2940. /* Selection
  2941. ------------------------------------------------------------------------------------------------------------------*/
  2942. // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses.
  2943. // Given a span (unzoned start/end and other misc data)
  2944. renderSelection: function (span) {
  2945. this.renderHighlight(span);
  2946. },
  2947. // Unrenders any visual indications of a selection. Will unrender a highlight by default.
  2948. unrenderSelection: function () {
  2949. this.unrenderHighlight();
  2950. },
  2951. // Given the first and last date-spans of a selection, returns another date-span object.
  2952. // Subclasses can override and provide additional data in the span object. Will be passed to renderSelection().
  2953. // Will return false if the selection is invalid and this should be indicated to the user.
  2954. // Will return null/undefined if a selection invalid but no error should be reported.
  2955. computeSelection: function (span0, span1) {
  2956. var span = this.computeSelectionSpan(span0, span1);
  2957. if (span && !this.view.calendar.isSelectionSpanAllowed(span)) {
  2958. return false;
  2959. }
  2960. return span;
  2961. },
  2962. // Given two spans, must return the combination of the two.
  2963. // TODO: do this separation of concerns (combining VS validation) for event dnd/resize too.
  2964. computeSelectionSpan: function (span0, span1) {
  2965. var dates = [span0.start, span0.end, span1.start, span1.end];
  2966. dates.sort(compareNumbers); // sorts chronologically. works with Moments
  2967. return { start: dates[0].clone(), end: dates[3].clone() };
  2968. },
  2969. /* Highlight
  2970. ------------------------------------------------------------------------------------------------------------------*/
  2971. // Renders an emphasis on the given date range. Given a span (unzoned start/end and other misc data)
  2972. renderHighlight: function (span) {
  2973. this.renderFill('highlight', this.spanToSegs(span));
  2974. },
  2975. // Unrenders the emphasis on a date range
  2976. unrenderHighlight: function () {
  2977. this.unrenderFill('highlight');
  2978. },
  2979. // Generates an array of classNames for rendering the highlight. Used by the fill system.
  2980. highlightSegClasses: function () {
  2981. return ['fc-highlight'];
  2982. },
  2983. /* Business Hours
  2984. ------------------------------------------------------------------------------------------------------------------*/
  2985. renderBusinessHours: function () {
  2986. },
  2987. unrenderBusinessHours: function () {
  2988. },
  2989. /* Now Indicator
  2990. ------------------------------------------------------------------------------------------------------------------*/
  2991. getNowIndicatorUnit: function () {
  2992. },
  2993. renderNowIndicator: function (date) {
  2994. },
  2995. unrenderNowIndicator: function () {
  2996. },
  2997. /* Fill System (highlight, background events, business hours)
  2998. --------------------------------------------------------------------------------------------------------------------
  2999. TODO: remove this system. like we did in TimeGrid
  3000. */
  3001. // Renders a set of rectangles over the given segments of time.
  3002. // MUST RETURN a subset of segs, the segs that were actually rendered.
  3003. // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement
  3004. renderFill: function (type, segs) {
  3005. // subclasses must implement
  3006. },
  3007. // Unrenders a specific type of fill that is currently rendered on the grid
  3008. unrenderFill: function (type) {
  3009. var el = this.elsByFill[type];
  3010. if (el) {
  3011. el.remove();
  3012. delete this.elsByFill[type];
  3013. }
  3014. },
  3015. // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types.
  3016. // Only returns segments that successfully rendered.
  3017. // To be harnessed by renderFill (implemented by subclasses).
  3018. // Analagous to renderFgSegEls.
  3019. renderFillSegEls: function (type, segs) {
  3020. var _this = this;
  3021. var segElMethod = this[type + 'SegEl'];
  3022. var html = '';
  3023. var renderedSegs = [];
  3024. var i;
  3025. if (segs.length) {
  3026. // build a large concatenation of segment HTML
  3027. for (i = 0; i < segs.length; i++) {
  3028. html += this.fillSegHtml(type, segs[i]);
  3029. }
  3030. // Grab individual elements from the combined HTML string. Use each as the default rendering.
  3031. // Then, compute the 'el' for each segment.
  3032. $(html).each(function (i, node) {
  3033. var seg = segs[i];
  3034. var el = $(node);
  3035. // allow custom filter methods per-type
  3036. if (segElMethod) {
  3037. el = segElMethod.call(_this, seg, el);
  3038. }
  3039. if (el) { // custom filters did not cancel the render
  3040. el = $(el); // allow custom filter to return raw DOM node
  3041. // correct element type? (would be bad if a non-TD were inserted into a table for example)
  3042. if (el.is(_this.fillSegTag)) {
  3043. seg.el = el;
  3044. renderedSegs.push(seg);
  3045. }
  3046. }
  3047. });
  3048. }
  3049. return renderedSegs;
  3050. },
  3051. fillSegTag: 'div', // subclasses can override
  3052. // Builds the HTML needed for one fill segment. Generic enough to work with different types.
  3053. fillSegHtml: function (type, seg) {
  3054. // custom hooks per-type
  3055. var classesMethod = this[type + 'SegClasses'];
  3056. var cssMethod = this[type + 'SegCss'];
  3057. var classes = classesMethod ? classesMethod.call(this, seg) : [];
  3058. var css = cssToStr(cssMethod ? cssMethod.call(this, seg) : {});
  3059. return '<' + this.fillSegTag +
  3060. (classes.length ? ' class="' + classes.join(' ') + '"' : '') +
  3061. (css ? ' style="' + css + '"' : '') +
  3062. ' />';
  3063. },
  3064. /* Generic rendering utilities for subclasses
  3065. ------------------------------------------------------------------------------------------------------------------*/
  3066. // Computes HTML classNames for a single-day element
  3067. getDayClasses: function (date, noThemeHighlight) {
  3068. var view = this.view;
  3069. var today = view.calendar.getNow();
  3070. var classes = ['fc-' + dayIDs[date.day()]];
  3071. if (
  3072. view.intervalDuration.as('months') == 1 &&
  3073. date.month() != view.intervalStart.month()
  3074. ) {
  3075. classes.push('fc-other-month');
  3076. }
  3077. if (date.isSame(today, 'day')) {
  3078. classes.push('fc-today');
  3079. if (noThemeHighlight !== true) {
  3080. classes.push(view.highlightStateClass);
  3081. }
  3082. }
  3083. else if (date < today) {
  3084. classes.push('fc-past');
  3085. }
  3086. else {
  3087. classes.push('fc-future');
  3088. }
  3089. return classes;
  3090. }
  3091. });
  3092. ;;
  3093. /* Event-rendering and event-interaction methods for the abstract Grid class
  3094. ----------------------------------------------------------------------------------------------------------------------*/
  3095. Grid.mixin({
  3096. // self-config, overridable by subclasses
  3097. segSelector: '.fc-event-container > *', // what constitutes an event element?
  3098. mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing
  3099. isDraggingSeg: false, // is a segment being dragged? boolean
  3100. isResizingSeg: false, // is a segment being resized? boolean
  3101. isDraggingExternal: false, // jqui-dragging an external element? boolean
  3102. segs: null, // the *event* segments currently rendered in the grid. TODO: rename to `eventSegs`
  3103. // Renders the given events onto the grid
  3104. renderEvents: function (events) {
  3105. var bgEvents = [];
  3106. var fgEvents = [];
  3107. var i;
  3108. for (i = 0; i < events.length; i++) {
  3109. (isBgEvent(events[i]) ? bgEvents : fgEvents).push(events[i]);
  3110. }
  3111. this.segs = [].concat( // record all segs
  3112. this.renderBgEvents(bgEvents),
  3113. this.renderFgEvents(fgEvents)
  3114. );
  3115. },
  3116. renderBgEvents: function (events) {
  3117. var segs = this.eventsToSegs(events);
  3118. // renderBgSegs might return a subset of segs, segs that were actually rendered
  3119. return this.renderBgSegs(segs) || segs;
  3120. },
  3121. renderFgEvents: function (events) {
  3122. var segs = this.eventsToSegs(events);
  3123. // renderFgSegs might return a subset of segs, segs that were actually rendered
  3124. return this.renderFgSegs(segs) || segs;
  3125. },
  3126. // Unrenders all events currently rendered on the grid
  3127. unrenderEvents: function () {
  3128. this.handleSegMouseout(); // trigger an eventMouseout if user's mouse is over an event
  3129. this.clearDragListeners();
  3130. this.unrenderFgSegs();
  3131. this.unrenderBgSegs();
  3132. this.segs = null;
  3133. },
  3134. // Retrieves all rendered segment objects currently rendered on the grid
  3135. getEventSegs: function () {
  3136. return this.segs || [];
  3137. },
  3138. /* Foreground Segment Rendering
  3139. ------------------------------------------------------------------------------------------------------------------*/
  3140. // Renders foreground event segments onto the grid. May return a subset of segs that were rendered.
  3141. renderFgSegs: function (segs) {
  3142. // subclasses must implement
  3143. },
  3144. // Unrenders all currently rendered foreground segments
  3145. unrenderFgSegs: function () {
  3146. // subclasses must implement
  3147. },
  3148. // Renders and assigns an `el` property for each foreground event segment.
  3149. // Only returns segments that successfully rendered.
  3150. // A utility that subclasses may use.
  3151. renderFgSegEls: function (segs, disableResizing) {
  3152. var view = this.view;
  3153. var html = '';
  3154. var renderedSegs = [];
  3155. var i;
  3156. if (segs.length) { // don't build an empty html string
  3157. // build a large concatenation of event segment HTML
  3158. for (i = 0; i < segs.length; i++) {
  3159. html += this.fgSegHtml(segs[i], disableResizing);
  3160. }
  3161. // Grab individual elements from the combined HTML string. Use each as the default rendering.
  3162. // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false.
  3163. $(html).each(function (i, node) {
  3164. var seg = segs[i];
  3165. var el = view.resolveEventEl(seg.event, $(node));
  3166. if (el) {
  3167. el.data('fc-seg', seg); // used by handlers
  3168. seg.el = el;
  3169. renderedSegs.push(seg);
  3170. }
  3171. });
  3172. }
  3173. return renderedSegs;
  3174. },
  3175. // Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls()
  3176. fgSegHtml: function (seg, disableResizing) {
  3177. // subclasses should implement
  3178. },
  3179. /* Background Segment Rendering
  3180. ------------------------------------------------------------------------------------------------------------------*/
  3181. // Renders the given background event segments onto the grid.
  3182. // Returns a subset of the segs that were actually rendered.
  3183. renderBgSegs: function (segs) {
  3184. return this.renderFill('bgEvent', segs);
  3185. },
  3186. // Unrenders all the currently rendered background event segments
  3187. unrenderBgSegs: function () {
  3188. this.unrenderFill('bgEvent');
  3189. },
  3190. // Renders a background event element, given the default rendering. Called by the fill system.
  3191. bgEventSegEl: function (seg, el) {
  3192. return this.view.resolveEventEl(seg.event, el); // will filter through eventRender
  3193. },
  3194. // Generates an array of classNames to be used for the default rendering of a background event.
  3195. // Called by fillSegHtml.
  3196. bgEventSegClasses: function (seg) {
  3197. var event = seg.event;
  3198. var source = event.source || {};
  3199. return ['fc-bgevent'].concat(
  3200. event.className,
  3201. source.className || []
  3202. );
  3203. },
  3204. // Generates a semicolon-separated CSS string to be used for the default rendering of a background event.
  3205. // Called by fillSegHtml.
  3206. bgEventSegCss: function (seg) {
  3207. return {
  3208. 'background-color': this.getSegSkinCss(seg)['background-color']
  3209. };
  3210. },
  3211. // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system.
  3212. // Called by fillSegHtml.
  3213. businessHoursSegClasses: function (seg) {
  3214. return ['fc-nonbusiness', 'fc-bgevent'];
  3215. },
  3216. /* Business Hours
  3217. ------------------------------------------------------------------------------------------------------------------*/
  3218. // Compute business hour segs for the grid's current date range.
  3219. // Caller must ask if whole-day business hours are needed.
  3220. // If no `businessHours` configuration value is specified, assumes the calendar default.
  3221. buildBusinessHourSegs: function (wholeDay, businessHours) {
  3222. return this.eventsToSegs(
  3223. this.buildBusinessHourEvents(wholeDay, businessHours)
  3224. );
  3225. },
  3226. // Compute business hour *events* for the grid's current date range.
  3227. // Caller must ask if whole-day business hours are needed.
  3228. // If no `businessHours` configuration value is specified, assumes the calendar default.
  3229. buildBusinessHourEvents: function (wholeDay, businessHours) {
  3230. var calendar = this.view.calendar;
  3231. var events;
  3232. if (businessHours == null) {
  3233. // fallback
  3234. // access from calendawr. don't access from view. doesn't update with dynamic options.
  3235. businessHours = calendar.options.businessHours;
  3236. }
  3237. events = calendar.computeBusinessHourEvents(wholeDay, businessHours);
  3238. // HACK. Eventually refactor business hours "events" system.
  3239. // If no events are given, but businessHours is activated, this means the entire visible range should be
  3240. // marked as *not* business-hours, via inverse-background rendering.
  3241. if (!events.length && businessHours) {
  3242. events = [
  3243. $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, {
  3244. start: this.view.end, // guaranteed out-of-range
  3245. end: this.view.end, // "
  3246. dow: null
  3247. })
  3248. ];
  3249. }
  3250. return events;
  3251. },
  3252. /* Handlers
  3253. ------------------------------------------------------------------------------------------------------------------*/
  3254. // Attaches event-element-related handlers for *all* rendered event segments of the view.
  3255. bindSegHandlers: function () {
  3256. this.bindSegHandlersToEl(this.el);
  3257. },
  3258. // Attaches event-element-related handlers to an arbitrary container element. leverages bubbling.
  3259. bindSegHandlersToEl: function (el) {
  3260. this.bindSegHandlerToEl(el, 'touchstart', this.handleSegTouchStart);
  3261. this.bindSegHandlerToEl(el, 'touchend', this.handleSegTouchEnd);
  3262. this.bindSegHandlerToEl(el, 'mouseenter', this.handleSegMouseover);
  3263. this.bindSegHandlerToEl(el, 'mouseleave', this.handleSegMouseout);
  3264. this.bindSegHandlerToEl(el, 'mousedown', this.handleSegMousedown);
  3265. this.bindSegHandlerToEl(el, 'click', this.handleSegClick);
  3266. },
  3267. // Executes a handler for any a user-interaction on a segment.
  3268. // Handler gets called with (seg, ev), and with the `this` context of the Grid
  3269. bindSegHandlerToEl: function (el, name, handler) {
  3270. var _this = this;
  3271. el.on(name, this.segSelector, function (ev) {
  3272. var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents
  3273. // only call the handlers if there is not a drag/resize in progress
  3274. if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) {
  3275. return handler.call(_this, seg, ev); // context will be the Grid
  3276. }
  3277. });
  3278. },
  3279. handleSegClick: function (seg, ev) {
  3280. var res = this.view.publiclyTrigger('eventClick', seg.el[0], seg.event, ev); // can return `false` to cancel
  3281. if (res === false) {
  3282. ev.preventDefault();
  3283. }
  3284. },
  3285. // Updates internal state and triggers handlers for when an event element is moused over
  3286. handleSegMouseover: function (seg, ev) {
  3287. if (
  3288. !this.isIgnoringMouse &&
  3289. !this.mousedOverSeg
  3290. ) {
  3291. this.mousedOverSeg = seg;
  3292. if (this.view.isEventResizable(seg.event)) {
  3293. seg.el.addClass('fc-allow-mouse-resize');
  3294. }
  3295. this.view.publiclyTrigger('eventMouseover', seg.el[0], seg.event, ev);
  3296. }
  3297. },
  3298. // Updates internal state and triggers handlers for when an event element is moused out.
  3299. // Can be given no arguments, in which case it will mouseout the segment that was previously moused over.
  3300. handleSegMouseout: function (seg, ev) {
  3301. ev = ev || {}; // if given no args, make a mock mouse event
  3302. if (this.mousedOverSeg) {
  3303. seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment
  3304. this.mousedOverSeg = null;
  3305. if (this.view.isEventResizable(seg.event)) {
  3306. seg.el.removeClass('fc-allow-mouse-resize');
  3307. }
  3308. this.view.publiclyTrigger('eventMouseout', seg.el[0], seg.event, ev);
  3309. }
  3310. },
  3311. handleSegMousedown: function (seg, ev) {
  3312. var isResizing = this.startSegResize(seg, ev, { distance: 5 });
  3313. if (!isResizing && this.view.isEventDraggable(seg.event)) {
  3314. this.buildSegDragListener(seg)
  3315. .startInteraction(ev, {
  3316. distance: 5
  3317. });
  3318. }
  3319. },
  3320. handleSegTouchStart: function (seg, ev) {
  3321. var view = this.view;
  3322. var event = seg.event;
  3323. var isSelected = view.isEventSelected(event);
  3324. var isDraggable = view.isEventDraggable(event);
  3325. var isResizable = view.isEventResizable(event);
  3326. var isResizing = false;
  3327. var dragListener;
  3328. var eventLongPressDelay;
  3329. if (isSelected && isResizable) {
  3330. // only allow resizing of the event is selected
  3331. isResizing = this.startSegResize(seg, ev);
  3332. }
  3333. if (!isResizing && (isDraggable || isResizable)) { // allowed to be selected?
  3334. eventLongPressDelay = view.opt('eventLongPressDelay');
  3335. if (eventLongPressDelay == null) {
  3336. eventLongPressDelay = view.opt('longPressDelay'); // fallback
  3337. }
  3338. dragListener = isDraggable ?
  3339. this.buildSegDragListener(seg) :
  3340. this.buildSegSelectListener(seg); // seg isn't draggable, but still needs to be selected
  3341. dragListener.startInteraction(ev, { // won't start if already started
  3342. delay: isSelected ? 0 : eventLongPressDelay // do delay if not already selected
  3343. });
  3344. }
  3345. // a long tap simulates a mouseover. ignore this bogus mouseover.
  3346. this.tempIgnoreMouse();
  3347. },
  3348. handleSegTouchEnd: function (seg, ev) {
  3349. // touchstart+touchend = click, which simulates a mouseover.
  3350. // ignore this bogus mouseover.
  3351. this.tempIgnoreMouse();
  3352. },
  3353. // returns boolean whether resizing actually started or not.
  3354. // assumes the seg allows resizing.
  3355. // `dragOptions` are optional.
  3356. startSegResize: function (seg, ev, dragOptions) {
  3357. if ($(ev.target).is('.fc-resizer')) {
  3358. this.buildSegResizeListener(seg, $(ev.target).is('.fc-start-resizer'))
  3359. .startInteraction(ev, dragOptions);
  3360. return true;
  3361. }
  3362. return false;
  3363. },
  3364. /* Event Dragging
  3365. ------------------------------------------------------------------------------------------------------------------*/
  3366. // Builds a listener that will track user-dragging on an event segment.
  3367. // Generic enough to work with any type of Grid.
  3368. // Has side effect of setting/unsetting `segDragListener`
  3369. buildSegDragListener: function (seg) {
  3370. var _this = this;
  3371. var view = this.view;
  3372. var calendar = view.calendar;
  3373. var el = seg.el;
  3374. var event = seg.event;
  3375. var isDragging;
  3376. var mouseFollower; // A clone of the original element that will move with the mouse
  3377. var dropLocation; // zoned event date properties
  3378. if (this.segDragListener) {
  3379. return this.segDragListener;
  3380. }
  3381. // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents
  3382. // of the view.
  3383. var dragListener = this.segDragListener = new HitDragListener(view, {
  3384. scroll: view.opt('dragScroll'),
  3385. subjectEl: el,
  3386. subjectCenter: true,
  3387. interactionStart: function (ev) {
  3388. seg.component = _this; // for renderDrag
  3389. isDragging = false;
  3390. mouseFollower = new MouseFollower(seg.el, {
  3391. additionalClass: 'fc-dragging',
  3392. parentEl: view.el,
  3393. opacity: dragListener.isTouch ? null : view.opt('dragOpacity'),
  3394. revertDuration: view.opt('dragRevertDuration'),
  3395. zIndex: 2 // one above the .fc-view
  3396. });
  3397. mouseFollower.hide(); // don't show until we know this is a real drag
  3398. mouseFollower.start(ev);
  3399. },
  3400. dragStart: function (ev) {
  3401. if (dragListener.isTouch && !view.isEventSelected(event)) {
  3402. // if not previously selected, will fire after a delay. then, select the event
  3403. view.selectEvent(event);
  3404. }
  3405. isDragging = true;
  3406. _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
  3407. _this.segDragStart(seg, ev);
  3408. view.hideEvent(event); // hide all event segments. our mouseFollower will take over
  3409. },
  3410. hitOver: function (hit, isOrig, origHit) {
  3411. var dragHelperEls;
  3412. // starting hit could be forced (DayGrid.limit)
  3413. if (seg.hit) {
  3414. origHit = seg.hit;
  3415. }
  3416. // since we are querying the parent view, might not belong to this grid
  3417. dropLocation = _this.computeEventDrop(
  3418. origHit.component.getHitSpan(origHit),
  3419. hit.component.getHitSpan(hit),
  3420. event
  3421. );
  3422. if (dropLocation && !calendar.isEventSpanAllowed(_this.eventToSpan(dropLocation), event)) {
  3423. disableCursor();
  3424. dropLocation = null;
  3425. }
  3426. // if a valid drop location, have the subclass render a visual indication
  3427. if (dropLocation && (dragHelperEls = view.renderDrag(dropLocation, seg))) {
  3428. dragHelperEls.addClass('fc-dragging');
  3429. if (!dragListener.isTouch) {
  3430. _this.applyDragOpacity(dragHelperEls);
  3431. }
  3432. mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own
  3433. }
  3434. else {
  3435. mouseFollower.show(); // otherwise, have the helper follow the mouse (no snapping)
  3436. }
  3437. if (isOrig) {
  3438. dropLocation = null; // needs to have moved hits to be a valid drop
  3439. }
  3440. },
  3441. hitOut: function () { // called before mouse moves to a different hit OR moved out of all hits
  3442. view.unrenderDrag(); // unrender whatever was done in renderDrag
  3443. mouseFollower.show(); // show in case we are moving out of all hits
  3444. dropLocation = null;
  3445. },
  3446. hitDone: function () { // Called after a hitOut OR before a dragEnd
  3447. enableCursor();
  3448. },
  3449. interactionEnd: function (ev) {
  3450. delete seg.component; // prevent side effects
  3451. // do revert animation if hasn't changed. calls a callback when finished (whether animation or not)
  3452. mouseFollower.stop(!dropLocation, function () {
  3453. if (isDragging) {
  3454. view.unrenderDrag();
  3455. _this.segDragStop(seg, ev);
  3456. }
  3457. if (dropLocation) {
  3458. // no need to re-show original, will rerender all anyways. esp important if eventRenderWait
  3459. view.reportEventDrop(event, dropLocation, _this.largeUnit, el, ev);
  3460. }
  3461. else {
  3462. view.showEvent(event);
  3463. }
  3464. });
  3465. _this.segDragListener = null;
  3466. }
  3467. });
  3468. return dragListener;
  3469. },
  3470. // seg isn't draggable, but let's use a generic DragListener
  3471. // simply for the delay, so it can be selected.
  3472. // Has side effect of setting/unsetting `segDragListener`
  3473. buildSegSelectListener: function (seg) {
  3474. var _this = this;
  3475. var view = this.view;
  3476. var event = seg.event;
  3477. if (this.segDragListener) {
  3478. return this.segDragListener;
  3479. }
  3480. var dragListener = this.segDragListener = new DragListener({
  3481. dragStart: function (ev) {
  3482. if (dragListener.isTouch && !view.isEventSelected(event)) {
  3483. // if not previously selected, will fire after a delay. then, select the event
  3484. view.selectEvent(event);
  3485. }
  3486. },
  3487. interactionEnd: function (ev) {
  3488. _this.segDragListener = null;
  3489. }
  3490. });
  3491. return dragListener;
  3492. },
  3493. // Called before event segment dragging starts
  3494. segDragStart: function (seg, ev) {
  3495. this.isDraggingSeg = true;
  3496. this.view.publiclyTrigger('eventDragStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
  3497. },
  3498. // Called after event segment dragging stops
  3499. segDragStop: function (seg, ev) {
  3500. this.isDraggingSeg = false;
  3501. this.view.publiclyTrigger('eventDragStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
  3502. },
  3503. // Given the spans an event drag began, and the span event was dropped, calculates the new zoned start/end/allDay
  3504. // values for the event. Subclasses may override and set additional properties to be used by renderDrag.
  3505. // A falsy returned value indicates an invalid drop.
  3506. // DOES NOT consider overlap/constraint.
  3507. computeEventDrop: function (startSpan, endSpan, event) {
  3508. var calendar = this.view.calendar;
  3509. var dragStart = startSpan.start;
  3510. var dragEnd = endSpan.start;
  3511. var delta;
  3512. var dropLocation; // zoned event date properties
  3513. if (dragStart.hasTime() === dragEnd.hasTime()) {
  3514. delta = this.diffDates(dragEnd, dragStart);
  3515. // if an all-day event was in a timed area and it was dragged to a different time,
  3516. // guarantee an end and adjust start/end to have times
  3517. if (event.allDay && durationHasTime(delta)) {
  3518. dropLocation = {
  3519. start: event.start.clone(),
  3520. end: calendar.getEventEnd(event), // will be an ambig day
  3521. allDay: false // for normalizeEventTimes
  3522. };
  3523. calendar.normalizeEventTimes(dropLocation);
  3524. }
  3525. // othewise, work off existing values
  3526. else {
  3527. dropLocation = pluckEventDateProps(event);
  3528. }
  3529. dropLocation.start.add(delta);
  3530. if (dropLocation.end) {
  3531. dropLocation.end.add(delta);
  3532. }
  3533. }
  3534. else {
  3535. // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared
  3536. dropLocation = {
  3537. start: dragEnd.clone(),
  3538. end: null, // end should be cleared
  3539. allDay: !dragEnd.hasTime()
  3540. };
  3541. }
  3542. return dropLocation;
  3543. },
  3544. // Utility for apply dragOpacity to a jQuery set
  3545. applyDragOpacity: function (els) {
  3546. var opacity = this.view.opt('dragOpacity');
  3547. if (opacity != null) {
  3548. els.css('opacity', opacity);
  3549. }
  3550. },
  3551. /* External Element Dragging
  3552. ------------------------------------------------------------------------------------------------------------------*/
  3553. // Called when a jQuery UI drag is initiated anywhere in the DOM
  3554. externalDragStart: function (ev, ui) {
  3555. var view = this.view;
  3556. var el;
  3557. var accept;
  3558. if (view.opt('droppable')) { // only listen if this setting is on
  3559. el = $((ui ? ui.item : null) || ev.target);
  3560. // Test that the dragged element passes the dropAccept selector or filter function.
  3561. // FYI, the default is "*" (matches all)
  3562. accept = view.opt('dropAccept');
  3563. if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) {
  3564. if (!this.isDraggingExternal) { // prevent double-listening if fired twice
  3565. this.listenToExternalDrag(el, ev, ui);
  3566. }
  3567. }
  3568. }
  3569. },
  3570. // Called when a jQuery UI drag starts and it needs to be monitored for dropping
  3571. listenToExternalDrag: function (el, ev, ui) {
  3572. var _this = this;
  3573. var calendar = this.view.calendar;
  3574. var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create
  3575. var dropLocation; // a null value signals an unsuccessful drag
  3576. // listener that tracks mouse movement over date-associated pixel regions
  3577. var dragListener = _this.externalDragListener = new HitDragListener(this, {
  3578. interactionStart: function () {
  3579. _this.isDraggingExternal = true;
  3580. },
  3581. hitOver: function (hit) {
  3582. dropLocation = _this.computeExternalDrop(
  3583. hit.component.getHitSpan(hit), // since we are querying the parent view, might not belong to this grid
  3584. meta
  3585. );
  3586. if ( // invalid hit?
  3587. dropLocation &&
  3588. !calendar.isExternalSpanAllowed(_this.eventToSpan(dropLocation), dropLocation, meta.eventProps)
  3589. ) {
  3590. disableCursor();
  3591. dropLocation = null;
  3592. }
  3593. if (dropLocation) {
  3594. _this.renderDrag(dropLocation); // called without a seg parameter
  3595. }
  3596. },
  3597. hitOut: function () {
  3598. dropLocation = null; // signal unsuccessful
  3599. },
  3600. hitDone: function () { // Called after a hitOut OR before a dragEnd
  3601. enableCursor();
  3602. _this.unrenderDrag();
  3603. },
  3604. interactionEnd: function (ev) {
  3605. if (dropLocation) { // element was dropped on a valid hit
  3606. _this.view.reportExternalDrop(meta, dropLocation, el, ev, ui);
  3607. }
  3608. _this.isDraggingExternal = false;
  3609. _this.externalDragListener = null;
  3610. }
  3611. });
  3612. dragListener.startDrag(ev); // start listening immediately
  3613. },
  3614. // Given a hit to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object),
  3615. // returns the zoned start/end dates for the event that would result from the hypothetical drop. end might be null.
  3616. // Returning a null value signals an invalid drop hit.
  3617. // DOES NOT consider overlap/constraint.
  3618. computeExternalDrop: function (span, meta) {
  3619. var calendar = this.view.calendar;
  3620. var dropLocation = {
  3621. start: calendar.applyTimezone(span.start), // simulate a zoned event start date
  3622. end: null
  3623. };
  3624. // if dropped on an all-day span, and element's metadata specified a time, set it
  3625. if (meta.startTime && !dropLocation.start.hasTime()) {
  3626. dropLocation.start.time(meta.startTime);
  3627. }
  3628. if (meta.duration) {
  3629. dropLocation.end = dropLocation.start.clone().add(meta.duration);
  3630. }
  3631. return dropLocation;
  3632. },
  3633. /* Drag Rendering (for both events and an external elements)
  3634. ------------------------------------------------------------------------------------------------------------------*/
  3635. // Renders a visual indication of an event or external element being dragged.
  3636. // `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null.
  3637. // `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null.
  3638. // A truthy returned value indicates this method has rendered a helper element.
  3639. // Must return elements used for any mock events.
  3640. renderDrag: function (dropLocation, seg) {
  3641. // subclasses must implement
  3642. },
  3643. // Unrenders a visual indication of an event or external element being dragged
  3644. unrenderDrag: function () {
  3645. // subclasses must implement
  3646. },
  3647. /* Resizing
  3648. ------------------------------------------------------------------------------------------------------------------*/
  3649. // Creates a listener that tracks the user as they resize an event segment.
  3650. // Generic enough to work with any type of Grid.
  3651. buildSegResizeListener: function (seg, isStart) {
  3652. var _this = this;
  3653. var view = this.view;
  3654. var calendar = view.calendar;
  3655. var el = seg.el;
  3656. var event = seg.event;
  3657. var eventEnd = calendar.getEventEnd(event);
  3658. var isDragging;
  3659. var resizeLocation; // zoned event date properties. falsy if invalid resize
  3660. // Tracks mouse movement over the *grid's* coordinate map
  3661. var dragListener = this.segResizeListener = new HitDragListener(this, {
  3662. scroll: view.opt('dragScroll'),
  3663. subjectEl: el,
  3664. interactionStart: function () {
  3665. isDragging = false;
  3666. },
  3667. dragStart: function (ev) {
  3668. isDragging = true;
  3669. _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
  3670. _this.segResizeStart(seg, ev);
  3671. },
  3672. hitOver: function (hit, isOrig, origHit) {
  3673. var origHitSpan = _this.getHitSpan(origHit);
  3674. var hitSpan = _this.getHitSpan(hit);
  3675. resizeLocation = isStart ?
  3676. _this.computeEventStartResize(origHitSpan, hitSpan, event) :
  3677. _this.computeEventEndResize(origHitSpan, hitSpan, event);
  3678. if (resizeLocation) {
  3679. if (!calendar.isEventSpanAllowed(_this.eventToSpan(resizeLocation), event)) {
  3680. disableCursor();
  3681. resizeLocation = null;
  3682. }
  3683. // no change? (FYI, event dates might have zones)
  3684. else if (
  3685. resizeLocation.start.isSame(event.start.clone().stripZone()) &&
  3686. resizeLocation.end.isSame(eventEnd.clone().stripZone())
  3687. ) {
  3688. resizeLocation = null;
  3689. }
  3690. }
  3691. if (resizeLocation) {
  3692. view.hideEvent(event);
  3693. _this.renderEventResize(resizeLocation, seg);
  3694. }
  3695. },
  3696. hitOut: function () { // called before mouse moves to a different hit OR moved out of all hits
  3697. resizeLocation = null;
  3698. view.showEvent(event); // for when out-of-bounds. show original
  3699. },
  3700. hitDone: function () { // resets the rendering to show the original event
  3701. _this.unrenderEventResize();
  3702. enableCursor();
  3703. },
  3704. interactionEnd: function (ev) {
  3705. if (isDragging) {
  3706. _this.segResizeStop(seg, ev);
  3707. }
  3708. if (resizeLocation) { // valid date to resize to?
  3709. // no need to re-show original, will rerender all anyways. esp important if eventRenderWait
  3710. view.reportEventResize(event, resizeLocation, _this.largeUnit, el, ev);
  3711. }
  3712. else {
  3713. view.showEvent(event);
  3714. }
  3715. _this.segResizeListener = null;
  3716. }
  3717. });
  3718. return dragListener;
  3719. },
  3720. // Called before event segment resizing starts
  3721. segResizeStart: function (seg, ev) {
  3722. this.isResizingSeg = true;
  3723. this.view.publiclyTrigger('eventResizeStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
  3724. },
  3725. // Called after event segment resizing stops
  3726. segResizeStop: function (seg, ev) {
  3727. this.isResizingSeg = false;
  3728. this.view.publiclyTrigger('eventResizeStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
  3729. },
  3730. // Returns new date-information for an event segment being resized from its start
  3731. computeEventStartResize: function (startSpan, endSpan, event) {
  3732. return this.computeEventResize('start', startSpan, endSpan, event);
  3733. },
  3734. // Returns new date-information for an event segment being resized from its end
  3735. computeEventEndResize: function (startSpan, endSpan, event) {
  3736. return this.computeEventResize('end', startSpan, endSpan, event);
  3737. },
  3738. // Returns new zoned date information for an event segment being resized from its start OR end
  3739. // `type` is either 'start' or 'end'.
  3740. // DOES NOT consider overlap/constraint.
  3741. computeEventResize: function (type, startSpan, endSpan, event) {
  3742. var calendar = this.view.calendar;
  3743. var delta = this.diffDates(endSpan[type], startSpan[type]);
  3744. var resizeLocation; // zoned event date properties
  3745. var defaultDuration;
  3746. // build original values to work from, guaranteeing a start and end
  3747. resizeLocation = {
  3748. start: event.start.clone(),
  3749. end: calendar.getEventEnd(event),
  3750. allDay: event.allDay
  3751. };
  3752. // if an all-day event was in a timed area and was resized to a time, adjust start/end to have times
  3753. if (resizeLocation.allDay && durationHasTime(delta)) {
  3754. resizeLocation.allDay = false;
  3755. calendar.normalizeEventTimes(resizeLocation);
  3756. }
  3757. resizeLocation[type].add(delta); // apply delta to start or end
  3758. // if the event was compressed too small, find a new reasonable duration for it
  3759. if (!resizeLocation.start.isBefore(resizeLocation.end)) {
  3760. defaultDuration =
  3761. this.minResizeDuration || // TODO: hack
  3762. (event.allDay ?
  3763. calendar.defaultAllDayEventDuration :
  3764. calendar.defaultTimedEventDuration);
  3765. if (type == 'start') { // resizing the start?
  3766. resizeLocation.start = resizeLocation.end.clone().subtract(defaultDuration);
  3767. }
  3768. else { // resizing the end?
  3769. resizeLocation.end = resizeLocation.start.clone().add(defaultDuration);
  3770. }
  3771. }
  3772. return resizeLocation;
  3773. },
  3774. // Renders a visual indication of an event being resized.
  3775. // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag.
  3776. // Must return elements used for any mock events.
  3777. renderEventResize: function (range, seg) {
  3778. // subclasses must implement
  3779. },
  3780. // Unrenders a visual indication of an event being resized.
  3781. unrenderEventResize: function () {
  3782. // subclasses must implement
  3783. },
  3784. /* Rendering Utils
  3785. ------------------------------------------------------------------------------------------------------------------*/
  3786. // Compute the text that should be displayed on an event's element.
  3787. // `range` can be the Event object itself, or something range-like, with at least a `start`.
  3788. // If event times are disabled, or the event has no time, will return a blank string.
  3789. // If not specified, formatStr will default to the eventTimeFormat setting,
  3790. // and displayEnd will default to the displayEventEnd setting.
  3791. getEventTimeText: function (range, formatStr, displayEnd) {
  3792. if (formatStr == null) {
  3793. formatStr = this.eventTimeFormat;
  3794. }
  3795. if (displayEnd == null) {
  3796. displayEnd = this.displayEventEnd;
  3797. }
  3798. if (this.displayEventTime && range.start.hasTime()) {
  3799. if (displayEnd && range.end) {
  3800. return this.view.formatRange(range, formatStr);
  3801. }
  3802. else {
  3803. return range.start.format(formatStr);
  3804. }
  3805. }
  3806. return '';
  3807. },
  3808. // Generic utility for generating the HTML classNames for an event segment's element
  3809. getSegClasses: function (seg, isDraggable, isResizable) {
  3810. var view = this.view;
  3811. var classes = [
  3812. 'fc-event',
  3813. seg.isStart ? 'fc-start' : 'fc-not-start',
  3814. seg.isEnd ? 'fc-end' : 'fc-not-end'
  3815. ].concat(this.getSegCustomClasses(seg));
  3816. if (isDraggable) {
  3817. classes.push('fc-draggable');
  3818. }
  3819. if (isResizable) {
  3820. classes.push('fc-resizable');
  3821. }
  3822. // event is currently selected? attach a className.
  3823. if (view.isEventSelected(seg.event)) {
  3824. classes.push('fc-selected');
  3825. }
  3826. return classes;
  3827. },
  3828. // List of classes that were defined by the caller of the API in some way
  3829. getSegCustomClasses: function (seg) {
  3830. var event = seg.event;
  3831. return [].concat(
  3832. event.className, // guaranteed to be an array
  3833. event.source ? event.source.className : []
  3834. );
  3835. },
  3836. // Utility for generating event skin-related CSS properties
  3837. getSegSkinCss: function (seg) {
  3838. return {
  3839. 'background-color': this.getSegBackgroundColor(seg),
  3840. 'border-color': this.getSegBorderColor(seg),
  3841. color: this.getSegTextColor(seg)
  3842. };
  3843. },
  3844. // Queries for caller-specified color, then falls back to default
  3845. getSegBackgroundColor: function (seg) {
  3846. return seg.event.backgroundColor ||
  3847. seg.event.color ||
  3848. this.getSegDefaultBackgroundColor(seg);
  3849. },
  3850. getSegDefaultBackgroundColor: function (seg) {
  3851. var source = seg.event.source || {};
  3852. return source.backgroundColor ||
  3853. source.color ||
  3854. this.view.opt('eventBackgroundColor') ||
  3855. this.view.opt('eventColor');
  3856. },
  3857. // Queries for caller-specified color, then falls back to default
  3858. getSegBorderColor: function (seg) {
  3859. return seg.event.borderColor ||
  3860. seg.event.color ||
  3861. this.getSegDefaultBorderColor(seg);
  3862. },
  3863. getSegDefaultBorderColor: function (seg) {
  3864. var source = seg.event.source || {};
  3865. return source.borderColor ||
  3866. source.color ||
  3867. this.view.opt('eventBorderColor') ||
  3868. this.view.opt('eventColor');
  3869. },
  3870. // Queries for caller-specified color, then falls back to default
  3871. getSegTextColor: function (seg) {
  3872. return seg.event.textColor ||
  3873. this.getSegDefaultTextColor(seg);
  3874. },
  3875. getSegDefaultTextColor: function (seg) {
  3876. var source = seg.event.source || {};
  3877. return source.textColor ||
  3878. this.view.opt('eventTextColor');
  3879. },
  3880. /* Converting events -> eventRange -> eventSpan -> eventSegs
  3881. ------------------------------------------------------------------------------------------------------------------*/
  3882. // Generates an array of segments for the given single event
  3883. // Can accept an event "location" as well (which only has start/end and no allDay)
  3884. eventToSegs: function (event) {
  3885. return this.eventsToSegs([event]);
  3886. },
  3887. eventToSpan: function (event) {
  3888. return this.eventToSpans(event)[0];
  3889. },
  3890. // Generates spans (always unzoned) for the given event.
  3891. // Does not do any inverting for inverse-background events.
  3892. // Can accept an event "location" as well (which only has start/end and no allDay)
  3893. eventToSpans: function (event) {
  3894. var range = this.eventToRange(event);
  3895. return this.eventRangeToSpans(range, event);
  3896. },
  3897. // Converts an array of event objects into an array of event segment objects.
  3898. // A custom `segSliceFunc` may be given for arbitrarily slicing up events.
  3899. // Doesn't guarantee an order for the resulting array.
  3900. eventsToSegs: function (allEvents, segSliceFunc) {
  3901. var _this = this;
  3902. var eventsById = groupEventsById(allEvents);
  3903. var segs = [];
  3904. $.each(eventsById, function (id, events) {
  3905. var ranges = [];
  3906. var i;
  3907. for (i = 0; i < events.length; i++) {
  3908. ranges.push(_this.eventToRange(events[i]));
  3909. }
  3910. // inverse-background events (utilize only the first event in calculations)
  3911. if (isInverseBgEvent(events[0])) {
  3912. ranges = _this.invertRanges(ranges);
  3913. for (i = 0; i < ranges.length; i++) {
  3914. segs.push.apply(segs, // append to
  3915. _this.eventRangeToSegs(ranges[i], events[0], segSliceFunc));
  3916. }
  3917. }
  3918. // normal event ranges
  3919. else {
  3920. for (i = 0; i < ranges.length; i++) {
  3921. segs.push.apply(segs, // append to
  3922. _this.eventRangeToSegs(ranges[i], events[i], segSliceFunc));
  3923. }
  3924. }
  3925. });
  3926. return segs;
  3927. },
  3928. // Generates the unzoned start/end dates an event appears to occupy
  3929. // Can accept an event "location" as well (which only has start/end and no allDay)
  3930. eventToRange: function (event) {
  3931. var calendar = this.view.calendar;
  3932. var start = event.start.clone().stripZone();
  3933. var end = (
  3934. event.end ?
  3935. event.end.clone() :
  3936. // derive the end from the start and allDay. compute allDay if necessary
  3937. calendar.getDefaultEventEnd(
  3938. event.allDay != null ?
  3939. event.allDay :
  3940. !event.start.hasTime(),
  3941. event.start
  3942. )
  3943. ).stripZone();
  3944. // hack: dynamic locale change forgets to upate stored event localed
  3945. calendar.localizeMoment(start);
  3946. calendar.localizeMoment(end);
  3947. return { start: start, end: end };
  3948. },
  3949. // Given an event's range (unzoned start/end), and the event itself,
  3950. // slice into segments (using the segSliceFunc function if specified)
  3951. eventRangeToSegs: function (range, event, segSliceFunc) {
  3952. var spans = this.eventRangeToSpans(range, event);
  3953. var segs = [];
  3954. var i;
  3955. for (i = 0; i < spans.length; i++) {
  3956. segs.push.apply(segs, // append to
  3957. this.eventSpanToSegs(spans[i], event, segSliceFunc));
  3958. }
  3959. return segs;
  3960. },
  3961. // Given an event's unzoned date range, return an array of "span" objects.
  3962. // Subclasses can override.
  3963. eventRangeToSpans: function (range, event) {
  3964. return [$.extend({}, range)]; // copy into a single-item array
  3965. },
  3966. // Given an event's span (unzoned start/end and other misc data), and the event itself,
  3967. // slices into segments and attaches event-derived properties to them.
  3968. eventSpanToSegs: function (span, event, segSliceFunc) {
  3969. var segs = segSliceFunc ? segSliceFunc(span) : this.spanToSegs(span);
  3970. var i, seg;
  3971. for (i = 0; i < segs.length; i++) {
  3972. seg = segs[i];
  3973. seg.event = event;
  3974. seg.eventStartMS = +span.start; // TODO: not the best name after making spans unzoned
  3975. seg.eventDurationMS = span.end - span.start;
  3976. }
  3977. return segs;
  3978. },
  3979. // Produces a new array of range objects that will cover all the time NOT covered by the given ranges.
  3980. // SIDE EFFECT: will mutate the given array and will use its date references.
  3981. invertRanges: function (ranges) {
  3982. var view = this.view;
  3983. var viewStart = view.start.clone(); // need a copy
  3984. var viewEnd = view.end.clone(); // need a copy
  3985. var inverseRanges = [];
  3986. var start = viewStart; // the end of the previous range. the start of the new range
  3987. var i, range;
  3988. // ranges need to be in order. required for our date-walking algorithm
  3989. ranges.sort(compareRanges);
  3990. for (i = 0; i < ranges.length; i++) {
  3991. range = ranges[i];
  3992. // add the span of time before the event (if there is any)
  3993. if (range.start > start) { // compare millisecond time (skip any ambig logic)
  3994. inverseRanges.push({
  3995. start: start,
  3996. end: range.start
  3997. });
  3998. }
  3999. start = range.end;
  4000. }
  4001. // add the span of time after the last event (if there is any)
  4002. if (start < viewEnd) { // compare millisecond time (skip any ambig logic)
  4003. inverseRanges.push({
  4004. start: start,
  4005. end: viewEnd
  4006. });
  4007. }
  4008. return inverseRanges;
  4009. },
  4010. sortEventSegs: function (segs) {
  4011. segs.sort(proxy(this, 'compareEventSegs'));
  4012. },
  4013. // A cmp function for determining which segments should take visual priority
  4014. compareEventSegs: function (seg1, seg2) {
  4015. return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first
  4016. seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first
  4017. seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1)
  4018. compareByFieldSpecs(seg1.event, seg2.event, this.view.eventOrderSpecs);
  4019. }
  4020. });
  4021. /* Utilities
  4022. ----------------------------------------------------------------------------------------------------------------------*/
  4023. function pluckEventDateProps(event) {
  4024. return {
  4025. start: event.start.clone(),
  4026. end: event.end ? event.end.clone() : null,
  4027. allDay: event.allDay // keep it the same
  4028. };
  4029. }
  4030. FC.pluckEventDateProps = pluckEventDateProps;
  4031. function isBgEvent(event) { // returns true if background OR inverse-background
  4032. var rendering = getEventRendering(event);
  4033. return rendering === 'background' || rendering === 'inverse-background';
  4034. }
  4035. FC.isBgEvent = isBgEvent; // export
  4036. function isInverseBgEvent(event) {
  4037. return getEventRendering(event) === 'inverse-background';
  4038. }
  4039. function getEventRendering(event) {
  4040. return firstDefined((event.source || {}).rendering, event.rendering);
  4041. }
  4042. function groupEventsById(events) {
  4043. var eventsById = {};
  4044. var i, event;
  4045. for (i = 0; i < events.length; i++) {
  4046. event = events[i];
  4047. (eventsById[event._id] || (eventsById[event._id] = [])).push(event);
  4048. }
  4049. return eventsById;
  4050. }
  4051. // A cmp function for determining which non-inverted "ranges" (see above) happen earlier
  4052. function compareRanges(range1, range2) {
  4053. return range1.start - range2.start; // earlier ranges go first
  4054. }
  4055. /* External-Dragging-Element Data
  4056. ----------------------------------------------------------------------------------------------------------------------*/
  4057. // Require all HTML5 data-* attributes used by FullCalendar to have this prefix.
  4058. // A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event.
  4059. FC.dataAttrPrefix = '';
  4060. // Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure
  4061. // to be used for Event Object creation.
  4062. // A defined `.eventProps`, even when empty, indicates that an event should be created.
  4063. function getDraggedElMeta(el) {
  4064. var prefix = FC.dataAttrPrefix;
  4065. var eventProps; // properties for creating the event, not related to date/time
  4066. var startTime; // a Duration
  4067. var duration;
  4068. var stick;
  4069. if (prefix) { prefix += '-'; }
  4070. eventProps = el.data(prefix + 'event') || null;
  4071. if (eventProps) {
  4072. if (typeof eventProps === 'object') {
  4073. eventProps = $.extend({}, eventProps); // make a copy
  4074. }
  4075. else { // something like 1 or true. still signal event creation
  4076. eventProps = {};
  4077. }
  4078. // pluck special-cased date/time properties
  4079. startTime = eventProps.start;
  4080. if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well
  4081. duration = eventProps.duration;
  4082. stick = eventProps.stick;
  4083. delete eventProps.start;
  4084. delete eventProps.time;
  4085. delete eventProps.duration;
  4086. delete eventProps.stick;
  4087. }
  4088. // fallback to standalone attribute values for each of the date/time properties
  4089. if (startTime == null) { startTime = el.data(prefix + 'start'); }
  4090. if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well
  4091. if (duration == null) { duration = el.data(prefix + 'duration'); }
  4092. if (stick == null) { stick = el.data(prefix + 'stick'); }
  4093. // massage into correct data types
  4094. startTime = startTime != null ? moment.duration(startTime) : null;
  4095. duration = duration != null ? moment.duration(duration) : null;
  4096. stick = Boolean(stick);
  4097. return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick };
  4098. }
  4099. ;;
  4100. /*
  4101. A set of rendering and date-related methods for a visual component comprised of one or more rows of day columns.
  4102. Prerequisite: the object being mixed into needs to be a *Grid*
  4103. */
  4104. var DayTableMixin = FC.DayTableMixin = {
  4105. breakOnWeeks: false, // should create a new row for each week?
  4106. dayDates: null, // whole-day dates for each column. left to right
  4107. dayIndices: null, // for each day from start, the offset
  4108. daysPerRow: null,
  4109. rowCnt: null,
  4110. colCnt: null,
  4111. colHeadFormat: null,
  4112. // Populates internal variables used for date calculation and rendering
  4113. updateDayTable: function () {
  4114. var view = this.view;
  4115. var date = this.start.clone();
  4116. var dayIndex = -1;
  4117. var dayIndices = [];
  4118. var dayDates = [];
  4119. var daysPerRow;
  4120. var firstDay;
  4121. var rowCnt;
  4122. while (date.isBefore(this.end)) { // loop each day from start to end
  4123. if (view.isHiddenDay(date)) {
  4124. dayIndices.push(dayIndex + 0.5); // mark that it's between indices
  4125. }
  4126. else {
  4127. dayIndex++;
  4128. dayIndices.push(dayIndex);
  4129. dayDates.push(date.clone());
  4130. }
  4131. date.add(1, 'days');
  4132. }
  4133. if (this.breakOnWeeks) {
  4134. // count columns until the day-of-week repeats
  4135. firstDay = dayDates[0].day();
  4136. for (daysPerRow = 1; daysPerRow < dayDates.length; daysPerRow++) {
  4137. if (dayDates[daysPerRow].day() == firstDay) {
  4138. break;
  4139. }
  4140. }
  4141. rowCnt = Math.ceil(dayDates.length / daysPerRow);
  4142. }
  4143. else {
  4144. rowCnt = 1;
  4145. daysPerRow = dayDates.length;
  4146. }
  4147. this.dayDates = dayDates;
  4148. this.dayIndices = dayIndices;
  4149. this.daysPerRow = daysPerRow;
  4150. this.rowCnt = rowCnt;
  4151. this.updateDayTableCols();
  4152. },
  4153. // Computes and assigned the colCnt property and updates any options that may be computed from it
  4154. updateDayTableCols: function () {
  4155. this.colCnt = this.computeColCnt();
  4156. this.colHeadFormat = this.view.opt('columnFormat') || this.computeColHeadFormat();
  4157. },
  4158. // Determines how many columns there should be in the table
  4159. computeColCnt: function () {
  4160. return this.daysPerRow;
  4161. },
  4162. // Computes the ambiguously-timed moment for the given cell
  4163. getCellDate: function (row, col) {
  4164. return this.dayDates[
  4165. this.getCellDayIndex(row, col)
  4166. ].clone();
  4167. },
  4168. // Computes the ambiguously-timed date range for the given cell
  4169. getCellRange: function (row, col) {
  4170. var start = this.getCellDate(row, col);
  4171. var end = start.clone().add(1, 'days');
  4172. return { start: start, end: end };
  4173. },
  4174. // Returns the number of day cells, chronologically, from the first of the grid (0-based)
  4175. getCellDayIndex: function (row, col) {
  4176. return row * this.daysPerRow + this.getColDayIndex(col);
  4177. },
  4178. // Returns the numner of day cells, chronologically, from the first cell in *any given row*
  4179. getColDayIndex: function (col) {
  4180. if (this.isRTL) {
  4181. return this.colCnt - 1 - col;
  4182. }
  4183. else {
  4184. return col;
  4185. }
  4186. },
  4187. // Given a date, returns its chronolocial cell-index from the first cell of the grid.
  4188. // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets.
  4189. // If before the first offset, returns a negative number.
  4190. // If after the last offset, returns an offset past the last cell offset.
  4191. // Only works for *start* dates of cells. Will not work for exclusive end dates for cells.
  4192. getDateDayIndex: function (date) {
  4193. var dayIndices = this.dayIndices;
  4194. var dayOffset = date.diff(this.start, 'days');
  4195. if (dayOffset < 0) {
  4196. return dayIndices[0] - 1;
  4197. }
  4198. else if (dayOffset >= dayIndices.length) {
  4199. return dayIndices[dayIndices.length - 1] + 1;
  4200. }
  4201. else {
  4202. return dayIndices[dayOffset];
  4203. }
  4204. },
  4205. /* Options
  4206. ------------------------------------------------------------------------------------------------------------------*/
  4207. // Computes a default column header formatting string if `colFormat` is not explicitly defined
  4208. computeColHeadFormat: function () {
  4209. // if more than one week row, or if there are a lot of columns with not much space,
  4210. // put just the day numbers will be in each cell
  4211. if (this.rowCnt > 1 || this.colCnt > 10) {
  4212. return 'ddd'; // "Sat"
  4213. }
  4214. // multiple days, so full single date string WON'T be in title text
  4215. else if (this.colCnt > 1) {
  4216. return this.view.opt('dayOfMonthFormat'); // "Sat 12/10"
  4217. }
  4218. // single day, so full single date string will probably be in title text
  4219. else {
  4220. return 'dddd'; // "Saturday"
  4221. }
  4222. },
  4223. /* Slicing
  4224. ------------------------------------------------------------------------------------------------------------------*/
  4225. // Slices up a date range into a segment for every week-row it intersects with
  4226. sliceRangeByRow: function (range) {
  4227. var daysPerRow = this.daysPerRow;
  4228. var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold
  4229. var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index
  4230. var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index
  4231. var segs = [];
  4232. var row;
  4233. var rowFirst, rowLast; // inclusive day-index range for current row
  4234. var segFirst, segLast; // inclusive day-index range for segment
  4235. for (row = 0; row < this.rowCnt; row++) {
  4236. rowFirst = row * daysPerRow;
  4237. rowLast = rowFirst + daysPerRow - 1;
  4238. // intersect segment's offset range with the row's
  4239. segFirst = Math.max(rangeFirst, rowFirst);
  4240. segLast = Math.min(rangeLast, rowLast);
  4241. // deal with in-between indices
  4242. segFirst = Math.ceil(segFirst); // in-between starts round to next cell
  4243. segLast = Math.floor(segLast); // in-between ends round to prev cell
  4244. if (segFirst <= segLast) { // was there any intersection with the current row?
  4245. segs.push({
  4246. row: row,
  4247. // normalize to start of row
  4248. firstRowDayIndex: segFirst - rowFirst,
  4249. lastRowDayIndex: segLast - rowFirst,
  4250. // must be matching integers to be the segment's start/end
  4251. isStart: segFirst === rangeFirst,
  4252. isEnd: segLast === rangeLast
  4253. });
  4254. }
  4255. }
  4256. return segs;
  4257. },
  4258. // Slices up a date range into a segment for every day-cell it intersects with.
  4259. // TODO: make more DRY with sliceRangeByRow somehow.
  4260. sliceRangeByDay: function (range) {
  4261. var daysPerRow = this.daysPerRow;
  4262. var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold
  4263. var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index
  4264. var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index
  4265. var segs = [];
  4266. var row;
  4267. var rowFirst, rowLast; // inclusive day-index range for current row
  4268. var i;
  4269. var segFirst, segLast; // inclusive day-index range for segment
  4270. for (row = 0; row < this.rowCnt; row++) {
  4271. rowFirst = row * daysPerRow;
  4272. rowLast = rowFirst + daysPerRow - 1;
  4273. for (i = rowFirst; i <= rowLast; i++) {
  4274. // intersect segment's offset range with the row's
  4275. segFirst = Math.max(rangeFirst, i);
  4276. segLast = Math.min(rangeLast, i);
  4277. // deal with in-between indices
  4278. segFirst = Math.ceil(segFirst); // in-between starts round to next cell
  4279. segLast = Math.floor(segLast); // in-between ends round to prev cell
  4280. if (segFirst <= segLast) { // was there any intersection with the current row?
  4281. segs.push({
  4282. row: row,
  4283. // normalize to start of row
  4284. firstRowDayIndex: segFirst - rowFirst,
  4285. lastRowDayIndex: segLast - rowFirst,
  4286. // must be matching integers to be the segment's start/end
  4287. isStart: segFirst === rangeFirst,
  4288. isEnd: segLast === rangeLast
  4289. });
  4290. }
  4291. }
  4292. }
  4293. return segs;
  4294. },
  4295. /* Header Rendering
  4296. ------------------------------------------------------------------------------------------------------------------*/
  4297. renderHeadHtml: function () {
  4298. var view = this.view;
  4299. return '' +
  4300. '<div class="fc-row ' + view.widgetHeaderClass + '">' +
  4301. '<table>' +
  4302. '<thead>' +
  4303. this.renderHeadTrHtml() +
  4304. '</thead>' +
  4305. '</table>' +
  4306. '</div>';
  4307. },
  4308. renderHeadIntroHtml: function () {
  4309. return this.renderIntroHtml(); // fall back to generic
  4310. },
  4311. renderHeadTrHtml: function () {
  4312. return '' +
  4313. '<tr>' +
  4314. (this.isRTL ? '' : this.renderHeadIntroHtml()) +
  4315. this.renderHeadDateCellsHtml() +
  4316. (this.isRTL ? this.renderHeadIntroHtml() : '') +
  4317. '</tr>';
  4318. },
  4319. renderHeadDateCellsHtml: function () {
  4320. var htmls = [];
  4321. var col, date;
  4322. for (col = 0; col < this.colCnt; col++) {
  4323. date = this.getCellDate(0, col);
  4324. htmls.push(this.renderHeadDateCellHtml(date));
  4325. }
  4326. return htmls.join('');
  4327. },
  4328. // TODO: when internalApiVersion, accept an object for HTML attributes
  4329. // (colspan should be no different)
  4330. renderHeadDateCellHtml: function (date, colspan, otherAttrs) {
  4331. var view = this.view;
  4332. var classNames = [
  4333. 'fc-day-header',
  4334. view.widgetHeaderClass
  4335. ];
  4336. // if only one row of days, the classNames on the header can represent the specific days beneath
  4337. if (this.rowCnt === 1) {
  4338. classNames = classNames.concat(
  4339. // includes the day-of-week class
  4340. // noThemeHighlight=true (don't highlight the header)
  4341. this.getDayClasses(date, true)
  4342. );
  4343. }
  4344. else {
  4345. classNames.push('fc-' + dayIDs[date.day()]); // only add the day-of-week class
  4346. }
  4347. return '' +
  4348. '<th class="' + classNames.join(' ') + '"' +
  4349. (this.rowCnt === 1 ?
  4350. ' data-date="' + date.format('YYYY-MM-DD') + '"' :
  4351. '') +
  4352. (colspan > 1 ?
  4353. ' colspan="' + colspan + '"' :
  4354. '') +
  4355. (otherAttrs ?
  4356. ' ' + otherAttrs :
  4357. '') +
  4358. '>' +
  4359. // don't make a link if the heading could represent multiple days, or if there's only one day (forceOff)
  4360. view.buildGotoAnchorHtml(
  4361. { date: date, forceOff: this.rowCnt > 1 || this.colCnt === 1 },
  4362. htmlEscape(date.format(this.colHeadFormat)) // inner HTML
  4363. ) +
  4364. '</th>';
  4365. },
  4366. /* Background Rendering
  4367. ------------------------------------------------------------------------------------------------------------------*/
  4368. renderBgTrHtml: function (row) {
  4369. return '' +
  4370. '<tr>' +
  4371. (this.isRTL ? '' : this.renderBgIntroHtml(row)) +
  4372. this.renderBgCellsHtml(row) +
  4373. (this.isRTL ? this.renderBgIntroHtml(row) : '') +
  4374. '</tr>';
  4375. },
  4376. renderBgIntroHtml: function (row) {
  4377. return this.renderIntroHtml(); // fall back to generic
  4378. },
  4379. renderBgCellsHtml: function (row) {
  4380. var htmls = [];
  4381. var col, date;
  4382. for (col = 0; col < this.colCnt; col++) {
  4383. date = this.getCellDate(row, col);
  4384. htmls.push(this.renderBgCellHtml(date));
  4385. }
  4386. return htmls.join('');
  4387. },
  4388. renderBgCellHtml: function (date, otherAttrs) {
  4389. var view = this.view;
  4390. var classes = this.getDayClasses(date);
  4391. classes.unshift('fc-day', view.widgetContentClass);
  4392. return '<td class="' + classes.join(' ') + '"' +
  4393. ' data-date="' + date.format('YYYY-MM-DD') + '"' + // if date has a time, won't format it
  4394. (otherAttrs ?
  4395. ' ' + otherAttrs :
  4396. '') +
  4397. '></td>';
  4398. },
  4399. /* Generic
  4400. ------------------------------------------------------------------------------------------------------------------*/
  4401. // Generates the default HTML intro for any row. User classes should override
  4402. renderIntroHtml: function () {
  4403. },
  4404. // TODO: a generic method for dealing with <tr>, RTL, intro
  4405. // when increment internalApiVersion
  4406. // wrapTr (scheduler)
  4407. /* Utils
  4408. ------------------------------------------------------------------------------------------------------------------*/
  4409. // Applies the generic "intro" and "outro" HTML to the given cells.
  4410. // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro.
  4411. bookendCells: function (trEl) {
  4412. var introHtml = this.renderIntroHtml();
  4413. if (introHtml) {
  4414. if (this.isRTL) {
  4415. trEl.append(introHtml);
  4416. }
  4417. else {
  4418. trEl.prepend(introHtml);
  4419. }
  4420. }
  4421. }
  4422. };
  4423. ;;
  4424. /* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week.
  4425. ----------------------------------------------------------------------------------------------------------------------*/
  4426. var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, {
  4427. numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal
  4428. bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid
  4429. rowEls: null, // set of fake row elements
  4430. cellEls: null, // set of whole-day elements comprising the row's background
  4431. helperEls: null, // set of cell skeleton elements for rendering the mock event "helper"
  4432. rowCoordCache: null,
  4433. colCoordCache: null,
  4434. // Renders the rows and columns into the component's `this.el`, which should already be assigned.
  4435. // isRigid determins whether the individual rows should ignore the contents and be a constant height.
  4436. // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient.
  4437. renderDates: function (isRigid) {
  4438. var view = this.view;
  4439. var rowCnt = this.rowCnt;
  4440. var colCnt = this.colCnt;
  4441. var html = '';
  4442. var row;
  4443. var col;
  4444. for (row = 0; row < rowCnt; row++) {
  4445. html += this.renderDayRowHtml(row, isRigid);
  4446. }
  4447. this.el.html(html);
  4448. this.rowEls = this.el.find('.fc-row');
  4449. this.cellEls = this.el.find('.fc-day');
  4450. this.rowCoordCache = new CoordCache({
  4451. els: this.rowEls,
  4452. isVertical: true
  4453. });
  4454. this.colCoordCache = new CoordCache({
  4455. els: this.cellEls.slice(0, this.colCnt), // only the first row
  4456. isHorizontal: true
  4457. });
  4458. // trigger dayRender with each cell's element
  4459. for (row = 0; row < rowCnt; row++) {
  4460. for (col = 0; col < colCnt; col++) {
  4461. view.publiclyTrigger(
  4462. 'dayRender',
  4463. null,
  4464. this.getCellDate(row, col),
  4465. this.getCellEl(row, col)
  4466. );
  4467. }
  4468. }
  4469. },
  4470. unrenderDates: function () {
  4471. this.removeSegPopover();
  4472. },
  4473. renderBusinessHours: function () {
  4474. var segs = this.buildBusinessHourSegs(true); // wholeDay=true
  4475. this.renderFill('businessHours', segs, 'bgevent');
  4476. },
  4477. unrenderBusinessHours: function () {
  4478. this.unrenderFill('businessHours');
  4479. },
  4480. // Generates the HTML for a single row, which is a div that wraps a table.
  4481. // `row` is the row number.
  4482. renderDayRowHtml: function (row, isRigid) {
  4483. var view = this.view;
  4484. var classes = ['fc-row', 'fc-week', view.widgetContentClass];
  4485. if (isRigid) {
  4486. classes.push('fc-rigid');
  4487. }
  4488. return '' +
  4489. '<div class="' + classes.join(' ') + '">' +
  4490. '<div class="fc-bg">' +
  4491. '<table>' +
  4492. this.renderBgTrHtml(row) +
  4493. '</table>' +
  4494. '</div>' +
  4495. '<div class="fc-content-skeleton">' +
  4496. '<table>' +
  4497. (this.numbersVisible ?
  4498. '<thead>' +
  4499. this.renderNumberTrHtml(row) +
  4500. '</thead>' :
  4501. ''
  4502. ) +
  4503. '</table>' +
  4504. '</div>' +
  4505. '</div>';
  4506. },
  4507. /* Grid Number Rendering
  4508. ------------------------------------------------------------------------------------------------------------------*/
  4509. renderNumberTrHtml: function (row) {
  4510. return '' +
  4511. '<tr>' +
  4512. (this.isRTL ? '' : this.renderNumberIntroHtml(row)) +
  4513. this.renderNumberCellsHtml(row) +
  4514. (this.isRTL ? this.renderNumberIntroHtml(row) : '') +
  4515. '</tr>';
  4516. },
  4517. renderNumberIntroHtml: function (row) {
  4518. return this.renderIntroHtml();
  4519. },
  4520. renderNumberCellsHtml: function (row) {
  4521. var htmls = [];
  4522. var col, date;
  4523. for (col = 0; col < this.colCnt; col++) {
  4524. date = this.getCellDate(row, col);
  4525. htmls.push(this.renderNumberCellHtml(date));
  4526. }
  4527. return htmls.join('');
  4528. },
  4529. // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton.
  4530. // The number row will only exist if either day numbers or week numbers are turned on.
  4531. renderNumberCellHtml: function (date) {
  4532. var html = '';
  4533. var classes;
  4534. var weekCalcFirstDoW;
  4535. if (!this.view.dayNumbersVisible && !this.view.cellWeekNumbersVisible) {
  4536. // no numbers in day cell (week number must be along the side)
  4537. return '<td/>'; // will create an empty space above events :(
  4538. }
  4539. classes = this.getDayClasses(date);
  4540. classes.unshift('fc-day-top');
  4541. if (this.view.cellWeekNumbersVisible) {
  4542. // To determine the day of week number change under ISO, we cannot
  4543. // rely on moment.js methods such as firstDayOfWeek() or weekday(),
  4544. // because they rely on the locale's dow (possibly overridden by
  4545. // our firstDay option), which may not be Monday. We cannot change
  4546. // dow, because that would affect the calendar start day as well.
  4547. if (date._locale._fullCalendar_weekCalc === 'ISO') {
  4548. weekCalcFirstDoW = 1; // Monday by ISO 8601 definition
  4549. }
  4550. else {
  4551. weekCalcFirstDoW = date._locale.firstDayOfWeek();
  4552. }
  4553. }
  4554. html += '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '">';
  4555. if (this.view.cellWeekNumbersVisible && (date.day() == weekCalcFirstDoW)) {
  4556. html += this.view.buildGotoAnchorHtml(
  4557. { date: date, type: 'week' },
  4558. { 'class': 'fc-week-number' },
  4559. date.format('w') // inner HTML
  4560. );
  4561. }
  4562. if (this.view.dayNumbersVisible) {
  4563. html += this.view.buildGotoAnchorHtml(
  4564. date,
  4565. { 'class': 'fc-day-number' },
  4566. date.date() // inner HTML
  4567. );
  4568. }
  4569. html += '</td>';
  4570. return html;
  4571. },
  4572. /* Options
  4573. ------------------------------------------------------------------------------------------------------------------*/
  4574. // Computes a default event time formatting string if `timeFormat` is not explicitly defined
  4575. computeEventTimeFormat: function () {
  4576. return this.view.opt('extraSmallTimeFormat'); // like "6p" or "6:30p"
  4577. },
  4578. // Computes a default `displayEventEnd` value if one is not expliclty defined
  4579. computeDisplayEventEnd: function () {
  4580. return this.colCnt == 1; // we'll likely have space if there's only one day
  4581. },
  4582. /* Dates
  4583. ------------------------------------------------------------------------------------------------------------------*/
  4584. rangeUpdated: function () {
  4585. this.updateDayTable();
  4586. },
  4587. // Slices up the given span (unzoned start/end with other misc data) into an array of segments
  4588. spanToSegs: function (span) {
  4589. var segs = this.sliceRangeByRow(span);
  4590. var i, seg;
  4591. for (i = 0; i < segs.length; i++) {
  4592. seg = segs[i];
  4593. if (this.isRTL) {
  4594. seg.leftCol = this.daysPerRow - 1 - seg.lastRowDayIndex;
  4595. seg.rightCol = this.daysPerRow - 1 - seg.firstRowDayIndex;
  4596. }
  4597. else {
  4598. seg.leftCol = seg.firstRowDayIndex;
  4599. seg.rightCol = seg.lastRowDayIndex;
  4600. }
  4601. }
  4602. return segs;
  4603. },
  4604. /* Hit System
  4605. ------------------------------------------------------------------------------------------------------------------*/
  4606. prepareHits: function () {
  4607. this.colCoordCache.build();
  4608. this.rowCoordCache.build();
  4609. this.rowCoordCache.bottoms[this.rowCnt - 1] += this.bottomCoordPadding; // hack
  4610. },
  4611. releaseHits: function () {
  4612. this.colCoordCache.clear();
  4613. this.rowCoordCache.clear();
  4614. },
  4615. queryHit: function (leftOffset, topOffset) {
  4616. if (this.colCoordCache.isLeftInBounds(leftOffset) && this.rowCoordCache.isTopInBounds(topOffset)) {
  4617. var col = this.colCoordCache.getHorizontalIndex(leftOffset);
  4618. var row = this.rowCoordCache.getVerticalIndex(topOffset);
  4619. if (row != null && col != null) {
  4620. return this.getCellHit(row, col);
  4621. }
  4622. }
  4623. },
  4624. getHitSpan: function (hit) {
  4625. return this.getCellRange(hit.row, hit.col);
  4626. },
  4627. getHitEl: function (hit) {
  4628. return this.getCellEl(hit.row, hit.col);
  4629. },
  4630. /* Cell System
  4631. ------------------------------------------------------------------------------------------------------------------*/
  4632. // FYI: the first column is the leftmost column, regardless of date
  4633. getCellHit: function (row, col) {
  4634. return {
  4635. row: row,
  4636. col: col,
  4637. component: this, // needed unfortunately :(
  4638. left: this.colCoordCache.getLeftOffset(col),
  4639. right: this.colCoordCache.getRightOffset(col),
  4640. top: this.rowCoordCache.getTopOffset(row),
  4641. bottom: this.rowCoordCache.getBottomOffset(row)
  4642. };
  4643. },
  4644. getCellEl: function (row, col) {
  4645. return this.cellEls.eq(row * this.colCnt + col);
  4646. },
  4647. /* Event Drag Visualization
  4648. ------------------------------------------------------------------------------------------------------------------*/
  4649. // TODO: move to DayGrid.event, similar to what we did with Grid's drag methods
  4650. // Renders a visual indication of an event or external element being dragged.
  4651. // `eventLocation` has zoned start and end (optional)
  4652. renderDrag: function (eventLocation, seg) {
  4653. // always render a highlight underneath
  4654. this.renderHighlight(this.eventToSpan(eventLocation));
  4655. // if a segment from the same calendar but another component is being dragged, render a helper event
  4656. if (seg && seg.component !== this) {
  4657. return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
  4658. }
  4659. },
  4660. // Unrenders any visual indication of a hovering event
  4661. unrenderDrag: function () {
  4662. this.unrenderHighlight();
  4663. this.unrenderHelper();
  4664. },
  4665. /* Event Resize Visualization
  4666. ------------------------------------------------------------------------------------------------------------------*/
  4667. // Renders a visual indication of an event being resized
  4668. renderEventResize: function (eventLocation, seg) {
  4669. this.renderHighlight(this.eventToSpan(eventLocation));
  4670. return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
  4671. },
  4672. // Unrenders a visual indication of an event being resized
  4673. unrenderEventResize: function () {
  4674. this.unrenderHighlight();
  4675. this.unrenderHelper();
  4676. },
  4677. /* Event Helper
  4678. ------------------------------------------------------------------------------------------------------------------*/
  4679. // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null.
  4680. renderHelper: function (event, sourceSeg) {
  4681. var helperNodes = [];
  4682. var segs = this.eventToSegs(event);
  4683. var rowStructs;
  4684. segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered
  4685. rowStructs = this.renderSegRows(segs);
  4686. // inject each new event skeleton into each associated row
  4687. this.rowEls.each(function (row, rowNode) {
  4688. var rowEl = $(rowNode); // the .fc-row
  4689. var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned
  4690. var skeletonTop;
  4691. // If there is an original segment, match the top position. Otherwise, put it at the row's top level
  4692. if (sourceSeg && sourceSeg.row === row) {
  4693. skeletonTop = sourceSeg.el.position().top;
  4694. }
  4695. else {
  4696. skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top;
  4697. }
  4698. skeletonEl.css('top', skeletonTop)
  4699. .find('table')
  4700. .append(rowStructs[row].tbodyEl);
  4701. rowEl.append(skeletonEl);
  4702. helperNodes.push(skeletonEl[0]);
  4703. });
  4704. return ( // must return the elements rendered
  4705. this.helperEls = $(helperNodes) // array -> jQuery set
  4706. );
  4707. },
  4708. // Unrenders any visual indication of a mock helper event
  4709. unrenderHelper: function () {
  4710. if (this.helperEls) {
  4711. this.helperEls.remove();
  4712. this.helperEls = null;
  4713. }
  4714. },
  4715. /* Fill System (highlight, background events, business hours)
  4716. ------------------------------------------------------------------------------------------------------------------*/
  4717. fillSegTag: 'td', // override the default tag name
  4718. // Renders a set of rectangles over the given segments of days.
  4719. // Only returns segments that successfully rendered.
  4720. renderFill: function (type, segs, className) {
  4721. var nodes = [];
  4722. var i, seg;
  4723. var skeletonEl;
  4724. segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs
  4725. for (i = 0; i < segs.length; i++) {
  4726. seg = segs[i];
  4727. skeletonEl = this.renderFillRow(type, seg, className);
  4728. this.rowEls.eq(seg.row).append(skeletonEl);
  4729. nodes.push(skeletonEl[0]);
  4730. }
  4731. this.elsByFill[type] = $(nodes);
  4732. return segs;
  4733. },
  4734. // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered.
  4735. renderFillRow: function (type, seg, className) {
  4736. var colCnt = this.colCnt;
  4737. var startCol = seg.leftCol;
  4738. var endCol = seg.rightCol + 1;
  4739. var skeletonEl;
  4740. var trEl;
  4741. className = className || type.toLowerCase();
  4742. skeletonEl = $(
  4743. '<div class="fc-' + className + '-skeleton">' +
  4744. '<table><tr/></table>' +
  4745. '</div>'
  4746. );
  4747. trEl = skeletonEl.find('tr');
  4748. if (startCol > 0) {
  4749. trEl.append('<td colspan="' + startCol + '"/>');
  4750. }
  4751. trEl.append(
  4752. seg.el.attr('colspan', endCol - startCol)
  4753. );
  4754. if (endCol < colCnt) {
  4755. trEl.append('<td colspan="' + (colCnt - endCol) + '"/>');
  4756. }
  4757. this.bookendCells(trEl);
  4758. return skeletonEl;
  4759. }
  4760. });
  4761. ;;
  4762. /* Event-rendering methods for the DayGrid class
  4763. ----------------------------------------------------------------------------------------------------------------------*/
  4764. DayGrid.mixin({
  4765. rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering
  4766. // Unrenders all events currently rendered on the grid
  4767. unrenderEvents: function () {
  4768. this.removeSegPopover(); // removes the "more.." events popover
  4769. Grid.prototype.unrenderEvents.apply(this, arguments); // calls the super-method
  4770. },
  4771. // Retrieves all rendered segment objects currently rendered on the grid
  4772. getEventSegs: function () {
  4773. return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method
  4774. .concat(this.popoverSegs || []); // append the segments from the "more..." popover
  4775. },
  4776. // Renders the given background event segments onto the grid
  4777. renderBgSegs: function (segs) {
  4778. // don't render timed background events
  4779. var allDaySegs = $.grep(segs, function (seg) {
  4780. return seg.event.allDay;
  4781. });
  4782. return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method
  4783. },
  4784. // Renders the given foreground event segments onto the grid
  4785. renderFgSegs: function (segs) {
  4786. var rowStructs;
  4787. // render an `.el` on each seg
  4788. // returns a subset of the segs. segs that were actually rendered
  4789. segs = this.renderFgSegEls(segs);
  4790. rowStructs = this.rowStructs = this.renderSegRows(segs);
  4791. // append to each row's content skeleton
  4792. this.rowEls.each(function (i, rowNode) {
  4793. $(rowNode).find('.fc-content-skeleton > table').append(
  4794. rowStructs[i].tbodyEl
  4795. );
  4796. });
  4797. return segs; // return only the segs that were actually rendered
  4798. },
  4799. // Unrenders all currently rendered foreground event segments
  4800. unrenderFgSegs: function () {
  4801. var rowStructs = this.rowStructs || [];
  4802. var rowStruct;
  4803. while ((rowStruct = rowStructs.pop())) {
  4804. rowStruct.tbodyEl.remove();
  4805. }
  4806. this.rowStructs = null;
  4807. },
  4808. // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton.
  4809. // Returns an array of rowStruct objects (see the bottom of `renderSegRow`).
  4810. // PRECONDITION: each segment shoud already have a rendered and assigned `.el`
  4811. renderSegRows: function (segs) {
  4812. var rowStructs = [];
  4813. var segRows;
  4814. var row;
  4815. segRows = this.groupSegRows(segs); // group into nested arrays
  4816. // iterate each row of segment groupings
  4817. for (row = 0; row < segRows.length; row++) {
  4818. rowStructs.push(
  4819. this.renderSegRow(row, segRows[row])
  4820. );
  4821. }
  4822. return rowStructs;
  4823. },
  4824. // Builds the HTML to be used for the default element for an individual segment
  4825. fgSegHtml: function (seg, disableResizing) {
  4826. var view = this.view;
  4827. var event = seg.event;
  4828. var isDraggable = view.isEventDraggable(event);
  4829. var isResizableFromStart = !disableResizing && event.allDay &&
  4830. seg.isStart && view.isEventResizableFromStart(event);
  4831. var isResizableFromEnd = !disableResizing && event.allDay &&
  4832. seg.isEnd && view.isEventResizableFromEnd(event);
  4833. var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd);
  4834. var skinCss = cssToStr(this.getSegSkinCss(seg));
  4835. var timeHtml = '';
  4836. var timeText;
  4837. var titleHtml;
  4838. classes.unshift('fc-day-grid-event', 'fc-h-event');
  4839. // Only display a timed events time if it is the starting segment
  4840. if (seg.isStart) {
  4841. timeText = this.getEventTimeText(event);
  4842. if (timeText) {
  4843. timeHtml = '<span class="fc-time">' + htmlEscape(timeText) + '</span>';
  4844. }
  4845. }
  4846. titleHtml =
  4847. '<span class="fc-title">' +
  4848. (htmlEscape(event.title || '') || '&nbsp;') + // we always want one line of height
  4849. '</span>';
  4850. return '<a tooltips class="' + classes.join(' ') + '"' +
  4851. (event.url ?
  4852. ' href="' + htmlEscape(event.url) + '"' :
  4853. ''
  4854. ) +
  4855. (skinCss ?
  4856. ' style="' + skinCss + '"' :
  4857. ''
  4858. ) +
  4859. (event.content ?
  4860. ' title="' + htmlEscape(event.content) + '"' :
  4861. ''
  4862. ) +
  4863. '>' +
  4864. '<div class="fc-content">' +
  4865. (this.isRTL ?
  4866. titleHtml + ' ' + timeHtml : // put a natural space in between
  4867. timeHtml + ' ' + titleHtml //
  4868. ) +
  4869. '</div>' +
  4870. (isResizableFromStart ?
  4871. '<div class="fc-resizer fc-start-resizer" />' :
  4872. ''
  4873. ) +
  4874. (isResizableFromEnd ?
  4875. '<div class="fc-resizer fc-end-resizer" />' :
  4876. ''
  4877. ) +
  4878. '</a>';
  4879. },
  4880. // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains
  4881. // the segments. Returns object with a bunch of internal data about how the render was calculated.
  4882. // NOTE: modifies rowSegs
  4883. renderSegRow: function (row, rowSegs) {
  4884. var colCnt = this.colCnt;
  4885. var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels
  4886. var levelCnt = Math.max(1, segLevels.length); // ensure at least one level
  4887. var tbody = $('<tbody/>');
  4888. var segMatrix = []; // lookup for which segments are rendered into which level+col cells
  4889. var cellMatrix = []; // lookup for all <td> elements of the level+col matrix
  4890. var loneCellMatrix = []; // lookup for <td> elements that only take up a single column
  4891. var i, levelSegs;
  4892. var col;
  4893. var tr;
  4894. var j, seg;
  4895. var td;
  4896. // populates empty cells from the current column (`col`) to `endCol`
  4897. function emptyCellsUntil(endCol) {
  4898. while (col < endCol) {
  4899. // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell
  4900. td = (loneCellMatrix[i - 1] || [])[col];
  4901. if (td) {
  4902. td.attr(
  4903. 'rowspan',
  4904. parseInt(td.attr('rowspan') || 1, 10) + 1
  4905. );
  4906. }
  4907. else {
  4908. td = $('<td/>');
  4909. tr.append(td);
  4910. }
  4911. cellMatrix[i][col] = td;
  4912. loneCellMatrix[i][col] = td;
  4913. col++;
  4914. }
  4915. }
  4916. for (i = 0; i < levelCnt; i++) { // iterate through all levels
  4917. levelSegs = segLevels[i];
  4918. col = 0;
  4919. tr = $('<tr/>');
  4920. segMatrix.push([]);
  4921. cellMatrix.push([]);
  4922. loneCellMatrix.push([]);
  4923. // levelCnt might be 1 even though there are no actual levels. protect against this.
  4924. // this single empty row is useful for styling.
  4925. if (levelSegs) {
  4926. for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level
  4927. seg = levelSegs[j];
  4928. emptyCellsUntil(seg.leftCol);
  4929. // create a container that occupies or more columns. append the event element.
  4930. td = $('<td class="fc-event-container"/>').append(seg.el);
  4931. if (seg.leftCol != seg.rightCol) {
  4932. td.attr('colspan', seg.rightCol - seg.leftCol + 1);
  4933. }
  4934. else { // a single-column segment
  4935. loneCellMatrix[i][col] = td;
  4936. }
  4937. while (col <= seg.rightCol) {
  4938. cellMatrix[i][col] = td;
  4939. segMatrix[i][col] = seg;
  4940. col++;
  4941. }
  4942. tr.append(td);
  4943. }
  4944. }
  4945. emptyCellsUntil(colCnt); // finish off the row
  4946. this.bookendCells(tr);
  4947. tbody.append(tr);
  4948. }
  4949. return { // a "rowStruct"
  4950. row: row, // the row number
  4951. tbodyEl: tbody,
  4952. cellMatrix: cellMatrix,
  4953. segMatrix: segMatrix,
  4954. segLevels: segLevels,
  4955. segs: rowSegs
  4956. };
  4957. },
  4958. // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels.
  4959. // NOTE: modifies segs
  4960. buildSegLevels: function (segs) {
  4961. var levels = [];
  4962. var i, seg;
  4963. var j;
  4964. // Give preference to elements with certain criteria, so they have
  4965. // a chance to be closer to the top.
  4966. this.sortEventSegs(segs);
  4967. for (i = 0; i < segs.length; i++) {
  4968. seg = segs[i];
  4969. // loop through levels, starting with the topmost, until the segment doesn't collide with other segments
  4970. for (j = 0; j < levels.length; j++) {
  4971. if (!isDaySegCollision(seg, levels[j])) {
  4972. break;
  4973. }
  4974. }
  4975. // `j` now holds the desired subrow index
  4976. seg.level = j;
  4977. // create new level array if needed and append segment
  4978. (levels[j] || (levels[j] = [])).push(seg);
  4979. }
  4980. // order segments left-to-right. very important if calendar is RTL
  4981. for (j = 0; j < levels.length; j++) {
  4982. levels[j].sort(compareDaySegCols);
  4983. }
  4984. return levels;
  4985. },
  4986. // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row
  4987. groupSegRows: function (segs) {
  4988. var segRows = [];
  4989. var i;
  4990. for (i = 0; i < this.rowCnt; i++) {
  4991. segRows.push([]);
  4992. }
  4993. for (i = 0; i < segs.length; i++) {
  4994. segRows[segs[i].row].push(segs[i]);
  4995. }
  4996. return segRows;
  4997. }
  4998. });
  4999. // Computes whether two segments' columns collide. They are assumed to be in the same row.
  5000. function isDaySegCollision(seg, otherSegs) {
  5001. var i, otherSeg;
  5002. for (i = 0; i < otherSegs.length; i++) {
  5003. otherSeg = otherSegs[i];
  5004. if (
  5005. otherSeg.leftCol <= seg.rightCol &&
  5006. otherSeg.rightCol >= seg.leftCol
  5007. ) {
  5008. return true;
  5009. }
  5010. }
  5011. return false;
  5012. }
  5013. // A cmp function for determining the leftmost event
  5014. function compareDaySegCols(a, b) {
  5015. return a.leftCol - b.leftCol;
  5016. }
  5017. ;;
  5018. /* Methods relate to limiting the number events for a given day on a DayGrid
  5019. ----------------------------------------------------------------------------------------------------------------------*/
  5020. // NOTE: all the segs being passed around in here are foreground segs
  5021. DayGrid.mixin({
  5022. segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible
  5023. popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible
  5024. removeSegPopover: function () {
  5025. if (this.segPopover) {
  5026. this.segPopover.hide(); // in handler, will call segPopover's removeElement
  5027. }
  5028. },
  5029. // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid.
  5030. // `levelLimit` can be false (don't limit), a number, or true (should be computed).
  5031. limitRows: function (levelLimit) {
  5032. var rowStructs = this.rowStructs || [];
  5033. var row; // row #
  5034. var rowLevelLimit;
  5035. for (row = 0; row < rowStructs.length; row++) {
  5036. this.unlimitRow(row);
  5037. if (!levelLimit) {
  5038. rowLevelLimit = false;
  5039. }
  5040. else if (typeof levelLimit === 'number') {
  5041. rowLevelLimit = levelLimit;
  5042. }
  5043. else {
  5044. rowLevelLimit = this.computeRowLevelLimit(row);
  5045. }
  5046. if (rowLevelLimit !== false) {
  5047. this.limitRow(row, rowLevelLimit);
  5048. }
  5049. }
  5050. },
  5051. // Computes the number of levels a row will accomodate without going outside its bounds.
  5052. // Assumes the row is "rigid" (maintains a constant height regardless of what is inside).
  5053. // `row` is the row number.
  5054. computeRowLevelLimit: function (row) {
  5055. var rowEl = this.rowEls.eq(row); // the containing "fake" row div
  5056. var rowHeight = rowEl.height(); // TODO: cache somehow?
  5057. var trEls = this.rowStructs[row].tbodyEl.children();
  5058. var i, trEl;
  5059. var trHeight;
  5060. function iterInnerHeights(i, childNode) {
  5061. trHeight = Math.max(trHeight, $(childNode).outerHeight());
  5062. }
  5063. // Reveal one level <tr> at a time and stop when we find one out of bounds
  5064. for (i = 0; i < trEls.length; i++) {
  5065. trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal)
  5066. // with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell,
  5067. // so instead, find the tallest inner content element.
  5068. trHeight = 0;
  5069. trEl.find('> td > :first-child').each(iterInnerHeights);
  5070. if (trEl.position().top + trHeight > rowHeight) {
  5071. return i;
  5072. }
  5073. }
  5074. return false; // should not limit at all
  5075. },
  5076. // Limits the given grid row to the maximum number of levels and injects "more" links if necessary.
  5077. // `row` is the row number.
  5078. // `levelLimit` is a number for the maximum (inclusive) number of levels allowed.
  5079. limitRow: function (row, levelLimit) {
  5080. var _this = this;
  5081. var rowStruct = this.rowStructs[row];
  5082. var moreNodes = []; // array of "more" <a> links and <td> DOM nodes
  5083. var col = 0; // col #, left-to-right (not chronologically)
  5084. var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right
  5085. var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row
  5086. var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes
  5087. var i, seg;
  5088. var segsBelow; // array of segment objects below `seg` in the current `col`
  5089. var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies
  5090. var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column)
  5091. var td, rowspan;
  5092. var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell
  5093. var j;
  5094. var moreTd, moreWrap, moreLink;
  5095. // Iterates through empty level cells and places "more" links inside if need be
  5096. function emptyCellsUntil(endCol) { // goes from current `col` to `endCol`
  5097. while (col < endCol) {
  5098. segsBelow = _this.getCellSegs(row, col, levelLimit);
  5099. if (segsBelow.length) {
  5100. td = cellMatrix[levelLimit - 1][col];
  5101. moreLink = _this.renderMoreLink(row, col, segsBelow);
  5102. moreWrap = $('<div/>').append(moreLink);
  5103. td.append(moreWrap);
  5104. moreNodes.push(moreWrap[0]);
  5105. }
  5106. col++;
  5107. }
  5108. }
  5109. if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit?
  5110. levelSegs = rowStruct.segLevels[levelLimit - 1];
  5111. cellMatrix = rowStruct.cellMatrix;
  5112. limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit
  5113. .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array
  5114. // iterate though segments in the last allowable level
  5115. for (i = 0; i < levelSegs.length; i++) {
  5116. seg = levelSegs[i];
  5117. emptyCellsUntil(seg.leftCol); // process empty cells before the segment
  5118. // determine *all* segments below `seg` that occupy the same columns
  5119. colSegsBelow = [];
  5120. totalSegsBelow = 0;
  5121. while (col <= seg.rightCol) {
  5122. segsBelow = this.getCellSegs(row, col, levelLimit);
  5123. colSegsBelow.push(segsBelow);
  5124. totalSegsBelow += segsBelow.length;
  5125. col++;
  5126. }
  5127. if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links?
  5128. td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell
  5129. rowspan = td.attr('rowspan') || 1;
  5130. segMoreNodes = [];
  5131. // make a replacement <td> for each column the segment occupies. will be one for each colspan
  5132. for (j = 0; j < colSegsBelow.length; j++) {
  5133. moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan);
  5134. segsBelow = colSegsBelow[j];
  5135. moreLink = this.renderMoreLink(
  5136. row,
  5137. seg.leftCol + j,
  5138. [seg].concat(segsBelow) // count seg as hidden too
  5139. );
  5140. moreWrap = $('<div/>').append(moreLink);
  5141. moreTd.append(moreWrap);
  5142. segMoreNodes.push(moreTd[0]);
  5143. moreNodes.push(moreTd[0]);
  5144. }
  5145. td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements
  5146. limitedNodes.push(td[0]);
  5147. }
  5148. }
  5149. emptyCellsUntil(this.colCnt); // finish off the level
  5150. rowStruct.moreEls = $(moreNodes); // for easy undoing later
  5151. rowStruct.limitedEls = $(limitedNodes); // for easy undoing later
  5152. }
  5153. },
  5154. // Reveals all levels and removes all "more"-related elements for a grid's row.
  5155. // `row` is a row number.
  5156. unlimitRow: function (row) {
  5157. var rowStruct = this.rowStructs[row];
  5158. if (rowStruct.moreEls) {
  5159. rowStruct.moreEls.remove();
  5160. rowStruct.moreEls = null;
  5161. }
  5162. if (rowStruct.limitedEls) {
  5163. rowStruct.limitedEls.removeClass('fc-limited');
  5164. rowStruct.limitedEls = null;
  5165. }
  5166. },
  5167. // Renders an <a> element that represents hidden event element for a cell.
  5168. // Responsible for attaching click handler as well.
  5169. renderMoreLink: function (row, col, hiddenSegs) {
  5170. var _this = this;
  5171. var view = this.view;
  5172. return $('<a class="fc-more"/>')
  5173. .text(
  5174. this.getMoreLinkText(hiddenSegs.length)
  5175. )
  5176. .on('click', function (ev) {
  5177. var clickOption = view.opt('eventLimitClick');
  5178. var date = _this.getCellDate(row, col);
  5179. var moreEl = $(this);
  5180. var dayEl = _this.getCellEl(row, col);
  5181. var allSegs = _this.getCellSegs(row, col);
  5182. // rescope the segments to be within the cell's date
  5183. var reslicedAllSegs = _this.resliceDaySegs(allSegs, date);
  5184. var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date);
  5185. if (typeof clickOption === 'function') {
  5186. // the returned value can be an atomic option
  5187. clickOption = view.publiclyTrigger('eventLimitClick', null, {
  5188. date: date,
  5189. dayEl: dayEl,
  5190. moreEl: moreEl,
  5191. segs: reslicedAllSegs,
  5192. hiddenSegs: reslicedHiddenSegs
  5193. }, ev);
  5194. }
  5195. if (clickOption === 'popover') {
  5196. _this.showSegPopover(row, col, moreEl, reslicedAllSegs);
  5197. }
  5198. else if (typeof clickOption === 'string') { // a view name
  5199. view.calendar.zoomTo(date, clickOption);
  5200. }
  5201. });
  5202. },
  5203. // Reveals the popover that displays all events within a cell
  5204. showSegPopover: function (row, col, moreLink, segs) {
  5205. var _this = this;
  5206. var view = this.view;
  5207. var moreWrap = moreLink.parent(); // the <div> wrapper around the <a>
  5208. var topEl; // the element we want to match the top coordinate of
  5209. var options;
  5210. if (this.rowCnt == 1) {
  5211. topEl = view.el; // will cause the popover to cover any sort of header
  5212. }
  5213. else {
  5214. topEl = this.rowEls.eq(row); // will align with top of row
  5215. }
  5216. options = {
  5217. className: 'fc-more-popover',
  5218. content: this.renderSegPopoverContent(row, col, segs),
  5219. parentEl: this.view.el, // attach to root of view. guarantees outside of scrollbars.
  5220. top: topEl.offset().top,
  5221. autoHide: true, // when the user clicks elsewhere, hide the popover
  5222. viewportConstrain: view.opt('popoverViewportConstrain'),
  5223. hide: function () {
  5224. // kill everything when the popover is hidden
  5225. // notify events to be removed
  5226. if (_this.popoverSegs) {
  5227. var seg;
  5228. for (var i = 0; i < _this.popoverSegs.length; ++i) {
  5229. seg = _this.popoverSegs[i];
  5230. view.publiclyTrigger('eventDestroy', seg.event, seg.event, seg.el);
  5231. }
  5232. }
  5233. _this.segPopover.removeElement();
  5234. _this.segPopover = null;
  5235. _this.popoverSegs = null;
  5236. }
  5237. };
  5238. // Determine horizontal coordinate.
  5239. // We use the moreWrap instead of the <td> to avoid border confusion.
  5240. if (this.isRTL) {
  5241. options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border
  5242. }
  5243. else {
  5244. options.left = moreWrap.offset().left - 1; // -1 to be over cell border
  5245. }
  5246. this.segPopover = new Popover(options);
  5247. this.segPopover.show();
  5248. // the popover doesn't live within the grid's container element, and thus won't get the event
  5249. // delegated-handlers for free. attach event-related handlers to the popover.
  5250. this.bindSegHandlersToEl(this.segPopover.el);
  5251. },
  5252. // Builds the inner DOM contents of the segment popover
  5253. renderSegPopoverContent: function (row, col, segs) {
  5254. var view = this.view;
  5255. var isTheme = view.opt('theme');
  5256. var title = this.getCellDate(row, col).format(view.opt('dayPopoverFormat'));
  5257. var content = $(
  5258. '<div class="fc-header ' + view.widgetHeaderClass + '">' +
  5259. '<span class="fc-close ' +
  5260. (isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') +
  5261. '"></span>' +
  5262. '<span class="fc-title">' +
  5263. htmlEscape(title) +
  5264. '</span>' +
  5265. '<div class="fc-clear"/>' +
  5266. '</div>' +
  5267. '<div class="fc-body ' + view.widgetContentClass + '">' +
  5268. '<div class="fc-event-container"></div>' +
  5269. '</div>'
  5270. );
  5271. var segContainer = content.find('.fc-event-container');
  5272. var i;
  5273. // render each seg's `el` and only return the visible segs
  5274. segs = this.renderFgSegEls(segs, true); // disableResizing=true
  5275. this.popoverSegs = segs;
  5276. for (i = 0; i < segs.length; i++) {
  5277. // because segments in the popover are not part of a grid coordinate system, provide a hint to any
  5278. // grids that want to do drag-n-drop about which cell it came from
  5279. this.prepareHits();
  5280. segs[i].hit = this.getCellHit(row, col);
  5281. this.releaseHits();
  5282. segContainer.append(segs[i].el);
  5283. }
  5284. return content;
  5285. },
  5286. // Given the events within an array of segment objects, reslice them to be in a single day
  5287. resliceDaySegs: function (segs, dayDate) {
  5288. // build an array of the original events
  5289. var events = $.map(segs, function (seg) {
  5290. return seg.event;
  5291. });
  5292. var dayStart = dayDate.clone();
  5293. var dayEnd = dayStart.clone().add(1, 'days');
  5294. var dayRange = { start: dayStart, end: dayEnd };
  5295. // slice the events with a custom slicing function
  5296. segs = this.eventsToSegs(
  5297. events,
  5298. function (range) {
  5299. var seg = intersectRanges(range, dayRange); // undefind if no intersection
  5300. return seg ? [seg] : []; // must return an array of segments
  5301. }
  5302. );
  5303. // force an order because eventsToSegs doesn't guarantee one
  5304. this.sortEventSegs(segs);
  5305. return segs;
  5306. },
  5307. // Generates the text that should be inside a "more" link, given the number of events it represents
  5308. getMoreLinkText: function (num) {
  5309. var opt = this.view.opt('eventLimitText');
  5310. if (typeof opt === 'function') {
  5311. return opt(num);
  5312. }
  5313. else {
  5314. return '+' + num + ' ' + opt;
  5315. }
  5316. },
  5317. // Returns segments within a given cell.
  5318. // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs.
  5319. getCellSegs: function (row, col, startLevel) {
  5320. var segMatrix = this.rowStructs[row].segMatrix;
  5321. var level = startLevel || 0;
  5322. var segs = [];
  5323. var seg;
  5324. while (level < segMatrix.length) {
  5325. seg = segMatrix[level][col];
  5326. if (seg) {
  5327. segs.push(seg);
  5328. }
  5329. level++;
  5330. }
  5331. return segs;
  5332. }
  5333. });
  5334. ;;
  5335. /* A component that renders one or more columns of vertical time slots
  5336. ----------------------------------------------------------------------------------------------------------------------*/
  5337. // We mixin DayTable, even though there is only a single row of days
  5338. var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
  5339. slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines
  5340. snapDuration: null, // granularity of time for dragging and selecting
  5341. snapsPerSlot: null,
  5342. minTime: null, // Duration object that denotes the first visible time of any given day
  5343. maxTime: null, // Duration object that denotes the exclusive visible end time of any given day
  5344. labelFormat: null, // formatting string for times running along vertical axis
  5345. labelInterval: null, // duration of how often a label should be displayed for a slot
  5346. colEls: null, // cells elements in the day-row background
  5347. slatContainerEl: null, // div that wraps all the slat rows
  5348. slatEls: null, // elements running horizontally across all columns
  5349. nowIndicatorEls: null,
  5350. colCoordCache: null,
  5351. slatCoordCache: null,
  5352. constructor: function () {
  5353. Grid.apply(this, arguments); // call the super-constructor
  5354. this.processOptions();
  5355. },
  5356. // Renders the time grid into `this.el`, which should already be assigned.
  5357. // Relies on the view's colCnt. In the future, this component should probably be self-sufficient.
  5358. renderDates: function () {
  5359. this.el.html(this.renderHtml());
  5360. this.colEls = this.el.find('.fc-day');
  5361. this.slatContainerEl = this.el.find('.fc-slats');
  5362. this.slatEls = this.slatContainerEl.find('tr');
  5363. this.colCoordCache = new CoordCache({
  5364. els: this.colEls,
  5365. isHorizontal: true
  5366. });
  5367. this.slatCoordCache = new CoordCache({
  5368. els: this.slatEls,
  5369. isVertical: true
  5370. });
  5371. this.renderContentSkeleton();
  5372. },
  5373. // Renders the basic HTML skeleton for the grid
  5374. renderHtml: function () {
  5375. return '' +
  5376. '<div class="fc-bg">' +
  5377. '<table>' +
  5378. this.renderBgTrHtml(0) + // row=0
  5379. '</table>' +
  5380. '</div>' +
  5381. '<div class="fc-slats">' +
  5382. '<table>' +
  5383. this.renderSlatRowHtml() +
  5384. '</table>' +
  5385. '</div>';
  5386. },
  5387. // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL.
  5388. renderSlatRowHtml: function () {
  5389. var view = this.view;
  5390. var isRTL = this.isRTL;
  5391. var html = '';
  5392. var slotTime = moment.duration(+this.minTime); // wish there was .clone() for durations
  5393. var slotDate; // will be on the view's first day, but we only care about its time
  5394. var isLabeled;
  5395. var axisHtml;
  5396. // Calculate the time for each slot
  5397. while (slotTime < this.maxTime) {
  5398. slotDate = this.start.clone().time(slotTime);
  5399. isLabeled = isInt(divideDurationByDuration(slotTime, this.labelInterval));
  5400. axisHtml =
  5401. '<td class="fc-axis fc-time ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' +
  5402. (isLabeled ?
  5403. '<span>' + // for matchCellWidths
  5404. htmlEscape(slotDate.format(this.labelFormat)) +
  5405. '</span>' :
  5406. ''
  5407. ) +
  5408. '</td>';
  5409. html +=
  5410. '<tr data-time="' + slotDate.format('HH:mm:ss') + '"' +
  5411. (isLabeled ? '' : ' class="fc-minor"') +
  5412. '>' +
  5413. (!isRTL ? axisHtml : '') +
  5414. '<td class="' + view.widgetContentClass + '"/>' +
  5415. (isRTL ? axisHtml : '') +
  5416. "</tr>";
  5417. slotTime.add(this.slotDuration);
  5418. }
  5419. return html;
  5420. },
  5421. /* Options
  5422. ------------------------------------------------------------------------------------------------------------------*/
  5423. // Parses various options into properties of this object
  5424. processOptions: function () {
  5425. var view = this.view;
  5426. var slotDuration = view.opt('slotDuration');
  5427. var snapDuration = view.opt('snapDuration');
  5428. var input;
  5429. slotDuration = moment.duration(slotDuration);
  5430. snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration;
  5431. this.slotDuration = slotDuration;
  5432. this.snapDuration = snapDuration;
  5433. this.snapsPerSlot = slotDuration / snapDuration; // TODO: ensure an integer multiple?
  5434. this.minResizeDuration = snapDuration; // hack
  5435. this.minTime = moment.duration(view.opt('minTime'));
  5436. this.maxTime = moment.duration(view.opt('maxTime'));
  5437. // might be an array value (for TimelineView).
  5438. // if so, getting the most granular entry (the last one probably).
  5439. input = view.opt('slotLabelFormat');
  5440. if ($.isArray(input)) {
  5441. input = input[input.length - 1];
  5442. }
  5443. this.labelFormat =
  5444. input ||
  5445. view.opt('smallTimeFormat'); // the computed default
  5446. input = view.opt('slotLabelInterval');
  5447. this.labelInterval = input ?
  5448. moment.duration(input) :
  5449. this.computeLabelInterval(slotDuration);
  5450. },
  5451. // Computes an automatic value for slotLabelInterval
  5452. computeLabelInterval: function (slotDuration) {
  5453. var i;
  5454. var labelInterval;
  5455. var slotsPerLabel;
  5456. // find the smallest stock label interval that results in more than one slots-per-label
  5457. for (i = AGENDA_STOCK_SUB_DURATIONS.length - 1; i >= 0; i--) {
  5458. labelInterval = moment.duration(AGENDA_STOCK_SUB_DURATIONS[i]);
  5459. slotsPerLabel = divideDurationByDuration(labelInterval, slotDuration);
  5460. if (isInt(slotsPerLabel) && slotsPerLabel > 1) {
  5461. return labelInterval;
  5462. }
  5463. }
  5464. return moment.duration(slotDuration); // fall back. clone
  5465. },
  5466. // Computes a default event time formatting string if `timeFormat` is not explicitly defined
  5467. computeEventTimeFormat: function () {
  5468. return this.view.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM)
  5469. },
  5470. // Computes a default `displayEventEnd` value if one is not expliclty defined
  5471. computeDisplayEventEnd: function () {
  5472. return true;
  5473. },
  5474. /* Hit System
  5475. ------------------------------------------------------------------------------------------------------------------*/
  5476. prepareHits: function () {
  5477. this.colCoordCache.build();
  5478. this.slatCoordCache.build();
  5479. },
  5480. releaseHits: function () {
  5481. this.colCoordCache.clear();
  5482. // NOTE: don't clear slatCoordCache because we rely on it for computeTimeTop
  5483. },
  5484. queryHit: function (leftOffset, topOffset) {
  5485. var snapsPerSlot = this.snapsPerSlot;
  5486. var colCoordCache = this.colCoordCache;
  5487. var slatCoordCache = this.slatCoordCache;
  5488. if (colCoordCache.isLeftInBounds(leftOffset) && slatCoordCache.isTopInBounds(topOffset)) {
  5489. var colIndex = colCoordCache.getHorizontalIndex(leftOffset);
  5490. var slatIndex = slatCoordCache.getVerticalIndex(topOffset);
  5491. if (colIndex != null && slatIndex != null) {
  5492. var slatTop = slatCoordCache.getTopOffset(slatIndex);
  5493. var slatHeight = slatCoordCache.getHeight(slatIndex);
  5494. var partial = (topOffset - slatTop) / slatHeight; // floating point number between 0 and 1
  5495. var localSnapIndex = Math.floor(partial * snapsPerSlot); // the snap # relative to start of slat
  5496. var snapIndex = slatIndex * snapsPerSlot + localSnapIndex;
  5497. var snapTop = slatTop + (localSnapIndex / snapsPerSlot) * slatHeight;
  5498. var snapBottom = slatTop + ((localSnapIndex + 1) / snapsPerSlot) * slatHeight;
  5499. return {
  5500. col: colIndex,
  5501. snap: snapIndex,
  5502. component: this, // needed unfortunately :(
  5503. left: colCoordCache.getLeftOffset(colIndex),
  5504. right: colCoordCache.getRightOffset(colIndex),
  5505. top: snapTop,
  5506. bottom: snapBottom
  5507. };
  5508. }
  5509. }
  5510. },
  5511. getHitSpan: function (hit) {
  5512. var start = this.getCellDate(0, hit.col); // row=0
  5513. var time = this.computeSnapTime(hit.snap); // pass in the snap-index
  5514. var end;
  5515. start.time(time);
  5516. end = start.clone().add(this.snapDuration);
  5517. return { start: start, end: end };
  5518. },
  5519. getHitEl: function (hit) {
  5520. return this.colEls.eq(hit.col);
  5521. },
  5522. /* Dates
  5523. ------------------------------------------------------------------------------------------------------------------*/
  5524. rangeUpdated: function () {
  5525. this.updateDayTable();
  5526. },
  5527. // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day
  5528. computeSnapTime: function (snapIndex) {
  5529. return moment.duration(this.minTime + this.snapDuration * snapIndex);
  5530. },
  5531. // Slices up the given span (unzoned start/end with other misc data) into an array of segments
  5532. spanToSegs: function (span) {
  5533. var segs = this.sliceRangeByTimes(span);
  5534. var i;
  5535. for (i = 0; i < segs.length; i++) {
  5536. if (this.isRTL) {
  5537. segs[i].col = this.daysPerRow - 1 - segs[i].dayIndex;
  5538. }
  5539. else {
  5540. segs[i].col = segs[i].dayIndex;
  5541. }
  5542. }
  5543. return segs;
  5544. },
  5545. sliceRangeByTimes: function (range) {
  5546. var segs = [];
  5547. var seg;
  5548. var dayIndex;
  5549. var dayDate;
  5550. var dayRange;
  5551. for (dayIndex = 0; dayIndex < this.daysPerRow; dayIndex++) {
  5552. dayDate = this.dayDates[dayIndex].clone(); // TODO: better API for this?
  5553. dayRange = {
  5554. start: dayDate.clone().time(this.minTime),
  5555. end: dayDate.clone().time(this.maxTime)
  5556. };
  5557. seg = intersectRanges(range, dayRange); // both will be ambig timezone
  5558. if (seg) {
  5559. seg.dayIndex = dayIndex;
  5560. segs.push(seg);
  5561. }
  5562. }
  5563. return segs;
  5564. },
  5565. /* Coordinates
  5566. ------------------------------------------------------------------------------------------------------------------*/
  5567. updateSize: function (isResize) { // NOT a standard Grid method
  5568. this.slatCoordCache.build();
  5569. if (isResize) {
  5570. this.updateSegVerticals(
  5571. [].concat(this.fgSegs || [], this.bgSegs || [], this.businessSegs || [])
  5572. );
  5573. }
  5574. },
  5575. getTotalSlatHeight: function () {
  5576. return this.slatContainerEl.outerHeight();
  5577. },
  5578. // Computes the top coordinate, relative to the bounds of the grid, of the given date.
  5579. // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight.
  5580. computeDateTop: function (date, startOfDayDate) {
  5581. return this.computeTimeTop(
  5582. moment.duration(
  5583. date - startOfDayDate.clone().stripTime()
  5584. )
  5585. );
  5586. },
  5587. // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration).
  5588. computeTimeTop: function (time) {
  5589. var len = this.slatEls.length;
  5590. var slatCoverage = (time - this.minTime) / this.slotDuration; // floating-point value of # of slots covered
  5591. var slatIndex;
  5592. var slatRemainder;
  5593. // compute a floating-point number for how many slats should be progressed through.
  5594. // from 0 to number of slats (inclusive)
  5595. // constrained because minTime/maxTime might be customized.
  5596. slatCoverage = Math.max(0, slatCoverage);
  5597. slatCoverage = Math.min(len, slatCoverage);
  5598. // an integer index of the furthest whole slat
  5599. // from 0 to number slats (*exclusive*, so len-1)
  5600. slatIndex = Math.floor(slatCoverage);
  5601. slatIndex = Math.min(slatIndex, len - 1);
  5602. // how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition.
  5603. // could be 1.0 if slatCoverage is covering *all* the slots
  5604. slatRemainder = slatCoverage - slatIndex;
  5605. return this.slatCoordCache.getTopPosition(slatIndex) +
  5606. this.slatCoordCache.getHeight(slatIndex) * slatRemainder;
  5607. },
  5608. /* Event Drag Visualization
  5609. ------------------------------------------------------------------------------------------------------------------*/
  5610. // Renders a visual indication of an event being dragged over the specified date(s).
  5611. // A returned value of `true` signals that a mock "helper" event has been rendered.
  5612. renderDrag: function (eventLocation, seg) {
  5613. if (seg) { // if there is event information for this drag, render a helper event
  5614. // returns mock event elements
  5615. // signal that a helper has been rendered
  5616. return this.renderEventLocationHelper(eventLocation, seg);
  5617. }
  5618. else {
  5619. // otherwise, just render a highlight
  5620. this.renderHighlight(this.eventToSpan(eventLocation));
  5621. }
  5622. },
  5623. // Unrenders any visual indication of an event being dragged
  5624. unrenderDrag: function () {
  5625. this.unrenderHelper();
  5626. this.unrenderHighlight();
  5627. },
  5628. /* Event Resize Visualization
  5629. ------------------------------------------------------------------------------------------------------------------*/
  5630. // Renders a visual indication of an event being resized
  5631. renderEventResize: function (eventLocation, seg) {
  5632. return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
  5633. },
  5634. // Unrenders any visual indication of an event being resized
  5635. unrenderEventResize: function () {
  5636. this.unrenderHelper();
  5637. },
  5638. /* Event Helper
  5639. ------------------------------------------------------------------------------------------------------------------*/
  5640. // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag)
  5641. renderHelper: function (event, sourceSeg) {
  5642. return this.renderHelperSegs(this.eventToSegs(event), sourceSeg); // returns mock event elements
  5643. },
  5644. // Unrenders any mock helper event
  5645. unrenderHelper: function () {
  5646. this.unrenderHelperSegs();
  5647. },
  5648. /* Business Hours
  5649. ------------------------------------------------------------------------------------------------------------------*/
  5650. renderBusinessHours: function () {
  5651. this.renderBusinessSegs(
  5652. this.buildBusinessHourSegs()
  5653. );
  5654. },
  5655. unrenderBusinessHours: function () {
  5656. this.unrenderBusinessSegs();
  5657. },
  5658. /* Now Indicator
  5659. ------------------------------------------------------------------------------------------------------------------*/
  5660. getNowIndicatorUnit: function () {
  5661. return 'minute'; // will refresh on the minute
  5662. },
  5663. renderNowIndicator: function (date) {
  5664. // seg system might be overkill, but it handles scenario where line needs to be rendered
  5665. // more than once because of columns with the same date (resources columns for example)
  5666. var segs = this.spanToSegs({ start: date, end: date });
  5667. var top = this.computeDateTop(date, date);
  5668. var nodes = [];
  5669. var i;
  5670. // render lines within the columns
  5671. for (i = 0; i < segs.length; i++) {
  5672. nodes.push($('<div class="fc-now-indicator fc-now-indicator-line"></div>')
  5673. .css('top', top)
  5674. .appendTo(this.colContainerEls.eq(segs[i].col))[0]);
  5675. }
  5676. // render an arrow over the axis
  5677. if (segs.length > 0) { // is the current time in view?
  5678. nodes.push($('<div class="fc-now-indicator fc-now-indicator-arrow"></div>')
  5679. .css('top', top)
  5680. .appendTo(this.el.find('.fc-content-skeleton'))[0]);
  5681. }
  5682. this.nowIndicatorEls = $(nodes);
  5683. },
  5684. unrenderNowIndicator: function () {
  5685. if (this.nowIndicatorEls) {
  5686. this.nowIndicatorEls.remove();
  5687. this.nowIndicatorEls = null;
  5688. }
  5689. },
  5690. /* Selection
  5691. ------------------------------------------------------------------------------------------------------------------*/
  5692. // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight.
  5693. renderSelection: function (span) {
  5694. if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered
  5695. // normally acceps an eventLocation, span has a start/end, which is good enough
  5696. this.renderEventLocationHelper(span);
  5697. }
  5698. else {
  5699. this.renderHighlight(span);
  5700. }
  5701. },
  5702. // Unrenders any visual indication of a selection
  5703. unrenderSelection: function () {
  5704. this.unrenderHelper();
  5705. this.unrenderHighlight();
  5706. },
  5707. /* Highlight
  5708. ------------------------------------------------------------------------------------------------------------------*/
  5709. renderHighlight: function (span) {
  5710. this.renderHighlightSegs(this.spanToSegs(span));
  5711. },
  5712. unrenderHighlight: function () {
  5713. this.unrenderHighlightSegs();
  5714. }
  5715. });
  5716. ;;
  5717. /* Methods for rendering SEGMENTS, pieces of content that live on the view
  5718. ( this file is no longer just for events )
  5719. ----------------------------------------------------------------------------------------------------------------------*/
  5720. TimeGrid.mixin({
  5721. colContainerEls: null, // containers for each column
  5722. // inner-containers for each column where different types of segs live
  5723. fgContainerEls: null,
  5724. bgContainerEls: null,
  5725. helperContainerEls: null,
  5726. highlightContainerEls: null,
  5727. businessContainerEls: null,
  5728. // arrays of different types of displayed segments
  5729. fgSegs: null,
  5730. bgSegs: null,
  5731. helperSegs: null,
  5732. highlightSegs: null,
  5733. businessSegs: null,
  5734. // Renders the DOM that the view's content will live in
  5735. renderContentSkeleton: function () {
  5736. var cellHtml = '';
  5737. var i;
  5738. var skeletonEl;
  5739. for (i = 0; i < this.colCnt; i++) {
  5740. cellHtml +=
  5741. '<td>' +
  5742. '<div class="fc-content-col">' +
  5743. '<div class="fc-event-container fc-helper-container"></div>' +
  5744. '<div class="fc-event-container"></div>' +
  5745. '<div class="fc-highlight-container"></div>' +
  5746. '<div class="fc-bgevent-container"></div>' +
  5747. '<div class="fc-business-container"></div>' +
  5748. '</div>' +
  5749. '</td>';
  5750. }
  5751. skeletonEl = $(
  5752. '<div class="fc-content-skeleton">' +
  5753. '<table>' +
  5754. '<tr>' + cellHtml + '</tr>' +
  5755. '</table>' +
  5756. '</div>'
  5757. );
  5758. this.colContainerEls = skeletonEl.find('.fc-content-col');
  5759. this.helperContainerEls = skeletonEl.find('.fc-helper-container');
  5760. this.fgContainerEls = skeletonEl.find('.fc-event-container:not(.fc-helper-container)');
  5761. this.bgContainerEls = skeletonEl.find('.fc-bgevent-container');
  5762. this.highlightContainerEls = skeletonEl.find('.fc-highlight-container');
  5763. this.businessContainerEls = skeletonEl.find('.fc-business-container');
  5764. this.bookendCells(skeletonEl.find('tr')); // TODO: do this on string level
  5765. this.el.append(skeletonEl);
  5766. },
  5767. /* Foreground Events
  5768. ------------------------------------------------------------------------------------------------------------------*/
  5769. renderFgSegs: function (segs) {
  5770. segs = this.renderFgSegsIntoContainers(segs, this.fgContainerEls);
  5771. this.fgSegs = segs;
  5772. return segs; // needed for Grid::renderEvents
  5773. },
  5774. unrenderFgSegs: function () {
  5775. this.unrenderNamedSegs('fgSegs');
  5776. },
  5777. /* Foreground Helper Events
  5778. ------------------------------------------------------------------------------------------------------------------*/
  5779. renderHelperSegs: function (segs, sourceSeg) {
  5780. var helperEls = [];
  5781. var i, seg;
  5782. var sourceEl;
  5783. segs = this.renderFgSegsIntoContainers(segs, this.helperContainerEls);
  5784. // Try to make the segment that is in the same row as sourceSeg look the same
  5785. for (i = 0; i < segs.length; i++) {
  5786. seg = segs[i];
  5787. if (sourceSeg && sourceSeg.col === seg.col) {
  5788. sourceEl = sourceSeg.el;
  5789. seg.el.css({
  5790. left: sourceEl.css('left'),
  5791. right: sourceEl.css('right'),
  5792. 'margin-left': sourceEl.css('margin-left'),
  5793. 'margin-right': sourceEl.css('margin-right')
  5794. });
  5795. }
  5796. helperEls.push(seg.el[0]);
  5797. }
  5798. this.helperSegs = segs;
  5799. return $(helperEls); // must return rendered helpers
  5800. },
  5801. unrenderHelperSegs: function () {
  5802. this.unrenderNamedSegs('helperSegs');
  5803. },
  5804. /* Background Events
  5805. ------------------------------------------------------------------------------------------------------------------*/
  5806. renderBgSegs: function (segs) {
  5807. segs = this.renderFillSegEls('bgEvent', segs); // TODO: old fill system
  5808. this.updateSegVerticals(segs);
  5809. this.attachSegsByCol(this.groupSegsByCol(segs), this.bgContainerEls);
  5810. this.bgSegs = segs;
  5811. return segs; // needed for Grid::renderEvents
  5812. },
  5813. unrenderBgSegs: function () {
  5814. this.unrenderNamedSegs('bgSegs');
  5815. },
  5816. /* Highlight
  5817. ------------------------------------------------------------------------------------------------------------------*/
  5818. renderHighlightSegs: function (segs) {
  5819. segs = this.renderFillSegEls('highlight', segs); // TODO: old fill system
  5820. this.updateSegVerticals(segs);
  5821. this.attachSegsByCol(this.groupSegsByCol(segs), this.highlightContainerEls);
  5822. this.highlightSegs = segs;
  5823. },
  5824. unrenderHighlightSegs: function () {
  5825. this.unrenderNamedSegs('highlightSegs');
  5826. },
  5827. /* Business Hours
  5828. ------------------------------------------------------------------------------------------------------------------*/
  5829. renderBusinessSegs: function (segs) {
  5830. segs = this.renderFillSegEls('businessHours', segs); // TODO: old fill system
  5831. this.updateSegVerticals(segs);
  5832. this.attachSegsByCol(this.groupSegsByCol(segs), this.businessContainerEls);
  5833. this.businessSegs = segs;
  5834. },
  5835. unrenderBusinessSegs: function () {
  5836. this.unrenderNamedSegs('businessSegs');
  5837. },
  5838. /* Seg Rendering Utils
  5839. ------------------------------------------------------------------------------------------------------------------*/
  5840. // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col
  5841. groupSegsByCol: function (segs) {
  5842. var segsByCol = [];
  5843. var i;
  5844. for (i = 0; i < this.colCnt; i++) {
  5845. segsByCol.push([]);
  5846. }
  5847. for (i = 0; i < segs.length; i++) {
  5848. segsByCol[segs[i].col].push(segs[i]);
  5849. }
  5850. return segsByCol;
  5851. },
  5852. // Given segments grouped by column, insert the segments' elements into a parallel array of container
  5853. // elements, each living within a column.
  5854. attachSegsByCol: function (segsByCol, containerEls) {
  5855. var col;
  5856. var segs;
  5857. var i;
  5858. for (col = 0; col < this.colCnt; col++) { // iterate each column grouping
  5859. segs = segsByCol[col];
  5860. for (i = 0; i < segs.length; i++) {
  5861. containerEls.eq(col).append(segs[i].el);
  5862. }
  5863. }
  5864. },
  5865. // Given the name of a property of `this` object, assumed to be an array of segments,
  5866. // loops through each segment and removes from DOM. Will null-out the property afterwards.
  5867. unrenderNamedSegs: function (propName) {
  5868. var segs = this[propName];
  5869. var i;
  5870. if (segs) {
  5871. for (i = 0; i < segs.length; i++) {
  5872. segs[i].el.remove();
  5873. }
  5874. this[propName] = null;
  5875. }
  5876. },
  5877. /* Foreground Event Rendering Utils
  5878. ------------------------------------------------------------------------------------------------------------------*/
  5879. // Given an array of foreground segments, render a DOM element for each, computes position,
  5880. // and attaches to the column inner-container elements.
  5881. renderFgSegsIntoContainers: function (segs, containerEls) {
  5882. var segsByCol;
  5883. var col;
  5884. segs = this.renderFgSegEls(segs); // will call fgSegHtml
  5885. segsByCol = this.groupSegsByCol(segs);
  5886. for (col = 0; col < this.colCnt; col++) {
  5887. this.updateFgSegCoords(segsByCol[col]);
  5888. }
  5889. this.attachSegsByCol(segsByCol, containerEls);
  5890. return segs;
  5891. },
  5892. // Renders the HTML for a single event segment's default rendering
  5893. fgSegHtml: function (seg, disableResizing) {
  5894. var view = this.view;
  5895. var event = seg.event;
  5896. var isDraggable = view.isEventDraggable(event);
  5897. var isResizableFromStart = !disableResizing && seg.isStart && view.isEventResizableFromStart(event);
  5898. var isResizableFromEnd = !disableResizing && seg.isEnd && view.isEventResizableFromEnd(event);
  5899. var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd);
  5900. var skinCss = cssToStr(this.getSegSkinCss(seg));
  5901. var timeText;
  5902. var fullTimeText; // more verbose time text. for the print stylesheet
  5903. var startTimeText; // just the start time text
  5904. classes.unshift('fc-time-grid-event', 'fc-v-event');
  5905. if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day...
  5906. // Don't display time text on segments that run entirely through a day.
  5907. // That would appear as midnight-midnight and would look dumb.
  5908. // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am)
  5909. if (seg.isStart || seg.isEnd) {
  5910. timeText = this.getEventTimeText(seg);
  5911. fullTimeText = this.getEventTimeText(seg, 'LT');
  5912. startTimeText = this.getEventTimeText(seg, null, false); // displayEnd=false
  5913. }
  5914. } else {
  5915. // Display the normal time text for the *event's* times
  5916. timeText = this.getEventTimeText(event);
  5917. fullTimeText = this.getEventTimeText(event, 'LT');
  5918. startTimeText = this.getEventTimeText(event, null, false); // displayEnd=false
  5919. }
  5920. return '<a tooltips class="' + classes.join(' ') + '"' +
  5921. (event.url ?
  5922. ' href="' + htmlEscape(event.url) + '"' :
  5923. ''
  5924. ) +
  5925. (skinCss ?
  5926. ' style="' + skinCss + '"' :
  5927. ''
  5928. ) +
  5929. (event.content ?
  5930. ' title="' + htmlEscape(event.content) + '"' :
  5931. ''
  5932. ) +
  5933. '>' +
  5934. '<div class="fc-content">' +
  5935. (timeText ?
  5936. '<div class="fc-time"' +
  5937. ' data-start="' + htmlEscape(startTimeText) + '"' +
  5938. ' data-full="' + htmlEscape(fullTimeText) + '"' +
  5939. '>' +
  5940. '<span>' + htmlEscape(timeText) + '</span>' +
  5941. '</div>' :
  5942. ''
  5943. ) +
  5944. (event.title ?
  5945. '<div class="fc-title">' +
  5946. htmlEscape(event.title) +
  5947. '</div>' :
  5948. ''
  5949. ) +
  5950. (event.content ?
  5951. '<div class="fc-content">' +
  5952. htmlEscape(event.content) +
  5953. '</div>' :
  5954. ''
  5955. ) +
  5956. '</div>' +
  5957. '<div class="fc-bg"/>' +
  5958. /* TODO: write CSS for this
  5959. (isResizableFromStart ?
  5960. '<div class="fc-resizer fc-start-resizer" />' :
  5961. ''
  5962. ) +
  5963. */
  5964. (isResizableFromEnd ?
  5965. '<div class="fc-resizer fc-end-resizer" />' :
  5966. ''
  5967. ) +
  5968. '</a>';
  5969. },
  5970. /* Seg Position Utils
  5971. ------------------------------------------------------------------------------------------------------------------*/
  5972. // Refreshes the CSS top/bottom coordinates for each segment element.
  5973. // Works when called after initial render, after a window resize/zoom for example.
  5974. updateSegVerticals: function (segs) {
  5975. this.computeSegVerticals(segs);
  5976. this.assignSegVerticals(segs);
  5977. },
  5978. // For each segment in an array, computes and assigns its top and bottom properties
  5979. computeSegVerticals: function (segs) {
  5980. var i, seg;
  5981. for (i = 0; i < segs.length; i++) {
  5982. seg = segs[i];
  5983. seg.top = this.computeDateTop(seg.start, seg.start);
  5984. seg.bottom = this.computeDateTop(seg.end, seg.start);
  5985. }
  5986. },
  5987. // Given segments that already have their top/bottom properties computed, applies those values to
  5988. // the segments' elements.
  5989. assignSegVerticals: function (segs) {
  5990. var i, seg;
  5991. for (i = 0; i < segs.length; i++) {
  5992. seg = segs[i];
  5993. seg.el.css(this.generateSegVerticalCss(seg));
  5994. }
  5995. },
  5996. // Generates an object with CSS properties for the top/bottom coordinates of a segment element
  5997. generateSegVerticalCss: function (seg) {
  5998. return {
  5999. top: seg.top,
  6000. bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container
  6001. };
  6002. },
  6003. /* Foreground Event Positioning Utils
  6004. ------------------------------------------------------------------------------------------------------------------*/
  6005. // Given segments that are assumed to all live in the *same column*,
  6006. // compute their verical/horizontal coordinates and assign to their elements.
  6007. updateFgSegCoords: function (segs) {
  6008. this.computeSegVerticals(segs); // horizontals relies on this
  6009. this.computeFgSegHorizontals(segs); // compute horizontal coordinates, z-index's, and reorder the array
  6010. this.assignSegVerticals(segs);
  6011. this.assignFgSegHorizontals(segs);
  6012. },
  6013. // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each.
  6014. // NOTE: Also reorders the given array by date!
  6015. computeFgSegHorizontals: function (segs) {
  6016. var levels;
  6017. var level0;
  6018. var i;
  6019. this.sortEventSegs(segs); // order by certain criteria
  6020. levels = buildSlotSegLevels(segs);
  6021. computeForwardSlotSegs(levels);
  6022. if ((level0 = levels[0])) {
  6023. for (i = 0; i < level0.length; i++) {
  6024. computeSlotSegPressures(level0[i]);
  6025. }
  6026. for (i = 0; i < level0.length; i++) {
  6027. this.computeFgSegForwardBack(level0[i], 0, 0);
  6028. }
  6029. }
  6030. },
  6031. // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range
  6032. // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and
  6033. // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left.
  6034. //
  6035. // The segment might be part of a "series", which means consecutive segments with the same pressure
  6036. // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of
  6037. // segments behind this one in the current series, and `seriesBackwardCoord` is the starting
  6038. // coordinate of the first segment in the series.
  6039. computeFgSegForwardBack: function (seg, seriesBackwardPressure, seriesBackwardCoord) {
  6040. var forwardSegs = seg.forwardSegs;
  6041. var i;
  6042. if (seg.forwardCoord === undefined) { // not already computed
  6043. if (!forwardSegs.length) {
  6044. // if there are no forward segments, this segment should butt up against the edge
  6045. seg.forwardCoord = 1;
  6046. }
  6047. else {
  6048. // sort highest pressure first
  6049. this.sortForwardSegs(forwardSegs);
  6050. // this segment's forwardCoord will be calculated from the backwardCoord of the
  6051. // highest-pressure forward segment.
  6052. this.computeFgSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord);
  6053. seg.forwardCoord = forwardSegs[0].backwardCoord;
  6054. }
  6055. // calculate the backwardCoord from the forwardCoord. consider the series
  6056. seg.backwardCoord = seg.forwardCoord -
  6057. (seg.forwardCoord - seriesBackwardCoord) / // available width for series
  6058. (seriesBackwardPressure + 1); // # of segments in the series
  6059. // use this segment's coordinates to computed the coordinates of the less-pressurized
  6060. // forward segments
  6061. for (i = 0; i < forwardSegs.length; i++) {
  6062. this.computeFgSegForwardBack(forwardSegs[i], 0, seg.forwardCoord);
  6063. }
  6064. }
  6065. },
  6066. sortForwardSegs: function (forwardSegs) {
  6067. forwardSegs.sort(proxy(this, 'compareForwardSegs'));
  6068. },
  6069. // A cmp function for determining which forward segment to rely on more when computing coordinates.
  6070. compareForwardSegs: function (seg1, seg2) {
  6071. // put higher-pressure first
  6072. return seg2.forwardPressure - seg1.forwardPressure ||
  6073. // put segments that are closer to initial edge first (and favor ones with no coords yet)
  6074. (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) ||
  6075. // do normal sorting...
  6076. this.compareEventSegs(seg1, seg2);
  6077. },
  6078. // Given foreground event segments that have already had their position coordinates computed,
  6079. // assigns position-related CSS values to their elements.
  6080. assignFgSegHorizontals: function (segs) {
  6081. var i, seg;
  6082. for (i = 0; i < segs.length; i++) {
  6083. seg = segs[i];
  6084. seg.el.css(this.generateFgSegHorizontalCss(seg));
  6085. // if the height is short, add a className for alternate styling
  6086. if (seg.bottom - seg.top < 30) {
  6087. seg.el.addClass('fc-short');
  6088. }
  6089. }
  6090. },
  6091. // Generates an object with CSS properties/values that should be applied to an event segment element.
  6092. // Contains important positioning-related properties that should be applied to any event element, customized or not.
  6093. generateFgSegHorizontalCss: function (seg) {
  6094. var shouldOverlap = this.view.opt('slotEventOverlap');
  6095. var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point
  6096. var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point
  6097. var props = this.generateSegVerticalCss(seg); // get top/bottom first
  6098. var left; // amount of space from left edge, a fraction of the total width
  6099. var right; // amount of space from right edge, a fraction of the total width
  6100. if (shouldOverlap) {
  6101. // double the width, but don't go beyond the maximum forward coordinate (1.0)
  6102. forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2);
  6103. }
  6104. if (this.isRTL) {
  6105. left = 1 - forwardCoord;
  6106. right = backwardCoord;
  6107. }
  6108. else {
  6109. left = backwardCoord;
  6110. right = 1 - forwardCoord;
  6111. }
  6112. props.zIndex = seg.level + 1; // convert from 0-base to 1-based
  6113. props.left = left * 100 + '%';
  6114. props.right = right * 100 + '%';
  6115. if (shouldOverlap && seg.forwardPressure) {
  6116. // add padding to the edge so that forward stacked events don't cover the resizer's icon
  6117. props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width
  6118. }
  6119. return props;
  6120. }
  6121. });
  6122. // Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is
  6123. // left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date.
  6124. function buildSlotSegLevels(segs) {
  6125. var levels = [];
  6126. var i, seg;
  6127. var j;
  6128. for (i = 0; i < segs.length; i++) {
  6129. seg = segs[i];
  6130. // go through all the levels and stop on the first level where there are no collisions
  6131. for (j = 0; j < levels.length; j++) {
  6132. if (!computeSlotSegCollisions(seg, levels[j]).length) {
  6133. break;
  6134. }
  6135. }
  6136. seg.level = j;
  6137. (levels[j] || (levels[j] = [])).push(seg);
  6138. }
  6139. return levels;
  6140. }
  6141. // For every segment, figure out the other segments that are in subsequent
  6142. // levels that also occupy the same vertical space. Accumulate in seg.forwardSegs
  6143. function computeForwardSlotSegs(levels) {
  6144. var i, level;
  6145. var j, seg;
  6146. var k;
  6147. for (i = 0; i < levels.length; i++) {
  6148. level = levels[i];
  6149. for (j = 0; j < level.length; j++) {
  6150. seg = level[j];
  6151. seg.forwardSegs = [];
  6152. for (k = i + 1; k < levels.length; k++) {
  6153. computeSlotSegCollisions(seg, levels[k], seg.forwardSegs);
  6154. }
  6155. }
  6156. }
  6157. }
  6158. // Figure out which path forward (via seg.forwardSegs) results in the longest path until
  6159. // the furthest edge is reached. The number of segments in this path will be seg.forwardPressure
  6160. function computeSlotSegPressures(seg) {
  6161. var forwardSegs = seg.forwardSegs;
  6162. var forwardPressure = 0;
  6163. var i, forwardSeg;
  6164. if (seg.forwardPressure === undefined) { // not already computed
  6165. for (i = 0; i < forwardSegs.length; i++) {
  6166. forwardSeg = forwardSegs[i];
  6167. // figure out the child's maximum forward path
  6168. computeSlotSegPressures(forwardSeg);
  6169. // either use the existing maximum, or use the child's forward pressure
  6170. // plus one (for the forwardSeg itself)
  6171. forwardPressure = Math.max(
  6172. forwardPressure,
  6173. 1 + forwardSeg.forwardPressure
  6174. );
  6175. }
  6176. seg.forwardPressure = forwardPressure;
  6177. }
  6178. }
  6179. // Find all the segments in `otherSegs` that vertically collide with `seg`.
  6180. // Append into an optionally-supplied `results` array and return.
  6181. function computeSlotSegCollisions(seg, otherSegs, results) {
  6182. results = results || [];
  6183. for (var i = 0; i < otherSegs.length; i++) {
  6184. if (isSlotSegCollision(seg, otherSegs[i])) {
  6185. results.push(otherSegs[i]);
  6186. }
  6187. }
  6188. return results;
  6189. }
  6190. // Do these segments occupy the same vertical space?
  6191. function isSlotSegCollision(seg1, seg2) {
  6192. return seg1.bottom > seg2.top && seg1.top < seg2.bottom;
  6193. }
  6194. ;;
  6195. /* An abstract class from which other views inherit from
  6196. ----------------------------------------------------------------------------------------------------------------------*/
  6197. var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, {
  6198. type: null, // subclass' view name (string)
  6199. name: null, // deprecated. use `type` instead
  6200. title: null, // the text that will be displayed in the header's title
  6201. calendar: null, // owner Calendar object
  6202. options: null, // hash containing all options. already merged with view-specific-options
  6203. el: null, // the view's containing element. set by Calendar
  6204. isDateSet: false,
  6205. isDateRendered: false,
  6206. dateRenderQueue: null,
  6207. isEventsBound: false,
  6208. isEventsSet: false,
  6209. isEventsRendered: false,
  6210. eventRenderQueue: null,
  6211. // range the view is actually displaying (moments)
  6212. start: null,
  6213. end: null, // exclusive
  6214. // range the view is formally responsible for (moments)
  6215. // may be different from start/end. for example, a month view might have 1st-31st, excluding padded dates
  6216. intervalStart: null,
  6217. intervalEnd: null, // exclusive
  6218. intervalDuration: null,
  6219. intervalUnit: null, // name of largest unit being displayed, like "month" or "week"
  6220. isRTL: false,
  6221. isSelected: false, // boolean whether a range of time is user-selected or not
  6222. selectedEvent: null,
  6223. eventOrderSpecs: null, // criteria for ordering events when they have same date/time
  6224. // classNames styled by jqui themes
  6225. widgetHeaderClass: null,
  6226. widgetContentClass: null,
  6227. highlightStateClass: null,
  6228. // for date utils, computed from options
  6229. nextDayThreshold: null,
  6230. isHiddenDayHash: null,
  6231. // now indicator
  6232. isNowIndicatorRendered: null,
  6233. initialNowDate: null, // result first getNow call
  6234. initialNowQueriedMs: null, // ms time the getNow was called
  6235. nowIndicatorTimeoutID: null, // for refresh timing of now indicator
  6236. nowIndicatorIntervalID: null, // "
  6237. constructor: function (calendar, type, options, intervalDuration) {
  6238. this.calendar = calendar;
  6239. this.type = this.name = type; // .name is deprecated
  6240. this.options = options;
  6241. this.intervalDuration = intervalDuration || moment.duration(1, 'day');
  6242. this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold'));
  6243. this.initThemingProps();
  6244. this.initHiddenDays();
  6245. this.isRTL = this.opt('isRTL');
  6246. this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder'));
  6247. this.dateRenderQueue = new TaskQueue();
  6248. this.eventRenderQueue = new TaskQueue(this.opt('eventRenderWait'));
  6249. this.initialize();
  6250. },
  6251. // A good place for subclasses to initialize member variables
  6252. initialize: function () {
  6253. // subclasses can implement
  6254. },
  6255. // Retrieves an option with the given name
  6256. opt: function (name) {
  6257. return this.options[name];
  6258. },
  6259. // Triggers handlers that are view-related. Modifies args before passing to calendar.
  6260. publiclyTrigger: function (name, thisObj) { // arguments beyond thisObj are passed along
  6261. var calendar = this.calendar;
  6262. return calendar.publiclyTrigger.apply(
  6263. calendar,
  6264. [name, thisObj || this].concat(
  6265. Array.prototype.slice.call(arguments, 2), // arguments beyond thisObj
  6266. [this] // always make the last argument a reference to the view. TODO: deprecate
  6267. )
  6268. );
  6269. },
  6270. // Returns a proxy of the given promise that will be rejected if the given event fires
  6271. // before the promise resolves.
  6272. rejectOn: function (eventName, promise) {
  6273. var _this = this;
  6274. return new Promise(function (resolve, reject) {
  6275. _this.one(eventName, reject);
  6276. function cleanup() {
  6277. _this.off(eventName, reject);
  6278. }
  6279. promise.then(function (res) { // success
  6280. cleanup();
  6281. resolve(res);
  6282. }, function () { // failure
  6283. cleanup();
  6284. reject();
  6285. });
  6286. });
  6287. },
  6288. /* Date Computation
  6289. ------------------------------------------------------------------------------------------------------------------*/
  6290. // Updates all internal dates for displaying the given unzoned range.
  6291. setRange: function (range) {
  6292. $.extend(this, range); // assigns every property to this object's member variables
  6293. this.updateTitle();
  6294. },
  6295. // Given a single current unzoned date, produce information about what range to display.
  6296. // Subclasses can override. Must return all properties.
  6297. computeRange: function (date) {
  6298. var intervalUnit = computeIntervalUnit(this.intervalDuration);
  6299. var intervalStart = date.clone().startOf(intervalUnit);
  6300. var intervalEnd = intervalStart.clone().add(this.intervalDuration);
  6301. var start, end;
  6302. // normalize the range's time-ambiguity
  6303. if (/year|month|week|day/.test(intervalUnit)) { // whole-days?
  6304. intervalStart.stripTime();
  6305. intervalEnd.stripTime();
  6306. }
  6307. else { // needs to have a time?
  6308. if (!intervalStart.hasTime()) {
  6309. intervalStart = this.calendar.time(0); // give 00:00 time
  6310. }
  6311. if (!intervalEnd.hasTime()) {
  6312. intervalEnd = this.calendar.time(0); // give 00:00 time
  6313. }
  6314. }
  6315. start = intervalStart.clone();
  6316. start = this.skipHiddenDays(start);
  6317. end = intervalEnd.clone();
  6318. end = this.skipHiddenDays(end, -1, true); // exclusively move backwards
  6319. return {
  6320. intervalUnit: intervalUnit,
  6321. intervalStart: intervalStart,
  6322. intervalEnd: intervalEnd,
  6323. start: start,
  6324. end: end
  6325. };
  6326. },
  6327. // Computes the new date when the user hits the prev button, given the current date
  6328. computePrevDate: function (date) {
  6329. return this.massageCurrentDate(
  6330. date.clone().startOf(this.intervalUnit).subtract(this.intervalDuration), -1
  6331. );
  6332. },
  6333. // Computes the new date when the user hits the next button, given the current date
  6334. computeNextDate: function (date) {
  6335. return this.massageCurrentDate(
  6336. date.clone().startOf(this.intervalUnit).add(this.intervalDuration)
  6337. );
  6338. },
  6339. // Given an arbitrarily calculated current date of the calendar, returns a date that is ensured to be completely
  6340. // visible. `direction` is optional and indicates which direction the current date was being
  6341. // incremented or decremented (1 or -1).
  6342. massageCurrentDate: function (date, direction) {
  6343. if (this.intervalDuration.as('days') <= 1) { // if the view displays a single day or smaller
  6344. if (this.isHiddenDay(date)) {
  6345. date = this.skipHiddenDays(date, direction);
  6346. date.startOf('day');
  6347. }
  6348. }
  6349. return date;
  6350. },
  6351. /* Title and Date Formatting
  6352. ------------------------------------------------------------------------------------------------------------------*/
  6353. // Sets the view's title property to the most updated computed value
  6354. updateTitle: function () {
  6355. this.title = this.computeTitle();
  6356. this.calendar.setToolbarsTitle(this.title);
  6357. },
  6358. // Computes what the title at the top of the calendar should be for this view
  6359. computeTitle: function () {
  6360. return this.formatRange(
  6361. {
  6362. // in case intervalStart/End has a time, make sure timezone is correct
  6363. start: this.calendar.applyTimezone(this.intervalStart),
  6364. end: this.calendar.applyTimezone(this.intervalEnd)
  6365. },
  6366. this.opt('titleFormat') || this.computeTitleFormat(),
  6367. this.opt('titleRangeSeparator')
  6368. );
  6369. },
  6370. // Generates the format string that should be used to generate the title for the current date range.
  6371. // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`.
  6372. computeTitleFormat: function () {
  6373. if (this.intervalUnit == 'year') {
  6374. return 'YYYY';
  6375. }
  6376. else if (this.intervalUnit == 'month') {
  6377. return this.opt('monthYearFormat'); // like "September 2014"
  6378. }
  6379. else if (this.intervalDuration.as('days') > 1) {
  6380. return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014"
  6381. }
  6382. else {
  6383. return 'LL'; // one day. longer, like "September 9 2014"
  6384. }
  6385. },
  6386. // Utility for formatting a range. Accepts a range object, formatting string, and optional separator.
  6387. // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account.
  6388. // The timezones of the dates within `range` will be respected.
  6389. formatRange: function (range, formatStr, separator) {
  6390. var end = range.end;
  6391. if (!end.hasTime()) { // all-day?
  6392. end = end.clone().subtract(1); // convert to inclusive. last ms of previous day
  6393. }
  6394. return formatRange(range.start, end, formatStr, separator, this.opt('isRTL'));
  6395. },
  6396. getAllDayHtml: function () {
  6397. return this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'));
  6398. },
  6399. /* Navigation
  6400. ------------------------------------------------------------------------------------------------------------------*/
  6401. // Generates HTML for an anchor to another view into the calendar.
  6402. // Will either generate an <a> tag or a non-clickable <span> tag, depending on enabled settings.
  6403. // `gotoOptions` can either be a moment input, or an object with the form:
  6404. // { date, type, forceOff }
  6405. // `type` is a view-type like "day" or "week". default value is "day".
  6406. // `attrs` and `innerHtml` are use to generate the rest of the HTML tag.
  6407. buildGotoAnchorHtml: function (gotoOptions, attrs, innerHtml) {
  6408. var date, type, forceOff;
  6409. var finalOptions;
  6410. if ($.isPlainObject(gotoOptions)) {
  6411. date = gotoOptions.date;
  6412. type = gotoOptions.type;
  6413. forceOff = gotoOptions.forceOff;
  6414. }
  6415. else {
  6416. date = gotoOptions; // a single moment input
  6417. }
  6418. date = FC.moment(date); // if a string, parse it
  6419. finalOptions = { // for serialization into the link
  6420. date: date.format('YYYY-MM-DD'),
  6421. type: type || 'day'
  6422. };
  6423. if (typeof attrs === 'string') {
  6424. innerHtml = attrs;
  6425. attrs = null;
  6426. }
  6427. attrs = attrs ? ' ' + attrsToStr(attrs) : ''; // will have a leading space
  6428. innerHtml = innerHtml || '';
  6429. if (!forceOff && this.opt('navLinks')) {
  6430. return '<a' + attrs +
  6431. ' data-goto="' + htmlEscape(JSON.stringify(finalOptions)) + '">' +
  6432. innerHtml +
  6433. '</a>';
  6434. }
  6435. else {
  6436. return '<span' + attrs + '>' +
  6437. innerHtml +
  6438. '</span>';
  6439. }
  6440. },
  6441. // Rendering Non-date-related Content
  6442. // -----------------------------------------------------------------------------------------------------------------
  6443. // Sets the container element that the view should render inside of, does global DOM-related initializations,
  6444. // and renders all the non-date-related content inside.
  6445. setElement: function (el) {
  6446. this.el = el;
  6447. this.bindGlobalHandlers();
  6448. this.renderSkeleton();
  6449. },
  6450. // Removes the view's container element from the DOM, clearing any content beforehand.
  6451. // Undoes any other DOM-related attachments.
  6452. removeElement: function () {
  6453. this.unsetDate();
  6454. this.unrenderSkeleton();
  6455. this.unbindGlobalHandlers();
  6456. this.el.remove();
  6457. // NOTE: don't null-out this.el in case the View was destroyed within an API callback.
  6458. // We don't null-out the View's other jQuery element references upon destroy,
  6459. // so we shouldn't kill this.el either.
  6460. },
  6461. // Renders the basic structure of the view before any content is rendered
  6462. renderSkeleton: function () {
  6463. // subclasses should implement
  6464. },
  6465. // Unrenders the basic structure of the view
  6466. unrenderSkeleton: function () {
  6467. // subclasses should implement
  6468. },
  6469. // Date Setting/Unsetting
  6470. // -----------------------------------------------------------------------------------------------------------------
  6471. setDate: function (date) {
  6472. var isReset = this.isDateSet;
  6473. this.isDateSet = true;
  6474. this.handleDate(date, isReset);
  6475. this.trigger(isReset ? 'dateReset' : 'dateSet', date);
  6476. },
  6477. unsetDate: function () {
  6478. if (this.isDateSet) {
  6479. this.isDateSet = false;
  6480. this.handleDateUnset();
  6481. this.trigger('dateUnset');
  6482. }
  6483. },
  6484. // Date Handling
  6485. // -----------------------------------------------------------------------------------------------------------------
  6486. handleDate: function (date, isReset) {
  6487. var _this = this;
  6488. this.unbindEvents(); // will do nothing if not already bound
  6489. this.requestDateRender(date).then(function () {
  6490. // wish we could start earlier, but setRange/computeRange needs to execute first
  6491. _this.bindEvents(); // will request events
  6492. });
  6493. },
  6494. handleDateUnset: function () {
  6495. this.unbindEvents();
  6496. this.requestDateUnrender();
  6497. },
  6498. // Date Render Queuing
  6499. // -----------------------------------------------------------------------------------------------------------------
  6500. // if date not specified, uses current
  6501. requestDateRender: function (date) {
  6502. var _this = this;
  6503. return this.dateRenderQueue.add(function () {
  6504. return _this.executeDateRender(date);
  6505. });
  6506. },
  6507. requestDateUnrender: function () {
  6508. var _this = this;
  6509. return this.dateRenderQueue.add(function () {
  6510. return _this.executeDateUnrender();
  6511. });
  6512. },
  6513. // Date High-level Rendering
  6514. // -----------------------------------------------------------------------------------------------------------------
  6515. // if date not specified, uses current
  6516. executeDateRender: function (date) {
  6517. var _this = this;
  6518. // if rendering a new date, reset scroll to initial state (scrollTime)
  6519. if (date) {
  6520. this.captureInitialScroll();
  6521. }
  6522. else {
  6523. this.captureScroll(); // a rerender of the current date
  6524. }
  6525. this.freezeHeight();
  6526. return this.executeDateUnrender().then(function () {
  6527. if (date) {
  6528. _this.setRange(_this.computeRange(date));
  6529. }
  6530. if (_this.render) {
  6531. _this.render(); // TODO: deprecate
  6532. }
  6533. _this.renderDates();
  6534. _this.updateSize();
  6535. _this.renderBusinessHours(); // might need coordinates, so should go after updateSize()
  6536. _this.startNowIndicator();
  6537. _this.thawHeight();
  6538. _this.releaseScroll();
  6539. _this.isDateRendered = true;
  6540. _this.onDateRender();
  6541. _this.trigger('dateRender');
  6542. });
  6543. },
  6544. executeDateUnrender: function () {
  6545. var _this = this;
  6546. if (_this.isDateRendered) {
  6547. return this.requestEventsUnrender().then(function () {
  6548. _this.unselect();
  6549. _this.stopNowIndicator();
  6550. _this.triggerUnrender();
  6551. _this.unrenderBusinessHours();
  6552. _this.unrenderDates();
  6553. if (_this.destroy) {
  6554. _this.destroy(); // TODO: deprecate
  6555. }
  6556. _this.isDateRendered = false;
  6557. _this.trigger('dateUnrender');
  6558. });
  6559. }
  6560. else {
  6561. return Promise.resolve();
  6562. }
  6563. },
  6564. // Date Rendering Triggers
  6565. // -----------------------------------------------------------------------------------------------------------------
  6566. onDateRender: function () {
  6567. this.triggerRender();
  6568. },
  6569. // Date Low-level Rendering
  6570. // -----------------------------------------------------------------------------------------------------------------
  6571. // date-cell content only
  6572. renderDates: function () {
  6573. // subclasses should implement
  6574. },
  6575. // date-cell content only
  6576. unrenderDates: function () {
  6577. // subclasses should override
  6578. },
  6579. // Misc view rendering utils
  6580. // -------------------------
  6581. // Signals that the view's content has been rendered
  6582. triggerRender: function () {
  6583. this.publiclyTrigger('viewRender', this, this, this.el);
  6584. },
  6585. // Signals that the view's content is about to be unrendered
  6586. triggerUnrender: function () {
  6587. this.publiclyTrigger('viewDestroy', this, this, this.el);
  6588. },
  6589. // Binds DOM handlers to elements that reside outside the view container, such as the document
  6590. bindGlobalHandlers: function () {
  6591. this.listenTo($(document), 'mousedown', this.handleDocumentMousedown);
  6592. this.listenTo($(document), 'touchstart', this.processUnselect);
  6593. },
  6594. // Unbinds DOM handlers from elements that reside outside the view container
  6595. unbindGlobalHandlers: function () {
  6596. this.stopListeningTo($(document));
  6597. },
  6598. // Initializes internal variables related to theming
  6599. initThemingProps: function () {
  6600. var tm = this.opt('theme') ? 'ui' : 'fc';
  6601. this.widgetHeaderClass = tm + '-widget-header';
  6602. this.widgetContentClass = tm + '-widget-content';
  6603. this.highlightStateClass = tm + '-state-highlight';
  6604. },
  6605. /* Business Hours
  6606. ------------------------------------------------------------------------------------------------------------------*/
  6607. // Renders business-hours onto the view. Assumes updateSize has already been called.
  6608. renderBusinessHours: function () {
  6609. // subclasses should implement
  6610. },
  6611. // Unrenders previously-rendered business-hours
  6612. unrenderBusinessHours: function () {
  6613. // subclasses should implement
  6614. },
  6615. /* Now Indicator
  6616. ------------------------------------------------------------------------------------------------------------------*/
  6617. // Immediately render the current time indicator and begins re-rendering it at an interval,
  6618. // which is defined by this.getNowIndicatorUnit().
  6619. // TODO: somehow do this for the current whole day's background too
  6620. startNowIndicator: function () {
  6621. var _this = this;
  6622. var unit;
  6623. var update;
  6624. var delay; // ms wait value
  6625. if (this.opt('nowIndicator')) {
  6626. unit = this.getNowIndicatorUnit();
  6627. if (unit) {
  6628. update = proxy(this, 'updateNowIndicator'); // bind to `this`
  6629. this.initialNowDate = this.calendar.getNow();
  6630. this.initialNowQueriedMs = +new Date();
  6631. this.renderNowIndicator(this.initialNowDate);
  6632. this.isNowIndicatorRendered = true;
  6633. // wait until the beginning of the next interval
  6634. delay = this.initialNowDate.clone().startOf(unit).add(1, unit) - this.initialNowDate;
  6635. this.nowIndicatorTimeoutID = setTimeout(function () {
  6636. _this.nowIndicatorTimeoutID = null;
  6637. update();
  6638. delay = +moment.duration(1, unit);
  6639. delay = Math.max(100, delay); // prevent too frequent
  6640. _this.nowIndicatorIntervalID = setInterval(update, delay); // update every interval
  6641. }, delay);
  6642. }
  6643. }
  6644. },
  6645. // rerenders the now indicator, computing the new current time from the amount of time that has passed
  6646. // since the initial getNow call.
  6647. updateNowIndicator: function () {
  6648. if (this.isNowIndicatorRendered) {
  6649. this.unrenderNowIndicator();
  6650. this.renderNowIndicator(
  6651. this.initialNowDate.clone().add(new Date() - this.initialNowQueriedMs) // add ms
  6652. );
  6653. }
  6654. },
  6655. // Immediately unrenders the view's current time indicator and stops any re-rendering timers.
  6656. // Won't cause side effects if indicator isn't rendered.
  6657. stopNowIndicator: function () {
  6658. if (this.isNowIndicatorRendered) {
  6659. if (this.nowIndicatorTimeoutID) {
  6660. clearTimeout(this.nowIndicatorTimeoutID);
  6661. this.nowIndicatorTimeoutID = null;
  6662. }
  6663. if (this.nowIndicatorIntervalID) {
  6664. clearTimeout(this.nowIndicatorIntervalID);
  6665. this.nowIndicatorIntervalID = null;
  6666. }
  6667. this.unrenderNowIndicator();
  6668. this.isNowIndicatorRendered = false;
  6669. }
  6670. },
  6671. // Returns a string unit, like 'second' or 'minute' that defined how often the current time indicator
  6672. // should be refreshed. If something falsy is returned, no time indicator is rendered at all.
  6673. getNowIndicatorUnit: function () {
  6674. // subclasses should implement
  6675. },
  6676. // Renders a current time indicator at the given datetime
  6677. renderNowIndicator: function (date) {
  6678. // subclasses should implement
  6679. },
  6680. // Undoes the rendering actions from renderNowIndicator
  6681. unrenderNowIndicator: function () {
  6682. // subclasses should implement
  6683. },
  6684. /* Dimensions
  6685. ------------------------------------------------------------------------------------------------------------------*/
  6686. // Refreshes anything dependant upon sizing of the container element of the grid
  6687. updateSize: function (isResize) {
  6688. if (isResize) {
  6689. this.captureScroll();
  6690. }
  6691. this.updateHeight(isResize);
  6692. this.updateWidth(isResize);
  6693. this.updateNowIndicator();
  6694. if (isResize) {
  6695. this.releaseScroll();
  6696. }
  6697. },
  6698. // Refreshes the horizontal dimensions of the calendar
  6699. updateWidth: function (isResize) {
  6700. // subclasses should implement
  6701. },
  6702. // Refreshes the vertical dimensions of the calendar
  6703. updateHeight: function (isResize) {
  6704. var calendar = this.calendar; // we poll the calendar for height information
  6705. this.setHeight(
  6706. calendar.getSuggestedViewHeight(),
  6707. calendar.isHeightAuto()
  6708. );
  6709. },
  6710. // Updates the vertical dimensions of the calendar to the specified height.
  6711. // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height.
  6712. setHeight: function (height, isAuto) {
  6713. // subclasses should implement
  6714. },
  6715. /* Scroller
  6716. ------------------------------------------------------------------------------------------------------------------*/
  6717. capturedScroll: null,
  6718. capturedScrollDepth: 0,
  6719. captureScroll: function () {
  6720. if (!(this.capturedScrollDepth++)) {
  6721. this.capturedScroll = this.isDateRendered ? this.queryScroll() : {}; // require a render first
  6722. return true; // root?
  6723. }
  6724. return false;
  6725. },
  6726. captureInitialScroll: function (forcedScroll) {
  6727. if (this.captureScroll()) { // root?
  6728. this.capturedScroll.isInitial = true;
  6729. if (forcedScroll) {
  6730. $.extend(this.capturedScroll, forcedScroll);
  6731. }
  6732. else {
  6733. this.capturedScroll.isComputed = true;
  6734. }
  6735. }
  6736. },
  6737. releaseScroll: function () {
  6738. var scroll = this.capturedScroll;
  6739. var isRoot = this.discardScroll();
  6740. if (scroll.isComputed) {
  6741. if (isRoot) {
  6742. // only compute initial scroll if it will actually be used (is the root capture)
  6743. $.extend(scroll, this.computeInitialScroll());
  6744. }
  6745. else {
  6746. scroll = null; // scroll couldn't be computed. don't apply it to the DOM
  6747. }
  6748. }
  6749. if (scroll) {
  6750. // we act immediately on a releaseScroll operation, as opposed to captureScroll.
  6751. // if capture/release wraps a render operation that screws up the scroll,
  6752. // we still want to restore it a good state after, regardless of depth.
  6753. if (scroll.isInitial) {
  6754. this.hardSetScroll(scroll); // outsmart how browsers set scroll on initial DOM
  6755. }
  6756. else {
  6757. this.setScroll(scroll);
  6758. }
  6759. }
  6760. },
  6761. discardScroll: function () {
  6762. if (!(--this.capturedScrollDepth)) {
  6763. this.capturedScroll = null;
  6764. return true; // root?
  6765. }
  6766. return false;
  6767. },
  6768. computeInitialScroll: function () {
  6769. return {};
  6770. },
  6771. queryScroll: function () {
  6772. return {};
  6773. },
  6774. hardSetScroll: function (scroll) {
  6775. var _this = this;
  6776. var exec = function () { _this.setScroll(scroll); };
  6777. exec();
  6778. setTimeout(exec, 0); // to surely clear the browser's initial scroll for the DOM
  6779. },
  6780. setScroll: function (scroll) {
  6781. },
  6782. /* Height Freezing
  6783. ------------------------------------------------------------------------------------------------------------------*/
  6784. freezeHeight: function () {
  6785. this.calendar.freezeContentHeight();
  6786. },
  6787. thawHeight: function () {
  6788. this.calendar.thawContentHeight();
  6789. },
  6790. // Event Binding/Unbinding
  6791. // -----------------------------------------------------------------------------------------------------------------
  6792. bindEvents: function () {
  6793. var _this = this;
  6794. if (!this.isEventsBound) {
  6795. this.isEventsBound = true;
  6796. this.rejectOn('eventsUnbind', this.requestEvents()).then(function (events) { // TODO: test rejection
  6797. _this.listenTo(_this.calendar, 'eventsReset', _this.setEvents);
  6798. _this.setEvents(events);
  6799. });
  6800. }
  6801. },
  6802. unbindEvents: function () {
  6803. if (this.isEventsBound) {
  6804. this.isEventsBound = false;
  6805. this.stopListeningTo(this.calendar, 'eventsReset');
  6806. this.unsetEvents();
  6807. this.trigger('eventsUnbind');
  6808. }
  6809. },
  6810. // Event Setting/Unsetting
  6811. // -----------------------------------------------------------------------------------------------------------------
  6812. setEvents: function (events) {
  6813. var isReset = this.isEventSet;
  6814. this.isEventsSet = true;
  6815. this.handleEvents(events, isReset);
  6816. this.trigger(isReset ? 'eventsReset' : 'eventsSet', events);
  6817. },
  6818. unsetEvents: function () {
  6819. if (this.isEventsSet) {
  6820. this.isEventsSet = false;
  6821. this.handleEventsUnset();
  6822. this.trigger('eventsUnset');
  6823. }
  6824. },
  6825. whenEventsSet: function () {
  6826. var _this = this;
  6827. if (this.isEventsSet) {
  6828. return Promise.resolve(this.getCurrentEvents());
  6829. }
  6830. else {
  6831. return new Promise(function (resolve) {
  6832. _this.one('eventsSet', resolve);
  6833. });
  6834. }
  6835. },
  6836. // Event Handling
  6837. // -----------------------------------------------------------------------------------------------------------------
  6838. handleEvents: function (events, isReset) {
  6839. this.requestEventsRender(events);
  6840. },
  6841. handleEventsUnset: function () {
  6842. this.requestEventsUnrender();
  6843. },
  6844. // Event Render Queuing
  6845. // -----------------------------------------------------------------------------------------------------------------
  6846. // assumes any previous event renders have been cleared already
  6847. requestEventsRender: function (events) {
  6848. var _this = this;
  6849. return this.eventRenderQueue.add(function () { // might not return a promise if debounced!? bad
  6850. return _this.executeEventsRender(events);
  6851. });
  6852. },
  6853. requestEventsUnrender: function () {
  6854. var _this = this;
  6855. if (this.isEventsRendered) {
  6856. return this.eventRenderQueue.addQuickly(function () {
  6857. return _this.executeEventsUnrender();
  6858. });
  6859. }
  6860. else {
  6861. return Promise.resolve();
  6862. }
  6863. },
  6864. requestCurrentEventsRender: function () {
  6865. if (this.isEventsSet) {
  6866. this.requestEventsRender(this.getCurrentEvents());
  6867. }
  6868. else {
  6869. return Promise.reject();
  6870. }
  6871. },
  6872. // Event High-level Rendering
  6873. // -----------------------------------------------------------------------------------------------------------------
  6874. executeEventsRender: function (events) {
  6875. var _this = this;
  6876. this.captureScroll();
  6877. this.freezeHeight();
  6878. return this.executeEventsUnrender().then(function () {
  6879. _this.renderEvents(events);
  6880. _this.thawHeight();
  6881. _this.releaseScroll();
  6882. _this.isEventsRendered = true;
  6883. _this.onEventsRender();
  6884. _this.trigger('eventsRender');
  6885. });
  6886. },
  6887. executeEventsUnrender: function () {
  6888. if (this.isEventsRendered) {
  6889. this.onBeforeEventsUnrender();
  6890. this.captureScroll();
  6891. this.freezeHeight();
  6892. if (this.destroyEvents) {
  6893. this.destroyEvents(); // TODO: deprecate
  6894. }
  6895. this.unrenderEvents();
  6896. this.thawHeight();
  6897. this.releaseScroll();
  6898. this.isEventsRendered = false;
  6899. this.trigger('eventsUnrender');
  6900. }
  6901. return Promise.resolve(); // always synchronous
  6902. },
  6903. // Event Rendering Triggers
  6904. // -----------------------------------------------------------------------------------------------------------------
  6905. // Signals that all events have been rendered
  6906. onEventsRender: function () {
  6907. this.renderedEventSegEach(function (seg) {
  6908. this.publiclyTrigger('eventAfterRender', seg.event, seg.event, seg.el);
  6909. });
  6910. this.publiclyTrigger('eventAfterAllRender');
  6911. },
  6912. // Signals that all event elements are about to be removed
  6913. onBeforeEventsUnrender: function () {
  6914. this.renderedEventSegEach(function (seg) {
  6915. this.publiclyTrigger('eventDestroy', seg.event, seg.event, seg.el);
  6916. });
  6917. },
  6918. // Event Low-level Rendering
  6919. // -----------------------------------------------------------------------------------------------------------------
  6920. // Renders the events onto the view.
  6921. renderEvents: function (events) {
  6922. // subclasses should implement
  6923. },
  6924. // Removes event elements from the view.
  6925. unrenderEvents: function () {
  6926. // subclasses should implement
  6927. },
  6928. // Event Data Access
  6929. // -----------------------------------------------------------------------------------------------------------------
  6930. requestEvents: function () {
  6931. return this.calendar.requestEvents(this.start, this.end);
  6932. },
  6933. getCurrentEvents: function () {
  6934. return this.calendar.getPrunedEventCache();
  6935. },
  6936. // Event Rendering Utils
  6937. // -----------------------------------------------------------------------------------------------------------------
  6938. // Given an event and the default element used for rendering, returns the element that should actually be used.
  6939. // Basically runs events and elements through the eventRender hook.
  6940. resolveEventEl: function (event, el) {
  6941. var custom = this.publiclyTrigger('eventRender', event, event, el);
  6942. if (custom === false) { // means don't render at all
  6943. el = null;
  6944. }
  6945. else if (custom && custom !== true) {
  6946. el = $(custom);
  6947. }
  6948. return el;
  6949. },
  6950. // Hides all rendered event segments linked to the given event
  6951. showEvent: function (event) {
  6952. this.renderedEventSegEach(function (seg) {
  6953. seg.el.css('visibility', '');
  6954. }, event);
  6955. },
  6956. // Shows all rendered event segments linked to the given event
  6957. hideEvent: function (event) {
  6958. this.renderedEventSegEach(function (seg) {
  6959. seg.el.css('visibility', 'hidden');
  6960. }, event);
  6961. },
  6962. // Iterates through event segments that have been rendered (have an el). Goes through all by default.
  6963. // If the optional `event` argument is specified, only iterates through segments linked to that event.
  6964. // The `this` value of the callback function will be the view.
  6965. renderedEventSegEach: function (func, event) {
  6966. var segs = this.getEventSegs();
  6967. var i;
  6968. for (i = 0; i < segs.length; i++) {
  6969. if (!event || segs[i].event._id === event._id) {
  6970. if (segs[i].el) {
  6971. func.call(this, segs[i]);
  6972. }
  6973. }
  6974. }
  6975. },
  6976. // Retrieves all the rendered segment objects for the view
  6977. getEventSegs: function () {
  6978. // subclasses must implement
  6979. return [];
  6980. },
  6981. /* Event Drag-n-Drop
  6982. ------------------------------------------------------------------------------------------------------------------*/
  6983. // Computes if the given event is allowed to be dragged by the user
  6984. isEventDraggable: function (event) {
  6985. return this.isEventStartEditable(event);
  6986. },
  6987. isEventStartEditable: function (event) {
  6988. return firstDefined(
  6989. event.startEditable,
  6990. (event.source || {}).startEditable,
  6991. this.opt('eventStartEditable'),
  6992. this.isEventGenerallyEditable(event)
  6993. );
  6994. },
  6995. isEventGenerallyEditable: function (event) {
  6996. return firstDefined(
  6997. event.editable,
  6998. (event.source || {}).editable,
  6999. this.opt('editable')
  7000. );
  7001. },
  7002. // Must be called when an event in the view is dropped onto new location.
  7003. // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event.
  7004. reportEventDrop: function (event, dropLocation, largeUnit, el, ev) {
  7005. var calendar = this.calendar;
  7006. var mutateResult = calendar.mutateEvent(event, dropLocation, largeUnit);
  7007. var undoFunc = function () {
  7008. mutateResult.undo();
  7009. calendar.reportEventChange();
  7010. };
  7011. this.triggerEventDrop(event, mutateResult.dateDelta, undoFunc, el, ev);
  7012. calendar.reportEventChange(); // will rerender events
  7013. },
  7014. // Triggers event-drop handlers that have subscribed via the API
  7015. triggerEventDrop: function (event, dateDelta, undoFunc, el, ev) {
  7016. this.publiclyTrigger('eventDrop', el[0], event, dateDelta, undoFunc, ev, {}); // {} = jqui dummy
  7017. },
  7018. /* External Element Drag-n-Drop
  7019. ------------------------------------------------------------------------------------------------------------------*/
  7020. // Must be called when an external element, via jQuery UI, has been dropped onto the calendar.
  7021. // `meta` is the parsed data that has been embedded into the dragging event.
  7022. // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event.
  7023. reportExternalDrop: function (meta, dropLocation, el, ev, ui) {
  7024. var eventProps = meta.eventProps;
  7025. var eventInput;
  7026. var event;
  7027. // Try to build an event object and render it. TODO: decouple the two
  7028. if (eventProps) {
  7029. eventInput = $.extend({}, eventProps, dropLocation);
  7030. event = this.calendar.renderEvent(eventInput, meta.stick)[0]; // renderEvent returns an array
  7031. }
  7032. this.triggerExternalDrop(event, dropLocation, el, ev, ui);
  7033. },
  7034. // Triggers external-drop handlers that have subscribed via the API
  7035. triggerExternalDrop: function (event, dropLocation, el, ev, ui) {
  7036. // trigger 'drop' regardless of whether element represents an event
  7037. this.publiclyTrigger('drop', el[0], dropLocation.start, ev, ui);
  7038. if (event) {
  7039. this.publiclyTrigger('eventReceive', null, event); // signal an external event landed
  7040. }
  7041. },
  7042. /* Drag-n-Drop Rendering (for both events and external elements)
  7043. ------------------------------------------------------------------------------------------------------------------*/
  7044. // Renders a visual indication of a event or external-element drag over the given drop zone.
  7045. // If an external-element, seg will be `null`.
  7046. // Must return elements used for any mock events.
  7047. renderDrag: function (dropLocation, seg) {
  7048. // subclasses must implement
  7049. },
  7050. // Unrenders a visual indication of an event or external-element being dragged.
  7051. unrenderDrag: function () {
  7052. // subclasses must implement
  7053. },
  7054. /* Event Resizing
  7055. ------------------------------------------------------------------------------------------------------------------*/
  7056. // Computes if the given event is allowed to be resized from its starting edge
  7057. isEventResizableFromStart: function (event) {
  7058. return this.opt('eventResizableFromStart') && this.isEventResizable(event);
  7059. },
  7060. // Computes if the given event is allowed to be resized from its ending edge
  7061. isEventResizableFromEnd: function (event) {
  7062. return this.isEventResizable(event);
  7063. },
  7064. // Computes if the given event is allowed to be resized by the user at all
  7065. isEventResizable: function (event) {
  7066. var source = event.source || {};
  7067. return firstDefined(
  7068. event.durationEditable,
  7069. source.durationEditable,
  7070. this.opt('eventDurationEditable'),
  7071. event.editable,
  7072. source.editable,
  7073. this.opt('editable')
  7074. );
  7075. },
  7076. // Must be called when an event in the view has been resized to a new length
  7077. reportEventResize: function (event, resizeLocation, largeUnit, el, ev) {
  7078. var calendar = this.calendar;
  7079. var mutateResult = calendar.mutateEvent(event, resizeLocation, largeUnit);
  7080. var undoFunc = function () {
  7081. mutateResult.undo();
  7082. calendar.reportEventChange();
  7083. };
  7084. this.triggerEventResize(event, mutateResult.durationDelta, undoFunc, el, ev);
  7085. calendar.reportEventChange(); // will rerender events
  7086. },
  7087. // Triggers event-resize handlers that have subscribed via the API
  7088. triggerEventResize: function (event, durationDelta, undoFunc, el, ev) {
  7089. this.publiclyTrigger('eventResize', el[0], event, durationDelta, undoFunc, ev, {}); // {} = jqui dummy
  7090. },
  7091. /* Selection (time range)
  7092. ------------------------------------------------------------------------------------------------------------------*/
  7093. // Selects a date span on the view. `start` and `end` are both Moments.
  7094. // `ev` is the native mouse event that begin the interaction.
  7095. select: function (span, ev) {
  7096. this.unselect(ev);
  7097. this.renderSelection(span);
  7098. this.reportSelection(span, ev);
  7099. },
  7100. // Renders a visual indication of the selection
  7101. renderSelection: function (span) {
  7102. // subclasses should implement
  7103. },
  7104. // Called when a new selection is made. Updates internal state and triggers handlers.
  7105. reportSelection: function (span, ev) {
  7106. this.isSelected = true;
  7107. this.triggerSelect(span, ev);
  7108. },
  7109. // Triggers handlers to 'select'
  7110. triggerSelect: function (span, ev) {
  7111. this.publiclyTrigger(
  7112. 'select',
  7113. null,
  7114. this.calendar.applyTimezone(span.start), // convert to calendar's tz for external API
  7115. this.calendar.applyTimezone(span.end), // "
  7116. ev
  7117. );
  7118. },
  7119. // Undoes a selection. updates in the internal state and triggers handlers.
  7120. // `ev` is the native mouse event that began the interaction.
  7121. unselect: function (ev) {
  7122. if (this.isSelected) {
  7123. this.isSelected = false;
  7124. if (this.destroySelection) {
  7125. this.destroySelection(); // TODO: deprecate
  7126. }
  7127. this.unrenderSelection();
  7128. this.publiclyTrigger('unselect', null, ev);
  7129. }
  7130. },
  7131. // Unrenders a visual indication of selection
  7132. unrenderSelection: function () {
  7133. // subclasses should implement
  7134. },
  7135. /* Event Selection
  7136. ------------------------------------------------------------------------------------------------------------------*/
  7137. selectEvent: function (event) {
  7138. if (!this.selectedEvent || this.selectedEvent !== event) {
  7139. this.unselectEvent();
  7140. this.renderedEventSegEach(function (seg) {
  7141. seg.el.addClass('fc-selected');
  7142. }, event);
  7143. this.selectedEvent = event;
  7144. }
  7145. },
  7146. unselectEvent: function () {
  7147. if (this.selectedEvent) {
  7148. this.renderedEventSegEach(function (seg) {
  7149. seg.el.removeClass('fc-selected');
  7150. }, this.selectedEvent);
  7151. this.selectedEvent = null;
  7152. }
  7153. },
  7154. isEventSelected: function (event) {
  7155. // event references might change on refetchEvents(), while selectedEvent doesn't,
  7156. // so compare IDs
  7157. return this.selectedEvent && this.selectedEvent._id === event._id;
  7158. },
  7159. /* Mouse / Touch Unselecting (time range & event unselection)
  7160. ------------------------------------------------------------------------------------------------------------------*/
  7161. // TODO: move consistently to down/start or up/end?
  7162. // TODO: don't kill previous selection if touch scrolling
  7163. handleDocumentMousedown: function (ev) {
  7164. if (isPrimaryMouseButton(ev)) {
  7165. this.processUnselect(ev);
  7166. }
  7167. },
  7168. processUnselect: function (ev) {
  7169. this.processRangeUnselect(ev);
  7170. this.processEventUnselect(ev);
  7171. },
  7172. processRangeUnselect: function (ev) {
  7173. var ignore;
  7174. // is there a time-range selection?
  7175. if (this.isSelected && this.opt('unselectAuto')) {
  7176. // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element
  7177. ignore = this.opt('unselectCancel');
  7178. if (!ignore || !$(ev.target).closest(ignore).length) {
  7179. this.unselect(ev);
  7180. }
  7181. }
  7182. },
  7183. processEventUnselect: function (ev) {
  7184. if (this.selectedEvent) {
  7185. if (!$(ev.target).closest('.fc-selected').length) {
  7186. this.unselectEvent();
  7187. }
  7188. }
  7189. },
  7190. /* Day Click
  7191. ------------------------------------------------------------------------------------------------------------------*/
  7192. // Triggers handlers to 'dayClick'
  7193. // Span has start/end of the clicked area. Only the start is useful.
  7194. triggerDayClick: function (span, dayEl, ev) {
  7195. this.publiclyTrigger(
  7196. 'dayClick',
  7197. dayEl,
  7198. this.calendar.applyTimezone(span.start), // convert to calendar's timezone for external API
  7199. ev
  7200. );
  7201. },
  7202. /* Date Utils
  7203. ------------------------------------------------------------------------------------------------------------------*/
  7204. // Initializes internal variables related to calculating hidden days-of-week
  7205. initHiddenDays: function () {
  7206. var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden
  7207. var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool)
  7208. var dayCnt = 0;
  7209. var i;
  7210. if (this.opt('weekends') === false) {
  7211. hiddenDays.push(0, 6); // 0=sunday, 6=saturday
  7212. }
  7213. for (i = 0; i < 7; i++) {
  7214. if (
  7215. !(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1)
  7216. ) {
  7217. dayCnt++;
  7218. }
  7219. }
  7220. if (!dayCnt) {
  7221. throw 'invalid hiddenDays'; // all days were hidden? bad.
  7222. }
  7223. this.isHiddenDayHash = isHiddenDayHash;
  7224. },
  7225. // Is the current day hidden?
  7226. // `day` is a day-of-week index (0-6), or a Moment
  7227. isHiddenDay: function (day) {
  7228. if (moment.isMoment(day)) {
  7229. day = day.day();
  7230. }
  7231. return this.isHiddenDayHash[day];
  7232. },
  7233. // Incrementing the current day until it is no longer a hidden day, returning a copy.
  7234. // If the initial value of `date` is not a hidden day, don't do anything.
  7235. // Pass `isExclusive` as `true` if you are dealing with an end date.
  7236. // `inc` defaults to `1` (increment one day forward each time)
  7237. skipHiddenDays: function (date, inc, isExclusive) {
  7238. var out = date.clone();
  7239. inc = inc || 1;
  7240. while (
  7241. this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7]
  7242. ) {
  7243. out.add(inc, 'days');
  7244. }
  7245. return out;
  7246. },
  7247. // Returns the date range of the full days the given range visually appears to occupy.
  7248. // Returns a new range object.
  7249. computeDayRange: function (range) {
  7250. var startDay = range.start.clone().stripTime(); // the beginning of the day the range starts
  7251. var end = range.end;
  7252. var endDay = null;
  7253. var endTimeMS;
  7254. if (end) {
  7255. endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends
  7256. endTimeMS = +end.time(); // # of milliseconds into `endDay`
  7257. // If the end time is actually inclusively part of the next day and is equal to or
  7258. // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`.
  7259. // Otherwise, leaving it as inclusive will cause it to exclude `endDay`.
  7260. if (endTimeMS && endTimeMS >= this.nextDayThreshold) {
  7261. endDay.add(1, 'days');
  7262. }
  7263. }
  7264. // If no end was specified, or if it is within `startDay` but not past nextDayThreshold,
  7265. // assign the default duration of one day.
  7266. if (!end || endDay <= startDay) {
  7267. endDay = startDay.clone().add(1, 'days');
  7268. }
  7269. return { start: startDay, end: endDay };
  7270. },
  7271. // Does the given event visually appear to occupy more than one day?
  7272. isMultiDayEvent: function (event) {
  7273. var range = this.computeDayRange(event); // event is range-ish
  7274. return range.end.diff(range.start, 'days') > 1;
  7275. }
  7276. });
  7277. ;;
  7278. /*
  7279. Embodies a div that has potential scrollbars
  7280. */
  7281. var Scroller = FC.Scroller = Class.extend({
  7282. el: null, // the guaranteed outer element
  7283. scrollEl: null, // the element with the scrollbars
  7284. overflowX: null,
  7285. overflowY: null,
  7286. constructor: function (options) {
  7287. options = options || {};
  7288. this.overflowX = options.overflowX || options.overflow || 'auto';
  7289. this.overflowY = options.overflowY || options.overflow || 'auto';
  7290. },
  7291. render: function () {
  7292. this.el = this.renderEl();
  7293. this.applyOverflow();
  7294. },
  7295. renderEl: function () {
  7296. return (this.scrollEl = $('<div class="fc-scroller"></div>'));
  7297. },
  7298. // sets to natural height, unlocks overflow
  7299. clear: function () {
  7300. this.setHeight('auto');
  7301. this.applyOverflow();
  7302. },
  7303. destroy: function () {
  7304. this.el.remove();
  7305. },
  7306. // Overflow
  7307. // -----------------------------------------------------------------------------------------------------------------
  7308. applyOverflow: function () {
  7309. this.scrollEl.css({
  7310. 'overflow-x': this.overflowX,
  7311. 'overflow-y': this.overflowY
  7312. });
  7313. },
  7314. // Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'.
  7315. // Useful for preserving scrollbar widths regardless of future resizes.
  7316. // Can pass in scrollbarWidths for optimization.
  7317. lockOverflow: function (scrollbarWidths) {
  7318. var overflowX = this.overflowX;
  7319. var overflowY = this.overflowY;
  7320. scrollbarWidths = scrollbarWidths || this.getScrollbarWidths();
  7321. if (overflowX === 'auto') {
  7322. overflowX = (
  7323. scrollbarWidths.top || scrollbarWidths.bottom || // horizontal scrollbars?
  7324. // OR scrolling pane with massless scrollbars?
  7325. this.scrollEl[0].scrollWidth - 1 > this.scrollEl[0].clientWidth
  7326. // subtract 1 because of IE off-by-one issue
  7327. ) ? 'scroll' : 'hidden';
  7328. }
  7329. if (overflowY === 'auto') {
  7330. overflowY = (
  7331. scrollbarWidths.left || scrollbarWidths.right || // vertical scrollbars?
  7332. // OR scrolling pane with massless scrollbars?
  7333. this.scrollEl[0].scrollHeight - 1 > this.scrollEl[0].clientHeight
  7334. // subtract 1 because of IE off-by-one issue
  7335. ) ? 'scroll' : 'hidden';
  7336. }
  7337. this.scrollEl.css({ 'overflow-x': overflowX, 'overflow-y': overflowY });
  7338. },
  7339. // Getters / Setters
  7340. // -----------------------------------------------------------------------------------------------------------------
  7341. setHeight: function (height) {
  7342. this.scrollEl.height(height);
  7343. },
  7344. getScrollTop: function () {
  7345. return this.scrollEl.scrollTop();
  7346. },
  7347. setScrollTop: function (top) {
  7348. this.scrollEl.scrollTop(top);
  7349. },
  7350. getClientWidth: function () {
  7351. return this.scrollEl[0].clientWidth;
  7352. },
  7353. getClientHeight: function () {
  7354. return this.scrollEl[0].clientHeight;
  7355. },
  7356. getScrollbarWidths: function () {
  7357. return getScrollbarWidths(this.scrollEl);
  7358. }
  7359. });
  7360. ;;
  7361. function Iterator(items) {
  7362. this.items = items || [];
  7363. }
  7364. /* Calls a method on every item passing the arguments through */
  7365. Iterator.prototype.proxyCall = function (methodName) {
  7366. var args = Array.prototype.slice.call(arguments, 1);
  7367. var results = [];
  7368. this.items.forEach(function (item) {
  7369. results.push(item[methodName].apply(item, args));
  7370. });
  7371. return results;
  7372. };
  7373. ;;
  7374. /* Toolbar with buttons and title
  7375. ----------------------------------------------------------------------------------------------------------------------*/
  7376. function Toolbar(calendar, toolbarOptions) {
  7377. var t = this;
  7378. // exports
  7379. t.setToolbarOptions = setToolbarOptions;
  7380. t.render = render;
  7381. t.removeElement = removeElement;
  7382. t.updateTitle = updateTitle;
  7383. t.activateButton = activateButton;
  7384. t.deactivateButton = deactivateButton;
  7385. t.disableButton = disableButton;
  7386. t.enableButton = enableButton;
  7387. t.getViewsWithButtons = getViewsWithButtons;
  7388. t.el = null; // mirrors local `el`
  7389. // locals
  7390. var el;
  7391. var viewsWithButtons = [];
  7392. var tm;
  7393. // method to update toolbar-specific options, not calendar-wide options
  7394. function setToolbarOptions(newToolbarOptions) {
  7395. toolbarOptions = newToolbarOptions;
  7396. }
  7397. // can be called repeatedly and will rerender
  7398. function render() {
  7399. var sections = toolbarOptions.layout;
  7400. tm = calendar.options.theme ? 'ui' : 'fc';
  7401. if (sections) {
  7402. if (!el) {
  7403. el = this.el = $("<div class='fc-toolbar " + toolbarOptions.extraClasses + "'/>");
  7404. }
  7405. else {
  7406. el.empty();
  7407. }
  7408. el.append(renderSection('left'))
  7409. .append(renderSection('right'))
  7410. .append(renderSection('center'))
  7411. .append('<div class="fc-clear"/>');
  7412. }
  7413. else {
  7414. removeElement();
  7415. }
  7416. }
  7417. function removeElement() {
  7418. if (el) {
  7419. el.remove();
  7420. el = t.el = null;
  7421. }
  7422. }
  7423. function renderSection(position) {
  7424. var sectionEl = $('<div class="fc-' + position + '"/>');
  7425. var buttonStr = toolbarOptions.layout[position];
  7426. if (buttonStr) {
  7427. $.each(buttonStr.split(' '), function (i) {
  7428. var groupChildren = $();
  7429. var isOnlyButtons = true;
  7430. var groupEl;
  7431. $.each(this.split(','), function (j, buttonName) {
  7432. var customButtonProps;
  7433. var viewSpec;
  7434. var buttonClick;
  7435. var overrideText; // text explicitly set by calendar's constructor options. overcomes icons
  7436. var defaultText;
  7437. var themeIcon;
  7438. var normalIcon;
  7439. var innerHtml;
  7440. var classes;
  7441. var button; // the element
  7442. if (buttonName == 'title') {
  7443. groupChildren = groupChildren.add($('<h2>&nbsp;</h2>')); // we always want it to take up height
  7444. isOnlyButtons = false;
  7445. }
  7446. else {
  7447. if ((customButtonProps = (calendar.options.customButtons || {})[buttonName])) {
  7448. buttonClick = function (ev) {
  7449. if (customButtonProps.click) {
  7450. customButtonProps.click.call(button[0], ev);
  7451. }
  7452. };
  7453. overrideText = ''; // icons will override text
  7454. defaultText = customButtonProps.text;
  7455. }
  7456. else if ((viewSpec = calendar.getViewSpec(buttonName))) {
  7457. buttonClick = function () {
  7458. calendar.changeView(buttonName);
  7459. };
  7460. viewsWithButtons.push(buttonName);
  7461. overrideText = viewSpec.buttonTextOverride;
  7462. defaultText = viewSpec.buttonTextDefault;
  7463. }
  7464. else if (calendar[buttonName]) { // a calendar method
  7465. buttonClick = function () {
  7466. calendar[buttonName]();
  7467. };
  7468. overrideText = (calendar.overrides.buttonText || {})[buttonName];
  7469. defaultText = calendar.options.buttonText[buttonName]; // everything else is considered default
  7470. }
  7471. if (buttonClick) {
  7472. themeIcon =
  7473. customButtonProps ?
  7474. customButtonProps.themeIcon :
  7475. calendar.options.themeButtonIcons[buttonName];
  7476. normalIcon =
  7477. customButtonProps ?
  7478. customButtonProps.icon :
  7479. calendar.options.buttonIcons[buttonName];
  7480. if (overrideText) {
  7481. innerHtml = htmlEscape(overrideText);
  7482. }
  7483. else if (themeIcon && calendar.options.theme) {
  7484. innerHtml = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>";
  7485. }
  7486. else if (normalIcon && !calendar.options.theme) {
  7487. innerHtml = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>";
  7488. }
  7489. else {
  7490. innerHtml = htmlEscape(defaultText);
  7491. }
  7492. classes = [
  7493. 'fc-' + buttonName + '-button',
  7494. tm + '-button',
  7495. tm + '-state-default'
  7496. ];
  7497. button = $( // type="button" so that it doesn't submit a form
  7498. '<button type="button" data-id="' + (buttonName || '') + '" class="' + classes.join(' ') + '">' +
  7499. innerHtml +
  7500. '</button>'
  7501. )
  7502. .click(function (ev) {
  7503. // don't process clicks for disabled buttons
  7504. if (!button.hasClass(tm + '-state-disabled')) {
  7505. buttonClick(ev);
  7506. // after the click action, if the button becomes the "active" tab, or disabled,
  7507. // it should never have a hover class, so remove it now.
  7508. if (
  7509. button.hasClass(tm + '-state-active') ||
  7510. button.hasClass(tm + '-state-disabled')
  7511. ) {
  7512. button.removeClass(tm + '-state-hover');
  7513. }
  7514. }
  7515. })
  7516. .mousedown(function () {
  7517. // the *down* effect (mouse pressed in).
  7518. // only on buttons that are not the "active" tab, or disabled
  7519. button
  7520. .not('.' + tm + '-state-active')
  7521. .not('.' + tm + '-state-disabled')
  7522. .addClass(tm + '-state-down');
  7523. })
  7524. .mouseup(function () {
  7525. // undo the *down* effect
  7526. button.removeClass(tm + '-state-down');
  7527. })
  7528. .hover(
  7529. function () {
  7530. // the *hover* effect.
  7531. // only on buttons that are not the "active" tab, or disabled
  7532. button
  7533. .not('.' + tm + '-state-active')
  7534. .not('.' + tm + '-state-disabled')
  7535. .addClass(tm + '-state-hover');
  7536. },
  7537. function () {
  7538. // undo the *hover* effect
  7539. button
  7540. .removeClass(tm + '-state-hover')
  7541. .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup
  7542. }
  7543. );
  7544. groupChildren = groupChildren.add(button);
  7545. }
  7546. }
  7547. });
  7548. if (isOnlyButtons) {
  7549. groupChildren
  7550. .first().addClass(tm + '-corner-left').end()
  7551. .last().addClass(tm + '-corner-right').end();
  7552. }
  7553. if (groupChildren.length > 1) {
  7554. groupEl = $('<div/>');
  7555. if (isOnlyButtons) {
  7556. groupEl.addClass('fc-button-group');
  7557. }
  7558. groupEl.append(groupChildren);
  7559. sectionEl.append(groupEl);
  7560. }
  7561. else {
  7562. sectionEl.append(groupChildren); // 1 or 0 children
  7563. }
  7564. });
  7565. }
  7566. return sectionEl;
  7567. }
  7568. function updateTitle(text) {
  7569. if (el) {
  7570. el.find('h2').text(text);
  7571. }
  7572. }
  7573. function activateButton(buttonName) {
  7574. if (el) {
  7575. el.find('.fc-' + buttonName + '-button')
  7576. .addClass(tm + '-state-active');
  7577. }
  7578. }
  7579. function deactivateButton(buttonName) {
  7580. if (el) {
  7581. el.find('.fc-' + buttonName + '-button')
  7582. .removeClass(tm + '-state-active');
  7583. }
  7584. }
  7585. function disableButton(buttonName) {
  7586. if (el) {
  7587. el.find('.fc-' + buttonName + '-button')
  7588. .prop('disabled', true)
  7589. .addClass(tm + '-state-disabled');
  7590. }
  7591. }
  7592. function enableButton(buttonName) {
  7593. if (el) {
  7594. el.find('.fc-' + buttonName + '-button')
  7595. .prop('disabled', false)
  7596. .removeClass(tm + '-state-disabled');
  7597. }
  7598. }
  7599. function getViewsWithButtons() {
  7600. return viewsWithButtons;
  7601. }
  7602. }
  7603. ;;
  7604. var Calendar = FC.Calendar = Class.extend({
  7605. dirDefaults: null, // option defaults related to LTR or RTL
  7606. localeDefaults: null, // option defaults related to current locale
  7607. overrides: null, // option overrides given to the fullCalendar constructor
  7608. dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides.
  7609. options: null, // all defaults combined with overrides
  7610. viewSpecCache: null, // cache of view definitions
  7611. view: null, // current View object
  7612. header: null,
  7613. footer: null,
  7614. loadingLevel: 0, // number of simultaneous loading tasks
  7615. // a lot of this class' OOP logic is scoped within this constructor function,
  7616. // but in the future, write individual methods on the prototype.
  7617. constructor: Calendar_constructor,
  7618. // Subclasses can override this for initialization logic after the constructor has been called
  7619. initialize: function () {
  7620. },
  7621. // Computes the flattened options hash for the calendar and assigns to `this.options`.
  7622. // Assumes this.overrides and this.dynamicOverrides have already been initialized.
  7623. populateOptionsHash: function () {
  7624. var locale, localeDefaults;
  7625. var isRTL, dirDefaults;
  7626. locale = firstDefined( // explicit locale option given?
  7627. this.dynamicOverrides.locale,
  7628. this.overrides.locale
  7629. );
  7630. localeDefaults = localeOptionHash[locale];
  7631. if (!localeDefaults) { // explicit locale option not given or invalid?
  7632. locale = Calendar.defaults.locale;
  7633. localeDefaults = localeOptionHash[locale] || {};
  7634. }
  7635. isRTL = firstDefined( // based on options computed so far, is direction RTL?
  7636. this.dynamicOverrides.isRTL,
  7637. this.overrides.isRTL,
  7638. localeDefaults.isRTL,
  7639. Calendar.defaults.isRTL
  7640. );
  7641. dirDefaults = isRTL ? Calendar.rtlDefaults : {};
  7642. this.dirDefaults = dirDefaults;
  7643. this.localeDefaults = localeDefaults;
  7644. this.options = mergeOptions([ // merge defaults and overrides. lowest to highest precedence
  7645. Calendar.defaults, // global defaults
  7646. dirDefaults,
  7647. localeDefaults,
  7648. this.overrides,
  7649. this.dynamicOverrides
  7650. ]);
  7651. populateInstanceComputableOptions(this.options); // fill in gaps with computed options
  7652. },
  7653. // Gets information about how to create a view. Will use a cache.
  7654. getViewSpec: function (viewType) {
  7655. var cache = this.viewSpecCache;
  7656. return cache[viewType] || (cache[viewType] = this.buildViewSpec(viewType));
  7657. },
  7658. // Given a duration singular unit, like "week" or "day", finds a matching view spec.
  7659. // Preference is given to views that have corresponding buttons.
  7660. getUnitViewSpec: function (unit) {
  7661. var viewTypes;
  7662. var i;
  7663. var spec;
  7664. if ($.inArray(unit, intervalUnits) != -1) {
  7665. // put views that have buttons first. there will be duplicates, but oh well
  7666. viewTypes = this.header.getViewsWithButtons(); // TODO: include footer as well?
  7667. $.each(FC.views, function (viewType) { // all views
  7668. viewTypes.push(viewType);
  7669. });
  7670. for (i = 0; i < viewTypes.length; i++) {
  7671. spec = this.getViewSpec(viewTypes[i]);
  7672. if (spec) {
  7673. if (spec.singleUnit == unit) {
  7674. return spec;
  7675. }
  7676. }
  7677. }
  7678. }
  7679. },
  7680. // Builds an object with information on how to create a given view
  7681. buildViewSpec: function (requestedViewType) {
  7682. var viewOverrides = this.overrides.views || {};
  7683. var specChain = []; // for the view. lowest to highest priority
  7684. var defaultsChain = []; // for the view. lowest to highest priority
  7685. var overridesChain = []; // for the view. lowest to highest priority
  7686. var viewType = requestedViewType;
  7687. var spec; // for the view
  7688. var overrides; // for the view
  7689. var duration;
  7690. var unit;
  7691. // iterate from the specific view definition to a more general one until we hit an actual View class
  7692. while (viewType) {
  7693. spec = fcViews[viewType];
  7694. overrides = viewOverrides[viewType];
  7695. viewType = null; // clear. might repopulate for another iteration
  7696. if (typeof spec === 'function') { // TODO: deprecate
  7697. spec = { 'class': spec };
  7698. }
  7699. if (spec) {
  7700. specChain.unshift(spec);
  7701. defaultsChain.unshift(spec.defaults || {});
  7702. duration = duration || spec.duration;
  7703. viewType = viewType || spec.type;
  7704. }
  7705. if (overrides) {
  7706. overridesChain.unshift(overrides); // view-specific option hashes have options at zero-level
  7707. duration = duration || overrides.duration;
  7708. viewType = viewType || overrides.type;
  7709. }
  7710. }
  7711. spec = mergeProps(specChain);
  7712. spec.type = requestedViewType;
  7713. if (!spec['class']) {
  7714. return false;
  7715. }
  7716. if (duration) {
  7717. duration = moment.duration(duration);
  7718. if (duration.valueOf()) { // valid?
  7719. spec.duration = duration;
  7720. unit = computeIntervalUnit(duration);
  7721. // view is a single-unit duration, like "week" or "day"
  7722. // incorporate options for this. lowest priority
  7723. if (duration.as(unit) === 1) {
  7724. spec.singleUnit = unit;
  7725. overridesChain.unshift(viewOverrides[unit] || {});
  7726. }
  7727. }
  7728. }
  7729. spec.defaults = mergeOptions(defaultsChain);
  7730. spec.overrides = mergeOptions(overridesChain);
  7731. this.buildViewSpecOptions(spec);
  7732. this.buildViewSpecButtonText(spec, requestedViewType);
  7733. return spec;
  7734. },
  7735. // Builds and assigns a view spec's options object from its already-assigned defaults and overrides
  7736. buildViewSpecOptions: function (spec) {
  7737. spec.options = mergeOptions([ // lowest to highest priority
  7738. Calendar.defaults, // global defaults
  7739. spec.defaults, // view's defaults (from ViewSubclass.defaults)
  7740. this.dirDefaults,
  7741. this.localeDefaults, // locale and dir take precedence over view's defaults!
  7742. this.overrides, // calendar's overrides (options given to constructor)
  7743. spec.overrides, // view's overrides (view-specific options)
  7744. this.dynamicOverrides // dynamically set via setter. highest precedence
  7745. ]);
  7746. populateInstanceComputableOptions(spec.options);
  7747. },
  7748. // Computes and assigns a view spec's buttonText-related options
  7749. buildViewSpecButtonText: function (spec, requestedViewType) {
  7750. // given an options object with a possible `buttonText` hash, lookup the buttonText for the
  7751. // requested view, falling back to a generic unit entry like "week" or "day"
  7752. function queryButtonText(options) {
  7753. var buttonText = options.buttonText || {};
  7754. return buttonText[requestedViewType] ||
  7755. // view can decide to look up a certain key
  7756. (spec.buttonTextKey ? buttonText[spec.buttonTextKey] : null) ||
  7757. // a key like "month"
  7758. (spec.singleUnit ? buttonText[spec.singleUnit] : null);
  7759. }
  7760. // highest to lowest priority
  7761. spec.buttonTextOverride =
  7762. queryButtonText(this.dynamicOverrides) ||
  7763. queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence
  7764. spec.overrides.buttonText; // `buttonText` for view-specific options is a string
  7765. // highest to lowest priority. mirrors buildViewSpecOptions
  7766. spec.buttonTextDefault =
  7767. queryButtonText(this.localeDefaults) ||
  7768. queryButtonText(this.dirDefaults) ||
  7769. spec.defaults.buttonText || // a single string. from ViewSubclass.defaults
  7770. queryButtonText(Calendar.defaults) ||
  7771. (spec.duration ? this.humanizeDuration(spec.duration) : null) || // like "3 days"
  7772. requestedViewType; // fall back to given view name
  7773. },
  7774. // Given a view name for a custom view or a standard view, creates a ready-to-go View object
  7775. instantiateView: function (viewType) {
  7776. var spec = this.getViewSpec(viewType);
  7777. return new spec['class'](this, viewType, spec.options, spec.duration);
  7778. },
  7779. // Returns a boolean about whether the view is okay to instantiate at some point
  7780. isValidViewType: function (viewType) {
  7781. return Boolean(this.getViewSpec(viewType));
  7782. },
  7783. // Should be called when any type of async data fetching begins
  7784. pushLoading: function () {
  7785. if (!(this.loadingLevel++)) {
  7786. this.publiclyTrigger('loading', null, true, this.view);
  7787. }
  7788. },
  7789. // Should be called when any type of async data fetching completes
  7790. popLoading: function () {
  7791. if (!(--this.loadingLevel)) {
  7792. this.publiclyTrigger('loading', null, false, this.view);
  7793. }
  7794. },
  7795. // Given arguments to the select method in the API, returns a span (unzoned start/end and other info)
  7796. buildSelectSpan: function (zonedStartInput, zonedEndInput) {
  7797. var start = this.moment(zonedStartInput).stripZone();
  7798. var end;
  7799. if (zonedEndInput) {
  7800. end = this.moment(zonedEndInput).stripZone();
  7801. }
  7802. else if (start.hasTime()) {
  7803. end = start.clone().add(this.defaultTimedEventDuration);
  7804. }
  7805. else {
  7806. end = start.clone().add(this.defaultAllDayEventDuration);
  7807. }
  7808. return { start: start, end: end };
  7809. }
  7810. });
  7811. Calendar.mixin(EmitterMixin);
  7812. function Calendar_constructor(element, overrides) {
  7813. var t = this;
  7814. // Exports
  7815. // -----------------------------------------------------------------------------------
  7816. t.render = render;
  7817. t.destroy = destroy;
  7818. t.rerenderEvents = rerenderEvents;
  7819. t.changeView = renderView; // `renderView` will switch to another view
  7820. t.select = select;
  7821. t.unselect = unselect;
  7822. t.prev = prev;
  7823. t.next = next;
  7824. t.prevYear = prevYear;
  7825. t.nextYear = nextYear;
  7826. t.today = today;
  7827. t.gotoDate = gotoDate;
  7828. t.incrementDate = incrementDate;
  7829. t.zoomTo = zoomTo;
  7830. t.getDate = getDate;
  7831. t.getCalendar = getCalendar;
  7832. t.getView = getView;
  7833. t.option = option; // getter/setter method
  7834. t.publiclyTrigger = publiclyTrigger;
  7835. // Options
  7836. // -----------------------------------------------------------------------------------
  7837. t.dynamicOverrides = {};
  7838. t.viewSpecCache = {};
  7839. t.optionHandlers = {}; // for Calendar.options.js
  7840. t.overrides = $.extend({}, overrides); // make a copy
  7841. t.populateOptionsHash(); // sets this.options
  7842. // Locale-data Internals
  7843. // -----------------------------------------------------------------------------------
  7844. // Apply overrides to the current locale's data
  7845. var localeData;
  7846. // Called immediately, and when any of the options change.
  7847. // Happens before any internal objects rebuild or rerender, because this is very core.
  7848. t.bindOptions([
  7849. 'locale', 'monthNames', 'monthNamesShort', 'dayNames', 'dayNamesShort', 'firstDay', 'weekNumberCalculation'
  7850. ], function (locale, monthNames, monthNamesShort, dayNames, dayNamesShort, firstDay, weekNumberCalculation) {
  7851. // normalize
  7852. if (weekNumberCalculation === 'iso') {
  7853. weekNumberCalculation = 'ISO'; // normalize
  7854. }
  7855. localeData = createObject( // make a cheap copy
  7856. getMomentLocaleData(locale) // will fall back to en
  7857. );
  7858. if (monthNames) {
  7859. localeData._months = monthNames;
  7860. }
  7861. if (monthNamesShort) {
  7862. localeData._monthsShort = monthNamesShort;
  7863. }
  7864. if (dayNames) {
  7865. localeData._weekdays = dayNames;
  7866. }
  7867. if (dayNamesShort) {
  7868. localeData._weekdaysShort = dayNamesShort;
  7869. }
  7870. if (firstDay == null && weekNumberCalculation === 'ISO') {
  7871. firstDay = 1;
  7872. }
  7873. if (firstDay != null) {
  7874. var _week = createObject(localeData._week); // _week: { dow: # }
  7875. _week.dow = firstDay;
  7876. localeData._week = _week;
  7877. }
  7878. if ( // whitelist certain kinds of input
  7879. weekNumberCalculation === 'ISO' ||
  7880. weekNumberCalculation === 'local' ||
  7881. typeof weekNumberCalculation === 'function'
  7882. ) {
  7883. localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it
  7884. }
  7885. // If the internal current date object already exists, move to new locale.
  7886. // We do NOT need to do this technique for event dates, because this happens when converting to "segments".
  7887. if (date) {
  7888. localizeMoment(date); // sets to localeData
  7889. }
  7890. });
  7891. // Calendar-specific Date Utilities
  7892. // -----------------------------------------------------------------------------------
  7893. t.defaultAllDayEventDuration = moment.duration(t.options.defaultAllDayEventDuration);
  7894. t.defaultTimedEventDuration = moment.duration(t.options.defaultTimedEventDuration);
  7895. // Builds a moment using the settings of the current calendar: timezone and locale.
  7896. // Accepts anything the vanilla moment() constructor accepts.
  7897. t.moment = function () {
  7898. var mom;
  7899. if (t.options.timezone === 'local') {
  7900. mom = FC.moment.apply(null, arguments);
  7901. // Force the moment to be local, because FC.moment doesn't guarantee it.
  7902. if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone
  7903. mom.local();
  7904. }
  7905. }
  7906. else if (t.options.timezone === 'UTC') {
  7907. mom = FC.moment.utc.apply(null, arguments); // process as UTC
  7908. }
  7909. else {
  7910. mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone
  7911. }
  7912. localizeMoment(mom);
  7913. return mom;
  7914. };
  7915. // Updates the given moment's locale settings to the current calendar locale settings.
  7916. function localizeMoment(mom) {
  7917. mom._locale = localeData;
  7918. }
  7919. t.localizeMoment = localizeMoment;
  7920. // Returns a boolean about whether or not the calendar knows how to calculate
  7921. // the timezone offset of arbitrary dates in the current timezone.
  7922. t.getIsAmbigTimezone = function () {
  7923. return t.options.timezone !== 'local' && t.options.timezone !== 'UTC';
  7924. };
  7925. // Returns a copy of the given date in the current timezone. Has no effect on dates without times.
  7926. t.applyTimezone = function (date) {
  7927. if (!date.hasTime()) {
  7928. return date.clone();
  7929. }
  7930. var zonedDate = t.moment(date.toArray());
  7931. var timeAdjust = date.time() - zonedDate.time();
  7932. var adjustedZonedDate;
  7933. // Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396)
  7934. if (timeAdjust) { // is the time result different than expected?
  7935. adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds
  7936. if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now?
  7937. zonedDate = adjustedZonedDate;
  7938. }
  7939. }
  7940. return zonedDate;
  7941. };
  7942. // Returns a moment for the current date, as defined by the client's computer or from the `now` option.
  7943. // Will return an moment with an ambiguous timezone.
  7944. t.getNow = function () {
  7945. var now = t.options.now;
  7946. if (typeof now === 'function') {
  7947. now = now();
  7948. }
  7949. return t.moment(now).stripZone();
  7950. };
  7951. // Get an event's normalized end date. If not present, calculate it from the defaults.
  7952. t.getEventEnd = function (event) {
  7953. if (event.end) {
  7954. return event.end.clone();
  7955. }
  7956. else {
  7957. return t.getDefaultEventEnd(event.allDay, event.start);
  7958. }
  7959. };
  7960. // Given an event's allDay status and start date, return what its fallback end date should be.
  7961. // TODO: rename to computeDefaultEventEnd
  7962. t.getDefaultEventEnd = function (allDay, zonedStart) {
  7963. var end = zonedStart.clone();
  7964. if (allDay) {
  7965. end.stripTime().add(t.defaultAllDayEventDuration);
  7966. }
  7967. else {
  7968. end.add(t.defaultTimedEventDuration);
  7969. }
  7970. if (t.getIsAmbigTimezone()) {
  7971. end.stripZone(); // we don't know what the tzo should be
  7972. }
  7973. return end;
  7974. };
  7975. // Produces a human-readable string for the given duration.
  7976. // Side-effect: changes the locale of the given duration.
  7977. t.humanizeDuration = function (duration) {
  7978. return duration.locale(t.options.locale).humanize();
  7979. };
  7980. // Imports
  7981. // -----------------------------------------------------------------------------------
  7982. EventManager.call(t);
  7983. // Locals
  7984. // -----------------------------------------------------------------------------------
  7985. var _element = element[0];
  7986. var toolbarsManager;
  7987. var header;
  7988. var footer;
  7989. var content;
  7990. var tm; // for making theme classes
  7991. var currentView; // NOTE: keep this in sync with this.view
  7992. var viewsByType = {}; // holds all instantiated view instances, current or not
  7993. var suggestedViewHeight;
  7994. var windowResizeProxy; // wraps the windowResize function
  7995. var ignoreWindowResize = 0;
  7996. var date; // unzoned
  7997. // Main Rendering
  7998. // -----------------------------------------------------------------------------------
  7999. // compute the initial ambig-timezone date
  8000. if (t.options.defaultDate != null) {
  8001. date = t.moment(t.options.defaultDate).stripZone();
  8002. }
  8003. else {
  8004. date = t.getNow(); // getNow already returns unzoned
  8005. }
  8006. function render() {
  8007. if (!content) {
  8008. initialRender();
  8009. }
  8010. else if (elementVisible()) {
  8011. // mainly for the public API
  8012. calcSize();
  8013. renderView();
  8014. }
  8015. }
  8016. function initialRender() {
  8017. element.addClass('fc');
  8018. // event delegation for nav links
  8019. element.on('click.fc', 'a[data-goto]', function (ev) {
  8020. var anchorEl = $(this);
  8021. var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON
  8022. var date = t.moment(gotoOptions.date);
  8023. var viewType = gotoOptions.type;
  8024. // property like "navLinkDayClick". might be a string or a function
  8025. var customAction = currentView.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click');
  8026. if (typeof customAction === 'function') {
  8027. customAction(date, ev);
  8028. }
  8029. else {
  8030. if (typeof customAction === 'string') {
  8031. viewType = customAction;
  8032. }
  8033. zoomTo(date, viewType);
  8034. }
  8035. });
  8036. // called immediately, and upon option change
  8037. t.bindOption('theme', function (theme) {
  8038. tm = theme ? 'ui' : 'fc'; // affects a larger scope
  8039. element.toggleClass('ui-widget', theme);
  8040. element.toggleClass('fc-unthemed', !theme);
  8041. });
  8042. // called immediately, and upon option change.
  8043. // HACK: locale often affects isRTL, so we explicitly listen to that too.
  8044. t.bindOptions(['isRTL', 'locale'], function (isRTL) {
  8045. element.toggleClass('fc-ltr', !isRTL);
  8046. element.toggleClass('fc-rtl', isRTL);
  8047. });
  8048. content = $("<div class='fc-view-container'/>").prependTo(element);
  8049. var toolbars = buildToolbars();
  8050. toolbarsManager = new Iterator(toolbars);
  8051. header = t.header = toolbars[0];
  8052. footer = t.footer = toolbars[1];
  8053. renderHeader();
  8054. renderFooter();
  8055. renderView(t.options.defaultView);
  8056. if (t.options.handleWindowResize) {
  8057. windowResizeProxy = debounce(windowResize, t.options.windowResizeDelay); // prevents rapid calls
  8058. $(window).resize(windowResizeProxy);
  8059. }
  8060. }
  8061. function destroy() {
  8062. if (currentView) {
  8063. currentView.removeElement();
  8064. // NOTE: don't null-out currentView/t.view in case API methods are called after destroy.
  8065. // It is still the "current" view, just not rendered.
  8066. }
  8067. toolbarsManager.proxyCall('removeElement');
  8068. content.remove();
  8069. element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget');
  8070. element.off('.fc'); // unbind nav link handlers
  8071. if (windowResizeProxy) {
  8072. $(window).unbind('resize', windowResizeProxy);
  8073. }
  8074. }
  8075. function elementVisible() {
  8076. return element.is(':visible');
  8077. }
  8078. // View Rendering
  8079. // -----------------------------------------------------------------------------------
  8080. // Renders a view because of a date change, view-type change, or for the first time.
  8081. // If not given a viewType, keep the current view but render different dates.
  8082. // Accepts an optional scroll state to restore to.
  8083. function renderView(viewType, forcedScroll) {
  8084. ignoreWindowResize++;
  8085. var needsClearView = currentView && viewType && currentView.type !== viewType;
  8086. // if viewType is changing, remove the old view's rendering
  8087. if (needsClearView) {
  8088. freezeContentHeight(); // prevent a scroll jump when view element is removed
  8089. clearView();
  8090. }
  8091. // if viewType changed, or the view was never created, create a fresh view
  8092. if (!currentView && viewType) {
  8093. currentView = t.view =
  8094. viewsByType[viewType] ||
  8095. (viewsByType[viewType] = t.instantiateView(viewType));
  8096. currentView.setElement(
  8097. $("<div class='fc-view fc-" + viewType + "-view' />").appendTo(content)
  8098. );
  8099. toolbarsManager.proxyCall('activateButton', viewType);
  8100. }
  8101. if (currentView) {
  8102. // in case the view should render a period of time that is completely hidden
  8103. date = currentView.massageCurrentDate(date);
  8104. // render or rerender the view
  8105. if (
  8106. !currentView.isDateSet ||
  8107. !( // NOT within interval range signals an implicit date window change
  8108. date >= currentView.intervalStart &&
  8109. date < currentView.intervalEnd
  8110. )
  8111. ) {
  8112. if (elementVisible()) {
  8113. if (forcedScroll) {
  8114. currentView.captureInitialScroll(forcedScroll);
  8115. }
  8116. currentView.setDate(date, forcedScroll);
  8117. if (forcedScroll) {
  8118. currentView.releaseScroll();
  8119. }
  8120. // need to do this after View::render, so dates are calculated
  8121. // NOTE: view updates title text proactively
  8122. updateToolbarsTodayButton();
  8123. }
  8124. }
  8125. }
  8126. if (needsClearView) {
  8127. thawContentHeight();
  8128. }
  8129. ignoreWindowResize--;
  8130. }
  8131. // Unrenders the current view and reflects this change in the Header.
  8132. // Unregsiters the `currentView`, but does not remove from viewByType hash.
  8133. function clearView() {
  8134. toolbarsManager.proxyCall('deactivateButton', currentView.type);
  8135. currentView.removeElement();
  8136. currentView = t.view = null;
  8137. }
  8138. // Destroys the view, including the view object. Then, re-instantiates it and renders it.
  8139. // Maintains the same scroll state.
  8140. // TODO: maintain any other user-manipulated state.
  8141. function reinitView() {
  8142. ignoreWindowResize++;
  8143. freezeContentHeight();
  8144. var viewType = currentView.type;
  8145. var scrollState = currentView.queryScroll();
  8146. clearView();
  8147. calcSize();
  8148. renderView(viewType, scrollState);
  8149. thawContentHeight();
  8150. ignoreWindowResize--;
  8151. }
  8152. // Resizing
  8153. // -----------------------------------------------------------------------------------
  8154. t.getSuggestedViewHeight = function () {
  8155. if (suggestedViewHeight === undefined) {
  8156. calcSize();
  8157. }
  8158. return suggestedViewHeight;
  8159. };
  8160. t.isHeightAuto = function () {
  8161. return t.options.contentHeight === 'auto' || t.options.height === 'auto';
  8162. };
  8163. function updateSize(shouldRecalc) {
  8164. if (elementVisible()) {
  8165. if (shouldRecalc) {
  8166. _calcSize();
  8167. }
  8168. ignoreWindowResize++;
  8169. currentView.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto()
  8170. ignoreWindowResize--;
  8171. return true; // signal success
  8172. }
  8173. }
  8174. function calcSize() {
  8175. if (elementVisible()) {
  8176. _calcSize();
  8177. }
  8178. }
  8179. function _calcSize() { // assumes elementVisible
  8180. var contentHeightInput = t.options.contentHeight;
  8181. var heightInput = t.options.height;
  8182. if (typeof contentHeightInput === 'number') { // exists and not 'auto'
  8183. suggestedViewHeight = contentHeightInput;
  8184. }
  8185. else if (typeof contentHeightInput === 'function') { // exists and is a function
  8186. suggestedViewHeight = contentHeightInput();
  8187. }
  8188. else if (typeof heightInput === 'number') { // exists and not 'auto'
  8189. suggestedViewHeight = heightInput - queryToolbarsHeight();
  8190. }
  8191. else if (typeof heightInput === 'function') { // exists and is a function
  8192. suggestedViewHeight = heightInput() - queryToolbarsHeight();
  8193. }
  8194. else if (heightInput === 'parent') { // set to height of parent element
  8195. suggestedViewHeight = element.parent().height() - queryToolbarsHeight();
  8196. }
  8197. else {
  8198. suggestedViewHeight = Math.round(content.width() / Math.max(t.options.aspectRatio, .5));
  8199. }
  8200. }
  8201. function queryToolbarsHeight() {
  8202. return toolbarsManager.items.reduce(function (accumulator, toolbar) {
  8203. var toolbarHeight = toolbar.el ? toolbar.el.outerHeight(true) : 0; // includes margin
  8204. return accumulator + toolbarHeight;
  8205. }, 0);
  8206. }
  8207. function windowResize(ev) {
  8208. if (
  8209. !ignoreWindowResize &&
  8210. ev.target === window && // so we don't process jqui "resize" events that have bubbled up
  8211. currentView.start // view has already been rendered
  8212. ) {
  8213. if (updateSize(true)) {
  8214. currentView.publiclyTrigger('windowResize', _element);
  8215. }
  8216. }
  8217. }
  8218. /* Event Rendering
  8219. -----------------------------------------------------------------------------*/
  8220. function rerenderEvents() { // API method. destroys old events if previously rendered.
  8221. if (elementVisible()) {
  8222. t.reportEventChange(); // will re-trasmit events to the view, causing a rerender
  8223. }
  8224. }
  8225. /* Toolbars
  8226. -----------------------------------------------------------------------------*/
  8227. function buildToolbars() {
  8228. return [
  8229. new Toolbar(t, computeHeaderOptions()),
  8230. new Toolbar(t, computeFooterOptions())
  8231. ];
  8232. }
  8233. function computeHeaderOptions() {
  8234. return {
  8235. extraClasses: 'fc-header-toolbar',
  8236. layout: t.options.header
  8237. };
  8238. }
  8239. function computeFooterOptions() {
  8240. return {
  8241. extraClasses: 'fc-footer-toolbar',
  8242. layout: t.options.footer
  8243. };
  8244. }
  8245. // can be called repeatedly and Header will rerender
  8246. function renderHeader() {
  8247. header.setToolbarOptions(computeHeaderOptions());
  8248. header.render();
  8249. if (header.el) {
  8250. element.prepend(header.el);
  8251. }
  8252. }
  8253. // can be called repeatedly and Footer will rerender
  8254. function renderFooter() {
  8255. footer.setToolbarOptions(computeFooterOptions());
  8256. footer.render();
  8257. if (footer.el) {
  8258. element.append(footer.el);
  8259. }
  8260. }
  8261. t.setToolbarsTitle = function (title) {
  8262. toolbarsManager.proxyCall('updateTitle', title);
  8263. };
  8264. function updateToolbarsTodayButton() {
  8265. var now = t.getNow();
  8266. if (now >= currentView.intervalStart && now < currentView.intervalEnd) {
  8267. toolbarsManager.proxyCall('disableButton', 'today');
  8268. }
  8269. else {
  8270. toolbarsManager.proxyCall('enableButton', 'today');
  8271. }
  8272. }
  8273. /* Selection
  8274. -----------------------------------------------------------------------------*/
  8275. // this public method receives start/end dates in any format, with any timezone
  8276. function select(zonedStartInput, zonedEndInput) {
  8277. currentView.select(
  8278. t.buildSelectSpan.apply(t, arguments)
  8279. );
  8280. }
  8281. function unselect() { // safe to be called before renderView
  8282. if (currentView) {
  8283. currentView.unselect();
  8284. }
  8285. }
  8286. /* Date
  8287. -----------------------------------------------------------------------------*/
  8288. function prev() {
  8289. date = currentView.computePrevDate(date);
  8290. renderView();
  8291. }
  8292. function next() {
  8293. date = currentView.computeNextDate(date);
  8294. renderView();
  8295. }
  8296. function prevYear() {
  8297. date.add(-1, 'years');
  8298. renderView();
  8299. }
  8300. function nextYear() {
  8301. date.add(1, 'years');
  8302. renderView();
  8303. }
  8304. function today() {
  8305. date = t.getNow();
  8306. renderView();
  8307. }
  8308. function gotoDate(zonedDateInput) {
  8309. date = t.moment(zonedDateInput).stripZone();
  8310. renderView();
  8311. }
  8312. function incrementDate(delta) {
  8313. date.add(moment.duration(delta));
  8314. renderView();
  8315. }
  8316. // Forces navigation to a view for the given date.
  8317. // `viewType` can be a specific view name or a generic one like "week" or "day".
  8318. function zoomTo(newDate, viewType) {
  8319. var spec;
  8320. viewType = viewType || 'day'; // day is default zoom
  8321. spec = t.getViewSpec(viewType) || t.getUnitViewSpec(viewType);
  8322. date = newDate.clone();
  8323. renderView(spec ? spec.type : null);
  8324. }
  8325. // for external API
  8326. function getDate() {
  8327. return t.applyTimezone(date); // infuse the calendar's timezone
  8328. }
  8329. /* Height "Freezing"
  8330. -----------------------------------------------------------------------------*/
  8331. t.freezeContentHeight = freezeContentHeight;
  8332. t.thawContentHeight = thawContentHeight;
  8333. var freezeContentHeightDepth = 0;
  8334. function freezeContentHeight() {
  8335. if (!(freezeContentHeightDepth++)) {
  8336. content.css({
  8337. width: '100%',
  8338. height: content.height(),
  8339. overflow: 'hidden'
  8340. });
  8341. }
  8342. }
  8343. function thawContentHeight() {
  8344. if (!(--freezeContentHeightDepth)) {
  8345. content.css({
  8346. width: '',
  8347. height: '',
  8348. overflow: ''
  8349. });
  8350. }
  8351. }
  8352. /* Misc
  8353. -----------------------------------------------------------------------------*/
  8354. function getCalendar() {
  8355. return t;
  8356. }
  8357. function getView() {
  8358. return currentView;
  8359. }
  8360. function option(name, value) {
  8361. var newOptionHash;
  8362. if (typeof name === 'string') {
  8363. if (value === undefined) { // getter
  8364. return t.options[name];
  8365. }
  8366. else { // setter for individual option
  8367. newOptionHash = {};
  8368. newOptionHash[name] = value;
  8369. setOptions(newOptionHash);
  8370. }
  8371. }
  8372. else if (typeof name === 'object') { // compound setter with object input
  8373. setOptions(name);
  8374. }
  8375. }
  8376. function setOptions(newOptionHash) {
  8377. var optionCnt = 0;
  8378. var optionName;
  8379. for (optionName in newOptionHash) {
  8380. t.dynamicOverrides[optionName] = newOptionHash[optionName];
  8381. }
  8382. t.viewSpecCache = {}; // the dynamic override invalidates the options in this cache, so just clear it
  8383. t.populateOptionsHash(); // this.options needs to be recomputed after the dynamic override
  8384. // trigger handlers after this.options has been updated
  8385. for (optionName in newOptionHash) {
  8386. t.triggerOptionHandlers(optionName); // recall bindOption/bindOptions
  8387. optionCnt++;
  8388. }
  8389. // special-case handling of single option change.
  8390. // if only one option change, `optionName` will be its name.
  8391. if (optionCnt === 1) {
  8392. if (optionName === 'height' || optionName === 'contentHeight' || optionName === 'aspectRatio') {
  8393. updateSize(true); // true = allow recalculation of height
  8394. return;
  8395. }
  8396. else if (optionName === 'defaultDate') {
  8397. return; // can't change date this way. use gotoDate instead
  8398. }
  8399. else if (optionName === 'businessHours') {
  8400. if (currentView) {
  8401. currentView.unrenderBusinessHours();
  8402. currentView.renderBusinessHours();
  8403. }
  8404. return;
  8405. }
  8406. else if (optionName === 'timezone') {
  8407. t.rezoneArrayEventSources();
  8408. t.refetchEvents();
  8409. return;
  8410. }
  8411. }
  8412. // catch-all. rerender the header and footer and rebuild/rerender the current view
  8413. renderHeader();
  8414. renderFooter();
  8415. viewsByType = {}; // even non-current views will be affected by this option change. do before rerender
  8416. reinitView();
  8417. }
  8418. function publiclyTrigger(name, thisObj) {
  8419. var args = Array.prototype.slice.call(arguments, 2);
  8420. thisObj = thisObj || _element;
  8421. this.triggerWith(name, thisObj, args); // Emitter's method
  8422. if (t.options[name]) {
  8423. return t.options[name].apply(thisObj, args);
  8424. }
  8425. }
  8426. t.initialize();
  8427. }
  8428. ;;
  8429. /*
  8430. Options binding/triggering system.
  8431. */
  8432. Calendar.mixin({
  8433. // A map of option names to arrays of handler objects. Initialized to {} in Calendar.
  8434. // Format for a handler object:
  8435. // {
  8436. // func // callback function to be called upon change
  8437. // names // option names whose values should be given to func
  8438. // }
  8439. optionHandlers: null,
  8440. // Calls handlerFunc immediately, and when the given option has changed.
  8441. // handlerFunc will be given the option value.
  8442. bindOption: function (optionName, handlerFunc) {
  8443. this.bindOptions([optionName], handlerFunc);
  8444. },
  8445. // Calls handlerFunc immediately, and when any of the given options change.
  8446. // handlerFunc will be given each option value as ordered function arguments.
  8447. bindOptions: function (optionNames, handlerFunc) {
  8448. var handlerObj = { func: handlerFunc, names: optionNames };
  8449. var i;
  8450. for (i = 0; i < optionNames.length; i++) {
  8451. this.registerOptionHandlerObj(optionNames[i], handlerObj);
  8452. }
  8453. this.triggerOptionHandlerObj(handlerObj);
  8454. },
  8455. // Puts the given handler object into the internal hash
  8456. registerOptionHandlerObj: function (optionName, handlerObj) {
  8457. (this.optionHandlers[optionName] || (this.optionHandlers[optionName] = []))
  8458. .push(handlerObj);
  8459. },
  8460. // Reports that the given option has changed, and calls all appropriate handlers.
  8461. triggerOptionHandlers: function (optionName) {
  8462. var handlerObjs = this.optionHandlers[optionName] || [];
  8463. var i;
  8464. for (i = 0; i < handlerObjs.length; i++) {
  8465. this.triggerOptionHandlerObj(handlerObjs[i]);
  8466. }
  8467. },
  8468. // Calls the callback for a specific handler object, passing in the appropriate arguments.
  8469. triggerOptionHandlerObj: function (handlerObj) {
  8470. var optionNames = handlerObj.names;
  8471. var optionValues = [];
  8472. var i;
  8473. for (i = 0; i < optionNames.length; i++) {
  8474. optionValues.push(this.options[optionNames[i]]);
  8475. }
  8476. handlerObj.func.apply(this, optionValues); // maintain the Calendar's `this` context
  8477. }
  8478. });
  8479. ;;
  8480. Calendar.defaults = {
  8481. titleRangeSeparator: ' \u2013 ', // en dash
  8482. monthYearFormat: 'MMMM YYYY', // required for en. other locales rely on datepicker computable option
  8483. defaultTimedEventDuration: '02:00:00',
  8484. defaultAllDayEventDuration: { days: 1 },
  8485. forceEventDuration: false,
  8486. nextDayThreshold: '09:00:00', // 9am
  8487. // display
  8488. defaultView: 'month',
  8489. aspectRatio: 1.35,
  8490. header: {
  8491. left: 'title',
  8492. center: '',
  8493. right: 'today prev,next'
  8494. },
  8495. weekends: true,
  8496. weekNumbers: false,
  8497. weekNumberTitle: 'W',
  8498. weekNumberCalculation: 'local',
  8499. //editable: false,
  8500. //nowIndicator: false,
  8501. scrollTime: '06:00:00',
  8502. // event ajax
  8503. lazyFetching: true,
  8504. startParam: 'start',
  8505. endParam: 'end',
  8506. timezoneParam: 'timezone',
  8507. timezone: false,
  8508. //allDayDefault: undefined,
  8509. // locale
  8510. isRTL: false,
  8511. buttonText: {
  8512. prev: "prev",
  8513. next: "next",
  8514. prevYear: "prev year",
  8515. nextYear: "next year",
  8516. year: 'year', // TODO: locale files need to specify this
  8517. today: 'today',
  8518. month: 'month',
  8519. week: 'week',
  8520. day: 'day'
  8521. },
  8522. buttonIcons: {
  8523. prev: 'left-single-arrow',
  8524. next: 'right-single-arrow',
  8525. prevYear: 'left-double-arrow',
  8526. nextYear: 'right-double-arrow'
  8527. },
  8528. allDayText: 'all-day',
  8529. // jquery-ui theming
  8530. theme: false,
  8531. themeButtonIcons: {
  8532. prev: 'circle-triangle-w',
  8533. next: 'circle-triangle-e',
  8534. prevYear: 'seek-prev',
  8535. nextYear: 'seek-next'
  8536. },
  8537. //eventResizableFromStart: false,
  8538. dragOpacity: .75,
  8539. dragRevertDuration: 500,
  8540. dragScroll: true,
  8541. //selectable: false,
  8542. unselectAuto: true,
  8543. dropAccept: '*',
  8544. eventOrder: 'title',
  8545. //eventRenderWait: null,
  8546. eventLimit: false,
  8547. eventLimitText: 'more',
  8548. eventLimitClick: 'popover',
  8549. dayPopoverFormat: 'LL',
  8550. handleWindowResize: true,
  8551. windowResizeDelay: 100, // milliseconds before an updateSize happens
  8552. longPressDelay: 1000
  8553. };
  8554. Calendar.englishDefaults = { // used by locale.js
  8555. dayPopoverFormat: 'dddd, MMMM D'
  8556. };
  8557. Calendar.rtlDefaults = { // right-to-left defaults
  8558. header: { // TODO: smarter solution (first/center/last ?)
  8559. left: 'next,prev today',
  8560. center: '',
  8561. right: 'title'
  8562. },
  8563. buttonIcons: {
  8564. prev: 'right-single-arrow',
  8565. next: 'left-single-arrow',
  8566. prevYear: 'right-double-arrow',
  8567. nextYear: 'left-double-arrow'
  8568. },
  8569. themeButtonIcons: {
  8570. prev: 'circle-triangle-e',
  8571. next: 'circle-triangle-w',
  8572. nextYear: 'seek-prev',
  8573. prevYear: 'seek-next'
  8574. }
  8575. };
  8576. ;;
  8577. var localeOptionHash = FC.locales = {}; // initialize and expose
  8578. // TODO: document the structure and ordering of a FullCalendar locale file
  8579. // Initialize jQuery UI datepicker translations while using some of the translations
  8580. // Will set this as the default locales for datepicker.
  8581. FC.datepickerLocale = function (localeCode, dpLocaleCode, dpOptions) {
  8582. // get the FullCalendar internal option hash for this locale. create if necessary
  8583. var fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {});
  8584. // transfer some simple options from datepicker to fc
  8585. fcOptions.isRTL = dpOptions.isRTL;
  8586. fcOptions.weekNumberTitle = dpOptions.weekHeader;
  8587. // compute some more complex options from datepicker
  8588. $.each(dpComputableOptions, function (name, func) {
  8589. fcOptions[name] = func(dpOptions);
  8590. });
  8591. // is jQuery UI Datepicker is on the page?
  8592. if ($.datepicker) {
  8593. // Register the locale data.
  8594. // FullCalendar and MomentJS use locale codes like "pt-br" but Datepicker
  8595. // does it like "pt-BR" or if it doesn't have the locale, maybe just "pt".
  8596. // Make an alias so the locale can be referenced either way.
  8597. $.datepicker.regional[dpLocaleCode] =
  8598. $.datepicker.regional[localeCode] = // alias
  8599. dpOptions;
  8600. // Alias 'en' to the default locale data. Do this every time.
  8601. $.datepicker.regional.en = $.datepicker.regional[''];
  8602. // Set as Datepicker's global defaults.
  8603. $.datepicker.setDefaults(dpOptions);
  8604. }
  8605. };
  8606. // Sets FullCalendar-specific translations. Will set the locales as the global default.
  8607. FC.locale = function (localeCode, newFcOptions) {
  8608. var fcOptions;
  8609. var momOptions;
  8610. // get the FullCalendar internal option hash for this locale. create if necessary
  8611. fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {});
  8612. // provided new options for this locales? merge them in
  8613. if (newFcOptions) {
  8614. fcOptions = localeOptionHash[localeCode] = mergeOptions([fcOptions, newFcOptions]);
  8615. }
  8616. // compute locale options that weren't defined.
  8617. // always do this. newFcOptions can be undefined when initializing from i18n file,
  8618. // so no way to tell if this is an initialization or a default-setting.
  8619. momOptions = getMomentLocaleData(localeCode); // will fall back to en
  8620. $.each(momComputableOptions, function (name, func) {
  8621. if (fcOptions[name] == null) {
  8622. fcOptions[name] = func(momOptions, fcOptions);
  8623. }
  8624. });
  8625. // set it as the default locale for FullCalendar
  8626. Calendar.defaults.locale = localeCode;
  8627. };
  8628. // NOTE: can't guarantee any of these computations will run because not every locale has datepicker
  8629. // configs, so make sure there are English fallbacks for these in the defaults file.
  8630. var dpComputableOptions = {
  8631. buttonText: function (dpOptions) {
  8632. return {
  8633. // the translations sometimes wrongly contain HTML entities
  8634. prev: stripHtmlEntities(dpOptions.prevText),
  8635. next: stripHtmlEntities(dpOptions.nextText),
  8636. today: stripHtmlEntities(dpOptions.currentText)
  8637. };
  8638. },
  8639. // Produces format strings like "MMMM YYYY" -> "September 2014"
  8640. monthYearFormat: function (dpOptions) {
  8641. return dpOptions.showMonthAfterYear ?
  8642. 'YYYY[' + dpOptions.yearSuffix + '] MMMM' :
  8643. 'MMMM YYYY[' + dpOptions.yearSuffix + ']';
  8644. }
  8645. };
  8646. var momComputableOptions = {
  8647. // Produces format strings like "ddd M/D" -> "Fri 9/15"
  8648. dayOfMonthFormat: function (momOptions, fcOptions) {
  8649. var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY"
  8650. // strip the year off the edge, as well as other misc non-whitespace chars
  8651. format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, '');
  8652. if (fcOptions.isRTL) {
  8653. format += ' ddd'; // for RTL, add day-of-week to end
  8654. }
  8655. else {
  8656. format = 'ddd ' + format; // for LTR, add day-of-week to beginning
  8657. }
  8658. return format;
  8659. },
  8660. // Produces format strings like "h:mma" -> "6:00pm"
  8661. mediumTimeFormat: function (momOptions) { // can't be called `timeFormat` because collides with option
  8662. return momOptions.longDateFormat('LT')
  8663. .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
  8664. },
  8665. // Produces format strings like "h(:mm)a" -> "6pm" / "6:30pm"
  8666. smallTimeFormat: function (momOptions) {
  8667. return momOptions.longDateFormat('LT')
  8668. .replace(':mm', '(:mm)')
  8669. .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales
  8670. .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
  8671. },
  8672. // Produces format strings like "h(:mm)t" -> "6p" / "6:30p"
  8673. extraSmallTimeFormat: function (momOptions) {
  8674. return momOptions.longDateFormat('LT')
  8675. .replace(':mm', '(:mm)')
  8676. .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales
  8677. .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand
  8678. },
  8679. // Produces format strings like "ha" / "H" -> "6pm" / "18"
  8680. hourFormat: function (momOptions) {
  8681. return momOptions.longDateFormat('LT')
  8682. .replace(':mm', '')
  8683. .replace(/(\Wmm)$/, '') // like above, but for foreign locales
  8684. .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
  8685. },
  8686. // Produces format strings like "h:mm" -> "6:30" (with no AM/PM)
  8687. noMeridiemTimeFormat: function (momOptions) {
  8688. return momOptions.longDateFormat('LT')
  8689. .replace(/\s*a$/i, ''); // remove trailing AM/PM
  8690. }
  8691. };
  8692. // options that should be computed off live calendar options (considers override options)
  8693. // TODO: best place for this? related to locale?
  8694. // TODO: flipping text based on isRTL is a bad idea because the CSS `direction` might want to handle it
  8695. var instanceComputableOptions = {
  8696. // Produces format strings for results like "Mo 16"
  8697. smallDayDateFormat: function (options) {
  8698. return options.isRTL ?
  8699. 'D dd' :
  8700. 'dd D';
  8701. },
  8702. // Produces format strings for results like "Wk 5"
  8703. weekFormat: function (options) {
  8704. return options.isRTL ?
  8705. 'w[ ' + options.weekNumberTitle + ']' :
  8706. '[' + options.weekNumberTitle + ' ]w';
  8707. },
  8708. // Produces format strings for results like "Wk5"
  8709. smallWeekFormat: function (options) {
  8710. return options.isRTL ?
  8711. 'w[' + options.weekNumberTitle + ']' :
  8712. '[' + options.weekNumberTitle + ']w';
  8713. }
  8714. };
  8715. function populateInstanceComputableOptions(options) {
  8716. $.each(instanceComputableOptions, function (name, func) {
  8717. if (options[name] == null) {
  8718. options[name] = func(options);
  8719. }
  8720. });
  8721. }
  8722. // Returns moment's internal locale data. If doesn't exist, returns English.
  8723. function getMomentLocaleData(localeCode) {
  8724. return moment.localeData(localeCode) || moment.localeData('en');
  8725. }
  8726. // Initialize English by forcing computation of moment-derived options.
  8727. // Also, sets it as the default.
  8728. FC.locale('en', Calendar.englishDefaults);
  8729. ;;
  8730. FC.sourceNormalizers = [];
  8731. FC.sourceFetchers = [];
  8732. var ajaxDefaults = {
  8733. dataType: 'json',
  8734. cache: false
  8735. };
  8736. var eventGUID = 1;
  8737. function EventManager() { // assumed to be a calendar
  8738. var t = this;
  8739. // exports
  8740. t.requestEvents = requestEvents;
  8741. t.reportEventChange = reportEventChange;
  8742. t.isFetchNeeded = isFetchNeeded;
  8743. t.fetchEvents = fetchEvents;
  8744. t.fetchEventSources = fetchEventSources;
  8745. t.refetchEvents = refetchEvents;
  8746. t.refetchEventSources = refetchEventSources;
  8747. t.getEventSources = getEventSources;
  8748. t.getEventSourceById = getEventSourceById;
  8749. t.addEventSource = addEventSource;
  8750. t.removeEventSource = removeEventSource;
  8751. t.removeEventSources = removeEventSources;
  8752. t.updateEvent = updateEvent;
  8753. t.updateEvents = updateEvents;
  8754. t.renderEvent = renderEvent;
  8755. t.renderEvents = renderEvents;
  8756. t.removeEvents = removeEvents;
  8757. t.clientEvents = clientEvents;
  8758. t.mutateEvent = mutateEvent;
  8759. t.normalizeEventDates = normalizeEventDates;
  8760. t.normalizeEventTimes = normalizeEventTimes;
  8761. // locals
  8762. var stickySource = { events: [] };
  8763. var sources = [stickySource];
  8764. var rangeStart, rangeEnd;
  8765. var pendingSourceCnt = 0; // outstanding fetch requests, max one per source
  8766. var cache = []; // holds events that have already been expanded
  8767. var prunedCache; // like cache, but only events that intersect with rangeStart/rangeEnd
  8768. $.each(
  8769. (t.options.events ? [t.options.events] : []).concat(t.options.eventSources || []),
  8770. function (i, sourceInput) {
  8771. var source = buildEventSource(sourceInput);
  8772. if (source) {
  8773. sources.push(source);
  8774. }
  8775. }
  8776. );
  8777. function requestEvents(start, end) {
  8778. if (!t.options.lazyFetching || isFetchNeeded(start, end)) {
  8779. return fetchEvents(start, end);
  8780. }
  8781. else {
  8782. return Promise.resolve(prunedCache);
  8783. }
  8784. }
  8785. function reportEventChange() {
  8786. prunedCache = filterEventsWithinRange(cache);
  8787. t.trigger('eventsReset', prunedCache);
  8788. }
  8789. function filterEventsWithinRange(events) {
  8790. var filteredEvents = [];
  8791. var i, event;
  8792. for (i = 0; i < events.length; i++) {
  8793. event = events[i];
  8794. if (
  8795. event.start.clone().stripZone() < rangeEnd &&
  8796. t.getEventEnd(event).stripZone() > rangeStart
  8797. ) {
  8798. filteredEvents.push(event);
  8799. }
  8800. }
  8801. return filteredEvents;
  8802. }
  8803. t.getEventCache = function () {
  8804. return cache;
  8805. };
  8806. t.getPrunedEventCache = function () {
  8807. return prunedCache;
  8808. };
  8809. /* Fetching
  8810. -----------------------------------------------------------------------------*/
  8811. // start and end are assumed to be unzoned
  8812. function isFetchNeeded(start, end) {
  8813. return !rangeStart || // nothing has been fetched yet?
  8814. start < rangeStart || end > rangeEnd; // is part of the new range outside of the old range?
  8815. }
  8816. function fetchEvents(start, end) {
  8817. rangeStart = start;
  8818. rangeEnd = end;
  8819. return refetchEvents();
  8820. }
  8821. // poorly named. fetches all sources with current `rangeStart` and `rangeEnd`.
  8822. function refetchEvents() {
  8823. return fetchEventSources(sources, 'reset');
  8824. }
  8825. // poorly named. fetches a subset of event sources.
  8826. function refetchEventSources(matchInputs) {
  8827. return fetchEventSources(getEventSourcesByMatchArray(matchInputs));
  8828. }
  8829. // expects an array of event source objects (the originals, not copies)
  8830. // `specialFetchType` is an optimization parameter that affects purging of the event cache.
  8831. function fetchEventSources(specificSources, specialFetchType) {
  8832. var i, source;
  8833. if (specialFetchType === 'reset') {
  8834. cache = [];
  8835. }
  8836. else if (specialFetchType !== 'add') {
  8837. cache = excludeEventsBySources(cache, specificSources);
  8838. }
  8839. for (i = 0; i < specificSources.length; i++) {
  8840. source = specificSources[i];
  8841. // already-pending sources have already been accounted for in pendingSourceCnt
  8842. if (source._status !== 'pending') {
  8843. pendingSourceCnt++;
  8844. }
  8845. source._fetchId = (source._fetchId || 0) + 1;
  8846. source._status = 'pending';
  8847. }
  8848. for (i = 0; i < specificSources.length; i++) {
  8849. source = specificSources[i];
  8850. tryFetchEventSource(source, source._fetchId);
  8851. }
  8852. if (pendingSourceCnt) {
  8853. return new Promise(function (resolve) {
  8854. t.one('eventsReceived', resolve); // will send prunedCache
  8855. });
  8856. }
  8857. else { // executed all synchronously, or no sources at all
  8858. return Promise.resolve(prunedCache);
  8859. }
  8860. }
  8861. // fetches an event source and processes its result ONLY if it is still the current fetch.
  8862. // caller is responsible for incrementing pendingSourceCnt first.
  8863. function tryFetchEventSource(source, fetchId) {
  8864. _fetchEventSource(source, function (eventInputs) {
  8865. var isArraySource = $.isArray(source.events);
  8866. var i, eventInput;
  8867. var abstractEvent;
  8868. if (
  8869. // is this the source's most recent fetch?
  8870. // if not, rely on an upcoming fetch of this source to decrement pendingSourceCnt
  8871. fetchId === source._fetchId &&
  8872. // event source no longer valid?
  8873. source._status !== 'rejected'
  8874. ) {
  8875. source._status = 'resolved';
  8876. if (eventInputs) {
  8877. for (i = 0; i < eventInputs.length; i++) {
  8878. eventInput = eventInputs[i];
  8879. if (isArraySource) { // array sources have already been convert to Event Objects
  8880. abstractEvent = eventInput;
  8881. }
  8882. else {
  8883. abstractEvent = buildEventFromInput(eventInput, source);
  8884. }
  8885. if (abstractEvent) { // not false (an invalid event)
  8886. cache.push.apply( // append
  8887. cache,
  8888. expandEvent(abstractEvent) // add individual expanded events to the cache
  8889. );
  8890. }
  8891. }
  8892. }
  8893. decrementPendingSourceCnt();
  8894. }
  8895. });
  8896. }
  8897. function rejectEventSource(source) {
  8898. var wasPending = source._status === 'pending';
  8899. source._status = 'rejected';
  8900. if (wasPending) {
  8901. decrementPendingSourceCnt();
  8902. }
  8903. }
  8904. function decrementPendingSourceCnt() {
  8905. pendingSourceCnt--;
  8906. if (!pendingSourceCnt) {
  8907. reportEventChange(cache); // updates prunedCache
  8908. t.trigger('eventsReceived', prunedCache);
  8909. }
  8910. }
  8911. function _fetchEventSource(source, callback) {
  8912. var i;
  8913. var fetchers = FC.sourceFetchers;
  8914. var res;
  8915. for (i = 0; i < fetchers.length; i++) {
  8916. res = fetchers[i].call(
  8917. t, // this, the Calendar object
  8918. source,
  8919. rangeStart.clone(),
  8920. rangeEnd.clone(),
  8921. t.options.timezone,
  8922. callback
  8923. );
  8924. if (res === true) {
  8925. // the fetcher is in charge. made its own async request
  8926. return;
  8927. }
  8928. else if (typeof res == 'object') {
  8929. // the fetcher returned a new source. process it
  8930. _fetchEventSource(res, callback);
  8931. return;
  8932. }
  8933. }
  8934. var events = source.events;
  8935. if (events) {
  8936. if ($.isFunction(events)) {
  8937. t.pushLoading();
  8938. events.call(
  8939. t, // this, the Calendar object
  8940. rangeStart.clone(),
  8941. rangeEnd.clone(),
  8942. t.options.timezone,
  8943. function (events) {
  8944. callback(events);
  8945. t.popLoading();
  8946. }
  8947. );
  8948. }
  8949. else if ($.isArray(events)) {
  8950. callback(events);
  8951. }
  8952. else {
  8953. callback();
  8954. }
  8955. } else {
  8956. var url = source.url;
  8957. if (url) {
  8958. var success = source.success;
  8959. var error = source.error;
  8960. var complete = source.complete;
  8961. // retrieve any outbound GET/POST $.ajax data from the options
  8962. var customData;
  8963. if ($.isFunction(source.data)) {
  8964. // supplied as a function that returns a key/value object
  8965. customData = source.data();
  8966. }
  8967. else {
  8968. // supplied as a straight key/value object
  8969. customData = source.data;
  8970. }
  8971. // use a copy of the custom data so we can modify the parameters
  8972. // and not affect the passed-in object.
  8973. var data = $.extend({}, customData || {});
  8974. var startParam = firstDefined(source.startParam, t.options.startParam);
  8975. var endParam = firstDefined(source.endParam, t.options.endParam);
  8976. var timezoneParam = firstDefined(source.timezoneParam, t.options.timezoneParam);
  8977. if (startParam) {
  8978. data[startParam] = rangeStart.format();
  8979. }
  8980. if (endParam) {
  8981. data[endParam] = rangeEnd.format();
  8982. }
  8983. if (t.options.timezone && t.options.timezone != 'local') {
  8984. data[timezoneParam] = t.options.timezone;
  8985. }
  8986. t.pushLoading();
  8987. $.ajax($.extend({}, ajaxDefaults, source, {
  8988. data: data,
  8989. success: function (events) {
  8990. events = events || [];
  8991. var res = applyAll(success, this, arguments);
  8992. if ($.isArray(res)) {
  8993. events = res;
  8994. }
  8995. callback(events);
  8996. },
  8997. error: function () {
  8998. applyAll(error, this, arguments);
  8999. callback();
  9000. },
  9001. complete: function () {
  9002. applyAll(complete, this, arguments);
  9003. t.popLoading();
  9004. }
  9005. }));
  9006. } else {
  9007. callback();
  9008. }
  9009. }
  9010. }
  9011. /* Sources
  9012. -----------------------------------------------------------------------------*/
  9013. function addEventSource(sourceInput) {
  9014. var source = buildEventSource(sourceInput);
  9015. if (source) {
  9016. sources.push(source);
  9017. fetchEventSources([source], 'add'); // will eventually call reportEventChange
  9018. }
  9019. }
  9020. function buildEventSource(sourceInput) { // will return undefined if invalid source
  9021. var normalizers = FC.sourceNormalizers;
  9022. var source;
  9023. var i;
  9024. if ($.isFunction(sourceInput) || $.isArray(sourceInput)) {
  9025. source = { events: sourceInput };
  9026. }
  9027. else if (typeof sourceInput === 'string') {
  9028. source = { url: sourceInput };
  9029. }
  9030. else if (typeof sourceInput === 'object') {
  9031. source = $.extend({}, sourceInput); // shallow copy
  9032. }
  9033. if (source) {
  9034. // TODO: repeat code, same code for event classNames
  9035. if (source.className) {
  9036. if (typeof source.className === 'string') {
  9037. source.className = source.className.split(/\s+/);
  9038. }
  9039. // otherwise, assumed to be an array
  9040. }
  9041. else {
  9042. source.className = [];
  9043. }
  9044. // for array sources, we convert to standard Event Objects up front
  9045. if ($.isArray(source.events)) {
  9046. source.origArray = source.events; // for removeEventSource
  9047. source.events = $.map(source.events, function (eventInput) {
  9048. return buildEventFromInput(eventInput, source);
  9049. });
  9050. }
  9051. for (i = 0; i < normalizers.length; i++) {
  9052. normalizers[i].call(t, source);
  9053. }
  9054. return source;
  9055. }
  9056. }
  9057. function removeEventSource(matchInput) {
  9058. removeSpecificEventSources(
  9059. getEventSourcesByMatch(matchInput)
  9060. );
  9061. }
  9062. // if called with no arguments, removes all.
  9063. function removeEventSources(matchInputs) {
  9064. if (matchInputs == null) {
  9065. removeSpecificEventSources(sources, true); // isAll=true
  9066. }
  9067. else {
  9068. removeSpecificEventSources(
  9069. getEventSourcesByMatchArray(matchInputs)
  9070. );
  9071. }
  9072. }
  9073. function removeSpecificEventSources(targetSources, isAll) {
  9074. var i;
  9075. // cancel pending requests
  9076. for (i = 0; i < targetSources.length; i++) {
  9077. rejectEventSource(targetSources[i]);
  9078. }
  9079. if (isAll) { // an optimization
  9080. sources = [];
  9081. cache = [];
  9082. }
  9083. else {
  9084. // remove from persisted source list
  9085. sources = $.grep(sources, function (source) {
  9086. for (i = 0; i < targetSources.length; i++) {
  9087. if (source === targetSources[i]) {
  9088. return false; // exclude
  9089. }
  9090. }
  9091. return true; // include
  9092. });
  9093. cache = excludeEventsBySources(cache, targetSources);
  9094. }
  9095. reportEventChange();
  9096. }
  9097. function getEventSources() {
  9098. return sources.slice(1); // returns a shallow copy of sources with stickySource removed
  9099. }
  9100. function getEventSourceById(id) {
  9101. return $.grep(sources, function (source) {
  9102. return source.id && source.id === id;
  9103. })[0];
  9104. }
  9105. // like getEventSourcesByMatch, but accepts multple match criteria (like multiple IDs)
  9106. function getEventSourcesByMatchArray(matchInputs) {
  9107. // coerce into an array
  9108. if (!matchInputs) {
  9109. matchInputs = [];
  9110. }
  9111. else if (!$.isArray(matchInputs)) {
  9112. matchInputs = [matchInputs];
  9113. }
  9114. var matchingSources = [];
  9115. var i;
  9116. // resolve raw inputs to real event source objects
  9117. for (i = 0; i < matchInputs.length; i++) {
  9118. matchingSources.push.apply( // append
  9119. matchingSources,
  9120. getEventSourcesByMatch(matchInputs[i])
  9121. );
  9122. }
  9123. return matchingSources;
  9124. }
  9125. // matchInput can either by a real event source object, an ID, or the function/URL for the source.
  9126. // returns an array of matching source objects.
  9127. function getEventSourcesByMatch(matchInput) {
  9128. var i, source;
  9129. // given an proper event source object
  9130. for (i = 0; i < sources.length; i++) {
  9131. source = sources[i];
  9132. if (source === matchInput) {
  9133. return [source];
  9134. }
  9135. }
  9136. // an ID match
  9137. source = getEventSourceById(matchInput);
  9138. if (source) {
  9139. return [source];
  9140. }
  9141. return $.grep(sources, function (source) {
  9142. return isSourcesEquivalent(matchInput, source);
  9143. });
  9144. }
  9145. function isSourcesEquivalent(source1, source2) {
  9146. return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2);
  9147. }
  9148. function getSourcePrimitive(source) {
  9149. return (
  9150. (typeof source === 'object') ? // a normalized event source?
  9151. (source.origArray || source.googleCalendarId || source.url || source.events) : // get the primitive
  9152. null
  9153. ) ||
  9154. source; // the given argument *is* the primitive
  9155. }
  9156. // util
  9157. // returns a filtered array without events that are part of any of the given sources
  9158. function excludeEventsBySources(specificEvents, specificSources) {
  9159. return $.grep(specificEvents, function (event) {
  9160. for (var i = 0; i < specificSources.length; i++) {
  9161. if (event.source === specificSources[i]) {
  9162. return false; // exclude
  9163. }
  9164. }
  9165. return true; // keep
  9166. });
  9167. }
  9168. /* Manipulation
  9169. -----------------------------------------------------------------------------*/
  9170. // Only ever called from the externally-facing API
  9171. function updateEvent(event) {
  9172. updateEvents([event]);
  9173. }
  9174. // Only ever called from the externally-facing API
  9175. function updateEvents(events) {
  9176. var i, event;
  9177. for (i = 0; i < events.length; i++) {
  9178. event = events[i];
  9179. // massage start/end values, even if date string values
  9180. event.start = t.moment(event.start);
  9181. if (event.end) {
  9182. event.end = t.moment(event.end);
  9183. }
  9184. else {
  9185. event.end = null;
  9186. }
  9187. mutateEvent(event, getMiscEventProps(event)); // will handle start/end/allDay normalization
  9188. }
  9189. reportEventChange(); // reports event modifications (so we can redraw)
  9190. }
  9191. // Returns a hash of misc event properties that should be copied over to related events.
  9192. function getMiscEventProps(event) {
  9193. var props = {};
  9194. $.each(event, function (name, val) {
  9195. if (isMiscEventPropName(name)) {
  9196. if (val !== undefined && isAtomic(val)) { // a defined non-object
  9197. props[name] = val;
  9198. }
  9199. }
  9200. });
  9201. return props;
  9202. }
  9203. // non-date-related, non-id-related, non-secret
  9204. function isMiscEventPropName(name) {
  9205. return !/^_|^(id|allDay|start|end)$/.test(name);
  9206. }
  9207. // returns the expanded events that were created
  9208. function renderEvent(eventInput, stick) {
  9209. return renderEvents([eventInput], stick);
  9210. }
  9211. // returns the expanded events that were created
  9212. function renderEvents(eventInputs, stick) {
  9213. var renderedEvents = [];
  9214. var renderableEvents;
  9215. var abstractEvent;
  9216. var i, j, event;
  9217. for (i = 0; i < eventInputs.length; i++) {
  9218. abstractEvent = buildEventFromInput(eventInputs[i]);
  9219. if (abstractEvent) { // not false (a valid input)
  9220. renderableEvents = expandEvent(abstractEvent);
  9221. for (j = 0; j < renderableEvents.length; j++) {
  9222. event = renderableEvents[j];
  9223. if (!event.source) {
  9224. if (stick) {
  9225. stickySource.events.push(event);
  9226. event.source = stickySource;
  9227. }
  9228. cache.push(event);
  9229. }
  9230. }
  9231. renderedEvents = renderedEvents.concat(renderableEvents);
  9232. }
  9233. }
  9234. if (renderedEvents.length) { // any new events rendered?
  9235. reportEventChange();
  9236. }
  9237. return renderedEvents;
  9238. }
  9239. function removeEvents(filter) {
  9240. var eventID;
  9241. var i;
  9242. if (filter == null) { // null or undefined. remove all events
  9243. filter = function () { return true; }; // will always match
  9244. }
  9245. else if (!$.isFunction(filter)) { // an event ID
  9246. eventID = filter + '';
  9247. filter = function (event) {
  9248. return event._id == eventID;
  9249. };
  9250. }
  9251. // Purge event(s) from our local cache
  9252. cache = $.grep(cache, filter, true); // inverse=true
  9253. // Remove events from array sources.
  9254. // This works because they have been converted to official Event Objects up front.
  9255. // (and as a result, event._id has been calculated).
  9256. for (i = 0; i < sources.length; i++) {
  9257. if ($.isArray(sources[i].events)) {
  9258. sources[i].events = $.grep(sources[i].events, filter, true);
  9259. }
  9260. }
  9261. reportEventChange();
  9262. }
  9263. function clientEvents(filter) {
  9264. if ($.isFunction(filter)) {
  9265. return $.grep(cache, filter);
  9266. }
  9267. else if (filter != null) { // not null, not undefined. an event ID
  9268. filter += '';
  9269. return $.grep(cache, function (e) {
  9270. return e._id == filter;
  9271. });
  9272. }
  9273. return cache; // else, return all
  9274. }
  9275. // Makes sure all array event sources have their internal event objects
  9276. // converted over to the Calendar's current timezone.
  9277. t.rezoneArrayEventSources = function () {
  9278. var i;
  9279. var events;
  9280. var j;
  9281. for (i = 0; i < sources.length; i++) {
  9282. events = sources[i].events;
  9283. if ($.isArray(events)) {
  9284. for (j = 0; j < events.length; j++) {
  9285. rezoneEventDates(events[j]);
  9286. }
  9287. }
  9288. }
  9289. };
  9290. function rezoneEventDates(event) {
  9291. event.start = t.moment(event.start);
  9292. if (event.end) {
  9293. event.end = t.moment(event.end);
  9294. }
  9295. backupEventDates(event);
  9296. }
  9297. /* Event Normalization
  9298. -----------------------------------------------------------------------------*/
  9299. // Given a raw object with key/value properties, returns an "abstract" Event object.
  9300. // An "abstract" event is an event that, if recurring, will not have been expanded yet.
  9301. // Will return `false` when input is invalid.
  9302. // `source` is optional
  9303. function buildEventFromInput(input, source) {
  9304. var out = {};
  9305. var start, end;
  9306. var allDay;
  9307. if (t.options.eventDataTransform) {
  9308. input = t.options.eventDataTransform(input);
  9309. }
  9310. if (source && source.eventDataTransform) {
  9311. input = source.eventDataTransform(input);
  9312. }
  9313. // Copy all properties over to the resulting object.
  9314. // The special-case properties will be copied over afterwards.
  9315. $.extend(out, input);
  9316. if (source) {
  9317. out.source = source;
  9318. }
  9319. out._id = input._id || (input.id === undefined ? '_fc' + eventGUID++ : input.id + '');
  9320. if (input.className) {
  9321. if (typeof input.className == 'string') {
  9322. out.className = input.className.split(/\s+/);
  9323. }
  9324. else { // assumed to be an array
  9325. out.className = input.className;
  9326. }
  9327. }
  9328. else {
  9329. out.className = [];
  9330. }
  9331. start = input.start || input.date; // "date" is an alias for "start"
  9332. end = input.end;
  9333. // parse as a time (Duration) if applicable
  9334. if (isTimeString(start)) {
  9335. start = moment.duration(start);
  9336. }
  9337. if (isTimeString(end)) {
  9338. end = moment.duration(end);
  9339. }
  9340. if (input.dow || moment.isDuration(start) || moment.isDuration(end)) {
  9341. // the event is "abstract" (recurring) so don't calculate exact start/end dates just yet
  9342. out.start = start ? moment.duration(start) : null; // will be a Duration or null
  9343. out.end = end ? moment.duration(end) : null; // will be a Duration or null
  9344. out._recurring = true; // our internal marker
  9345. }
  9346. else {
  9347. if (start) {
  9348. start = t.moment(start);
  9349. if (!start.isValid()) {
  9350. return false;
  9351. }
  9352. }
  9353. if (end) {
  9354. end = t.moment(end);
  9355. if (!end.isValid()) {
  9356. end = null; // let defaults take over
  9357. }
  9358. }
  9359. allDay = input.allDay;
  9360. if (allDay === undefined) { // still undefined? fallback to default
  9361. allDay = firstDefined(
  9362. source ? source.allDayDefault : undefined,
  9363. t.options.allDayDefault
  9364. );
  9365. // still undefined? normalizeEventDates will calculate it
  9366. }
  9367. assignDatesToEvent(start, end, allDay, out);
  9368. }
  9369. t.normalizeEvent(out); // hook for external use. a prototype method
  9370. return out;
  9371. }
  9372. t.buildEventFromInput = buildEventFromInput;
  9373. // Normalizes and assigns the given dates to the given partially-formed event object.
  9374. // NOTE: mutates the given start/end moments. does not make a copy.
  9375. function assignDatesToEvent(start, end, allDay, event) {
  9376. event.start = start;
  9377. event.end = end;
  9378. event.allDay = allDay;
  9379. normalizeEventDates(event);
  9380. backupEventDates(event);
  9381. }
  9382. // Ensures proper values for allDay/start/end. Accepts an Event object, or a plain object with event-ish properties.
  9383. // NOTE: Will modify the given object.
  9384. function normalizeEventDates(eventProps) {
  9385. normalizeEventTimes(eventProps);
  9386. if (eventProps.end && !eventProps.end.isAfter(eventProps.start)) {
  9387. eventProps.end = null;
  9388. }
  9389. if (!eventProps.end) {
  9390. if (t.options.forceEventDuration) {
  9391. eventProps.end = t.getDefaultEventEnd(eventProps.allDay, eventProps.start);
  9392. }
  9393. else {
  9394. eventProps.end = null;
  9395. }
  9396. }
  9397. }
  9398. // Ensures the allDay property exists and the timeliness of the start/end dates are consistent
  9399. function normalizeEventTimes(eventProps) {
  9400. if (eventProps.allDay == null) {
  9401. eventProps.allDay = !(eventProps.start.hasTime() || (eventProps.end && eventProps.end.hasTime()));
  9402. }
  9403. if (eventProps.allDay) {
  9404. eventProps.start.stripTime();
  9405. if (eventProps.end) {
  9406. // TODO: consider nextDayThreshold here? If so, will require a lot of testing and adjustment
  9407. eventProps.end.stripTime();
  9408. }
  9409. }
  9410. else {
  9411. if (!eventProps.start.hasTime()) {
  9412. eventProps.start = t.applyTimezone(eventProps.start.time(0)); // will assign a 00:00 time
  9413. }
  9414. if (eventProps.end && !eventProps.end.hasTime()) {
  9415. eventProps.end = t.applyTimezone(eventProps.end.time(0)); // will assign a 00:00 time
  9416. }
  9417. }
  9418. }
  9419. // If the given event is a recurring event, break it down into an array of individual instances.
  9420. // If not a recurring event, return an array with the single original event.
  9421. // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array.
  9422. // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours).
  9423. function expandEvent(abstractEvent, _rangeStart, _rangeEnd) {
  9424. var events = [];
  9425. var dowHash;
  9426. var dow;
  9427. var i;
  9428. var date;
  9429. var startTime, endTime;
  9430. var start, end;
  9431. var event;
  9432. _rangeStart = _rangeStart || rangeStart;
  9433. _rangeEnd = _rangeEnd || rangeEnd;
  9434. if (abstractEvent) {
  9435. if (abstractEvent._recurring) {
  9436. // make a boolean hash as to whether the event occurs on each day-of-week
  9437. if ((dow = abstractEvent.dow)) {
  9438. dowHash = {};
  9439. for (i = 0; i < dow.length; i++) {
  9440. dowHash[dow[i]] = true;
  9441. }
  9442. }
  9443. // iterate through every day in the current range
  9444. date = _rangeStart.clone().stripTime(); // holds the date of the current day
  9445. while (date.isBefore(_rangeEnd)) {
  9446. if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week
  9447. startTime = abstractEvent.start; // the stored start and end properties are times (Durations)
  9448. endTime = abstractEvent.end; // "
  9449. start = date.clone();
  9450. end = null;
  9451. if (startTime) {
  9452. start = start.time(startTime);
  9453. }
  9454. if (endTime) {
  9455. end = date.clone().time(endTime);
  9456. }
  9457. event = $.extend({}, abstractEvent); // make a copy of the original
  9458. assignDatesToEvent(
  9459. start, end,
  9460. !startTime && !endTime, // allDay?
  9461. event
  9462. );
  9463. events.push(event);
  9464. }
  9465. date.add(1, 'days');
  9466. }
  9467. }
  9468. else {
  9469. events.push(abstractEvent); // return the original event. will be a one-item array
  9470. }
  9471. }
  9472. return events;
  9473. }
  9474. t.expandEvent = expandEvent;
  9475. /* Event Modification Math
  9476. -----------------------------------------------------------------------------------------*/
  9477. // Modifies an event and all related events by applying the given properties.
  9478. // Special date-diffing logic is used for manipulation of dates.
  9479. // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end.
  9480. // All date comparisons are done against the event's pristine _start and _end dates.
  9481. // Returns an object with delta information and a function to undo all operations.
  9482. // For making computations in a granularity greater than day/time, specify largeUnit.
  9483. // NOTE: The given `newProps` might be mutated for normalization purposes.
  9484. function mutateEvent(event, newProps, largeUnit) {
  9485. var miscProps = {};
  9486. var oldProps;
  9487. var clearEnd;
  9488. var startDelta;
  9489. var endDelta;
  9490. var durationDelta;
  9491. var undoFunc;
  9492. // diffs the dates in the appropriate way, returning a duration
  9493. function diffDates(date1, date0) { // date1 - date0
  9494. if (largeUnit) {
  9495. return diffByUnit(date1, date0, largeUnit);
  9496. }
  9497. else if (newProps.allDay) {
  9498. return diffDay(date1, date0);
  9499. }
  9500. else {
  9501. return diffDayTime(date1, date0);
  9502. }
  9503. }
  9504. newProps = newProps || {};
  9505. // normalize new date-related properties
  9506. if (!newProps.start) {
  9507. newProps.start = event.start.clone();
  9508. }
  9509. if (newProps.end === undefined) {
  9510. newProps.end = event.end ? event.end.clone() : null;
  9511. }
  9512. if (newProps.allDay == null) { // is null or undefined?
  9513. newProps.allDay = event.allDay;
  9514. }
  9515. normalizeEventDates(newProps);
  9516. // create normalized versions of the original props to compare against
  9517. // need a real end value, for diffing
  9518. oldProps = {
  9519. start: event._start.clone(),
  9520. end: event._end ? event._end.clone() : t.getDefaultEventEnd(event._allDay, event._start),
  9521. allDay: newProps.allDay // normalize the dates in the same regard as the new properties
  9522. };
  9523. normalizeEventDates(oldProps);
  9524. // need to clear the end date if explicitly changed to null
  9525. clearEnd = event._end !== null && newProps.end === null;
  9526. // compute the delta for moving the start date
  9527. startDelta = diffDates(newProps.start, oldProps.start);
  9528. // compute the delta for moving the end date
  9529. if (newProps.end) {
  9530. endDelta = diffDates(newProps.end, oldProps.end);
  9531. durationDelta = endDelta.subtract(startDelta);
  9532. }
  9533. else {
  9534. durationDelta = null;
  9535. }
  9536. // gather all non-date-related properties
  9537. $.each(newProps, function (name, val) {
  9538. if (isMiscEventPropName(name)) {
  9539. if (val !== undefined) {
  9540. miscProps[name] = val;
  9541. }
  9542. }
  9543. });
  9544. // apply the operations to the event and all related events
  9545. undoFunc = mutateEvents(
  9546. clientEvents(event._id), // get events with this ID
  9547. clearEnd,
  9548. newProps.allDay,
  9549. startDelta,
  9550. durationDelta,
  9551. miscProps
  9552. );
  9553. return {
  9554. dateDelta: startDelta,
  9555. durationDelta: durationDelta,
  9556. undo: undoFunc
  9557. };
  9558. }
  9559. // Modifies an array of events in the following ways (operations are in order):
  9560. // - clear the event's `end`
  9561. // - convert the event to allDay
  9562. // - add `dateDelta` to the start and end
  9563. // - add `durationDelta` to the event's duration
  9564. // - assign `miscProps` to the event
  9565. //
  9566. // Returns a function that can be called to undo all the operations.
  9567. //
  9568. // TODO: don't use so many closures. possible memory issues when lots of events with same ID.
  9569. //
  9570. function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) {
  9571. var isAmbigTimezone = t.getIsAmbigTimezone();
  9572. var undoFunctions = [];
  9573. // normalize zero-length deltas to be null
  9574. if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; }
  9575. if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; }
  9576. $.each(events, function (i, event) {
  9577. var oldProps;
  9578. var newProps;
  9579. // build an object holding all the old values, both date-related and misc.
  9580. // for the undo function.
  9581. oldProps = {
  9582. start: event.start.clone(),
  9583. end: event.end ? event.end.clone() : null,
  9584. allDay: event.allDay
  9585. };
  9586. $.each(miscProps, function (name) {
  9587. oldProps[name] = event[name];
  9588. });
  9589. // new date-related properties. work off the original date snapshot.
  9590. // ok to use references because they will be thrown away when backupEventDates is called.
  9591. newProps = {
  9592. start: event._start,
  9593. end: event._end,
  9594. allDay: allDay // normalize the dates in the same regard as the new properties
  9595. };
  9596. normalizeEventDates(newProps); // massages start/end/allDay
  9597. // strip or ensure the end date
  9598. if (clearEnd) {
  9599. newProps.end = null;
  9600. }
  9601. else if (durationDelta && !newProps.end) { // the duration translation requires an end date
  9602. newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start);
  9603. }
  9604. if (dateDelta) {
  9605. newProps.start.add(dateDelta);
  9606. if (newProps.end) {
  9607. newProps.end.add(dateDelta);
  9608. }
  9609. }
  9610. if (durationDelta) {
  9611. newProps.end.add(durationDelta); // end already ensured above
  9612. }
  9613. // if the dates have changed, and we know it is impossible to recompute the
  9614. // timezone offsets, strip the zone.
  9615. if (
  9616. isAmbigTimezone &&
  9617. !newProps.allDay &&
  9618. (dateDelta || durationDelta)
  9619. ) {
  9620. newProps.start.stripZone();
  9621. if (newProps.end) {
  9622. newProps.end.stripZone();
  9623. }
  9624. }
  9625. $.extend(event, miscProps, newProps); // copy over misc props, then date-related props
  9626. backupEventDates(event); // regenerate internal _start/_end/_allDay
  9627. undoFunctions.push(function () {
  9628. $.extend(event, oldProps);
  9629. backupEventDates(event); // regenerate internal _start/_end/_allDay
  9630. });
  9631. });
  9632. return function () {
  9633. for (var i = 0; i < undoFunctions.length; i++) {
  9634. undoFunctions[i]();
  9635. }
  9636. };
  9637. }
  9638. }
  9639. // hook for external libs to manipulate event properties upon creation.
  9640. // should manipulate the event in-place.
  9641. Calendar.prototype.normalizeEvent = function (event) {
  9642. };
  9643. // Does the given span (start, end, and other location information)
  9644. // fully contain the other?
  9645. Calendar.prototype.spanContainsSpan = function (outerSpan, innerSpan) {
  9646. var eventStart = outerSpan.start.clone().stripZone();
  9647. var eventEnd = this.getEventEnd(outerSpan).stripZone();
  9648. return innerSpan.start >= eventStart && innerSpan.end <= eventEnd;
  9649. };
  9650. // Returns a list of events that the given event should be compared against when being considered for a move to
  9651. // the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar.
  9652. Calendar.prototype.getPeerEvents = function (span, event) {
  9653. var cache = this.getEventCache();
  9654. var peerEvents = [];
  9655. var i, otherEvent;
  9656. for (i = 0; i < cache.length; i++) {
  9657. otherEvent = cache[i];
  9658. if (
  9659. !event ||
  9660. event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events
  9661. ) {
  9662. peerEvents.push(otherEvent);
  9663. }
  9664. }
  9665. return peerEvents;
  9666. };
  9667. // updates the "backup" properties, which are preserved in order to compute diffs later on.
  9668. function backupEventDates(event) {
  9669. event._allDay = event.allDay;
  9670. event._start = event.start.clone();
  9671. event._end = event.end ? event.end.clone() : null;
  9672. }
  9673. /* Overlapping / Constraining
  9674. -----------------------------------------------------------------------------------------*/
  9675. // Determines if the given event can be relocated to the given span (unzoned start/end with other misc data)
  9676. Calendar.prototype.isEventSpanAllowed = function (span, event) {
  9677. var source = event.source || {};
  9678. var constraint = firstDefined(
  9679. event.constraint,
  9680. source.constraint,
  9681. this.options.eventConstraint
  9682. );
  9683. var overlap = firstDefined(
  9684. event.overlap,
  9685. source.overlap,
  9686. this.options.eventOverlap
  9687. );
  9688. return this.isSpanAllowed(span, constraint, overlap, event) &&
  9689. (!this.options.eventAllow || this.options.eventAllow(span, event) !== false);
  9690. };
  9691. // Determines if an external event can be relocated to the given span (unzoned start/end with other misc data)
  9692. Calendar.prototype.isExternalSpanAllowed = function (eventSpan, eventLocation, eventProps) {
  9693. var eventInput;
  9694. var event;
  9695. // note: very similar logic is in View's reportExternalDrop
  9696. if (eventProps) {
  9697. eventInput = $.extend({}, eventProps, eventLocation);
  9698. event = this.expandEvent(
  9699. this.buildEventFromInput(eventInput)
  9700. )[0];
  9701. }
  9702. if (event) {
  9703. return this.isEventSpanAllowed(eventSpan, event);
  9704. }
  9705. else { // treat it as a selection
  9706. return this.isSelectionSpanAllowed(eventSpan);
  9707. }
  9708. };
  9709. // Determines the given span (unzoned start/end with other misc data) can be selected.
  9710. Calendar.prototype.isSelectionSpanAllowed = function (span) {
  9711. return this.isSpanAllowed(span, this.options.selectConstraint, this.options.selectOverlap) &&
  9712. (!this.options.selectAllow || this.options.selectAllow(span) !== false);
  9713. };
  9714. // Returns true if the given span (caused by an event drop/resize or a selection) is allowed to exist
  9715. // according to the constraint/overlap settings.
  9716. // `event` is not required if checking a selection.
  9717. Calendar.prototype.isSpanAllowed = function (span, constraint, overlap, event) {
  9718. var constraintEvents;
  9719. var anyContainment;
  9720. var peerEvents;
  9721. var i, peerEvent;
  9722. var peerOverlap;
  9723. // the range must be fully contained by at least one of produced constraint events
  9724. if (constraint != null) {
  9725. // not treated as an event! intermediate data structure
  9726. // TODO: use ranges in the future
  9727. constraintEvents = this.constraintToEvents(constraint);
  9728. if (constraintEvents) { // not invalid
  9729. anyContainment = false;
  9730. for (i = 0; i < constraintEvents.length; i++) {
  9731. if (this.spanContainsSpan(constraintEvents[i], span)) {
  9732. anyContainment = true;
  9733. break;
  9734. }
  9735. }
  9736. if (!anyContainment) {
  9737. return false;
  9738. }
  9739. }
  9740. }
  9741. peerEvents = this.getPeerEvents(span, event);
  9742. for (i = 0; i < peerEvents.length; i++) {
  9743. peerEvent = peerEvents[i];
  9744. // there needs to be an actual intersection before disallowing anything
  9745. if (this.eventIntersectsRange(peerEvent, span)) {
  9746. // evaluate overlap for the given range and short-circuit if necessary
  9747. if (overlap === false) {
  9748. return false;
  9749. }
  9750. // if the event's overlap is a test function, pass the peer event in question as the first param
  9751. else if (typeof overlap === 'function' && !overlap(peerEvent, event)) {
  9752. return false;
  9753. }
  9754. // if we are computing if the given range is allowable for an event, consider the other event's
  9755. // EventObject-specific or Source-specific `overlap` property
  9756. if (event) {
  9757. peerOverlap = firstDefined(
  9758. peerEvent.overlap,
  9759. (peerEvent.source || {}).overlap
  9760. // we already considered the global `eventOverlap`
  9761. );
  9762. if (peerOverlap === false) {
  9763. return false;
  9764. }
  9765. // if the peer event's overlap is a test function, pass the subject event as the first param
  9766. if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) {
  9767. return false;
  9768. }
  9769. }
  9770. }
  9771. }
  9772. return true;
  9773. };
  9774. // Given an event input from the API, produces an array of event objects. Possible event inputs:
  9775. // 'businessHours'
  9776. // An event ID (number or string)
  9777. // An object with specific start/end dates or a recurring event (like what businessHours accepts)
  9778. Calendar.prototype.constraintToEvents = function (constraintInput) {
  9779. if (constraintInput === 'businessHours') {
  9780. return this.getCurrentBusinessHourEvents();
  9781. }
  9782. if (typeof constraintInput === 'object') {
  9783. if (constraintInput.start != null) { // needs to be event-like input
  9784. return this.expandEvent(this.buildEventFromInput(constraintInput));
  9785. }
  9786. else {
  9787. return null; // invalid
  9788. }
  9789. }
  9790. return this.clientEvents(constraintInput); // probably an ID
  9791. };
  9792. // Does the event's date range intersect with the given range?
  9793. // start/end already assumed to have stripped zones :(
  9794. Calendar.prototype.eventIntersectsRange = function (event, range) {
  9795. var eventStart = event.start.clone().stripZone();
  9796. var eventEnd = this.getEventEnd(event).stripZone();
  9797. return range.start < eventEnd && range.end > eventStart;
  9798. };
  9799. /* Business Hours
  9800. -----------------------------------------------------------------------------------------*/
  9801. var BUSINESS_HOUR_EVENT_DEFAULTS = {
  9802. id: '_fcBusinessHours', // will relate events from different calls to expandEvent
  9803. start: '09:00',
  9804. end: '17:00',
  9805. dow: [1, 2, 3, 4, 5], // monday - friday
  9806. rendering: 'inverse-background'
  9807. // classNames are defined in businessHoursSegClasses
  9808. };
  9809. // Return events objects for business hours within the current view.
  9810. // Abuse of our event system :(
  9811. Calendar.prototype.getCurrentBusinessHourEvents = function (wholeDay) {
  9812. return this.computeBusinessHourEvents(wholeDay, this.options.businessHours);
  9813. };
  9814. // Given a raw input value from options, return events objects for business hours within the current view.
  9815. Calendar.prototype.computeBusinessHourEvents = function (wholeDay, input) {
  9816. if (input === true) {
  9817. return this.expandBusinessHourEvents(wholeDay, [{}]);
  9818. }
  9819. else if ($.isPlainObject(input)) {
  9820. return this.expandBusinessHourEvents(wholeDay, [input]);
  9821. }
  9822. else if ($.isArray(input)) {
  9823. return this.expandBusinessHourEvents(wholeDay, input, true);
  9824. }
  9825. else {
  9826. return [];
  9827. }
  9828. };
  9829. // inputs expected to be an array of objects.
  9830. // if ignoreNoDow is true, will ignore entries that don't specify a day-of-week (dow) key.
  9831. Calendar.prototype.expandBusinessHourEvents = function (wholeDay, inputs, ignoreNoDow) {
  9832. var view = this.getView();
  9833. var events = [];
  9834. var i, input;
  9835. for (i = 0; i < inputs.length; i++) {
  9836. input = inputs[i];
  9837. if (ignoreNoDow && !input.dow) {
  9838. continue;
  9839. }
  9840. // give defaults. will make a copy
  9841. input = $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, input);
  9842. // if a whole-day series is requested, clear the start/end times
  9843. if (wholeDay) {
  9844. input.start = null;
  9845. input.end = null;
  9846. }
  9847. events.push.apply(events, // append
  9848. this.expandEvent(
  9849. this.buildEventFromInput(input),
  9850. view.start,
  9851. view.end
  9852. )
  9853. );
  9854. }
  9855. return events;
  9856. };
  9857. ;;
  9858. /* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells.
  9859. ----------------------------------------------------------------------------------------------------------------------*/
  9860. // It is a manager for a DayGrid subcomponent, which does most of the heavy lifting.
  9861. // It is responsible for managing width/height.
  9862. var BasicView = FC.BasicView = View.extend({
  9863. scroller: null,
  9864. dayGridClass: DayGrid, // class the dayGrid will be instantiated from (overridable by subclasses)
  9865. dayGrid: null, // the main subcomponent that does most of the heavy lifting
  9866. dayNumbersVisible: false, // display day numbers on each day cell?
  9867. colWeekNumbersVisible: false, // display week numbers along the side?
  9868. cellWeekNumbersVisible: false, // display week numbers in day cell?
  9869. weekNumberWidth: null, // width of all the week-number cells running down the side
  9870. headContainerEl: null, // div that hold's the dayGrid's rendered date header
  9871. headRowEl: null, // the fake row element of the day-of-week header
  9872. initialize: function () {
  9873. this.dayGrid = this.instantiateDayGrid();
  9874. this.scroller = new Scroller({
  9875. overflowX: 'hidden',
  9876. overflowY: 'auto'
  9877. });
  9878. },
  9879. // Generates the DayGrid object this view needs. Draws from this.dayGridClass
  9880. instantiateDayGrid: function () {
  9881. // generate a subclass on the fly with BasicView-specific behavior
  9882. // TODO: cache this subclass
  9883. var subclass = this.dayGridClass.extend(basicDayGridMethods);
  9884. return new subclass(this);
  9885. },
  9886. // Sets the display range and computes all necessary dates
  9887. setRange: function (range) {
  9888. View.prototype.setRange.call(this, range); // call the super-method
  9889. this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange
  9890. this.dayGrid.setRange(range);
  9891. },
  9892. // Compute the value to feed into setRange. Overrides superclass.
  9893. computeRange: function (date) {
  9894. var range = View.prototype.computeRange.call(this, date); // get value from the super-method
  9895. // year and month views should be aligned with weeks. this is already done for week
  9896. if (/year|month/.test(range.intervalUnit)) {
  9897. range.start.startOf('week');
  9898. range.start = this.skipHiddenDays(range.start);
  9899. // make end-of-week if not already
  9900. if (range.end.weekday()) {
  9901. range.end.add(1, 'week').startOf('week');
  9902. range.end = this.skipHiddenDays(range.end, -1, true); // exclusively move backwards
  9903. }
  9904. }
  9905. return range;
  9906. },
  9907. // Renders the view into `this.el`, which should already be assigned
  9908. renderDates: function () {
  9909. this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible
  9910. if (this.opt('weekNumbers')) {
  9911. if (this.opt('weekNumbersWithinDays')) {
  9912. this.cellWeekNumbersVisible = true;
  9913. this.colWeekNumbersVisible = false;
  9914. }
  9915. else {
  9916. this.cellWeekNumbersVisible = false;
  9917. this.colWeekNumbersVisible = true;
  9918. };
  9919. }
  9920. this.dayGrid.numbersVisible = this.dayNumbersVisible ||
  9921. this.cellWeekNumbersVisible || this.colWeekNumbersVisible;
  9922. this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml());
  9923. this.renderHead();
  9924. this.scroller.render();
  9925. var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container');
  9926. var dayGridEl = $('<div class="fc-day-grid" />').appendTo(dayGridContainerEl);
  9927. this.el.find('.fc-body > tr > td').append(dayGridContainerEl);
  9928. this.dayGrid.setElement(dayGridEl);
  9929. this.dayGrid.renderDates(this.hasRigidRows());
  9930. },
  9931. // render the day-of-week headers
  9932. renderHead: function () {
  9933. this.headContainerEl =
  9934. this.el.find('.fc-head-container')
  9935. .html(this.dayGrid.renderHeadHtml());
  9936. this.headRowEl = this.headContainerEl.find('.fc-row');
  9937. },
  9938. // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering,
  9939. // always completely kill the dayGrid's rendering.
  9940. unrenderDates: function () {
  9941. this.dayGrid.unrenderDates();
  9942. this.dayGrid.removeElement();
  9943. this.scroller.destroy();
  9944. },
  9945. renderBusinessHours: function () {
  9946. this.dayGrid.renderBusinessHours();
  9947. },
  9948. unrenderBusinessHours: function () {
  9949. this.dayGrid.unrenderBusinessHours();
  9950. },
  9951. // Builds the HTML skeleton for the view.
  9952. // The day-grid component will render inside of a container defined by this HTML.
  9953. renderSkeletonHtml: function () {
  9954. return '' +
  9955. '<table>' +
  9956. '<thead class="fc-head">' +
  9957. '<tr>' +
  9958. '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' +
  9959. '</tr>' +
  9960. '</thead>' +
  9961. '<tbody class="fc-body">' +
  9962. '<tr>' +
  9963. '<td class="' + this.widgetContentClass + '"></td>' +
  9964. '</tr>' +
  9965. '</tbody>' +
  9966. '</table>';
  9967. },
  9968. // Generates an HTML attribute string for setting the width of the week number column, if it is known
  9969. weekNumberStyleAttr: function () {
  9970. if (this.weekNumberWidth !== null) {
  9971. return 'style="width:' + this.weekNumberWidth + 'px"';
  9972. }
  9973. return '';
  9974. },
  9975. // Determines whether each row should have a constant height
  9976. hasRigidRows: function () {
  9977. var eventLimit = this.opt('eventLimit');
  9978. return eventLimit && typeof eventLimit !== 'number';
  9979. },
  9980. /* Dimensions
  9981. ------------------------------------------------------------------------------------------------------------------*/
  9982. // Refreshes the horizontal dimensions of the view
  9983. updateWidth: function () {
  9984. if (this.colWeekNumbersVisible) {
  9985. // Make sure all week number cells running down the side have the same width.
  9986. // Record the width for cells created later.
  9987. this.weekNumberWidth = matchCellWidths(
  9988. this.el.find('.fc-week-number')
  9989. );
  9990. }
  9991. },
  9992. // Adjusts the vertical dimensions of the view to the specified values
  9993. setHeight: function (totalHeight, isAuto) {
  9994. var eventLimit = this.opt('eventLimit');
  9995. var scrollerHeight;
  9996. var scrollbarWidths;
  9997. // reset all heights to be natural
  9998. this.scroller.clear();
  9999. uncompensateScroll(this.headRowEl);
  10000. this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
  10001. // is the event limit a constant level number?
  10002. if (eventLimit && typeof eventLimit === 'number') {
  10003. this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after
  10004. }
  10005. // distribute the height to the rows
  10006. // (totalHeight is a "recommended" value if isAuto)
  10007. scrollerHeight = this.computeScrollerHeight(totalHeight);
  10008. this.setGridHeight(scrollerHeight, isAuto);
  10009. // is the event limit dynamically calculated?
  10010. if (eventLimit && typeof eventLimit !== 'number') {
  10011. this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set
  10012. }
  10013. if (!isAuto) { // should we force dimensions of the scroll container?
  10014. this.scroller.setHeight(scrollerHeight);
  10015. scrollbarWidths = this.scroller.getScrollbarWidths();
  10016. if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
  10017. compensateScroll(this.headRowEl, scrollbarWidths);
  10018. // doing the scrollbar compensation might have created text overflow which created more height. redo
  10019. scrollerHeight = this.computeScrollerHeight(totalHeight);
  10020. this.scroller.setHeight(scrollerHeight);
  10021. }
  10022. // guarantees the same scrollbar widths
  10023. this.scroller.lockOverflow(scrollbarWidths);
  10024. }
  10025. },
  10026. // given a desired total height of the view, returns what the height of the scroller should be
  10027. computeScrollerHeight: function (totalHeight) {
  10028. return totalHeight -
  10029. subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
  10030. },
  10031. // Sets the height of just the DayGrid component in this view
  10032. setGridHeight: function (height, isAuto) {
  10033. if (isAuto) {
  10034. undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding
  10035. }
  10036. else {
  10037. distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows
  10038. }
  10039. },
  10040. /* Scroll
  10041. ------------------------------------------------------------------------------------------------------------------*/
  10042. computeInitialScroll: function () {
  10043. return { top: 0 };
  10044. },
  10045. queryScroll: function () {
  10046. return { top: this.scroller.getScrollTop() };
  10047. },
  10048. setScroll: function (scroll) {
  10049. this.scroller.setScrollTop(scroll.top);
  10050. },
  10051. /* Hit Areas
  10052. ------------------------------------------------------------------------------------------------------------------*/
  10053. // forward all hit-related method calls to dayGrid
  10054. prepareHits: function () {
  10055. this.dayGrid.prepareHits();
  10056. },
  10057. releaseHits: function () {
  10058. this.dayGrid.releaseHits();
  10059. },
  10060. queryHit: function (left, top) {
  10061. return this.dayGrid.queryHit(left, top);
  10062. },
  10063. getHitSpan: function (hit) {
  10064. return this.dayGrid.getHitSpan(hit);
  10065. },
  10066. getHitEl: function (hit) {
  10067. return this.dayGrid.getHitEl(hit);
  10068. },
  10069. /* Events
  10070. ------------------------------------------------------------------------------------------------------------------*/
  10071. // Renders the given events onto the view and populates the segments array
  10072. renderEvents: function (events) {
  10073. this.dayGrid.renderEvents(events);
  10074. this.updateHeight(); // must compensate for events that overflow the row
  10075. },
  10076. // Retrieves all segment objects that are rendered in the view
  10077. getEventSegs: function () {
  10078. return this.dayGrid.getEventSegs();
  10079. },
  10080. // Unrenders all event elements and clears internal segment data
  10081. unrenderEvents: function () {
  10082. this.dayGrid.unrenderEvents();
  10083. // we DON'T need to call updateHeight() because
  10084. // a renderEvents() call always happens after this, which will eventually call updateHeight()
  10085. },
  10086. /* Dragging (for both events and external elements)
  10087. ------------------------------------------------------------------------------------------------------------------*/
  10088. // A returned value of `true` signals that a mock "helper" event has been rendered.
  10089. renderDrag: function (dropLocation, seg) {
  10090. return this.dayGrid.renderDrag(dropLocation, seg);
  10091. },
  10092. unrenderDrag: function () {
  10093. this.dayGrid.unrenderDrag();
  10094. },
  10095. /* Selection
  10096. ------------------------------------------------------------------------------------------------------------------*/
  10097. // Renders a visual indication of a selection
  10098. renderSelection: function (span) {
  10099. this.dayGrid.renderSelection(span);
  10100. },
  10101. // Unrenders a visual indications of a selection
  10102. unrenderSelection: function () {
  10103. this.dayGrid.unrenderSelection();
  10104. }
  10105. });
  10106. // Methods that will customize the rendering behavior of the BasicView's dayGrid
  10107. var basicDayGridMethods = {
  10108. // Generates the HTML that will go before the day-of week header cells
  10109. renderHeadIntroHtml: function () {
  10110. var view = this.view;
  10111. if (view.colWeekNumbersVisible) {
  10112. return '' +
  10113. '<th class="fc-week-number ' + view.widgetHeaderClass + '" ' + view.weekNumberStyleAttr() + '>' +
  10114. '<span>' + // needed for matchCellWidths
  10115. htmlEscape(view.opt('weekNumberTitle')) +
  10116. '</span>' +
  10117. '</th>';
  10118. }
  10119. return '';
  10120. },
  10121. // Generates the HTML that will go before content-skeleton cells that display the day/week numbers
  10122. renderNumberIntroHtml: function (row) {
  10123. var view = this.view;
  10124. var weekStart = this.getCellDate(row, 0);
  10125. if (view.colWeekNumbersVisible) {
  10126. return '' +
  10127. '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '>' +
  10128. view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths
  10129. { date: weekStart, type: 'week', forceOff: this.colCnt === 1 },
  10130. weekStart.format('w') // inner HTML
  10131. ) +
  10132. '</td>';
  10133. }
  10134. return '';
  10135. },
  10136. // Generates the HTML that goes before the day bg cells for each day-row
  10137. renderBgIntroHtml: function () {
  10138. var view = this.view;
  10139. if (view.colWeekNumbersVisible) {
  10140. return '<td class="fc-week-number ' + view.widgetContentClass + '" ' +
  10141. view.weekNumberStyleAttr() + '></td>';
  10142. }
  10143. return '';
  10144. },
  10145. // Generates the HTML that goes before every other type of row generated by DayGrid.
  10146. // Affects helper-skeleton and highlight-skeleton rows.
  10147. renderIntroHtml: function () {
  10148. var view = this.view;
  10149. if (view.colWeekNumbersVisible) {
  10150. return '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '></td>';
  10151. }
  10152. return '';
  10153. }
  10154. };
  10155. ;;
  10156. /* A month view with day cells running in rows (one-per-week) and columns
  10157. ----------------------------------------------------------------------------------------------------------------------*/
  10158. var MonthView = FC.MonthView = BasicView.extend({
  10159. // Produces information about what range to display
  10160. computeRange: function (date) {
  10161. var range = BasicView.prototype.computeRange.call(this, date); // get value from super-method
  10162. var rowCnt;
  10163. // ensure 6 weeks
  10164. if (this.isFixedWeeks()) {
  10165. rowCnt = Math.ceil(range.end.diff(range.start, 'weeks', true)); // could be partial weeks due to hiddenDays
  10166. range.end.add(6 - rowCnt, 'weeks');
  10167. }
  10168. return range;
  10169. },
  10170. // Overrides the default BasicView behavior to have special multi-week auto-height logic
  10171. setGridHeight: function (height, isAuto) {
  10172. // if auto, make the height of each row the height that it would be if there were 6 weeks
  10173. if (isAuto) {
  10174. height *= this.rowCnt / 6;
  10175. }
  10176. distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows
  10177. },
  10178. isFixedWeeks: function () {
  10179. return this.opt('fixedWeekCount');
  10180. }
  10181. });
  10182. ;;
  10183. fcViews.basic = {
  10184. 'class': BasicView
  10185. };
  10186. fcViews.basicDay = {
  10187. type: 'basic',
  10188. duration: { days: 1 }
  10189. };
  10190. fcViews.basicWeek = {
  10191. type: 'basic',
  10192. duration: { weeks: 1 }
  10193. };
  10194. fcViews.month = {
  10195. 'class': MonthView,
  10196. duration: { months: 1 }, // important for prev/next
  10197. defaults: {
  10198. fixedWeekCount: true
  10199. }
  10200. };
  10201. ;;
  10202. /* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically.
  10203. ----------------------------------------------------------------------------------------------------------------------*/
  10204. // Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on).
  10205. // Responsible for managing width/height.
  10206. var AgendaView = FC.AgendaView = View.extend({
  10207. scroller: null,
  10208. timeGridClass: TimeGrid, // class used to instantiate the timeGrid. subclasses can override
  10209. timeGrid: null, // the main time-grid subcomponent of this view
  10210. dayGridClass: DayGrid, // class used to instantiate the dayGrid. subclasses can override
  10211. dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null
  10212. axisWidth: null, // the width of the time axis running down the side
  10213. headContainerEl: null, // div that hold's the timeGrid's rendered date header
  10214. noScrollRowEls: null, // set of fake row elements that must compensate when scroller has scrollbars
  10215. // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath
  10216. bottomRuleEl: null,
  10217. initialize: function () {
  10218. this.timeGrid = this.instantiateTimeGrid();
  10219. if (this.opt('allDaySlot')) { // should we display the "all-day" area?
  10220. this.dayGrid = this.instantiateDayGrid(); // the all-day subcomponent of this view
  10221. }
  10222. this.scroller = new Scroller({
  10223. overflowX: 'hidden',
  10224. overflowY: 'auto'
  10225. });
  10226. },
  10227. // Instantiates the TimeGrid object this view needs. Draws from this.timeGridClass
  10228. instantiateTimeGrid: function () {
  10229. var subclass = this.timeGridClass.extend(agendaTimeGridMethods);
  10230. return new subclass(this);
  10231. },
  10232. // Instantiates the DayGrid object this view might need. Draws from this.dayGridClass
  10233. instantiateDayGrid: function () {
  10234. var subclass = this.dayGridClass.extend(agendaDayGridMethods);
  10235. return new subclass(this);
  10236. },
  10237. /* Rendering
  10238. ------------------------------------------------------------------------------------------------------------------*/
  10239. // Sets the display range and computes all necessary dates
  10240. setRange: function (range) {
  10241. View.prototype.setRange.call(this, range); // call the super-method
  10242. this.timeGrid.setRange(range);
  10243. if (this.dayGrid) {
  10244. this.dayGrid.setRange(range);
  10245. }
  10246. },
  10247. // Renders the view into `this.el`, which has already been assigned
  10248. renderDates: function () {
  10249. this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml());
  10250. this.renderHead();
  10251. this.scroller.render();
  10252. var timeGridWrapEl = this.scroller.el.addClass('fc-time-grid-container');
  10253. var timeGridEl = $('<div class="fc-time-grid" />').appendTo(timeGridWrapEl);
  10254. this.el.find('.fc-body > tr > td').append(timeGridWrapEl);
  10255. this.timeGrid.setElement(timeGridEl);
  10256. this.timeGrid.renderDates();
  10257. // the <hr> that sometimes displays under the time-grid
  10258. this.bottomRuleEl = $('<hr class="fc-divider ' + this.widgetHeaderClass + '"/>')
  10259. .appendTo(this.timeGrid.el); // inject it into the time-grid
  10260. if (this.dayGrid) {
  10261. this.dayGrid.setElement(this.el.find('.fc-day-grid'));
  10262. this.dayGrid.renderDates();
  10263. // have the day-grid extend it's coordinate area over the <hr> dividing the two grids
  10264. this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight();
  10265. }
  10266. this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller
  10267. },
  10268. // render the day-of-week headers
  10269. renderHead: function () {
  10270. this.headContainerEl =
  10271. this.el.find('.fc-head-container')
  10272. .html(this.timeGrid.renderHeadHtml());
  10273. },
  10274. // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering,
  10275. // always completely kill each grid's rendering.
  10276. unrenderDates: function () {
  10277. this.timeGrid.unrenderDates();
  10278. this.timeGrid.removeElement();
  10279. if (this.dayGrid) {
  10280. this.dayGrid.unrenderDates();
  10281. this.dayGrid.removeElement();
  10282. }
  10283. this.scroller.destroy();
  10284. },
  10285. // Builds the HTML skeleton for the view.
  10286. // The day-grid and time-grid components will render inside containers defined by this HTML.
  10287. renderSkeletonHtml: function () {
  10288. return '' +
  10289. '<table>' +
  10290. '<thead class="fc-head">' +
  10291. '<tr>' +
  10292. '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' +
  10293. '</tr>' +
  10294. '</thead>' +
  10295. '<tbody class="fc-body">' +
  10296. '<tr>' +
  10297. '<td class="' + this.widgetContentClass + '">' +
  10298. (this.dayGrid ?
  10299. '<div class="fc-day-grid"/>' +
  10300. '<hr class="fc-divider ' + this.widgetHeaderClass + '"/>' :
  10301. ''
  10302. ) +
  10303. '</td>' +
  10304. '</tr>' +
  10305. '</tbody>' +
  10306. '</table>';
  10307. },
  10308. // Generates an HTML attribute string for setting the width of the axis, if it is known
  10309. axisStyleAttr: function () {
  10310. if (this.axisWidth !== null) {
  10311. return 'style="width:' + this.axisWidth + 'px"';
  10312. }
  10313. return '';
  10314. },
  10315. /* Business Hours
  10316. ------------------------------------------------------------------------------------------------------------------*/
  10317. renderBusinessHours: function () {
  10318. this.timeGrid.renderBusinessHours();
  10319. if (this.dayGrid) {
  10320. this.dayGrid.renderBusinessHours();
  10321. }
  10322. },
  10323. unrenderBusinessHours: function () {
  10324. this.timeGrid.unrenderBusinessHours();
  10325. if (this.dayGrid) {
  10326. this.dayGrid.unrenderBusinessHours();
  10327. }
  10328. },
  10329. /* Now Indicator
  10330. ------------------------------------------------------------------------------------------------------------------*/
  10331. getNowIndicatorUnit: function () {
  10332. return this.timeGrid.getNowIndicatorUnit();
  10333. },
  10334. renderNowIndicator: function (date) {
  10335. this.timeGrid.renderNowIndicator(date);
  10336. },
  10337. unrenderNowIndicator: function () {
  10338. this.timeGrid.unrenderNowIndicator();
  10339. },
  10340. /* Dimensions
  10341. ------------------------------------------------------------------------------------------------------------------*/
  10342. updateSize: function (isResize) {
  10343. this.timeGrid.updateSize(isResize);
  10344. View.prototype.updateSize.call(this, isResize); // call the super-method
  10345. },
  10346. // Refreshes the horizontal dimensions of the view
  10347. updateWidth: function () {
  10348. // make all axis cells line up, and record the width so newly created axis cells will have it
  10349. this.axisWidth = matchCellWidths(this.el.find('.fc-axis'));
  10350. },
  10351. // Adjusts the vertical dimensions of the view to the specified values
  10352. setHeight: function (totalHeight, isAuto) {
  10353. var eventLimit;
  10354. var scrollerHeight;
  10355. var scrollbarWidths;
  10356. // reset all dimensions back to the original state
  10357. this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary
  10358. this.scroller.clear(); // sets height to 'auto' and clears overflow
  10359. uncompensateScroll(this.noScrollRowEls);
  10360. // limit number of events in the all-day area
  10361. if (this.dayGrid) {
  10362. this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
  10363. eventLimit = this.opt('eventLimit');
  10364. if (eventLimit && typeof eventLimit !== 'number') {
  10365. eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number
  10366. }
  10367. if (eventLimit) {
  10368. this.dayGrid.limitRows(eventLimit);
  10369. }
  10370. }
  10371. if (!isAuto) { // should we force dimensions of the scroll container?
  10372. scrollerHeight = this.computeScrollerHeight(totalHeight);
  10373. this.scroller.setHeight(scrollerHeight);
  10374. scrollbarWidths = this.scroller.getScrollbarWidths();
  10375. if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
  10376. // make the all-day and header rows lines up
  10377. compensateScroll(this.noScrollRowEls, scrollbarWidths);
  10378. // the scrollbar compensation might have changed text flow, which might affect height, so recalculate
  10379. // and reapply the desired height to the scroller.
  10380. scrollerHeight = this.computeScrollerHeight(totalHeight);
  10381. this.scroller.setHeight(scrollerHeight);
  10382. }
  10383. // guarantees the same scrollbar widths
  10384. this.scroller.lockOverflow(scrollbarWidths);
  10385. // if there's any space below the slats, show the horizontal rule.
  10386. // this won't cause any new overflow, because lockOverflow already called.
  10387. if (this.timeGrid.getTotalSlatHeight() < scrollerHeight) {
  10388. this.bottomRuleEl.show();
  10389. }
  10390. }
  10391. },
  10392. // given a desired total height of the view, returns what the height of the scroller should be
  10393. computeScrollerHeight: function (totalHeight) {
  10394. return totalHeight -
  10395. subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
  10396. },
  10397. /* Scroll
  10398. ------------------------------------------------------------------------------------------------------------------*/
  10399. // Computes the initial pre-configured scroll state prior to allowing the user to change it
  10400. computeInitialScroll: function () {
  10401. var scrollTime = moment.duration(this.opt('scrollTime'));
  10402. var top = this.timeGrid.computeTimeTop(scrollTime);
  10403. // zoom can give weird floating-point values. rather scroll a little bit further
  10404. top = Math.ceil(top);
  10405. if (top) {
  10406. top++; // to overcome top border that slots beyond the first have. looks better
  10407. }
  10408. return { top: top };
  10409. },
  10410. queryScroll: function () {
  10411. return { top: this.scroller.getScrollTop() };
  10412. },
  10413. setScroll: function (scroll) {
  10414. this.scroller.setScrollTop(scroll.top);
  10415. },
  10416. /* Hit Areas
  10417. ------------------------------------------------------------------------------------------------------------------*/
  10418. // forward all hit-related method calls to the grids (dayGrid might not be defined)
  10419. prepareHits: function () {
  10420. this.timeGrid.prepareHits();
  10421. if (this.dayGrid) {
  10422. this.dayGrid.prepareHits();
  10423. }
  10424. },
  10425. releaseHits: function () {
  10426. this.timeGrid.releaseHits();
  10427. if (this.dayGrid) {
  10428. this.dayGrid.releaseHits();
  10429. }
  10430. },
  10431. queryHit: function (left, top) {
  10432. var hit = this.timeGrid.queryHit(left, top);
  10433. if (!hit && this.dayGrid) {
  10434. hit = this.dayGrid.queryHit(left, top);
  10435. }
  10436. return hit;
  10437. },
  10438. getHitSpan: function (hit) {
  10439. // TODO: hit.component is set as a hack to identify where the hit came from
  10440. return hit.component.getHitSpan(hit);
  10441. },
  10442. getHitEl: function (hit) {
  10443. // TODO: hit.component is set as a hack to identify where the hit came from
  10444. return hit.component.getHitEl(hit);
  10445. },
  10446. /* Events
  10447. ------------------------------------------------------------------------------------------------------------------*/
  10448. // Renders events onto the view and populates the View's segment array
  10449. renderEvents: function (events) {
  10450. var dayEvents = [];
  10451. var timedEvents = [];
  10452. var daySegs = [];
  10453. var timedSegs;
  10454. var i;
  10455. // separate the events into all-day and timed
  10456. for (i = 0; i < events.length; i++) {
  10457. if (events[i].allDay) {
  10458. dayEvents.push(events[i]);
  10459. }
  10460. else {
  10461. timedEvents.push(events[i]);
  10462. }
  10463. }
  10464. // render the events in the subcomponents
  10465. timedSegs = this.timeGrid.renderEvents(timedEvents);
  10466. if (this.dayGrid) {
  10467. daySegs = this.dayGrid.renderEvents(dayEvents);
  10468. }
  10469. // the all-day area is flexible and might have a lot of events, so shift the height
  10470. this.updateHeight();
  10471. },
  10472. // Retrieves all segment objects that are rendered in the view
  10473. getEventSegs: function () {
  10474. return this.timeGrid.getEventSegs().concat(
  10475. this.dayGrid ? this.dayGrid.getEventSegs() : []
  10476. );
  10477. },
  10478. // Unrenders all event elements and clears internal segment data
  10479. unrenderEvents: function () {
  10480. // unrender the events in the subcomponents
  10481. this.timeGrid.unrenderEvents();
  10482. if (this.dayGrid) {
  10483. this.dayGrid.unrenderEvents();
  10484. }
  10485. // we DON'T need to call updateHeight() because
  10486. // a renderEvents() call always happens after this, which will eventually call updateHeight()
  10487. },
  10488. /* Dragging (for events and external elements)
  10489. ------------------------------------------------------------------------------------------------------------------*/
  10490. // A returned value of `true` signals that a mock "helper" event has been rendered.
  10491. renderDrag: function (dropLocation, seg) {
  10492. if (dropLocation.start.hasTime()) {
  10493. return this.timeGrid.renderDrag(dropLocation, seg);
  10494. }
  10495. else if (this.dayGrid) {
  10496. return this.dayGrid.renderDrag(dropLocation, seg);
  10497. }
  10498. },
  10499. unrenderDrag: function () {
  10500. this.timeGrid.unrenderDrag();
  10501. if (this.dayGrid) {
  10502. this.dayGrid.unrenderDrag();
  10503. }
  10504. },
  10505. /* Selection
  10506. ------------------------------------------------------------------------------------------------------------------*/
  10507. // Renders a visual indication of a selection
  10508. renderSelection: function (span) {
  10509. if (span.start.hasTime() || span.end.hasTime()) {
  10510. this.timeGrid.renderSelection(span);
  10511. }
  10512. else if (this.dayGrid) {
  10513. this.dayGrid.renderSelection(span);
  10514. }
  10515. },
  10516. // Unrenders a visual indications of a selection
  10517. unrenderSelection: function () {
  10518. this.timeGrid.unrenderSelection();
  10519. if (this.dayGrid) {
  10520. this.dayGrid.unrenderSelection();
  10521. }
  10522. }
  10523. });
  10524. // Methods that will customize the rendering behavior of the AgendaView's timeGrid
  10525. // TODO: move into TimeGrid
  10526. var agendaTimeGridMethods = {
  10527. // Generates the HTML that will go before the day-of week header cells
  10528. renderHeadIntroHtml: function () {
  10529. var view = this.view;
  10530. var weekText;
  10531. if (view.opt('weekNumbers')) {
  10532. weekText = this.start.format(view.opt('smallWeekFormat'));
  10533. return '' +
  10534. '<th class="fc-axis fc-week-number ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '>' +
  10535. view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths
  10536. { date: this.start, type: 'week', forceOff: this.colCnt > 1 },
  10537. htmlEscape(weekText) // inner HTML
  10538. ) +
  10539. '</th>';
  10540. }
  10541. else {
  10542. return '<th class="fc-axis ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '></th>';
  10543. }
  10544. },
  10545. // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column.
  10546. renderBgIntroHtml: function () {
  10547. var view = this.view;
  10548. return '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '></td>';
  10549. },
  10550. // Generates the HTML that goes before all other types of cells.
  10551. // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid.
  10552. renderIntroHtml: function () {
  10553. var view = this.view;
  10554. return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>';
  10555. }
  10556. };
  10557. // Methods that will customize the rendering behavior of the AgendaView's dayGrid
  10558. var agendaDayGridMethods = {
  10559. // Generates the HTML that goes before the all-day cells
  10560. renderBgIntroHtml: function () {
  10561. var view = this.view;
  10562. return '' +
  10563. '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' +
  10564. '<span>' + // needed for matchCellWidths
  10565. view.getAllDayHtml() +
  10566. '</span>' +
  10567. '</td>';
  10568. },
  10569. // Generates the HTML that goes before all other types of cells.
  10570. // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid.
  10571. renderIntroHtml: function () {
  10572. var view = this.view;
  10573. return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>';
  10574. }
  10575. };
  10576. ;;
  10577. var AGENDA_ALL_DAY_EVENT_LIMIT = 5;
  10578. // potential nice values for the slot-duration and interval-duration
  10579. // from largest to smallest
  10580. var AGENDA_STOCK_SUB_DURATIONS = [
  10581. { hours: 1 },
  10582. { minutes: 30 },
  10583. { minutes: 15 },
  10584. { seconds: 30 },
  10585. { seconds: 15 }
  10586. ];
  10587. fcViews.agenda = {
  10588. 'class': AgendaView,
  10589. defaults: {
  10590. allDaySlot: true,
  10591. slotDuration: '00:30:00',
  10592. minTime: '00:00:00',
  10593. maxTime: '24:00:00',
  10594. slotEventOverlap: true // a bad name. confused with overlap/constraint system
  10595. }
  10596. };
  10597. fcViews.agendaDay = {
  10598. type: 'agenda',
  10599. duration: { days: 1 }
  10600. };
  10601. fcViews.agendaWeek = {
  10602. type: 'agenda',
  10603. duration: { weeks: 1 }
  10604. };
  10605. ;;
  10606. /*
  10607. Responsible for the scroller, and forwarding event-related actions into the "grid"
  10608. */
  10609. var ListView = View.extend({
  10610. grid: null,
  10611. scroller: null,
  10612. initialize: function () {
  10613. this.grid = new ListViewGrid(this);
  10614. this.scroller = new Scroller({
  10615. overflowX: 'hidden',
  10616. overflowY: 'auto'
  10617. });
  10618. },
  10619. setRange: function (range) {
  10620. View.prototype.setRange.call(this, range); // super
  10621. this.grid.setRange(range); // needs to process range-related options
  10622. },
  10623. renderSkeleton: function () {
  10624. this.el.addClass(
  10625. 'fc-list-view ' +
  10626. this.widgetContentClass
  10627. );
  10628. this.scroller.render();
  10629. this.scroller.el.appendTo(this.el);
  10630. this.grid.setElement(this.scroller.scrollEl);
  10631. },
  10632. unrenderSkeleton: function () {
  10633. this.scroller.destroy(); // will remove the Grid too
  10634. },
  10635. setHeight: function (totalHeight, isAuto) {
  10636. this.scroller.setHeight(this.computeScrollerHeight(totalHeight));
  10637. },
  10638. computeScrollerHeight: function (totalHeight) {
  10639. return totalHeight -
  10640. subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
  10641. },
  10642. renderEvents: function (events) {
  10643. this.grid.renderEvents(events);
  10644. },
  10645. unrenderEvents: function () {
  10646. this.grid.unrenderEvents();
  10647. },
  10648. isEventResizable: function (event) {
  10649. return false;
  10650. },
  10651. isEventDraggable: function (event) {
  10652. return false;
  10653. }
  10654. });
  10655. /*
  10656. Responsible for event rendering and user-interaction.
  10657. Its "el" is the inner-content of the above view's scroller.
  10658. */
  10659. var ListViewGrid = Grid.extend({
  10660. segSelector: '.fc-list-item', // which elements accept event actions
  10661. hasDayInteractions: false, // no day selection or day clicking
  10662. // slices by day
  10663. spanToSegs: function (span) {
  10664. var view = this.view;
  10665. var dayStart = view.start.clone().time(0); // timed, so segs get times!
  10666. var dayIndex = 0;
  10667. var seg;
  10668. var segs = [];
  10669. while (dayStart < view.end) {
  10670. seg = intersectRanges(span, {
  10671. start: dayStart,
  10672. end: dayStart.clone().add(1, 'day')
  10673. });
  10674. if (seg) {
  10675. seg.dayIndex = dayIndex;
  10676. segs.push(seg);
  10677. }
  10678. dayStart.add(1, 'day');
  10679. dayIndex++;
  10680. // detect when span won't go fully into the next day,
  10681. // and mutate the latest seg to the be the end.
  10682. if (
  10683. seg && !seg.isEnd && span.end.hasTime() &&
  10684. span.end < dayStart.clone().add(this.view.nextDayThreshold)
  10685. ) {
  10686. seg.end = span.end.clone();
  10687. seg.isEnd = true;
  10688. break;
  10689. }
  10690. }
  10691. return segs;
  10692. },
  10693. // like "4:00am"
  10694. computeEventTimeFormat: function () {
  10695. return this.view.opt('mediumTimeFormat');
  10696. },
  10697. // for events with a url, the whole <tr> should be clickable,
  10698. // but it's impossible to wrap with an <a> tag. simulate this.
  10699. handleSegClick: function (seg, ev) {
  10700. var url;
  10701. Grid.prototype.handleSegClick.apply(this, arguments); // super. might prevent the default action
  10702. // not clicking on or within an <a> with an href
  10703. if (!$(ev.target).closest('a[href]').length) {
  10704. url = seg.event.url;
  10705. if (url && !ev.isDefaultPrevented()) { // jsEvent not cancelled in handler
  10706. window.location.href = url; // simulate link click
  10707. }
  10708. }
  10709. },
  10710. // returns list of foreground segs that were actually rendered
  10711. renderFgSegs: function (segs) {
  10712. segs = this.renderFgSegEls(segs); // might filter away hidden events
  10713. if (!segs.length) {
  10714. this.renderEmptyMessage();
  10715. }
  10716. else {
  10717. this.renderSegList(segs);
  10718. }
  10719. return segs;
  10720. },
  10721. renderEmptyMessage: function () {
  10722. this.el.html(
  10723. '<div class="fc-list-empty-wrap2">' + // TODO: try less wraps
  10724. '<div class="fc-list-empty-wrap1">' +
  10725. '<div class="fc-list-empty">' +
  10726. htmlEscape(this.view.opt('noEventsMessage')) +
  10727. '</div>' +
  10728. '</div>' +
  10729. '</div>'
  10730. );
  10731. },
  10732. // render the event segments in the view
  10733. renderSegList: function (allSegs) {
  10734. var segsByDay = this.groupSegsByDay(allSegs); // sparse array
  10735. var dayIndex;
  10736. var daySegs;
  10737. var i;
  10738. var tableEl = $('<table class="fc-list-table"><tbody/></table>');
  10739. var tbodyEl = tableEl.find('tbody');
  10740. for (dayIndex = 0; dayIndex < segsByDay.length; dayIndex++) {
  10741. daySegs = segsByDay[dayIndex];
  10742. if (daySegs) { // sparse array, so might be undefined
  10743. // append a day header
  10744. tbodyEl.append(this.dayHeaderHtml(
  10745. this.view.start.clone().add(dayIndex, 'days')
  10746. ));
  10747. this.sortEventSegs(daySegs);
  10748. for (i = 0; i < daySegs.length; i++) {
  10749. tbodyEl.append(daySegs[i].el); // append event row
  10750. }
  10751. }
  10752. }
  10753. this.el.empty().append(tableEl);
  10754. },
  10755. // Returns a sparse array of arrays, segs grouped by their dayIndex
  10756. groupSegsByDay: function (segs) {
  10757. var segsByDay = []; // sparse array
  10758. var i, seg;
  10759. for (i = 0; i < segs.length; i++) {
  10760. seg = segs[i];
  10761. (segsByDay[seg.dayIndex] || (segsByDay[seg.dayIndex] = []))
  10762. .push(seg);
  10763. }
  10764. return segsByDay;
  10765. },
  10766. // generates the HTML for the day headers that live amongst the event rows
  10767. dayHeaderHtml: function (dayDate) {
  10768. var view = this.view;
  10769. var mainFormat = view.opt('listDayFormat');
  10770. var altFormat = view.opt('listDayAltFormat');
  10771. return '<tr class="fc-list-heading" data-date="' + dayDate.format('YYYY-MM-DD') + '">' +
  10772. '<td class="' + view.widgetHeaderClass + '" colspan="3">' +
  10773. (mainFormat ?
  10774. view.buildGotoAnchorHtml(
  10775. dayDate,
  10776. { 'class': 'fc-list-heading-main' },
  10777. htmlEscape(dayDate.format(mainFormat)) // inner HTML
  10778. ) :
  10779. '') +
  10780. (altFormat ?
  10781. view.buildGotoAnchorHtml(
  10782. dayDate,
  10783. { 'class': 'fc-list-heading-alt' },
  10784. htmlEscape(dayDate.format(altFormat)) // inner HTML
  10785. ) :
  10786. '') +
  10787. '</td>' +
  10788. '</tr>';
  10789. },
  10790. // generates the HTML for a single event row
  10791. fgSegHtml: function (seg) {
  10792. var view = this.view;
  10793. var classes = ['fc-list-item'].concat(this.getSegCustomClasses(seg));
  10794. var bgColor = this.getSegBackgroundColor(seg);
  10795. var event = seg.event;
  10796. var url = event.url;
  10797. var timeHtml;
  10798. if (event.allDay) {
  10799. timeHtml = view.getAllDayHtml();
  10800. }
  10801. else if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day
  10802. if (seg.isStart || seg.isEnd) { // outer segment that probably lasts part of the day
  10803. timeHtml = htmlEscape(this.getEventTimeText(seg));
  10804. }
  10805. else { // inner segment that lasts the whole day
  10806. timeHtml = view.getAllDayHtml();
  10807. }
  10808. }
  10809. else {
  10810. // Display the normal time text for the *event's* times
  10811. timeHtml = htmlEscape(this.getEventTimeText(event));
  10812. }
  10813. if (url) {
  10814. classes.push('fc-has-url');
  10815. }
  10816. return '<tr class="' + classes.join(' ') + '">' +
  10817. (this.displayEventTime ?
  10818. '<td class="fc-list-item-time ' + view.widgetContentClass + '">' +
  10819. (timeHtml || '') +
  10820. '</td>' :
  10821. '') +
  10822. '<td class="fc-list-item-marker ' + view.widgetContentClass + '">' +
  10823. '<span class="fc-event-dot"' +
  10824. (bgColor ?
  10825. ' style="background-color:' + bgColor + '"' :
  10826. '') +
  10827. '></span>' +
  10828. '</td>' +
  10829. '<td class="fc-list-item-title ' + view.widgetContentClass + '">' +
  10830. '<a tooltips' + (url ? ' href="' + htmlEscape(url) + '"' : '') + '>' +
  10831. 'title: ' + htmlEscape(event.title || '') +
  10832. (event.content ?
  10833. ': ' + htmlEscape(event.content) :
  10834. ''
  10835. ) +
  10836. '</a>' +
  10837. '</td>' +
  10838. '</tr>';
  10839. }
  10840. });
  10841. ;;
  10842. fcViews.list = {
  10843. 'class': ListView,
  10844. buttonTextKey: 'list', // what to lookup in locale files
  10845. defaults: {
  10846. buttonText: 'list', // text to display for English
  10847. listDayFormat: 'LL', // like "January 1, 2016"
  10848. noEventsMessage: 'No events to display'
  10849. }
  10850. };
  10851. fcViews.listDay = {
  10852. type: 'list',
  10853. duration: { days: 1 },
  10854. defaults: {
  10855. listDayFormat: 'dddd' // day-of-week is all we need. full date is probably in header
  10856. }
  10857. };
  10858. fcViews.listWeek = {
  10859. type: 'list',
  10860. duration: { weeks: 1 },
  10861. defaults: {
  10862. listDayFormat: 'dddd', // day-of-week is more important
  10863. listDayAltFormat: 'LL'
  10864. }
  10865. };
  10866. fcViews.listMonth = {
  10867. type: 'list',
  10868. duration: { month: 1 },
  10869. defaults: {
  10870. listDayAltFormat: 'dddd' // day-of-week is nice-to-have
  10871. }
  10872. };
  10873. fcViews.listYear = {
  10874. type: 'list',
  10875. duration: { year: 1 },
  10876. defaults: {
  10877. listDayAltFormat: 'dddd' // day-of-week is nice-to-have
  10878. }
  10879. };
  10880. ;;
  10881. return FC; // export for Node/CommonJS
  10882. });