import { attrsToStr, htmlEscape } from '../util/html'
import { elementClosest } from '../util/dom-manip'
import { default as Component, RenderForceFlags } from './Component'
import Calendar from '../Calendar'
import View from '../View'
import { DateProfile } from '../DateProfileGenerator'
import { DateMarker, DAY_IDS, addDays, startOfDay, diffDays, diffWholeDays } from '../datelib/marker'
import { Duration, createDuration, asRoughMs } from '../datelib/duration'
import { DateSpan } from '../reducers/date-span'
import UnzonedRange from '../models/UnzonedRange'
import { EventRenderRange, sliceEventStore } from '../reducers/event-rendering'
import { EventStore } from '../reducers/event-store'
import { BusinessHourDef, buildBusinessHourEventStore } from '../reducers/business-hours'
import { DateEnv } from '../datelib/env'
import Theme from '../theme/Theme'
import { EventInteractionState } from '../reducers/event-interaction'
import { assignTo } from '../util/object'
import browserContext from '../common/browser-context'
import { Hit } from '../interactions/HitDragging'
export interface DateComponentRenderState {
dateProfile: DateProfile
eventStore: EventStore
selection: DateSpan | null
dragState: EventInteractionState | null
eventResizeState: EventInteractionState | null
businessHoursDef: BusinessHourDef // BusinessHourDef's `false` is the empty state
selectedEventInstanceId: string | null
}
// NOTE: for fg-events, eventRange.range is NOT sliced,
// thus, we need isStart/isEnd
export interface Seg {
component: DateComponent
isStart: boolean
isEnd: boolean
eventRange?: EventRenderRange
el?: HTMLElement
[otherProp: string]: any
}
export type DateComponentHash = { [id: string]: DateComponent }
let uid = 0
export default abstract class DateComponent extends Component {
// self-config, overridable by subclasses
isInteractable: boolean = false
segSelector: string = '.fc-event-container > *' // what constitutes an event element?
// if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity
// of the date areas. if not defined, assumes to be day and time granularity.
// TODO: port isTimeScale into same system?
largeUnit: any
eventRendererClass: any
helperRendererClass: any
fillRendererClass: any
uid: any
childrenByUid: any
isRTL: boolean = false // frequently accessed options
nextDayThreshold: Duration // "
view: View
eventRenderer: any
helperRenderer: any
fillRenderer: any
hasAllDayBusinessHours: boolean = false // TODO: unify with largeUnit and isTimeScale?
renderedFlags: any = {}
dirtySizeFlags: any = {}
dateProfile: DateProfile = null
businessHoursDef: BusinessHourDef = false
selection: DateSpan = null
eventStore: EventStore = null
dragState: EventInteractionState = null
eventResizeState: EventInteractionState = null
interactingEventDefId: string = null
selectedEventInstanceId: string = null
constructor(_view, _options?) {
super()
// hack to set options prior to the this.opt calls
this.view = _view || this
if (_options) {
this['options'] = _options
}
this.uid = String(uid++)
this.childrenByUid = {}
this.nextDayThreshold = createDuration(this.opt('nextDayThreshold'))
this.isRTL = this.opt('isRTL')
if (this.fillRendererClass) {
this.fillRenderer = new this.fillRendererClass(this)
}
if (this.eventRendererClass) { // fillRenderer is optional -----v
this.eventRenderer = new this.eventRendererClass(this, this.fillRenderer)
}
if (this.helperRendererClass && this.eventRenderer) {
this.helperRenderer = new this.helperRendererClass(this, this.eventRenderer)
}
}
addChild(child) {
if (!this.childrenByUid[child.uid]) {
this.childrenByUid[child.uid] = child
return true
}
return false
}
removeChild(child) {
if (this.childrenByUid[child.uid]) {
delete this.childrenByUid[child.uid]
return true
}
return false
}
updateSize(totalHeight, isAuto, force) {
let flags = this.dirtySizeFlags
if (force || flags.skeleton || flags.dates || flags.events) {
// sort of the catch-all sizing
// anything that might cause dimension changes
this.updateBaseSize(totalHeight, isAuto)
this.buildCoordCaches()
}
if (force || flags.businessHours) {
this.computeBusinessHoursSize()
}
// don't worry about updating the resize of the helper
if (force || flags.selection || flags.drag || flags.eventResize) {
this.computeHighlightSize()
}
if (force || flags.drag || flags.eventResize) {
this.computeHelperSize()
}
if (force || flags.events) {
this.computeEventsSize()
}
if (force || flags.businessHours) {
this.assignBusinessHoursSize()
}
if (force || flags.selection || flags.drag || flags.eventResize) {
this.assignHighlightSize()
}
if (force || flags.drag || flags.eventResize) {
this.assignHelperSize()
}
if (force || flags.events) {
this.assignEventsSize()
}
this.dirtySizeFlags = {}
this.callChildren('updateSize', arguments) // always do this at end?
}
updateBaseSize(totalHeight, isAuto) {
}
buildCoordCaches() {
}
queryHit(leftOffset, topOffset): Hit {
return null // this should be abstract
}
bindGlobalHandlers() {
if (this.isInteractable) {
browserContext.registerComponent(this)
}
}
unbindGlobalHandlers() {
if (this.isInteractable) {
browserContext.unregisterComponent(this)
}
}
// Options
// -----------------------------------------------------------------------------------------------------------------
opt(name) {
return this.view.options[name]
}
// Triggering
// -----------------------------------------------------------------------------------------------------------------
publiclyTrigger(name, args) {
let calendar = this.getCalendar()
return calendar.publiclyTrigger(name, args)
}
publiclyTriggerAfterSizing(name, args) {
let calendar = this.getCalendar()
return calendar.publiclyTriggerAfterSizing(name, args)
}
hasPublicHandlers(name) {
let calendar = this.getCalendar()
return calendar.hasPublicHandlers(name)
}
triggerRenderedSegs(segs: Seg[]) {
if (this.hasPublicHandlers('eventAfterRender')) {
for (let seg of segs) {
this.publiclyTriggerAfterSizing('eventAfterRender', [
{
event: seg.eventRange, // what to do here?
el: seg.el,
view: this
}
])
}
}
}
triggerWillRemoveSegs(segs: Seg[]) {
if (this.hasPublicHandlers('eventDestroy')) {
for (let seg of segs) {
this.publiclyTrigger('eventDestroy', [
{
event: seg.eventRange, // what to do here?
el: seg.el,
view: this
}
])
}
}
}
// Root Rendering
// -----------------------------------------------------------------------------------------------------------------
render(renderState: DateComponentRenderState, forceFlags: RenderForceFlags) {
let { renderedFlags } = this
let dirtyFlags = {
skeleton: false,
dates: renderState.dateProfile !== this.dateProfile,
businessHours: renderState.businessHoursDef !== this.businessHoursDef,
selection: renderState.selection !== this.selection,
events: renderState.eventStore !== this.eventStore,
selectedEvent: renderState.selectedEventInstanceId !== this.selectedEventInstanceId,
drag: renderState.dragState !== this.dragState,
eventResize: renderState.eventResizeState !== this.eventResizeState
}
assignTo(dirtyFlags, forceFlags)
if (forceFlags === true) {
// everthing must be marked as dirty when doing a forced resize
for (let name in dirtyFlags) {
dirtyFlags[name] = true
}
} else {
// mark things that are still not rendered as dirty
for (let name in dirtyFlags) {
if (!renderedFlags[name]) {
dirtyFlags[name] = true
}
}
// when the dates are dirty, mark nearly everything else as dirty too
if (dirtyFlags.dates) {
for (let name in dirtyFlags) {
if (name !== 'skeleton') {
forceFlags = true
}
}
}
}
this.unrender(dirtyFlags) // only unrender dirty things
assignTo(this, renderState) // assign incoming state to local state
this.renderByFlag(renderState, dirtyFlags) // only render dirty things
this.renderChildren(renderState, forceFlags)
}
renderByFlag(renderState: DateComponentRenderState, flags) {
let { renderedFlags, dirtySizeFlags } = this
if (flags.skeleton) {
this.renderSkeleton()
renderedFlags.skeleton = true
dirtySizeFlags.skeleton = true
}
if (flags.dates && renderState.dateProfile) {
this.renderDates() // pass in dateProfile too?
renderedFlags.dates = true
dirtySizeFlags.dates = true
}
if (flags.businessHours && renderState.businessHoursDef) {
this.renderBusinessHours(renderState.businessHoursDef)
renderedFlags.businessHours = true
dirtySizeFlags.businessHours = true
}
if (flags.selection && renderState.selection) {
this.renderSelection(renderState.selection)
renderedFlags.selection = true
dirtySizeFlags.selection = true
}
if (flags.events && renderState.eventStore) {
this.renderEvents(renderState.eventStore)
renderedFlags.events = true
dirtySizeFlags.events = true
}
if (flags.selectedEvent) {
this.selectEventsByInstanceId(renderState.selectedEventInstanceId)
renderedFlags.selectedEvent = true
dirtySizeFlags.selectedEvent = true
}
if (flags.drag && renderState.dragState) {
this.renderDragState(renderState.dragState)
renderedFlags.drag = true
dirtySizeFlags.drag = true
}
if (flags.eventResize && renderState.eventResizeState) {
this.renderEventResizeState(renderState.eventResizeState)
renderedFlags.eventResize = true
dirtySizeFlags.eventResize = true
}
}
unrender(flags?: any) {
let { renderedFlags } = this
if ((!flags || flags.eventResize) && renderedFlags.eventResize) {
this.unrenderEventResizeState()
renderedFlags.eventResize = false
}
if ((!flags || flags.drag) && renderedFlags.drag) {
this.unrenderDragState()
renderedFlags.drag = false
}
if ((!flags || flags.selectedEvent) && renderedFlags.selectedEvent) {
this.unselectAllEvents()
renderedFlags.selectedEvent = false
}
if ((!flags || flags.events) && renderedFlags.events) {
this.unrenderEvents()
renderedFlags.events = false
}
if ((!flags || flags.selection) && renderedFlags.selection) {
this.unrenderSelection()
renderedFlags.selection = false
}
if ((!flags || flags.businessHours) && renderedFlags.businessHours) {
this.unrenderBusinessHours()
renderedFlags.businessHours = false
}
if ((!flags || flags.dates) && renderedFlags.dates) {
this.unrenderDates()
renderedFlags.dates = false
}
if ((!flags || flags.skeleton) && renderedFlags.skeleton) {
this.unrenderSkeleton()
renderedFlags.skeleton = false
}
}
renderChildren(renderState: DateComponentRenderState, forceFlags: RenderForceFlags) {
this.callChildren('render', arguments)
}
removeElement() {
this.unrender()
this.dirtySizeFlags = {}
super.removeElement()
}
// Skeleton
// -----------------------------------------------------------------------------------------------------------------
renderSkeleton() {
// subclasses should implement
}
unrenderSkeleton() {
// subclasses should implement
}
// Date
// -----------------------------------------------------------------------------------------------------------------
// date-cell content only
renderDates() {
// subclasses should implement
}
// date-cell content only
unrenderDates() {
// subclasses should override
}
// Now-Indicator
// -----------------------------------------------------------------------------------------------------------------
// Returns a string unit, like 'second' or 'minute' that defined how often the current time indicator
// should be refreshed. If something falsy is returned, no time indicator is rendered at all.
getNowIndicatorUnit() {
// subclasses should implement
}
// Renders a current time indicator at the given datetime
renderNowIndicator(date) {
this.callChildren('renderNowIndicator', arguments)
}
// Undoes the rendering actions from renderNowIndicator
unrenderNowIndicator() {
this.callChildren('unrenderNowIndicator', arguments)
}
// Business Hours
// ---------------------------------------------------------------------------------------------------------------
renderBusinessHours(businessHoursDef: BusinessHourDef) {
if (this.fillRenderer) {
this.fillRenderer.renderSegs(
'businessHours',
this.eventStoreToSegs(
buildBusinessHourEventStore(
businessHoursDef,
this.hasAllDayBusinessHours,
this.dateProfile.activeUnzonedRange,
this.getCalendar()
)
),
{
getClasses(seg) {
return [ 'fc-bgevent' ].concat(seg.eventRange.eventDef.className)
}
}
)
}
}
// Unrenders previously-rendered business-hours
unrenderBusinessHours() {
if (this.fillRenderer) {
this.fillRenderer.unrender('businessHours')
}
}
computeBusinessHoursSize() {
if (this.fillRenderer) {
this.fillRenderer.computeSize('businessHours')
}
}
assignBusinessHoursSize() {
if (this.fillRenderer) {
this.fillRenderer.assignSize('businessHours')
}
}
// Event Displaying
// -----------------------------------------------------------------------------------------------------------------
renderEvents(eventStore: EventStore) {
if (this.eventRenderer) {
this.eventRenderer.rangeUpdated() // poorly named now
this.eventRenderer.renderSegs(
this.eventStoreToSegs(eventStore)
)
this.triggerRenderedSegs(this.eventRenderer.getSegs())
}
}
unrenderEvents() {
if (this.eventRenderer) {
this.triggerWillRemoveSegs(this.eventRenderer.getSegs())
this.eventRenderer.unrender()
}
}
computeEventsSize() {
if (this.eventRenderer) {
this.eventRenderer.computeFgSize()
}
}
assignEventsSize() {
if (this.eventRenderer) {
this.eventRenderer.assignFgSize()
}
}
// Drag-n-Drop Rendering (for both events and external elements)
// ---------------------------------------------------------------------------------------------------------------
renderDragState(dragState: EventInteractionState) {
if (dragState.origSeg) {
this.hideRelatedSegs(dragState.origSeg)
}
this.renderDrag(dragState.eventStore, dragState.origSeg, dragState.willCreateEvent)
}
unrenderDragState() {
if (this.dragState.origSeg) {
this.showRelatedSegs(this.dragState.origSeg)
}
this.unrenderDrag()
}
// Renders a visual indication of a event or external-element drag over the given drop zone.
// If an external-element, seg will be `null`.
// Must return elements used for any mock events.
renderDrag(eventStore: EventStore, origSeg, willCreateEvent) {
// subclasses can implement
}
// Unrenders a visual indication of an event or external-element being dragged.
unrenderDrag() {
// subclasses can implement
}
// Event Resizing
// ---------------------------------------------------------------------------------------------------------------
renderEventResizeState(eventResizeState: EventInteractionState) {
if (eventResizeState.origSeg) {
this.hideRelatedSegs(eventResizeState.origSeg)
}
this.renderEventResize(eventResizeState.eventStore, eventResizeState.origSeg)
}
unrenderEventResizeState() {
if (this.eventResizeState.origSeg) {
this.showRelatedSegs(this.eventResizeState.origSeg)
}
this.unrenderEventResize()
}
// Renders a visual indication of an event being resized.
renderEventResize(eventStore: EventStore, origSeg: any) {
// subclasses can implement
}
// Unrenders a visual indication of an event being resized.
unrenderEventResize() {
// subclasses can implement
}
// Seg Utils
// -----------------------------------------------------------------------------------------------------------------
hideRelatedSegs(targetSeg: Seg) {
this.getRelatedSegs(targetSeg).forEach(function(seg) {
seg.el.style.visibility = 'hidden'
})
}
showRelatedSegs(targetSeg: Seg) {
this.getRelatedSegs(targetSeg).forEach(function(seg) {
seg.el.style.visibility = ''
})
}
getRelatedSegs(targetSeg: Seg) {
let targetEventDef = targetSeg.eventRange.eventDef
return this.getAllEventSegs().filter(function(seg: Seg) {
let segEventDef = seg.eventRange.eventDef
return segEventDef.defId === targetEventDef.defId || // include defId as well???
segEventDef.groupId && segEventDef.groupId === targetEventDef.groupId
})
}
getAllEventSegs() {
if (this.eventRenderer) {
return this.eventRenderer.getSegs()
} else {
return []
}
}
// Event Instance Selection (aka long-touch focus)
// -----------------------------------------------------------------------------------------------------------------
// TODO: show/hide according to groupId?
selectEventsByInstanceId(instanceId) {
this.getAllEventSegs().forEach(function(seg) {
if (
seg.eventRange.eventInstance.instanceId === instanceId &&
seg.el // necessary?
) {
seg.el.classList.add('fc-selected')
}
})
}
unselectAllEvents() {
this.getAllEventSegs().forEach(function(seg) {
if (seg.el) { // necessary?
seg.el.classList.remove('fc-selected')
}
})
}
// EXTERNAL Drag-n-Drop
// ---------------------------------------------------------------------------------------------------------------
// Doesn't need to implement a response, but must pass to children
handlExternalDragStart(ev, el, skipBinding) {
this.callChildren('handlExternalDragStart', arguments)
}
handleExternalDragMove(ev) {
this.callChildren('handleExternalDragMove', arguments)
}
handleExternalDragStop(ev) {
this.callChildren('handleExternalDragStop', arguments)
}
// DateSpan
// ---------------------------------------------------------------------------------------------------------------
// Renders a visual indication of the selection
renderSelection(selection: DateSpan) {
this.renderHighlightSegs(this.selectionToSegs(selection))
}
// Unrenders a visual indication of selection
unrenderSelection() {
this.unrenderHighlight()
}
// Highlight
// ---------------------------------------------------------------------------------------------------------------
// Renders an emphasis on the given date range. Given a span (unzoned start/end and other misc data)
renderHighlightSegs(segs) {
if (this.fillRenderer) {
this.fillRenderer.renderSegs('highlight', segs, {
getClasses() {
return [ 'fc-highlight' ]
}
})
}
}
// Unrenders the emphasis on a date range
unrenderHighlight() {
if (this.fillRenderer) {
this.fillRenderer.unrender('highlight')
}
}
computeHighlightSize() {
if (this.fillRenderer) {
this.fillRenderer.computeSize('highlight')
}
}
assignHighlightSize() {
if (this.fillRenderer) {
this.fillRenderer.assignSize('highlight')
}
}
/*
------------------------------------------------------------------------------------------------------------------*/
computeHelperSize() {
if (this.helperRenderer) {
this.helperRenderer.computeSize()
}
}
assignHelperSize() {
if (this.helperRenderer) {
this.helperRenderer.assignSize()
}
}
/* Converting selection/eventRanges -> segs
------------------------------------------------------------------------------------------------------------------*/
eventStoreToSegs(eventStore: EventStore): Seg[] {
let activeUnzonedRange = this.dateProfile.activeUnzonedRange
let eventRenderRanges = sliceEventStore(eventStore, activeUnzonedRange)
let allSegs: Seg[] = []
for (let eventRenderRange of eventRenderRanges) {
let segs = this.rangeToSegs(eventRenderRange.range, eventRenderRange.eventDef.isAllDay)
for (let seg of segs) {
seg.eventRange = eventRenderRange
allSegs.push(seg)
}
}
return allSegs
}
selectionToSegs(selection: DateSpan): Seg[] {
return this.rangeToSegs(selection.range, selection.isAllDay)
}
// must implement if want to use many of the rendering utils
rangeToSegs(range: UnzonedRange, isAllDay: boolean): Seg[] {
return []
}
// Utils
// ---------------------------------------------------------------------------------------------------------------
callChildren(methodName, args) {
this.iterChildren(function(child) {
child[methodName].apply(child, args)
})
}
iterChildren(func) {
let childrenByUid = this.childrenByUid
let uid
for (uid in childrenByUid) {
func(childrenByUid[uid])
}
}
getCalendar(): Calendar {
return this.view.calendar
}
getDateEnv(): DateEnv {
return this.getCalendar().dateEnv
}
getTheme(): Theme {
return this.getCalendar().theme
}
// Generates HTML for an anchor to another view into the calendar.
// Will either generate an tag or a non-clickable tag, depending on enabled settings.
// `gotoOptions` can either be a date input, or an object with the form:
// { date, type, forceOff }
// `type` is a view-type like "day" or "week". default value is "day".
// `attrs` and `innerHtml` are use to generate the rest of the HTML tag.
buildGotoAnchorHtml(gotoOptions, attrs, innerHtml) {
let dateEnv = this.getDateEnv()
let date
let type
let forceOff
let finalOptions
if (gotoOptions instanceof Date || typeof gotoOptions !== 'object') {
date = gotoOptions // a single date-like input
} else {
date = gotoOptions.date
type = gotoOptions.type
forceOff = gotoOptions.forceOff
}
date = dateEnv.createMarker(date) // if a string, parse it
finalOptions = { // for serialization into the link
date: dateEnv.formatIso(date, { omitTime: true }),
type: type || 'day'
}
if (typeof attrs === 'string') {
innerHtml = attrs
attrs = null
}
attrs = attrs ? ' ' + attrsToStr(attrs) : '' // will have a leading space
innerHtml = innerHtml || ''
if (!forceOff && this.opt('navLinks')) {
return '' +
innerHtml +
''
} else {
return '' +
innerHtml +
''
}
}
getAllDayHtml() {
return this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'))
}
// Computes HTML classNames for a single-day element
getDayClasses(date: DateMarker, noThemeHighlight?) {
let view = this.view
let classes = []
let todayStart: DateMarker
let todayEnd: DateMarker
if (!this.dateProfile.activeUnzonedRange.containsDate(date)) {
classes.push('fc-disabled-day') // TODO: jQuery UI theme?
} else {
classes.push('fc-' + DAY_IDS[date.getUTCDay()])
if (view.isDateInOtherMonth(date, this.dateProfile)) { // TODO: use DateComponent subclass somehow
classes.push('fc-other-month')
}
todayStart = startOfDay(view.calendar.getNow())
todayEnd = addDays(todayStart, 1)
if (date < todayStart) {
classes.push('fc-past')
} else if (date >= todayEnd) {
classes.push('fc-future')
} else {
classes.push('fc-today')
if (noThemeHighlight !== true) {
classes.push(view.calendar.theme.getClass('today'))
}
}
}
return classes
}
// Compute the number of the give units in the "current" range.
// Won't go more precise than days.
// Will return `0` if there's not a clean whole interval.
currentRangeAs(unit) { // PLURAL :(
let dateEnv = this.getDateEnv()
let range = this.dateProfile.currentUnzonedRange
let res = null
if (unit === 'years') {
res = dateEnv.diffWholeYears(range.start, range.end)
} else if (unit === 'months') {
res = dateEnv.diffWholeMonths(range.start, range.end)
} else if (unit === 'weeks') {
res = dateEnv.diffWholeMonths(range.start, range.end)
} else if (unit === 'days') {
res = diffWholeDays(range.start, range.end)
}
return res || 0
}
// Returns the date range of the full days the given range visually appears to occupy.
// Returns a plain object with start/end, NOT an UnzonedRange!
computeDayRange(unzonedRange): UnzonedRange {
return computeDayRange(unzonedRange, this.nextDayThreshold)
}
// Does the given range visually appear to occupy more than one day?
isMultiDayRange(unzonedRange) {
let dayRange = this.computeDayRange(unzonedRange)
return diffDays(dayRange.start, dayRange.end) > 1
}
isValidSegInteraction(evTarget: HTMLElement) {
return !this.dragState &&
!this.eventResizeState &&
!elementClosest(evTarget, '.fc-helper')
}
isValidDateInteraction(evTarget: HTMLElement) {
return !elementClosest(evTarget, this.segSelector) &&
!elementClosest(evTarget, '.fc-more') && // a "more.." link
!elementClosest(evTarget, 'a[data-goto]') // a clickable nav link
}
}
function computeDayRange(unzonedRange: UnzonedRange, nextDayThreshold: Duration): UnzonedRange {
let startDay: DateMarker = startOfDay(unzonedRange.start) // the beginning of the day the range starts
let end: DateMarker = unzonedRange.end
let endDay: DateMarker = startOfDay(end)
let endTimeMS: number = end.valueOf() - endDay.valueOf() // # of milliseconds into `endDay`
// If the end time is actually inclusively part of the next day and is equal to or
// beyond the next day threshold, adjust the end to be the exclusive end of `endDay`.
// Otherwise, leaving it as inclusive will cause it to exclude `endDay`.
if (endTimeMS && endTimeMS >= asRoughMs(nextDayThreshold)) {
endDay = addDays(endDay, 1)
}
// If end is within `startDay` but not past nextDayThreshold, assign the default duration of one day.
if (endDay <= startDay) {
endDay = addDays(startDay, 1)
}
return new UnzonedRange(startDay, endDay)
}