View.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761
  1. /* An abstract class from which other views inherit from
  2. ----------------------------------------------------------------------------------------------------------------------*/
  3. var View = FC.View = InteractiveDateComponent.extend({
  4. type: null, // subclass' view name (string)
  5. name: null, // deprecated. use `type` instead
  6. title: null, // the text that will be displayed in the header's title
  7. calendar: null, // owner Calendar object
  8. viewSpec: null,
  9. options: null, // hash containing all options. already merged with view-specific-options
  10. queuedScroll: null,
  11. isSelected: false, // boolean whether a range of time is user-selected or not
  12. selectedEventInstance: null,
  13. eventOrderSpecs: null, // criteria for ordering events when they have same date/time
  14. // for date utils, computed from options
  15. isHiddenDayHash: null,
  16. // now indicator
  17. isNowIndicatorRendered: null,
  18. initialNowDate: null, // result first getNow call
  19. initialNowQueriedMs: null, // ms time the getNow was called
  20. nowIndicatorTimeoutID: null, // for refresh timing of now indicator
  21. nowIndicatorIntervalID: null, // "
  22. constructor: function(calendar, viewSpec) {
  23. this.calendar = calendar;
  24. this.viewSpec = viewSpec;
  25. // shortcuts
  26. this.type = viewSpec.type;
  27. this.options = viewSpec.options;
  28. // .name is deprecated
  29. this.name = this.type;
  30. InteractiveDateComponent.call(this);
  31. this.initHiddenDays();
  32. this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder'));
  33. // legacy
  34. if (this.initialize) {
  35. this.initialize();
  36. }
  37. },
  38. _getView: function() {
  39. return this;
  40. },
  41. // Retrieves an option with the given name
  42. opt: function(name) {
  43. return this.options[name];
  44. },
  45. /* Title and Date Formatting
  46. ------------------------------------------------------------------------------------------------------------------*/
  47. // Computes what the title at the top of the calendar should be for this view
  48. computeTitle: function(dateProfile) {
  49. var unzonedRange;
  50. // for views that span a large unit of time, show the proper interval, ignoring stray days before and after
  51. if (/^(year|month)$/.test(dateProfile.currentRangeUnit)) {
  52. unzonedRange = dateProfile.currentUnzonedRange;
  53. }
  54. else { // for day units or smaller, use the actual day range
  55. unzonedRange = dateProfile.activeUnzonedRange;
  56. }
  57. return this.formatRange(
  58. {
  59. start: this.calendar.msToMoment(unzonedRange.startMs, dateProfile.isRangeAllDay),
  60. end: this.calendar.msToMoment(unzonedRange.endMs, dateProfile.isRangeAllDay)
  61. },
  62. dateProfile.isRangeAllDay,
  63. this.opt('titleFormat') || this.computeTitleFormat(),
  64. this.opt('titleRangeSeparator')
  65. );
  66. },
  67. // Generates the format string that should be used to generate the title for the current date range.
  68. // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`.
  69. computeTitleFormat: function() {
  70. var currentRangeUnit = this.dateProfile.currentRangeUnit;
  71. if (currentRangeUnit == 'year') {
  72. return 'YYYY';
  73. }
  74. else if (currentRangeUnit == 'month') {
  75. return this.opt('monthYearFormat'); // like "September 2014"
  76. }
  77. else if (this.currentRangeAs('days') > 1) {
  78. return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014"
  79. }
  80. else {
  81. return 'LL'; // one day. longer, like "September 9 2014"
  82. }
  83. },
  84. // Element
  85. // -----------------------------------------------------------------------------------------------------------------
  86. setElement: function(el) {
  87. var _this = this;
  88. InteractiveDateComponent.prototype.setElement.apply(this, arguments);
  89. // TODO: not best place for this
  90. // TODO: better way of forwarding options from calendar -> view
  91. this.calendar.optionsModel.watch('viewRawBusinessHours' + this.uid, [ '?businessHours' ], function(deps) {
  92. _this.set('rawBusinessHours', deps.businessHours);
  93. }, function() {
  94. _this.unset('rawBusinessHours');
  95. });
  96. },
  97. removeElement: function() {
  98. this.calendar.optionsModel.unwatch('viewRawBusinessHours' + this.uid);
  99. InteractiveDateComponent.prototype.removeElement.apply(this, arguments);
  100. },
  101. // Date Setting/Unsetting
  102. // -----------------------------------------------------------------------------------------------------------------
  103. setDate: function(date) {
  104. var currentDateProfile = this.dateProfile;
  105. var newDateProfile = this.buildDateProfile(date, null, true); // forceToValid=true
  106. if (
  107. !currentDateProfile ||
  108. !currentDateProfile.activeUnzonedRange.equals(newDateProfile.activeUnzonedRange)
  109. ) {
  110. this.set('dateProfile', newDateProfile);
  111. }
  112. },
  113. unsetDate: function() {
  114. this.unset('dateProfile');
  115. },
  116. handleDateProfileSet: function(dateProfile) {
  117. InteractiveDateComponent.prototype.handleDateProfileSet.apply(this, arguments);
  118. var calendar = this.calendar;
  119. // DEPRECATED, but we need to keep it updated...
  120. this.start = calendar.msToMoment(dateProfile.activeUnzonedRange.startMs, dateProfile.isRangeAllDay);
  121. this.end = calendar.msToMoment(dateProfile.activeUnzonedRange.endMs, dateProfile.isRangeAllDay);
  122. this.intervalStart = calendar.msToMoment(dateProfile.currentUnzonedRange.startMs, dateProfile.isRangeAllDay);
  123. this.intervalEnd = calendar.msToMoment(dateProfile.currentUnzonedRange.endMs, dateProfile.isRangeAllDay);
  124. this.title = this.computeTitle(dateProfile);
  125. calendar.reportViewDatesChanged(this, dateProfile); // TODO: reverse the pubsub
  126. },
  127. // Event Data
  128. // -----------------------------------------------------------------------------------------------------------------
  129. // returns existing state at time of request
  130. requestEvents: function(dateProfile) {
  131. var calendar = this.calendar;
  132. var forceAllDay = dateProfile.isRangeAllDay && !this.usesMinMaxTime;
  133. return calendar.requestEvents(
  134. calendar.msToMoment(dateProfile.activeUnzonedRange.startMs, forceAllDay),
  135. calendar.msToMoment(dateProfile.activeUnzonedRange.endMs, forceAllDay)
  136. );
  137. },
  138. bindEventChanges: function() {
  139. this.listenTo(this.calendar.eventManager, 'receive', this._handleEventsChanged);
  140. },
  141. _handleEventsChanged: function(changeset) {
  142. this.startBatchRender();
  143. this.handleEventsChanged(changeset);
  144. this.stopBatchRender();
  145. },
  146. unbindEventChanges: function() {
  147. this.stopListeningTo(this.calendar.eventManager);
  148. },
  149. // Date High-level Rendering
  150. // -----------------------------------------------------------------------------------------------------------------
  151. // if dateProfile not specified, uses current
  152. executeDateRender: function(dateProfile) {
  153. if (this.render) {
  154. this.render(); // TODO: deprecate
  155. }
  156. this.renderDates(dateProfile);
  157. this.addScroll({ isDateInit: true });
  158. this.startNowIndicator(); // shouldn't render yet because updateSize will be called soon
  159. this.isDatesRendered = true;
  160. this.trigger('after:entity:render', 'date');
  161. },
  162. executeDateUnrender: function() {
  163. this.unselect();
  164. this.stopNowIndicator();
  165. this.trigger('before:entity:unrender', 'date');
  166. this.unrenderDates();
  167. if (this.destroy) {
  168. this.destroy(); // TODO: deprecate
  169. }
  170. this.isDatesRendered = false;
  171. },
  172. // Misc view rendering utils
  173. // -----------------------------------------------------------------------------------------------------------------
  174. // Binds DOM handlers to elements that reside outside the view container, such as the document
  175. bindGlobalHandlers: function() {
  176. InteractiveDateComponent.prototype.bindGlobalHandlers.apply(this, arguments);
  177. this.listenTo(GlobalEmitter.get(), {
  178. touchstart: this.processUnselect,
  179. mousedown: this.handleDocumentMousedown
  180. });
  181. },
  182. // Unbinds DOM handlers from elements that reside outside the view container
  183. unbindGlobalHandlers: function() {
  184. InteractiveDateComponent.prototype.unbindGlobalHandlers.apply(this, arguments);
  185. this.stopListeningTo(GlobalEmitter.get());
  186. },
  187. /* Now Indicator
  188. ------------------------------------------------------------------------------------------------------------------*/
  189. // Immediately render the current time indicator and begins re-rendering it at an interval,
  190. // which is defined by this.getNowIndicatorUnit().
  191. // TODO: somehow do this for the current whole day's background too
  192. startNowIndicator: function() {
  193. var _this = this;
  194. var unit;
  195. var update;
  196. var delay; // ms wait value
  197. if (this.opt('nowIndicator')) {
  198. unit = this.getNowIndicatorUnit();
  199. if (unit) {
  200. update = proxy(this, 'updateNowIndicator'); // bind to `this`
  201. this.initialNowDate = this.calendar.getNow();
  202. this.initialNowQueriedMs = +new Date();
  203. // wait until the beginning of the next interval
  204. delay = this.initialNowDate.clone().startOf(unit).add(1, unit) - this.initialNowDate;
  205. this.nowIndicatorTimeoutID = setTimeout(function() {
  206. _this.nowIndicatorTimeoutID = null;
  207. update();
  208. delay = +moment.duration(1, unit);
  209. delay = Math.max(100, delay); // prevent too frequent
  210. _this.nowIndicatorIntervalID = setInterval(update, delay); // update every interval
  211. }, delay);
  212. }
  213. // rendering will be initiated in updateSize
  214. }
  215. },
  216. // rerenders the now indicator, computing the new current time from the amount of time that has passed
  217. // since the initial getNow call.
  218. updateNowIndicator: function() {
  219. if (this.nowIndicatorTimeoutID) { // activated?
  220. this.unrenderNowIndicator(); // won't unrender if unnecessary
  221. this.renderNowIndicator(
  222. this.initialNowDate.clone().add(new Date() - this.initialNowQueriedMs) // add ms
  223. );
  224. this.isNowIndicatorRendered = true;
  225. }
  226. },
  227. // Immediately unrenders the view's current time indicator and stops any re-rendering timers.
  228. // Won't cause side effects if indicator isn't rendered.
  229. stopNowIndicator: function() {
  230. if (this.isNowIndicatorRendered) {
  231. if (this.nowIndicatorTimeoutID) {
  232. clearTimeout(this.nowIndicatorTimeoutID);
  233. this.nowIndicatorTimeoutID = null;
  234. }
  235. if (this.nowIndicatorIntervalID) {
  236. clearTimeout(this.nowIndicatorIntervalID);
  237. this.nowIndicatorIntervalID = null;
  238. }
  239. this.unrenderNowIndicator();
  240. this.isNowIndicatorRendered = false;
  241. }
  242. },
  243. /* Dimensions
  244. ------------------------------------------------------------------------------------------------------------------*/
  245. updateSize: function(totalHeight, isAuto, isResize) {
  246. if (this.setHeight) { // for legacy API
  247. this.setHeight(totalHeight, isAuto);
  248. }
  249. else {
  250. InteractiveDateComponent.prototype.updateSize.apply(this, arguments);
  251. }
  252. this.updateNowIndicator();
  253. },
  254. /* Scroller
  255. ------------------------------------------------------------------------------------------------------------------*/
  256. addScroll: function(scroll) {
  257. var queuedScroll = this.queuedScroll || (this.queuedScroll = {});
  258. if (!queuedScroll.isLocked) {
  259. $.extend(queuedScroll, scroll);
  260. }
  261. },
  262. popScroll: function() {
  263. this.applyQueuedScroll();
  264. this.queuedScroll = null;
  265. },
  266. applyQueuedScroll: function() {
  267. if (this.queuedScroll) {
  268. this.applyScroll(this.queuedScroll);
  269. }
  270. },
  271. queryScroll: function() {
  272. var scroll = {};
  273. if (this.isDatesRendered) {
  274. $.extend(scroll, this.queryDateScroll());
  275. }
  276. return scroll;
  277. },
  278. applyScroll: function(scroll) {
  279. if (scroll.isDateInit && !scroll.isLocked && this.isDatesRendered) {
  280. $.extend(scroll, this.computeInitialDateScroll());
  281. }
  282. if (this.isDatesRendered) {
  283. this.applyDateScroll(scroll);
  284. }
  285. },
  286. computeInitialDateScroll: function() {
  287. return {}; // subclasses must implement
  288. },
  289. queryDateScroll: function() {
  290. return {}; // subclasses must implement
  291. },
  292. applyDateScroll: function(scroll) {
  293. ; // subclasses must implement
  294. },
  295. /* Event Drag-n-Drop
  296. ------------------------------------------------------------------------------------------------------------------*/
  297. reportEventDrop: function(eventInstance, eventMutation, el, ev) {
  298. var eventManager = this.calendar.eventManager;
  299. var undoFunc = eventManager.mutateEventsWithId(
  300. eventInstance.def.id,
  301. eventMutation,
  302. this.calendar
  303. );
  304. var dateMutation = eventMutation.dateMutation;
  305. // update the EventInstance, for handlers
  306. if (dateMutation) {
  307. eventInstance.dateProfile = dateMutation.buildNewDateProfile(
  308. eventInstance.dateProfile,
  309. this.calendar
  310. );
  311. }
  312. this.triggerEventDrop(
  313. eventInstance,
  314. // a drop doesn't necessarily mean a date mutation (ex: resource change)
  315. (dateMutation && dateMutation.dateDelta) || moment.duration(),
  316. undoFunc,
  317. el, ev
  318. );
  319. },
  320. // Triggers event-drop handlers that have subscribed via the API
  321. triggerEventDrop: function(eventInstance, dateDelta, undoFunc, el, ev) {
  322. this.publiclyTrigger('eventDrop', {
  323. context: el[0],
  324. args: [
  325. eventInstance.toLegacy(),
  326. dateDelta,
  327. undoFunc,
  328. ev,
  329. {}, // {} = jqui dummy
  330. this
  331. ]
  332. });
  333. },
  334. /* External Element Drag-n-Drop
  335. ------------------------------------------------------------------------------------------------------------------*/
  336. // Must be called when an external element, via jQuery UI, has been dropped onto the calendar.
  337. // `meta` is the parsed data that has been embedded into the dragging event.
  338. // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event.
  339. reportExternalDrop: function(singleEventDef, isEvent, isSticky, el, ev, ui) {
  340. if (isEvent) {
  341. this.calendar.eventManager.addEventDef(singleEventDef, isSticky);
  342. }
  343. this.triggerExternalDrop(singleEventDef, isEvent, el, ev, ui);
  344. },
  345. // Triggers external-drop handlers that have subscribed via the API
  346. triggerExternalDrop: function(singleEventDef, isEvent, el, ev, ui) {
  347. // trigger 'drop' regardless of whether element represents an event
  348. this.publiclyTrigger('drop', {
  349. context: el[0],
  350. args: [
  351. singleEventDef.dateProfile.start.clone(),
  352. ev,
  353. ui,
  354. this
  355. ]
  356. });
  357. if (isEvent) {
  358. // signal an external event landed
  359. this.publiclyTrigger('eventReceive', {
  360. context: this,
  361. args: [
  362. singleEventDef.buildInstance().toLegacy(),
  363. this
  364. ]
  365. });
  366. }
  367. },
  368. /* Event Resizing
  369. ------------------------------------------------------------------------------------------------------------------*/
  370. // Must be called when an event in the view has been resized to a new length
  371. reportEventResize: function(eventInstance, eventMutation, el, ev) {
  372. var eventManager = this.calendar.eventManager;
  373. var undoFunc = eventManager.mutateEventsWithId(
  374. eventInstance.def.id,
  375. eventMutation,
  376. this.calendar
  377. );
  378. // update the EventInstance, for handlers
  379. eventInstance.dateProfile = eventMutation.dateMutation.buildNewDateProfile(
  380. eventInstance.dateProfile,
  381. this.calendar
  382. );
  383. this.triggerEventResize(
  384. eventInstance,
  385. eventMutation.dateMutation.endDelta,
  386. undoFunc,
  387. el, ev
  388. );
  389. },
  390. // Triggers event-resize handlers that have subscribed via the API
  391. triggerEventResize: function(eventInstance, durationDelta, undoFunc, el, ev) {
  392. this.publiclyTrigger('eventResize', {
  393. context: el[0],
  394. args: [
  395. eventInstance.toLegacy(),
  396. durationDelta,
  397. undoFunc,
  398. ev,
  399. {}, // {} = jqui dummy
  400. this
  401. ]
  402. });
  403. },
  404. /* Selection (time range)
  405. ------------------------------------------------------------------------------------------------------------------*/
  406. // Selects a date span on the view. `start` and `end` are both Moments.
  407. // `ev` is the native mouse event that begin the interaction.
  408. select: function(footprint, ev) {
  409. this.unselect(ev);
  410. this.renderSelectionFootprint(footprint);
  411. this.reportSelection(footprint, ev);
  412. },
  413. renderSelectionFootprint: function(footprint, ev) {
  414. if (this.renderSelection) { // legacy method in custom view classes
  415. this.renderSelection(
  416. footprint.toLegacy(this.calendar)
  417. );
  418. }
  419. else {
  420. InteractiveDateComponent.prototype.renderSelectionFootprint.apply(this, arguments);
  421. }
  422. },
  423. // Called when a new selection is made. Updates internal state and triggers handlers.
  424. reportSelection: function(footprint, ev) {
  425. this.isSelected = true;
  426. this.triggerSelect(footprint, ev);
  427. },
  428. // Triggers handlers to 'select'
  429. triggerSelect: function(footprint, ev) {
  430. var dateProfile = this.calendar.footprintToDateProfile(footprint); // abuse of "Event"DateProfile?
  431. this.publiclyTrigger('select', {
  432. context: this,
  433. args: [
  434. dateProfile.start,
  435. dateProfile.end,
  436. ev,
  437. this
  438. ]
  439. });
  440. },
  441. // Undoes a selection. updates in the internal state and triggers handlers.
  442. // `ev` is the native mouse event that began the interaction.
  443. unselect: function(ev) {
  444. if (this.isSelected) {
  445. this.isSelected = false;
  446. if (this.destroySelection) {
  447. this.destroySelection(); // TODO: deprecate
  448. }
  449. this.unrenderSelection();
  450. this.publiclyTrigger('unselect', {
  451. context: this,
  452. args: [ ev, this ]
  453. });
  454. }
  455. },
  456. /* Event Selection
  457. ------------------------------------------------------------------------------------------------------------------*/
  458. selectEventInstance: function(eventInstance) {
  459. if (
  460. !this.selectedEventInstance ||
  461. this.selectedEventInstance !== eventInstance
  462. ) {
  463. this.unselectEventInstance();
  464. this.getRecursiveEventSegs().forEach(function(seg) {
  465. if (
  466. seg.footprint.eventInstance === eventInstance &&
  467. seg.el // necessary?
  468. ) {
  469. seg.el.addClass('fc-selected');
  470. }
  471. });
  472. this.selectedEventInstance = eventInstance;
  473. }
  474. },
  475. unselectEventInstance: function() {
  476. if (this.selectedEventInstance) {
  477. this.getRecursiveEventSegs().forEach(function(seg) {
  478. if (seg.el) { // necessary?
  479. seg.el.removeClass('fc-selected');
  480. }
  481. });
  482. this.selectedEventInstance = null;
  483. }
  484. },
  485. isEventDefSelected: function(eventDef) {
  486. // event references might change on refetchEvents(), while selectedEventInstance doesn't,
  487. // so compare IDs
  488. return this.selectedEventInstance && this.selectedEventInstance.def.id === eventDef.id;
  489. },
  490. /* Mouse / Touch Unselecting (time range & event unselection)
  491. ------------------------------------------------------------------------------------------------------------------*/
  492. // TODO: move consistently to down/start or up/end?
  493. // TODO: don't kill previous selection if touch scrolling
  494. handleDocumentMousedown: function(ev) {
  495. if (isPrimaryMouseButton(ev)) {
  496. this.processUnselect(ev);
  497. }
  498. },
  499. processUnselect: function(ev) {
  500. this.processRangeUnselect(ev);
  501. this.processEventUnselect(ev);
  502. },
  503. processRangeUnselect: function(ev) {
  504. var ignore;
  505. // is there a time-range selection?
  506. if (this.isSelected && this.opt('unselectAuto')) {
  507. // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element
  508. ignore = this.opt('unselectCancel');
  509. if (!ignore || !$(ev.target).closest(ignore).length) {
  510. this.unselect(ev);
  511. }
  512. }
  513. },
  514. processEventUnselect: function(ev) {
  515. if (this.selectedEventInstance) {
  516. if (!$(ev.target).closest('.fc-selected').length) {
  517. this.unselectEventInstance();
  518. }
  519. }
  520. },
  521. /* Day Click
  522. ------------------------------------------------------------------------------------------------------------------*/
  523. // Triggers handlers to 'dayClick'
  524. // Span has start/end of the clicked area. Only the start is useful.
  525. triggerDayClick: function(footprint, dayEl, ev) {
  526. var dateProfile = this.calendar.footprintToDateProfile(footprint); // abuse of "Event"DateProfile?
  527. this.publiclyTrigger('dayClick', {
  528. context: dayEl,
  529. args: [ dateProfile.start, ev, this ]
  530. });
  531. }
  532. });
  533. // responsible for populating data that DateComponent relies on
  534. View.watch('businessHourGenerator', [ 'rawBusinessHours' ], function(deps) {
  535. return new BusinessHourGenerator(
  536. deps.rawBusinessHours,
  537. this.calendar // TODO: untangle
  538. );
  539. });
  540. View.watch('businessHours', [ 'businessHourGenerator', 'dateProfile' ], function(deps) {
  541. var businessHourGenerator = deps.businessHourGenerator;
  542. var unzonedRange = deps.dateProfile.activeUnzonedRange;
  543. return {
  544. allDay: businessHourGenerator.buildEventInstanceGroup(true, unzonedRange),
  545. timed: businessHourGenerator.buildEventInstanceGroup(false, unzonedRange)
  546. };
  547. });
  548. View.watch('bindingEvents', [ 'dateProfile' ], function(deps) {
  549. this.handleEventsBound();
  550. this.requestEvents(deps.dateProfile);
  551. if (this.calendar.eventManager.isFinalized()) {
  552. this._handleEventsChanged(this.calendar.eventManager.getFinalizedEvents());
  553. }
  554. this.bindEventChanges();
  555. }, function() {
  556. this.unbindEventChanges();
  557. this.handleEventsUnbound();
  558. });