import { assignTo } from '../util/object'
import {
createElement,
insertAfterElement,
findElements,
findChildren,
removeElement
} from '../util/dom-manip'
import { computeRect } from '../util/dom-geom'
import View from '../View'
import PositionCache from '../common/PositionCache'
import Popover from '../common/Popover'
import { default as DayTableMixin, DayTableInterface } from '../component/DayTableMixin'
import DayGridEventRenderer from './DayGridEventRenderer'
import DayGridMirrorRenderer from './DayGridMirrorRenderer'
import DayGridFillRenderer from './DayGridFillRenderer'
import { addDays } from '../datelib/marker'
import { createFormatter } from '../datelib/formatting'
import DateComponent, { Seg } from '../component/DateComponent'
import { EventStore } from '../structs/event-store'
import DayTile from './DayTile'
import { Hit } from '../interactions/HitDragging'
import { DateRange, rangeContainsMarker, intersectRanges } from '../datelib/date-range'
import OffsetTracker from '../common/OffsetTracker'
import { EventRenderRange, EventUiHash } from '../component/event-rendering'
const DAY_NUM_FORMAT = createFormatter({ day: 'numeric' })
const WEEK_NUM_FORMAT = createFormatter({ week: 'numeric' })
/* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week.
----------------------------------------------------------------------------------------------------------------------*/
export default class DayGrid extends DateComponent {
rowCnt: DayTableInterface['rowCnt']
colCnt: DayTableInterface['colCnt']
daysPerRow: DayTableInterface['daysPerRow']
sliceRangeByRow: DayTableInterface['sliceRangeByRow']
updateDayTable: DayTableInterface['updateDayTable']
renderHeadHtml: DayTableInterface['renderHeadHtml']
getCellDate: DayTableInterface['getCellDate']
renderBgTrHtml: DayTableInterface['renderBgTrHtml']
renderIntroHtml: DayTableInterface['renderIntroHtml']
getCellRange: DayTableInterface['getCellRange']
sliceRangeByDay: DayTableInterface['sliceRangeByDay']
bookendCells: DayTableInterface['bookendCells']
breakOnWeeks: DayTableInterface['breakOnWeeks']
isInteractable = true
doesDragMirror = false
doesDragHighlight = true
slicingType: 'all-day' = 'all-day' // stupid TypeScript
view: View // TODO: make more general and/or remove
mirrorRenderer: any
cellWeekNumbersVisible: boolean = false // display week numbers in day cell?
bottomCoordPadding: number = 0 // hack for extending the hit area for the last row of the coordinate grid
headContainerEl: HTMLElement // div that hold's the date header
rowEls: HTMLElement[] // set of fake row elements
cellEls: HTMLElement[] // set of whole-day elements comprising the row's background
rowPositions: PositionCache
colPositions: PositionCache
offsetTracker: OffsetTracker
// isRigid determines whether the individual rows should ignore the contents and be a constant height.
// Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient.
isRigid: boolean = false
segPopover: Popover // the Popover that holds events that can't fit in a cell. null when not visible
segPopoverTile: DayTile
constructor(view) { // view is required, unlike superclass
super(view)
}
// Slices up the given span (unzoned start/end with other misc data) into an array of segments
rangeToSegs(range: DateRange): Seg[] {
range = intersectRanges(range, this.dateProfile.validRange)
if (range) {
let segs = this.sliceRangeByRow(range)
for (let i = 0; i < segs.length; i++) {
let seg = segs[i]
seg.component = this
if (this.isRTL) {
seg.leftCol = this.daysPerRow - 1 - seg.lastRowDayIndex
seg.rightCol = this.daysPerRow - 1 - seg.firstRowDayIndex
} else {
seg.leftCol = seg.firstRowDayIndex
seg.rightCol = seg.lastRowDayIndex
}
}
return segs
} else {
return []
}
}
/* Date Rendering
------------------------------------------------------------------------------------------------------------------*/
renderDates() {
this.updateDayTable()
this.renderGrid()
}
unrenderDates() {
this.removeSegPopover()
}
// Renders the rows and columns into the component's `this.el`, which should already be assigned.
renderGrid() {
let view = this.view
let dateEnv = this.getDateEnv()
let rowCnt = this.rowCnt
let colCnt = this.colCnt
let html = ''
let row
let col
if (this.headContainerEl) {
this.headContainerEl.innerHTML = this.renderHeadHtml()
}
for (row = 0; row < rowCnt; row++) {
html += this.renderDayRowHtml(row, this.isRigid)
}
this.el.innerHTML = html
this.rowEls = findElements(this.el, '.fc-row')
this.cellEls = findElements(this.el, '.fc-day, .fc-disabled-day')
this.rowPositions = new PositionCache(
this.el,
this.rowEls,
false,
true // vertical
)
this.colPositions = new PositionCache(
this.el,
this.cellEls.slice(0, this.colCnt), // only the first row
true,
false // horizontal
)
// trigger dayRender with each cell's element
for (row = 0; row < rowCnt; row++) {
for (col = 0; col < colCnt; col++) {
this.publiclyTrigger('dayRender', [
{
date: dateEnv.toDate(this.getCellDate(row, col)),
isAllDay: true,
el: this.getCellEl(row, col),
view
}
])
}
}
}
// Generates the HTML for a single row, which is a div that wraps a table.
// `row` is the row number.
renderDayRowHtml(row, isRigid) {
let theme = this.getTheme()
let classes = [ 'fc-row', 'fc-week', theme.getClass('dayRow') ]
if (isRigid) {
classes.push('fc-rigid')
}
return '' +
'
' +
'
' +
'
' +
this.renderBgTrHtml(row) +
'
' +
'
' +
'
' +
'
' +
(this.getIsNumbersVisible() ?
'' +
this.renderNumberTrHtml(row) +
'' :
''
) +
'
' +
'
' +
'
'
}
getIsNumbersVisible() {
return this.getIsDayNumbersVisible() || this.cellWeekNumbersVisible
}
getIsDayNumbersVisible() {
return this.rowCnt > 1
}
/* Grid Number Rendering
------------------------------------------------------------------------------------------------------------------*/
renderNumberTrHtml(row) {
return '' +
'' +
(this.isRTL ? '' : this.renderNumberIntroHtml(row)) +
this.renderNumberCellsHtml(row) +
(this.isRTL ? this.renderNumberIntroHtml(row) : '') +
'
'
}
renderNumberIntroHtml(row) {
return this.renderIntroHtml()
}
renderNumberCellsHtml(row) {
let htmls = []
let col
let date
for (col = 0; col < this.colCnt; col++) {
date = this.getCellDate(row, col)
htmls.push(this.renderNumberCellHtml(date))
}
return htmls.join('')
}
// Generates the HTML for the s of the "number" row in the DayGrid's content skeleton.
// The number row will only exist if either day numbers or week numbers are turned on.
renderNumberCellHtml(date) {
let view = this.view
let dateEnv = this.getDateEnv()
let html = ''
let isDateValid = rangeContainsMarker(this.dateProfile.activeRange, date) // TODO: called too frequently. cache somehow.
let isDayNumberVisible = this.getIsDayNumbersVisible() && isDateValid
let classes
let weekCalcFirstDow
if (!isDayNumberVisible && !this.cellWeekNumbersVisible) {
// no numbers in day cell (week number must be along the side)
return ' | | ' // will create an empty space above events :(
}
classes = this.getDayClasses(date)
classes.unshift('fc-day-top')
if (this.cellWeekNumbersVisible) {
weekCalcFirstDow = dateEnv.weekDow
}
html += ''
if (this.cellWeekNumbersVisible && (date.getUTCDay() === weekCalcFirstDow)) {
html += view.buildGotoAnchorHtml(
{ date: date, type: 'week' },
{ 'class': 'fc-week-number' },
dateEnv.format(date, WEEK_NUM_FORMAT) // inner HTML
)
}
if (isDayNumberVisible) {
html += view.buildGotoAnchorHtml(
date,
{ 'class': 'fc-day-number' },
dateEnv.format(date, DAY_NUM_FORMAT) // inner HTML
)
}
html += ' | '
return html
}
/* Sizing
------------------------------------------------------------------------------------------------------------------*/
buildPositionCaches() {
this.colPositions.build()
this.rowPositions.build()
this.rowPositions.bottoms[this.rowCnt - 1] += this.bottomCoordPadding // hack
}
/* Hit System
------------------------------------------------------------------------------------------------------------------*/
prepareHits() {
this.offsetTracker = new OffsetTracker(this.el)
}
releaseHits() {
this.offsetTracker.destroy()
}
queryHit(leftOffset, topOffset): Hit {
let { colPositions, rowPositions, offsetTracker } = this
if (offsetTracker.isWithinClipping(leftOffset, topOffset)) {
let leftOrigin = offsetTracker.computeLeft()
let topOrigin = offsetTracker.computeTop()
let col = colPositions.leftToIndex(leftOffset - leftOrigin)
let row = rowPositions.topToIndex(topOffset - topOrigin)
if (row != null && col != null) {
return {
component: this,
dateSpan: {
range: this.getCellRange(row, col),
isAllDay: true
},
dayEl: this.getCellEl(row, col),
rect: {
left: colPositions.lefts[col] + leftOrigin,
right: colPositions.rights[col] + leftOrigin,
top: rowPositions.tops[row] + topOrigin,
bottom: rowPositions.bottoms[row] + topOrigin
},
layer: 0
}
}
}
}
/* Cell System
------------------------------------------------------------------------------------------------------------------*/
// FYI: the first column is the leftmost column, regardless of date
getCellEl(row, col) {
return this.cellEls[row * this.colCnt + col]
}
/* Event Rendering
------------------------------------------------------------------------------------------------------------------*/
// Unrenders all events currently rendered on the grid
unrenderEvents() {
this.removeSegPopover() // removes the "more.." events popover
super.unrenderEvents()
}
// Retrieves all rendered segment objects currently rendered on the grid
getAllEventSegs() {
// append the segments from the "more..." popover
return super.getAllEventSegs().concat(
this.segPopoverTile ?
this.segPopoverTile.getAllEventSegs() :
[]
)
}
/* Event Resize Visualization
------------------------------------------------------------------------------------------------------------------*/
// Renders a visual indication of an event being resized
renderEventResize(eventStore: EventStore, eventUis: EventUiHash, origSeg) {
let segs = this.eventRangesToSegs(
this.eventStoreToRanges(eventStore, eventUis)
)
this.renderHighlightSegs(segs)
this.mirrorRenderer.renderEventResizingSegs(segs, origSeg)
}
// Unrenders a visual indication of an event being resized
unrenderEventResize() {
this.unrenderHighlight()
this.mirrorRenderer.unrender()
}
/* More+ Link Popover
------------------------------------------------------------------------------------------------------------------*/
removeSegPopover() {
if (this.segPopover) {
this.segPopover.hide() // in handler, will call segPopover's removeElement
}
}
// Limits the number of "levels" (vertically stacking layers of events) for each row of the grid.
// `levelLimit` can be false (don't limit), a number, or true (should be computed).
limitRows(levelLimit) {
let rowStructs = this.eventRenderer.rowStructs || []
let row // row #
let rowLevelLimit
for (row = 0; row < rowStructs.length; row++) {
this.unlimitRow(row)
if (!levelLimit) {
rowLevelLimit = false
} else if (typeof levelLimit === 'number') {
rowLevelLimit = levelLimit
} else {
rowLevelLimit = this.computeRowLevelLimit(row)
}
if (rowLevelLimit !== false) {
this.limitRow(row, rowLevelLimit)
}
}
}
// Computes the number of levels a row will accomodate without going outside its bounds.
// Assumes the row is "rigid" (maintains a constant height regardless of what is inside).
// `row` is the row number.
computeRowLevelLimit(row): (number | false) {
let rowEl = this.rowEls[row] // the containing "fake" row div
let rowBottom = rowEl.getBoundingClientRect().bottom // relative to viewport!
let trEls = findChildren(this.eventRenderer.rowStructs[row].tbodyEl) as HTMLTableRowElement[]
let i
let trEl: HTMLTableRowElement
// Reveal one level at a time and stop when we find one out of bounds
for (i = 0; i < trEls.length; i++) {
trEl = trEls[i]
trEl.classList.remove('fc-limited') // reset to original state (reveal)
if (trEl.getBoundingClientRect().bottom > rowBottom) {
return i
}
}
return false // should not limit at all
}
// Limits the given grid row to the maximum number of levels and injects "more" links if necessary.
// `row` is the row number.
// `levelLimit` is a number for the maximum (inclusive) number of levels allowed.
limitRow(row, levelLimit) {
let rowStruct = this.eventRenderer.rowStructs[row]
let moreNodes = [] // array of "more" links and | DOM nodes
let col = 0 // col #, left-to-right (not chronologically)
let levelSegs // array of segment objects in the last allowable level, ordered left-to-right
let cellMatrix // a matrix (by level, then column) of all | elements in the row
let limitedNodes // array of temporarily hidden level |
and segment | DOM nodes
let i
let seg
let segsBelow // array of segment objects below `seg` in the current `col`
let totalSegsBelow // total number of segments below `seg` in any of the columns `seg` occupies
let colSegsBelow // array of segment arrays, below seg, one for each column (offset from segs's first column)
let td: HTMLTableCellElement
let rowSpan
let segMoreNodes // array of "more" | cells that will stand-in for the current seg's cell
let j
let moreTd: HTMLTableCellElement
let moreWrap
let moreLink
// Iterates through empty level cells and places "more" links inside if need be
let emptyCellsUntil = (endCol) => { // goes from current `col` to `endCol`
while (col < endCol) {
segsBelow = this.getCellSegs(row, col, levelLimit)
if (segsBelow.length) {
td = cellMatrix[levelLimit - 1][col]
moreLink = this.renderMoreLink(row, col, segsBelow)
moreWrap = createElement('div', null, moreLink)
td.appendChild(moreWrap)
moreNodes.push(moreWrap[0])
}
col++
}
}
if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit?
levelSegs = rowStruct.segLevels[levelLimit - 1]
cellMatrix = rowStruct.cellMatrix
limitedNodes = findChildren(rowStruct.tbodyEl).slice(levelLimit) // get level |
elements past the limit
limitedNodes.forEach(function(node) {
node.classList.add('fc-limited') // hide elements and get a simple DOM-nodes array
})
// iterate though segments in the last allowable level
for (i = 0; i < levelSegs.length; i++) {
seg = levelSegs[i]
emptyCellsUntil(seg.leftCol) // process empty cells before the segment
// determine *all* segments below `seg` that occupy the same columns
colSegsBelow = []
totalSegsBelow = 0
while (col <= seg.rightCol) {
segsBelow = this.getCellSegs(row, col, levelLimit)
colSegsBelow.push(segsBelow)
totalSegsBelow += segsBelow.length
col++
}
if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links?
td = cellMatrix[levelLimit - 1][seg.leftCol] // the segment's parent cell
rowSpan = td.rowSpan || 1
segMoreNodes = []
// make a replacement for each column the segment occupies. will be one for each colspan
for (j = 0; j < colSegsBelow.length; j++) {
moreTd = createElement('td', { className: 'fc-more-cell', rowSpan }) as HTMLTableCellElement
segsBelow = colSegsBelow[j]
moreLink = this.renderMoreLink(
row,
seg.leftCol + j,
[ seg ].concat(segsBelow) // count seg as hidden too
)
moreWrap = createElement('div', null, moreLink)
moreTd.appendChild(moreWrap)
segMoreNodes.push(moreTd)
moreNodes.push(moreTd)
}
td.classList.add('fc-limited')
insertAfterElement(td, segMoreNodes)
limitedNodes.push(td)
}
}
emptyCellsUntil(this.colCnt) // finish off the level
rowStruct.moreEls = moreNodes // for easy undoing later
rowStruct.limitedEls = limitedNodes // for easy undoing later
}
}
// Reveals all levels and removes all "more"-related elements for a grid's row.
// `row` is a row number.
unlimitRow(row) {
let rowStruct = this.eventRenderer.rowStructs[row]
if (rowStruct.moreEls) {
rowStruct.moreEls.forEach(removeElement)
rowStruct.moreEls = null
}
if (rowStruct.limitedEls) {
rowStruct.limitedEls.forEach(function(limitedEl) {
limitedEl.classList.remove('fc-limited')
})
rowStruct.limitedEls = null
}
}
// Renders an element that represents hidden event element for a cell.
// Responsible for attaching click handler as well.
renderMoreLink(row, col, hiddenSegs) {
let view = this.view
let dateEnv = this.getDateEnv()
let a = createElement('a', { className: 'fc-more' })
a.innerText = this.getMoreLinkText(hiddenSegs.length)
a.addEventListener('click', (ev) => {
let clickOption = this.opt('eventLimitClick')
let date = this.getCellDate(row, col)
let moreEl = ev.currentTarget as HTMLElement
let dayEl = this.getCellEl(row, col)
let allSegs = this.getCellSegs(row, col)
// rescope the segments to be within the cell's date
let reslicedAllSegs = this.resliceDaySegs(allSegs, date)
let reslicedHiddenSegs = this.resliceDaySegs(hiddenSegs, date)
if (typeof clickOption === 'function') {
// the returned value can be an atomic option
clickOption = this.publiclyTrigger('eventLimitClick', [
{
date: dateEnv.toDate(date),
isAllDay: true,
dayEl: dayEl,
moreEl: moreEl,
segs: reslicedAllSegs,
hiddenSegs: reslicedHiddenSegs,
jsEvent: ev,
view
}
])
}
if (clickOption === 'popover') {
this.showSegPopover(row, col, moreEl, reslicedAllSegs)
} else if (typeof clickOption === 'string') { // a view name
view.calendar.zoomTo(date, clickOption)
}
})
return a
}
// Reveals the popover that displays all events within a cell
showSegPopover(row, col, moreLink: HTMLElement, segs) {
let view = this.view
let moreWrap = moreLink.parentNode as HTMLElement // the wrapper around the
let topEl: HTMLElement // the element we want to match the top coordinate of
let options
if (this.rowCnt === 1) {
topEl = view.el // will cause the popover to cover any sort of header
} else {
topEl = this.rowEls[row] // will align with top of row
}
options = {
className: 'fc-more-popover ' + view.calendar.theme.getClass('popover'),
parentEl: this.el,
top: computeRect(topEl).top,
autoHide: true, // when the user clicks elsewhere, hide the popover
content: (el) => {
this.segPopoverTile.setElement(el)
// it would be more proper to call render() with a full render state,
// but hackily rendering segs directly is much easier
// simlate a lot of what happens in render() and renderEventRanges()
this.segPopoverTile.renderSkeleton()
this.segPopoverTile.eventRenderer.rangeUpdated()
this.segPopoverTile.eventRenderer.renderSegs(segs)
this.segPopoverTile.renderedFlags.events = true // so unrendering works
this.segPopoverTile.triggerRenderedSegs(segs) // for eventRendered
},
hide: () => {
this.segPopoverTile.removeElement()
this.segPopover.removeElement()
this.segPopover = null
}
}
// Determine horizontal coordinate.
// We use the moreWrap instead of the to avoid border confusion.
if (this.isRTL) {
options.right = computeRect(moreWrap).right + 1 // +1 to be over cell border
} else {
options.left = computeRect(moreWrap).left - 1 // -1 to be over cell border
}
this.segPopoverTile = new DayTile(this.view, this.getCellDate(row, col))
this.segPopover = new Popover(options)
this.segPopover.show()
this.getCalendar().releaseAfterSizingTriggers() // hack for eventRendered
}
// Given the events within an array of segment objects, reslice them to be in a single day
resliceDaySegs(segs, dayDate) {
let dayStart = dayDate
let dayEnd = addDays(dayStart, 1)
let dayRange = { start: dayStart, end: dayEnd }
let newSegs = []
for (let seg of segs) {
let eventRange = seg.eventRange
let origRange = eventRange.range
let slicedRange = intersectRanges(origRange, dayRange)
if (slicedRange) {
newSegs.push(
assignTo({}, seg, {
eventRange: {
def: eventRange.def,
ui: assignTo({}, eventRange.ui, { durationEditable: false }), // hack to disable resizing
instance: eventRange.instance,
range: slicedRange
} as EventRenderRange,
isStart: seg.isStart && slicedRange.start.valueOf() === origRange.start.valueOf(),
isEnd: seg.isEnd && slicedRange.end.valueOf() === origRange.end.valueOf()
})
)
}
}
return newSegs
}
// Generates the text that should be inside a "more" link, given the number of events it represents
getMoreLinkText(num) {
let opt = this.opt('eventLimitText')
if (typeof opt === 'function') {
return opt(num)
} else {
return '+' + num + ' ' + opt
}
}
// Returns segments within a given cell.
// If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs.
getCellSegs(row, col, startLevel?) {
let segMatrix = this.eventRenderer.rowStructs[row].segMatrix
let level = startLevel || 0
let segs = []
let seg
while (level < segMatrix.length) {
seg = segMatrix[level][col]
if (seg) {
segs.push(seg)
}
level++
}
return segs
}
}
DayGrid.prototype.eventRendererClass = DayGridEventRenderer
DayGrid.prototype.mirrorRendererClass = DayGridMirrorRenderer
DayGrid.prototype.fillRendererClass = DayGridFillRenderer
DayTableMixin.mixInto(DayGrid)
| |