View.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. import { assignTo } from './util/object'
  2. import { parseFieldSpecs } from './util/misc'
  3. import Calendar from './Calendar'
  4. import { default as DateProfileGenerator, DateProfile } from './DateProfileGenerator'
  5. import DateComponent from './component/DateComponent'
  6. import UnzonedRange from './models/UnzonedRange'
  7. import { DateMarker, addDays, addMs, diffWholeDays } from './datelib/marker'
  8. import { createDuration } from './datelib/duration'
  9. import { createFormatter } from './datelib/formatting'
  10. import { default as EmitterMixin, EmitterInterface } from './common/EmitterMixin'
  11. /* An abstract class from which other views inherit from
  12. ----------------------------------------------------------------------------------------------------------------------*/
  13. export default abstract class View extends DateComponent {
  14. on: EmitterInterface['on']
  15. one: EmitterInterface['one']
  16. off: EmitterInterface['off']
  17. trigger: EmitterInterface['trigger']
  18. triggerWith: EmitterInterface['triggerWith']
  19. hasHandlers: EmitterInterface['hasHandlers']
  20. type: string // subclass' view name (string)
  21. name: string // deprecated. use `type` instead
  22. title: string // the text that will be displayed in the header's title
  23. calendar: Calendar // owner Calendar object
  24. viewSpec: any
  25. options: any // hash containing all options. already merged with view-specific-options
  26. queuedScroll: object
  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.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder'))
  57. // legacy
  58. if (this['initialize']) {
  59. this['initialize']()
  60. }
  61. }
  62. // Retrieves an option with the given name
  63. opt(name) {
  64. return this.options[name]
  65. }
  66. /* Title and Date Formatting
  67. ------------------------------------------------------------------------------------------------------------------*/
  68. // Computes what the title at the top of the calendar should be for this view
  69. computeTitle(dateProfile) {
  70. let dateEnv = this.getDateEnv()
  71. let unzonedRange
  72. // for views that span a large unit of time, show the proper interval, ignoring stray days before and after
  73. if (/^(year|month)$/.test(dateProfile.currentRangeUnit)) {
  74. unzonedRange = dateProfile.currentUnzonedRange
  75. } else { // for day units or smaller, use the actual day range
  76. unzonedRange = dateProfile.activeUnzonedRange
  77. }
  78. // TODO: precompute
  79. // TODO: how will moment plugin deal with this?
  80. let rawTitleFormat = this.opt('titleFormat') || this.computeTitleFormat(dateProfile)
  81. if (typeof rawTitleFormat === 'object') {
  82. rawTitleFormat = assignTo(
  83. { separator: this.opt('titleRangeSeparator') },
  84. rawTitleFormat
  85. )
  86. }
  87. return dateEnv.formatRange(
  88. unzonedRange.start,
  89. unzonedRange.end,
  90. createFormatter(rawTitleFormat),
  91. { isEndExclusive: dateProfile.isRangeAllDay }
  92. )
  93. }
  94. // Generates the format string that should be used to generate the title for the current date range.
  95. // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`.
  96. computeTitleFormat(dateProfile) {
  97. let currentRangeUnit = dateProfile.currentRangeUnit
  98. if (currentRangeUnit === 'year') {
  99. return { year: 'numeric' }
  100. } else if (currentRangeUnit === 'month') {
  101. return { year: 'numeric', month: 'long' } // like "September 2014"
  102. } else {
  103. let days = diffWholeDays(
  104. dateProfile.currentUnzonedRange.start,
  105. dateProfile.currentUnzonedRange.end
  106. )
  107. if (days !== null && days > 1) {
  108. // multi-day range. shorter, like "Sep 9 - 10 2014"
  109. return { year: 'numeric', month: 'short', day: 'numeric' }
  110. } else {
  111. // one day. longer, like "September 9 2014"
  112. return { year: 'numeric', month: 'long', day: 'numeric' }
  113. }
  114. }
  115. }
  116. // Date Setting/Unsetting
  117. // -----------------------------------------------------------------------------------------------------------------
  118. computeDateProfile(date: DateMarker): DateProfile {
  119. let dateProfile = this.dateProfileGenerator.build(date, undefined, true) // forceToValid=true
  120. if ( // reuse current reference if possible, for rendering optimization
  121. this.dateProfile &&
  122. this.dateProfile.activeUnzonedRange.equals(dateProfile.activeUnzonedRange)
  123. ) {
  124. return this.dateProfile
  125. }
  126. return dateProfile
  127. }
  128. updateMiscDateProps(dateProfile) {
  129. let dateEnv = this.getDateEnv()
  130. this.title = this.computeTitle(dateProfile)
  131. // DEPRECATED, but we need to keep it updated...
  132. this.start = dateEnv.toDate(dateProfile.activeUnzonedRange.start)
  133. this.end = dateEnv.toDate(dateProfile.activeUnzonedRange.end)
  134. this.intervalStart = dateEnv.toDate(dateProfile.currentUnzonedRange.start)
  135. this.intervalEnd = dateEnv.toDate(dateProfile.currentUnzonedRange.end)
  136. }
  137. // Date Rendering
  138. // -----------------------------------------------------------------------------------------------------------------
  139. // if dateProfile not specified, uses current
  140. renderDates() {
  141. super.renderDates()
  142. this.addScroll({ isDateInit: true })
  143. this.startNowIndicator() // shouldn't render yet because updateSize will be called soon
  144. this.triggerRenderedDates()
  145. }
  146. unrenderDates() {
  147. this.triggerWillRemoveDates()
  148. this.stopNowIndicator()
  149. super.unrenderDates()
  150. }
  151. triggerRenderedDates() {
  152. this.publiclyTriggerAfterSizing('viewRender', [
  153. {
  154. view: this,
  155. el: this.el
  156. }
  157. ])
  158. }
  159. triggerWillRemoveDates() {
  160. this.publiclyTrigger('viewDestroy', [
  161. {
  162. view: this,
  163. el: this.el
  164. }
  165. ])
  166. }
  167. /* Now Indicator
  168. ------------------------------------------------------------------------------------------------------------------*/
  169. // Immediately render the current time indicator and begins re-rendering it at an interval,
  170. // which is defined by this.getNowIndicatorUnit().
  171. // TODO: somehow do this for the current whole day's background too
  172. startNowIndicator() {
  173. let dateEnv = this.getDateEnv()
  174. let unit
  175. let update
  176. let delay // ms wait value
  177. if (this.opt('nowIndicator')) {
  178. unit = this.getNowIndicatorUnit()
  179. if (unit) {
  180. update = this.updateNowIndicator.bind(this)
  181. this.initialNowDate = this.calendar.getNow()
  182. this.initialNowQueriedMs = new Date().valueOf()
  183. // wait until the beginning of the next interval
  184. delay = dateEnv.add(
  185. dateEnv.startOf(this.initialNowDate, unit),
  186. createDuration(1, unit)
  187. ).valueOf() - this.initialNowDate.valueOf()
  188. // TODO: maybe always use setTimeout, waiting until start of next unit
  189. this.nowIndicatorTimeoutID = setTimeout(() => {
  190. this.nowIndicatorTimeoutID = null
  191. update()
  192. if (unit === 'second') {
  193. delay = 1000 // every second
  194. } else {
  195. delay = 1000 * 60 // otherwise, every minute
  196. }
  197. this.nowIndicatorIntervalID = setInterval(update, delay) // update every interval
  198. }, delay)
  199. }
  200. // rendering will be initiated in updateSize
  201. }
  202. }
  203. // rerenders the now indicator, computing the new current time from the amount of time that has passed
  204. // since the initial getNow call.
  205. updateNowIndicator() {
  206. if (
  207. this.isDatesRendered &&
  208. this.initialNowDate // activated before?
  209. ) {
  210. this.unrenderNowIndicator() // won't unrender if unnecessary
  211. this.renderNowIndicator(
  212. addMs(this.initialNowDate, new Date().valueOf() - this.initialNowQueriedMs)
  213. )
  214. this.isNowIndicatorRendered = true
  215. }
  216. }
  217. // Immediately unrenders the view's current time indicator and stops any re-rendering timers.
  218. // Won't cause side effects if indicator isn't rendered.
  219. stopNowIndicator() {
  220. if (this.isNowIndicatorRendered) {
  221. if (this.nowIndicatorTimeoutID) {
  222. clearTimeout(this.nowIndicatorTimeoutID)
  223. this.nowIndicatorTimeoutID = null
  224. }
  225. if (this.nowIndicatorIntervalID) {
  226. clearInterval(this.nowIndicatorIntervalID)
  227. this.nowIndicatorIntervalID = null
  228. }
  229. this.unrenderNowIndicator()
  230. this.isNowIndicatorRendered = false
  231. }
  232. }
  233. /* Dimensions
  234. ------------------------------------------------------------------------------------------------------------------*/
  235. updateSize(totalHeight, isAuto) {
  236. super.updateSize(totalHeight, isAuto)
  237. this.updateNowIndicator()
  238. }
  239. /* Scroller
  240. ------------------------------------------------------------------------------------------------------------------*/
  241. addScroll(scroll) {
  242. let queuedScroll = this.queuedScroll || (this.queuedScroll = {})
  243. assignTo(queuedScroll, scroll)
  244. }
  245. popScroll() {
  246. this.applyQueuedScroll()
  247. this.queuedScroll = null
  248. }
  249. applyQueuedScroll() {
  250. this.applyScroll(this.queuedScroll || {})
  251. }
  252. queryScroll() {
  253. let scroll = {}
  254. if (this.isDatesRendered) {
  255. assignTo(scroll, this.queryDateScroll())
  256. }
  257. return scroll
  258. }
  259. applyScroll(scroll) {
  260. if (scroll.isDateInit && this.isDatesRendered) {
  261. assignTo(scroll, this.computeInitialDateScroll())
  262. }
  263. if (this.isDatesRendered) {
  264. this.applyDateScroll(scroll)
  265. }
  266. }
  267. computeInitialDateScroll() {
  268. return {} // subclasses must implement
  269. }
  270. queryDateScroll() {
  271. return {} // subclasses must implement
  272. }
  273. applyDateScroll(scroll) {
  274. // subclasses must implement
  275. }
  276. /* Date Utils
  277. ------------------------------------------------------------------------------------------------------------------*/
  278. // For DateComponent::getDayClasses
  279. isDateInOtherMonth(date: DateMarker, dateProfile) {
  280. return false
  281. }
  282. // Arguments after name will be forwarded to a hypothetical function value
  283. // WARNING: passed-in arguments will be given to generator functions as-is and can cause side-effects.
  284. // Always clone your objects if you fear mutation.
  285. getUnzonedRangeOption(name, ...otherArgs) {
  286. let val = this.opt(name)
  287. if (typeof val === 'function') {
  288. val = val.apply(null, otherArgs)
  289. }
  290. if (val) {
  291. return this.calendar.parseUnzonedRange(val)
  292. }
  293. }
  294. /* Hidden Days
  295. ------------------------------------------------------------------------------------------------------------------*/
  296. // Initializes internal variables related to calculating hidden days-of-week
  297. initHiddenDays() {
  298. let hiddenDays = this.opt('hiddenDays') || [] // array of day-of-week indices that are hidden
  299. let isHiddenDayHash = [] // is the day-of-week hidden? (hash with day-of-week-index -> bool)
  300. let dayCnt = 0
  301. let i
  302. if (this.opt('weekends') === false) {
  303. hiddenDays.push(0, 6) // 0=sunday, 6=saturday
  304. }
  305. for (i = 0; i < 7; i++) {
  306. if (
  307. !(isHiddenDayHash[i] = hiddenDays.indexOf(i) !== -1)
  308. ) {
  309. dayCnt++
  310. }
  311. }
  312. if (!dayCnt) {
  313. throw new Error('invalid hiddenDays') // all days were hidden? bad.
  314. }
  315. this.isHiddenDayHash = isHiddenDayHash
  316. }
  317. // Remove days from the beginning and end of the range that are computed as hidden.
  318. // If the whole range is trimmed off, returns null
  319. trimHiddenDays(inputUnzonedRange) {
  320. let start = inputUnzonedRange.start
  321. let end = inputUnzonedRange.end
  322. if (start) {
  323. start = this.skipHiddenDays(start)
  324. }
  325. if (end) {
  326. end = this.skipHiddenDays(end, -1, true)
  327. }
  328. if (start == null || end == null || start < end) {
  329. return new UnzonedRange(start, end)
  330. }
  331. return null
  332. }
  333. // Is the current day hidden?
  334. // `day` is a day-of-week index (0-6), or a Date (used for UTC)
  335. isHiddenDay(day) {
  336. if (day instanceof Date) {
  337. day = day.getUTCDay()
  338. }
  339. return this.isHiddenDayHash[day]
  340. }
  341. // Incrementing the current day until it is no longer a hidden day, returning a copy.
  342. // DOES NOT CONSIDER validUnzonedRange!
  343. // If the initial value of `date` is not a hidden day, don't do anything.
  344. // Pass `isExclusive` as `true` if you are dealing with an end date.
  345. // `inc` defaults to `1` (increment one day forward each time)
  346. skipHiddenDays(date: DateMarker, inc = 1, isExclusive = false) {
  347. while (
  348. this.isHiddenDayHash[(date.getUTCDay() + (isExclusive ? inc : 0) + 7) % 7]
  349. ) {
  350. date = addDays(date, inc)
  351. }
  352. return date
  353. }
  354. }
  355. EmitterMixin.mixInto(View)
  356. View.prototype.usesMinMaxTime = false
  357. View.prototype.dateProfileGeneratorClass = DateProfileGenerator