Grid.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
  1. /* An abstract class comprised of a "grid" of areas that each represent a specific datetime
  2. ----------------------------------------------------------------------------------------------------------------------*/
  3. var Grid = FC.Grid = Class.extend(ListenerMixin, {
  4. // self-config, overridable by subclasses
  5. hasDayInteractions: true, // can user click/select ranges of time?
  6. view: null, // a View object
  7. isRTL: null, // shortcut to the view's isRTL option
  8. start: null,
  9. end: null,
  10. el: null, // the containing element
  11. elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name.
  12. // derived from options
  13. eventTimeFormat: null,
  14. displayEventTime: null,
  15. displayEventEnd: null,
  16. minResizeDuration: null, // TODO: hack. set by subclasses. minumum event resize duration
  17. // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity
  18. // of the date areas. if not defined, assumes to be day and time granularity.
  19. // TODO: port isTimeScale into same system?
  20. largeUnit: null,
  21. dayClickListener: null,
  22. daySelectListener: null,
  23. segDragListener: null,
  24. segResizeListener: null,
  25. externalDragListener: null,
  26. constructor: function(view) {
  27. this.view = view;
  28. this.isRTL = view.opt('isRTL');
  29. this.elsByFill = {};
  30. this.dayClickListener = this.buildDayClickListener();
  31. this.daySelectListener = this.buildDaySelectListener();
  32. },
  33. /* Options
  34. ------------------------------------------------------------------------------------------------------------------*/
  35. // Generates the format string used for event time text, if not explicitly defined by 'timeFormat'
  36. computeEventTimeFormat: function() {
  37. return this.view.opt('smallTimeFormat');
  38. },
  39. // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventTime'.
  40. // Only applies to non-all-day events.
  41. computeDisplayEventTime: function() {
  42. return true;
  43. },
  44. // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd'
  45. computeDisplayEventEnd: function() {
  46. return true;
  47. },
  48. /* Dates
  49. ------------------------------------------------------------------------------------------------------------------*/
  50. // Tells the grid about what period of time to display.
  51. // Any date-related internal data should be generated.
  52. setRange: function(range) {
  53. this.start = range.start.clone();
  54. this.end = range.end.clone();
  55. this.rangeUpdated();
  56. this.processRangeOptions();
  57. },
  58. // Called when internal variables that rely on the range should be updated
  59. rangeUpdated: function() {
  60. },
  61. // Updates values that rely on options and also relate to range
  62. processRangeOptions: function() {
  63. var view = this.view;
  64. var displayEventTime;
  65. var displayEventEnd;
  66. this.eventTimeFormat =
  67. view.opt('eventTimeFormat') ||
  68. view.opt('timeFormat') || // deprecated
  69. this.computeEventTimeFormat();
  70. displayEventTime = view.opt('displayEventTime');
  71. if (displayEventTime == null) {
  72. displayEventTime = this.computeDisplayEventTime(); // might be based off of range
  73. }
  74. displayEventEnd = view.opt('displayEventEnd');
  75. if (displayEventEnd == null) {
  76. displayEventEnd = this.computeDisplayEventEnd(); // might be based off of range
  77. }
  78. this.displayEventTime = displayEventTime;
  79. this.displayEventEnd = displayEventEnd;
  80. },
  81. // Converts a span (has unzoned start/end and any other grid-specific location information)
  82. // into an array of segments (pieces of events whose format is decided by the grid).
  83. spanToSegs: function(span) {
  84. // subclasses must implement
  85. },
  86. // Diffs the two dates, returning a duration, based on granularity of the grid
  87. // TODO: port isTimeScale into this system?
  88. diffDates: function(a, b) {
  89. if (this.largeUnit) {
  90. return diffByUnit(a, b, this.largeUnit);
  91. }
  92. else {
  93. return diffDayTime(a, b);
  94. }
  95. },
  96. /* Hit Area
  97. ------------------------------------------------------------------------------------------------------------------*/
  98. hitsNeededDepth: 0, // necessary because multiple callers might need the same hits
  99. hitsNeeded: function() {
  100. if (!(this.hitsNeededDepth++)) {
  101. this.prepareHits();
  102. }
  103. },
  104. hitsNotNeeded: function() {
  105. if (this.hitsNeededDepth && !(--this.hitsNeededDepth)) {
  106. this.releaseHits();
  107. }
  108. },
  109. // Called before one or more queryHit calls might happen. Should prepare any cached coordinates for queryHit
  110. prepareHits: function() {
  111. },
  112. // Called when queryHit calls have subsided. Good place to clear any coordinate caches.
  113. releaseHits: function() {
  114. },
  115. // Given coordinates from the topleft of the document, return data about the date-related area underneath.
  116. // Can return an object with arbitrary properties (although top/right/left/bottom are encouraged).
  117. // Must have a `grid` property, a reference to this current grid. TODO: avoid this
  118. // The returned object will be processed by getHitSpan and getHitEl.
  119. queryHit: function(leftOffset, topOffset) {
  120. },
  121. // Given position-level information about a date-related area within the grid,
  122. // should return an object with at least a start/end date. Can provide other information as well.
  123. getHitSpan: function(hit) {
  124. },
  125. // Given position-level information about a date-related area within the grid,
  126. // should return a jQuery element that best represents it. passed to dayClick callback.
  127. getHitEl: function(hit) {
  128. },
  129. /* Rendering
  130. ------------------------------------------------------------------------------------------------------------------*/
  131. // Sets the container element that the grid should render inside of.
  132. // Does other DOM-related initializations.
  133. setElement: function(el) {
  134. this.el = el;
  135. if (this.hasDayInteractions) {
  136. preventSelection(el);
  137. this.bindDayHandler('touchstart', this.dayTouchStart);
  138. this.bindDayHandler('mousedown', this.dayMousedown);
  139. }
  140. // attach event-element-related handlers. in Grid.events
  141. // same garbage collection note as above.
  142. this.bindSegHandlers();
  143. this.bindGlobalHandlers();
  144. },
  145. bindDayHandler: function(name, handler) {
  146. var _this = this;
  147. // attach a handler to the grid's root element.
  148. // jQuery will take care of unregistering them when removeElement gets called.
  149. this.el.on(name, function(ev) {
  150. if (
  151. !$(ev.target).is(
  152. _this.segSelector + ',' + // directly on an event element
  153. _this.segSelector + ' *,' + // within an event element
  154. '.fc-more,' + // a "more.." link
  155. 'a[data-goto]' // a clickable nav link
  156. )
  157. ) {
  158. return handler.call(_this, ev);
  159. }
  160. });
  161. },
  162. // Removes the grid's container element from the DOM. Undoes any other DOM-related attachments.
  163. // DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View
  164. removeElement: function() {
  165. this.unbindGlobalHandlers();
  166. this.clearDragListeners();
  167. this.el.remove();
  168. // NOTE: we don't null-out this.el for the same reasons we don't do it within View::removeElement
  169. },
  170. // Renders the basic structure of grid view before any content is rendered
  171. renderSkeleton: function() {
  172. // subclasses should implement
  173. },
  174. // Renders the grid's date-related content (like areas that represent days/times).
  175. // Assumes setRange has already been called and the skeleton has already been rendered.
  176. renderDates: function() {
  177. // subclasses should implement
  178. },
  179. // Unrenders the grid's date-related content
  180. unrenderDates: function() {
  181. // subclasses should implement
  182. },
  183. /* Handlers
  184. ------------------------------------------------------------------------------------------------------------------*/
  185. // Binds DOM handlers to elements that reside outside the grid, such as the document
  186. bindGlobalHandlers: function() {
  187. this.listenTo($(document), {
  188. dragstart: this.externalDragStart, // jqui
  189. sortstart: this.externalDragStart // jqui
  190. });
  191. },
  192. // Unbinds DOM handlers from elements that reside outside the grid
  193. unbindGlobalHandlers: function() {
  194. this.stopListeningTo($(document));
  195. },
  196. // Process a mousedown on an element that represents a day. For day clicking and selecting.
  197. dayMousedown: function(ev) {
  198. var view = this.view;
  199. // prevent a user's clickaway for unselecting a range or an event from
  200. // causing a dayClick or starting an immediate new selection.
  201. if (view.isSelected || view.selectedEvent) {
  202. return;
  203. }
  204. this.dayClickListener.startInteraction(ev);
  205. if (view.opt('selectable')) {
  206. this.daySelectListener.startInteraction(ev, {
  207. distance: view.opt('selectMinDistance')
  208. });
  209. }
  210. },
  211. dayTouchStart: function(ev) {
  212. var view = this.view;
  213. var selectLongPressDelay;
  214. // prevent a user's clickaway for unselecting a range or an event from
  215. // causing a dayClick or starting an immediate new selection.
  216. if (view.isSelected || view.selectedEvent) {
  217. return;
  218. }
  219. selectLongPressDelay = view.opt('selectLongPressDelay');
  220. if (selectLongPressDelay == null) {
  221. selectLongPressDelay = view.opt('longPressDelay'); // fallback
  222. }
  223. this.dayClickListener.startInteraction(ev);
  224. if (view.opt('selectable')) {
  225. this.daySelectListener.startInteraction(ev, {
  226. delay: selectLongPressDelay
  227. });
  228. }
  229. },
  230. // Creates a listener that tracks the user's drag across day elements, for day clicking.
  231. buildDayClickListener: function() {
  232. var _this = this;
  233. var view = this.view;
  234. var dayClickHit; // null if invalid dayClick
  235. var dragListener = new HitDragListener(this, {
  236. scroll: view.opt('dragScroll'),
  237. interactionStart: function() {
  238. dayClickHit = dragListener.origHit;
  239. },
  240. hitOver: function(hit, isOrig, origHit) {
  241. // if user dragged to another cell at any point, it can no longer be a dayClick
  242. if (!isOrig) {
  243. dayClickHit = null;
  244. }
  245. },
  246. hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
  247. dayClickHit = null;
  248. },
  249. interactionEnd: function(ev, isCancelled) {
  250. if (!isCancelled && dayClickHit) {
  251. view.triggerDayClick(
  252. _this.getHitSpan(dayClickHit),
  253. _this.getHitEl(dayClickHit),
  254. ev
  255. );
  256. }
  257. }
  258. });
  259. // because dayClickListener won't be called with any time delay, "dragging" will begin immediately,
  260. // which will kill any touchmoving/scrolling. Prevent this.
  261. dragListener.shouldCancelTouchScroll = false;
  262. dragListener.scrollAlwaysKills = true;
  263. return dragListener;
  264. },
  265. // Creates a listener that tracks the user's drag across day elements, for day selecting.
  266. buildDaySelectListener: function() {
  267. var _this = this;
  268. var view = this.view;
  269. var selectionSpan; // null if invalid selection
  270. var dragListener = new HitDragListener(this, {
  271. scroll: view.opt('dragScroll'),
  272. interactionStart: function() {
  273. selectionSpan = null;
  274. },
  275. dragStart: function() {
  276. view.unselect(); // since we could be rendering a new selection, we want to clear any old one
  277. },
  278. hitOver: function(hit, isOrig, origHit) {
  279. if (origHit) { // click needs to have started on a hit
  280. selectionSpan = _this.computeSelection(
  281. _this.getHitSpan(origHit),
  282. _this.getHitSpan(hit)
  283. );
  284. if (selectionSpan) {
  285. _this.renderSelection(selectionSpan);
  286. }
  287. else if (selectionSpan === false) {
  288. disableCursor();
  289. }
  290. }
  291. },
  292. hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
  293. selectionSpan = null;
  294. _this.unrenderSelection();
  295. },
  296. hitDone: function() { // called after a hitOut OR before a dragEnd
  297. enableCursor();
  298. },
  299. interactionEnd: function(ev, isCancelled) {
  300. if (!isCancelled && selectionSpan) {
  301. // the selection will already have been rendered. just report it
  302. view.reportSelection(selectionSpan, ev);
  303. }
  304. }
  305. });
  306. return dragListener;
  307. },
  308. // Kills all in-progress dragging.
  309. // Useful for when public API methods that result in re-rendering are invoked during a drag.
  310. // Also useful for when touch devices misbehave and don't fire their touchend.
  311. clearDragListeners: function() {
  312. this.dayClickListener.endInteraction();
  313. this.daySelectListener.endInteraction();
  314. if (this.segDragListener) {
  315. this.segDragListener.endInteraction(); // will clear this.segDragListener
  316. }
  317. if (this.segResizeListener) {
  318. this.segResizeListener.endInteraction(); // will clear this.segResizeListener
  319. }
  320. if (this.externalDragListener) {
  321. this.externalDragListener.endInteraction(); // will clear this.externalDragListener
  322. }
  323. },
  324. /* Event Helper
  325. ------------------------------------------------------------------------------------------------------------------*/
  326. // TODO: should probably move this to Grid.events, like we did event dragging / resizing
  327. // Renders a mock event at the given event location, which contains zoned start/end properties.
  328. // Returns all mock event elements.
  329. renderEventLocationHelper: function(eventLocation, sourceSeg) {
  330. var fakeEvent = this.fabricateHelperEvent(eventLocation, sourceSeg);
  331. return this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering
  332. },
  333. // Builds a fake event given zoned event date properties and a segment is should be inspired from.
  334. // The range's end can be null, in which case the mock event that is rendered will have a null end time.
  335. // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging.
  336. fabricateHelperEvent: function(eventLocation, sourceSeg) {
  337. var fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible
  338. fakeEvent.start = eventLocation.start.clone();
  339. fakeEvent.end = eventLocation.end ? eventLocation.end.clone() : null;
  340. fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventDates
  341. this.view.calendar.normalizeEventDates(fakeEvent);
  342. // this extra className will be useful for differentiating real events from mock events in CSS
  343. fakeEvent.className = (fakeEvent.className || []).concat('fc-helper');
  344. // if something external is being dragged in, don't render a resizer
  345. if (!sourceSeg) {
  346. fakeEvent.editable = false;
  347. }
  348. return fakeEvent;
  349. },
  350. // Renders a mock event. Given zoned event date properties.
  351. // Must return all mock event elements.
  352. renderHelper: function(eventLocation, sourceSeg) {
  353. // subclasses must implement
  354. },
  355. // Unrenders a mock event
  356. unrenderHelper: function() {
  357. // subclasses must implement
  358. },
  359. /* Selection
  360. ------------------------------------------------------------------------------------------------------------------*/
  361. // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses.
  362. // Given a span (unzoned start/end and other misc data)
  363. renderSelection: function(span) {
  364. this.renderHighlight(span);
  365. },
  366. // Unrenders any visual indications of a selection. Will unrender a highlight by default.
  367. unrenderSelection: function() {
  368. this.unrenderHighlight();
  369. },
  370. // Given the first and last date-spans of a selection, returns another date-span object.
  371. // Subclasses can override and provide additional data in the span object. Will be passed to renderSelection().
  372. // Will return false if the selection is invalid and this should be indicated to the user.
  373. // Will return null/undefined if a selection invalid but no error should be reported.
  374. computeSelection: function(span0, span1) {
  375. var span = this.computeSelectionSpan(span0, span1);
  376. if (span && !this.view.calendar.isSelectionSpanAllowed(span)) {
  377. return false;
  378. }
  379. return span;
  380. },
  381. // Given two spans, must return the combination of the two.
  382. // TODO: do this separation of concerns (combining VS validation) for event dnd/resize too.
  383. computeSelectionSpan: function(span0, span1) {
  384. var dates = [ span0.start, span0.end, span1.start, span1.end ];
  385. dates.sort(compareNumbers); // sorts chronologically. works with Moments
  386. return { start: dates[0].clone(), end: dates[3].clone() };
  387. },
  388. /* Highlight
  389. ------------------------------------------------------------------------------------------------------------------*/
  390. // Renders an emphasis on the given date range. Given a span (unzoned start/end and other misc data)
  391. renderHighlight: function(span) {
  392. this.renderFill('highlight', this.spanToSegs(span));
  393. },
  394. // Unrenders the emphasis on a date range
  395. unrenderHighlight: function() {
  396. this.unrenderFill('highlight');
  397. },
  398. // Generates an array of classNames for rendering the highlight. Used by the fill system.
  399. highlightSegClasses: function() {
  400. return [ 'fc-highlight' ];
  401. },
  402. /* Business Hours
  403. ------------------------------------------------------------------------------------------------------------------*/
  404. renderBusinessHours: function() {
  405. },
  406. unrenderBusinessHours: function() {
  407. },
  408. /* Now Indicator
  409. ------------------------------------------------------------------------------------------------------------------*/
  410. getNowIndicatorUnit: function() {
  411. },
  412. renderNowIndicator: function(date) {
  413. },
  414. unrenderNowIndicator: function() {
  415. },
  416. /* Fill System (highlight, background events, business hours)
  417. --------------------------------------------------------------------------------------------------------------------
  418. TODO: remove this system. like we did in TimeGrid
  419. */
  420. // Renders a set of rectangles over the given segments of time.
  421. // MUST RETURN a subset of segs, the segs that were actually rendered.
  422. // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement
  423. renderFill: function(type, segs) {
  424. // subclasses must implement
  425. },
  426. // Unrenders a specific type of fill that is currently rendered on the grid
  427. unrenderFill: function(type) {
  428. var el = this.elsByFill[type];
  429. if (el) {
  430. el.remove();
  431. delete this.elsByFill[type];
  432. }
  433. },
  434. // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types.
  435. // Only returns segments that successfully rendered.
  436. // To be harnessed by renderFill (implemented by subclasses).
  437. // Analagous to renderFgSegEls.
  438. renderFillSegEls: function(type, segs) {
  439. var _this = this;
  440. var segElMethod = this[type + 'SegEl'];
  441. var html = '';
  442. var renderedSegs = [];
  443. var i;
  444. if (segs.length) {
  445. // build a large concatenation of segment HTML
  446. for (i = 0; i < segs.length; i++) {
  447. html += this.fillSegHtml(type, segs[i]);
  448. }
  449. // Grab individual elements from the combined HTML string. Use each as the default rendering.
  450. // Then, compute the 'el' for each segment.
  451. $(html).each(function(i, node) {
  452. var seg = segs[i];
  453. var el = $(node);
  454. // allow custom filter methods per-type
  455. if (segElMethod) {
  456. el = segElMethod.call(_this, seg, el);
  457. }
  458. if (el) { // custom filters did not cancel the render
  459. el = $(el); // allow custom filter to return raw DOM node
  460. // correct element type? (would be bad if a non-TD were inserted into a table for example)
  461. if (el.is(_this.fillSegTag)) {
  462. seg.el = el;
  463. renderedSegs.push(seg);
  464. }
  465. }
  466. });
  467. }
  468. return renderedSegs;
  469. },
  470. fillSegTag: 'div', // subclasses can override
  471. // Builds the HTML needed for one fill segment. Generic enough to work with different types.
  472. fillSegHtml: function(type, seg) {
  473. // custom hooks per-type
  474. var classesMethod = this[type + 'SegClasses'];
  475. var cssMethod = this[type + 'SegCss'];
  476. var classes = classesMethod ? classesMethod.call(this, seg) : [];
  477. var css = cssToStr(cssMethod ? cssMethod.call(this, seg) : {});
  478. return '<' + this.fillSegTag +
  479. (classes.length ? ' class="' + classes.join(' ') + '"' : '') +
  480. (css ? ' style="' + css + '"' : '') +
  481. ' />';
  482. },
  483. /* Generic rendering utilities for subclasses
  484. ------------------------------------------------------------------------------------------------------------------*/
  485. // Computes HTML classNames for a single-day element
  486. getDayClasses: function(date, noThemeHighlight) {
  487. var view = this.view;
  488. var classes = [];
  489. var today;
  490. if (!view.isDateWithinContentRange(date)) {
  491. classes.push('fc-disabled-day'); // TODO: jQuery UI theme?
  492. }
  493. else {
  494. classes.push('fc-' + dayIDs[date.day()]);
  495. if (
  496. view.intervalDuration.as('months') == 1 &&
  497. date.month() != view.intervalStart.month()
  498. ) {
  499. classes.push('fc-other-month');
  500. }
  501. today = view.calendar.getNow()
  502. if (date.isSame(today, 'day')) {
  503. classes.push('fc-today');
  504. if (noThemeHighlight !== true) {
  505. classes.push(view.highlightStateClass);
  506. }
  507. }
  508. else if (date < today) {
  509. classes.push('fc-past');
  510. }
  511. else {
  512. classes.push('fc-future');
  513. }
  514. }
  515. return classes;
  516. }
  517. });