View.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701
  1. import { assignTo } from './util/object'
  2. import { elementClosest } from './util/dom-manip'
  3. import { isPrimaryMouseButton } from './util/dom-event'
  4. import { parseFieldSpecs } from './util/misc'
  5. import Calendar from './Calendar'
  6. import { default as DateProfileGenerator, DateProfile } from './DateProfileGenerator'
  7. import InteractiveDateComponent from './component/InteractiveDateComponent'
  8. import GlobalEmitter from './common/GlobalEmitter'
  9. import UnzonedRange from './models/UnzonedRange'
  10. import { DateMarker, addDays, addMs, diffWholeDays } from './datelib/marker'
  11. import { createDuration } from './datelib/duration'
  12. import { createFormatter } from './datelib/formatting'
  13. import { EventInstance } from './reducers/event-store'
  14. import { Selection } from './reducers/selection'
  15. /* An abstract class from which other views inherit from
  16. ----------------------------------------------------------------------------------------------------------------------*/
  17. export default abstract class View extends InteractiveDateComponent {
  18. type: string // subclass' view name (string)
  19. name: string // deprecated. use `type` instead
  20. title: string // the text that will be displayed in the header's title
  21. calendar: Calendar // owner Calendar object
  22. viewSpec: any
  23. options: any // hash containing all options. already merged with view-specific-options
  24. queuedScroll: object
  25. isSelected: boolean = false // boolean whether a range of time is user-selected or not
  26. selectedEventInstance: EventInstance
  27. eventOrderSpecs: any // criteria for ordering events when they have same date/time
  28. // for date utils, computed from options
  29. isHiddenDayHash: boolean[]
  30. // now indicator
  31. isNowIndicatorRendered: boolean
  32. initialNowDate: DateMarker // result first getNow call
  33. initialNowQueriedMs: number // ms time the getNow was called
  34. nowIndicatorTimeoutID: any // for refresh timing of now indicator
  35. nowIndicatorIntervalID: any // "
  36. dateProfileGeneratorClass: any // initialized after class
  37. dateProfileGenerator: DateProfileGenerator
  38. // whether minTime/maxTime will affect the activeUnzonedRange. Views must opt-in.
  39. // initialized after class
  40. usesMinMaxTime: boolean
  41. // DEPRECATED
  42. start: Date // use activeUnzonedRange
  43. end: Date // use activeUnzonedRange
  44. intervalStart: Date // use currentUnzonedRange
  45. intervalEnd: Date // use currentUnzonedRange
  46. constructor(calendar, viewSpec) {
  47. super(null, viewSpec.options)
  48. this.calendar = calendar
  49. this.viewSpec = viewSpec
  50. // shortcuts
  51. this.type = viewSpec.type
  52. // .name is deprecated
  53. this.name = this.type
  54. this.initHiddenDays()
  55. this.dateProfileGenerator = new this.dateProfileGeneratorClass(this)
  56. this.bindBaseRenderHandlers()
  57. this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder'))
  58. // legacy
  59. if (this['initialize']) {
  60. this['initialize']()
  61. }
  62. }
  63. // Retrieves an option with the given name
  64. opt(name) {
  65. return this.options[name]
  66. }
  67. /* Render Queue
  68. ------------------------------------------------------------------------------------------------------------------*/
  69. // given func will auto-bind to `this`
  70. whenSizeUpdated(func) {
  71. }
  72. /* Title and Date Formatting
  73. ------------------------------------------------------------------------------------------------------------------*/
  74. // Computes what the title at the top of the calendar should be for this view
  75. computeTitle(dateProfile) {
  76. let dateEnv = this.getDateEnv()
  77. let unzonedRange
  78. // for views that span a large unit of time, show the proper interval, ignoring stray days before and after
  79. if (/^(year|month)$/.test(dateProfile.currentRangeUnit)) {
  80. unzonedRange = dateProfile.currentUnzonedRange
  81. } else { // for day units or smaller, use the actual day range
  82. unzonedRange = dateProfile.activeUnzonedRange
  83. }
  84. // TODO: precompute
  85. // TODO: how will moment plugin deal with this?
  86. let rawTitleFormat = this.opt('titleFormat') || this.computeTitleFormat(dateProfile)
  87. if (typeof rawTitleFormat === 'object') {
  88. rawTitleFormat = assignTo(
  89. { separator: this.opt('titleRangeSeparator') },
  90. rawTitleFormat
  91. )
  92. }
  93. return dateEnv.formatRange(
  94. unzonedRange.start,
  95. unzonedRange.end,
  96. createFormatter(rawTitleFormat),
  97. { isEndExclusive: dateProfile.isRangeAllDay }
  98. )
  99. }
  100. // Generates the format string that should be used to generate the title for the current date range.
  101. // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`.
  102. computeTitleFormat(dateProfile) {
  103. let currentRangeUnit = dateProfile.currentRangeUnit
  104. if (currentRangeUnit === 'year') {
  105. return { year: 'numeric' }
  106. } else if (currentRangeUnit === 'month') {
  107. return { year: 'numeric', month: 'long' } // like "September 2014"
  108. } else {
  109. let days = diffWholeDays(
  110. dateProfile.currentUnzonedRange.start,
  111. dateProfile.currentUnzonedRange.end
  112. )
  113. if (days !== null && days > 1) {
  114. // multi-day range. shorter, like "Sep 9 - 10 2014"
  115. return { year: 'numeric', month: 'short', day: 'numeric' }
  116. } else {
  117. // one day. longer, like "September 9 2014"
  118. return { year: 'numeric', month: 'long', day: 'numeric' }
  119. }
  120. }
  121. }
  122. // Date Setting/Unsetting
  123. // -----------------------------------------------------------------------------------------------------------------
  124. computeNewDateProfile(date: DateMarker): DateProfile {
  125. let currentDateProfile = this.dateProfile
  126. let newDateProfile = this.dateProfileGenerator.build(date, undefined, true) // forceToValid=true
  127. if (
  128. !currentDateProfile ||
  129. !currentDateProfile.activeUnzonedRange.equals(newDateProfile.activeUnzonedRange)
  130. ) {
  131. return newDateProfile
  132. }
  133. }
  134. updateMiscDateProps(dateProfile) {
  135. let dateEnv = this.getDateEnv()
  136. this.title = this.computeTitle(dateProfile)
  137. // DEPRECATED, but we need to keep it updated...
  138. this.start = dateEnv.toDate(dateProfile.activeUnzonedRange.start)
  139. this.end = dateEnv.toDate(dateProfile.activeUnzonedRange.end)
  140. this.intervalStart = dateEnv.toDate(dateProfile.currentUnzonedRange.start)
  141. this.intervalEnd = dateEnv.toDate(dateProfile.currentUnzonedRange.end)
  142. }
  143. // Date Rendering
  144. // -----------------------------------------------------------------------------------------------------------------
  145. // if dateProfile not specified, uses current
  146. renderDates() {
  147. super.renderDates()
  148. this.trigger('datesRendered')
  149. this.addScroll({ isDateInit: true })
  150. this.startNowIndicator() // shouldn't render yet because updateSize will be called soon
  151. }
  152. unrenderDates() {
  153. this.unselect()
  154. this.stopNowIndicator()
  155. this.trigger('before:datesUnrendered')
  156. super.unrenderDates()
  157. }
  158. // "Base" rendering
  159. // -----------------------------------------------------------------------------------------------------------------
  160. bindBaseRenderHandlers() {
  161. this.on('datesRendered', () => {
  162. this.whenSizeUpdated(
  163. this.triggerViewRender
  164. )
  165. })
  166. this.on('before:datesUnrendered', () => {
  167. this.triggerViewDestroy()
  168. })
  169. }
  170. triggerViewRender() {
  171. this.publiclyTrigger('viewRender', [
  172. {
  173. view: this,
  174. el: this.el
  175. }
  176. ])
  177. }
  178. triggerViewDestroy() {
  179. this.publiclyTrigger('viewDestroy', [
  180. {
  181. view: this,
  182. el: this.el
  183. }
  184. ])
  185. }
  186. // Misc view rendering utils
  187. // -----------------------------------------------------------------------------------------------------------------
  188. // Binds DOM handlers to elements that reside outside the view container, such as the document
  189. bindGlobalHandlers() {
  190. super.bindGlobalHandlers()
  191. this.listenTo(GlobalEmitter.get(), {
  192. touchstart: this.processUnselect,
  193. mousedown: this.handleDocumentMousedown
  194. })
  195. }
  196. // Unbinds DOM handlers from elements that reside outside the view container
  197. unbindGlobalHandlers() {
  198. super.unbindGlobalHandlers()
  199. this.stopListeningTo(GlobalEmitter.get())
  200. }
  201. /* Now Indicator
  202. ------------------------------------------------------------------------------------------------------------------*/
  203. // Immediately render the current time indicator and begins re-rendering it at an interval,
  204. // which is defined by this.getNowIndicatorUnit().
  205. // TODO: somehow do this for the current whole day's background too
  206. startNowIndicator() {
  207. let dateEnv = this.getDateEnv()
  208. let unit
  209. let update
  210. let delay // ms wait value
  211. if (this.opt('nowIndicator')) {
  212. unit = this.getNowIndicatorUnit()
  213. if (unit) {
  214. update = this.updateNowIndicator.bind(this)
  215. this.initialNowDate = this.calendar.getNow()
  216. this.initialNowQueriedMs = new Date().valueOf()
  217. // wait until the beginning of the next interval
  218. delay = dateEnv.add(
  219. dateEnv.startOf(this.initialNowDate, unit),
  220. createDuration(1, unit)
  221. ).valueOf() - this.initialNowDate.valueOf()
  222. // TODO: maybe always use setTimeout, waiting until start of next unit
  223. this.nowIndicatorTimeoutID = setTimeout(() => {
  224. this.nowIndicatorTimeoutID = null
  225. update()
  226. if (unit === 'second') {
  227. delay = 1000 // every second
  228. } else {
  229. delay = 1000 * 60 // otherwise, every minute
  230. }
  231. this.nowIndicatorIntervalID = setInterval(update, delay) // update every interval
  232. }, delay)
  233. }
  234. // rendering will be initiated in updateSize
  235. }
  236. }
  237. // rerenders the now indicator, computing the new current time from the amount of time that has passed
  238. // since the initial getNow call.
  239. updateNowIndicator() {
  240. if (
  241. this.isDatesRendered &&
  242. this.initialNowDate // activated before?
  243. ) {
  244. this.unrenderNowIndicator() // won't unrender if unnecessary
  245. this.renderNowIndicator(
  246. addMs(this.initialNowDate, new Date().valueOf() - this.initialNowQueriedMs)
  247. )
  248. this.isNowIndicatorRendered = true
  249. }
  250. }
  251. // Immediately unrenders the view's current time indicator and stops any re-rendering timers.
  252. // Won't cause side effects if indicator isn't rendered.
  253. stopNowIndicator() {
  254. if (this.isNowIndicatorRendered) {
  255. if (this.nowIndicatorTimeoutID) {
  256. clearTimeout(this.nowIndicatorTimeoutID)
  257. this.nowIndicatorTimeoutID = null
  258. }
  259. if (this.nowIndicatorIntervalID) {
  260. clearInterval(this.nowIndicatorIntervalID)
  261. this.nowIndicatorIntervalID = null
  262. }
  263. this.unrenderNowIndicator()
  264. this.isNowIndicatorRendered = false
  265. }
  266. }
  267. /* Dimensions
  268. ------------------------------------------------------------------------------------------------------------------*/
  269. updateSize(totalHeight, isAuto, isResize) {
  270. if (this['setHeight']) { // for legacy API
  271. this['setHeight'](totalHeight, isAuto)
  272. } else {
  273. super.updateSize(totalHeight, isAuto, isResize)
  274. }
  275. this.updateNowIndicator()
  276. }
  277. /* Scroller
  278. ------------------------------------------------------------------------------------------------------------------*/
  279. addScroll(scroll) {
  280. let queuedScroll = this.queuedScroll || (this.queuedScroll = {})
  281. assignTo(queuedScroll, scroll)
  282. }
  283. popScroll() {
  284. this.applyQueuedScroll()
  285. this.queuedScroll = null
  286. }
  287. applyQueuedScroll() {
  288. this.applyScroll(this.queuedScroll || {})
  289. }
  290. queryScroll() {
  291. let scroll = {}
  292. if (this.isDatesRendered) {
  293. assignTo(scroll, this.queryDateScroll())
  294. }
  295. return scroll
  296. }
  297. applyScroll(scroll) {
  298. if (scroll.isDateInit && this.isDatesRendered) {
  299. assignTo(scroll, this.computeInitialDateScroll())
  300. }
  301. if (this.isDatesRendered) {
  302. this.applyDateScroll(scroll)
  303. }
  304. }
  305. computeInitialDateScroll() {
  306. return {} // subclasses must implement
  307. }
  308. queryDateScroll() {
  309. return {} // subclasses must implement
  310. }
  311. applyDateScroll(scroll) {
  312. // subclasses must implement
  313. }
  314. /* Selection (time range)
  315. ------------------------------------------------------------------------------------------------------------------*/
  316. // Selects a date span on the view. `start` and `end` are both Moments.
  317. // `ev` is the native mouse event that begin the interaction.
  318. select(selection: Selection, ev?) {
  319. this.unselect(ev)
  320. this.renderSelection(selection)
  321. this.reportSelection(selection, ev)
  322. }
  323. // Called when a new selection is made. Updates internal state and triggers handlers.
  324. reportSelection(selection: Selection, ev?) {
  325. this.isSelected = true
  326. this.triggerSelect(selection, ev)
  327. }
  328. // Triggers handlers to 'select'
  329. triggerSelect(selection: Selection, ev?) {
  330. let dateEnv = this.getDateEnv()
  331. this.publiclyTrigger('select', [
  332. {
  333. start: dateEnv.toDate(selection.range.start),
  334. end: dateEnv.toDate(selection.range.end),
  335. isAllDay: selection.isAllDay,
  336. jsEvent: ev,
  337. view: this
  338. }
  339. ])
  340. }
  341. // Undoes a selection. updates in the internal state and triggers handlers.
  342. // `ev` is the native mouse event that began the interaction.
  343. unselect(ev?) {
  344. if (this.isSelected) {
  345. this.isSelected = false
  346. if (this['destroySelection']) {
  347. this['destroySelection']() // TODO: deprecate
  348. }
  349. this.unrenderSelection()
  350. this.publiclyTrigger('unselect', [
  351. {
  352. jsEvent: ev,
  353. view: this
  354. }
  355. ])
  356. }
  357. }
  358. /* Event Selection
  359. ------------------------------------------------------------------------------------------------------------------*/
  360. selectEventInstance(eventInstance) {
  361. if (
  362. !this.selectedEventInstance ||
  363. this.selectedEventInstance.instanceId !== eventInstance.instanceId
  364. ) {
  365. this.unselectEventInstance()
  366. this.getEventSegs().forEach(function(seg) {
  367. if (
  368. seg.eventRange.eventInstance.instanceId === eventInstance.instanceId &&
  369. seg.el // necessary?
  370. ) {
  371. seg.el.classList.add('fc-selected')
  372. }
  373. })
  374. this.selectedEventInstance = eventInstance
  375. }
  376. }
  377. unselectEventInstance() {
  378. if (this.selectedEventInstance) {
  379. this.getEventSegs().forEach(function(seg) {
  380. if (seg.el) { // necessary?
  381. seg.el.classList.remove('fc-selected')
  382. }
  383. })
  384. this.selectedEventInstance = null
  385. }
  386. }
  387. isEventDefSelected(eventDef) {
  388. // event references might change on refetchEvents(), while selectedEventInstance doesn't,
  389. // so compare IDs
  390. return this.selectedEventInstance && this.selectedEventInstance.defId === eventDef.defId
  391. }
  392. /* Mouse / Touch Unselecting (time range & event unselection)
  393. ------------------------------------------------------------------------------------------------------------------*/
  394. // TODO: move consistently to down/start or up/end?
  395. // TODO: don't kill previous selection if touch scrolling
  396. handleDocumentMousedown(ev) {
  397. if (isPrimaryMouseButton(ev)) {
  398. this.processUnselect(ev)
  399. }
  400. }
  401. processUnselect(ev) {
  402. this.processRangeUnselect(ev)
  403. this.processEventUnselect(ev)
  404. }
  405. processRangeUnselect(ev) {
  406. let ignore
  407. // is there a time-range selection?
  408. if (this.isSelected && this.opt('unselectAuto')) {
  409. // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element
  410. ignore = this.opt('unselectCancel')
  411. if (!ignore || !elementClosest(ev.target, ignore)) {
  412. this.unselect(ev)
  413. }
  414. }
  415. }
  416. processEventUnselect(ev) {
  417. if (this.selectedEventInstance) {
  418. if (!elementClosest(ev.target, '.fc-selected')) {
  419. this.unselectEventInstance()
  420. }
  421. }
  422. }
  423. /* Triggers
  424. ------------------------------------------------------------------------------------------------------------------*/
  425. triggerBaseRendered() {
  426. this.publiclyTrigger('viewRender', [
  427. {
  428. view: this,
  429. el: this.el
  430. }
  431. ])
  432. }
  433. triggerBaseUnrendered() {
  434. this.publiclyTrigger('viewDestroy', [
  435. {
  436. view: this,
  437. el: this.el
  438. }
  439. ])
  440. }
  441. /* Date Utils
  442. ------------------------------------------------------------------------------------------------------------------*/
  443. // For DateComponent::getDayClasses
  444. isDateInOtherMonth(date: DateMarker, dateProfile) {
  445. return false
  446. }
  447. // Arguments after name will be forwarded to a hypothetical function value
  448. // WARNING: passed-in arguments will be given to generator functions as-is and can cause side-effects.
  449. // Always clone your objects if you fear mutation.
  450. getUnzonedRangeOption(name, ...otherArgs) {
  451. let val = this.opt(name)
  452. if (typeof val === 'function') {
  453. val = val.apply(null, otherArgs)
  454. }
  455. if (val) {
  456. return this.calendar.parseUnzonedRange(val)
  457. }
  458. }
  459. /* Hidden Days
  460. ------------------------------------------------------------------------------------------------------------------*/
  461. // Initializes internal variables related to calculating hidden days-of-week
  462. initHiddenDays() {
  463. let hiddenDays = this.opt('hiddenDays') || [] // array of day-of-week indices that are hidden
  464. let isHiddenDayHash = [] // is the day-of-week hidden? (hash with day-of-week-index -> bool)
  465. let dayCnt = 0
  466. let i
  467. if (this.opt('weekends') === false) {
  468. hiddenDays.push(0, 6) // 0=sunday, 6=saturday
  469. }
  470. for (i = 0; i < 7; i++) {
  471. if (
  472. !(isHiddenDayHash[i] = hiddenDays.indexOf(i) !== -1)
  473. ) {
  474. dayCnt++
  475. }
  476. }
  477. if (!dayCnt) {
  478. throw new Error('invalid hiddenDays') // all days were hidden? bad.
  479. }
  480. this.isHiddenDayHash = isHiddenDayHash
  481. }
  482. // Remove days from the beginning and end of the range that are computed as hidden.
  483. // If the whole range is trimmed off, returns null
  484. trimHiddenDays(inputUnzonedRange) {
  485. let start = inputUnzonedRange.start
  486. let end = inputUnzonedRange.end
  487. if (start) {
  488. start = this.skipHiddenDays(start)
  489. }
  490. if (end) {
  491. end = this.skipHiddenDays(end, -1, true)
  492. }
  493. if (start == null || end == null || start < end) {
  494. return new UnzonedRange(start, end)
  495. }
  496. return null
  497. }
  498. // Is the current day hidden?
  499. // `day` is a day-of-week index (0-6), or a Date (used for UTC)
  500. isHiddenDay(day) {
  501. if (day instanceof Date) {
  502. day = day.getUTCDay()
  503. }
  504. return this.isHiddenDayHash[day]
  505. }
  506. // Incrementing the current day until it is no longer a hidden day, returning a copy.
  507. // DOES NOT CONSIDER validUnzonedRange!
  508. // If the initial value of `date` is not a hidden day, don't do anything.
  509. // Pass `isExclusive` as `true` if you are dealing with an end date.
  510. // `inc` defaults to `1` (increment one day forward each time)
  511. skipHiddenDays(date: DateMarker, inc = 1, isExclusive = false) {
  512. while (
  513. this.isHiddenDayHash[(date.getUTCDay() + (isExclusive ? inc : 0) + 7) % 7]
  514. ) {
  515. date = addDays(date, inc)
  516. }
  517. return date
  518. }
  519. }
  520. View.prototype.usesMinMaxTime = false
  521. View.prototype.dateProfileGeneratorClass = DateProfileGenerator