This commit is contained in:
romankko2 committed 2022-11-28 12:40:23 +01:00
1 parent f6d0764969
commit 944ea92cef
56 files changed
+5535

No files matched your search

@@ -0,0 +1,47 @@
<template lang="pug">
.vuecal__flex.vuecal__all-day(:style="cellOrSplitMinWidth && { height }")
.vuecal__all-day-text(v-if="!cellOrSplitMinWidth" style="width: 3em")
span {{ label }}
.vuecal__flex.vuecal__cells(
:class="`${view.id}-view`"
grow
:style="cellOrSplitMinWidth ? `min-width: ${cellOrSplitMinWidth}px` : ''")
vuecal-cell(
v-for="(cell, i) in cells"
:key="i"
:options="options"
:edit-events="editEvents"
:data="cell"
:all-day="true"
:cell-width="options.hideWeekdays.length && (vuecal.isWeekView || vuecal.isMonthView) && vuecal.cellWidth"
:min-timestamp="options.minTimestamp"
:max-timestamp="options.maxTimestamp"
:cell-splits="daySplits")
template(#event="{ event, view }")
slot(name="event" :view="view" :event="event")
</template>
<script>
import Cell from './cell.vue'
export default {
inject: ['vuecal', 'view', 'editEvents'],
components: { 'vuecal-cell': Cell },
props: {
// Vue-cal main component options (props).
options: { type: Object, required: true },
cells: { type: Array, required: true },
label: { type: String, required: true },
daySplits: { type: Array, default: () => [] },
shortEvents: { type: Boolean, default: true },
height: { type: String, default: '' },
cellOrSplitMinWidth: { type: Number, default: null }
},
computed: {
hasCellOrSplitWidth () {
return !!(this.options.minCellWidth || (this.daySplits.length && this.options.minSplitWidth))
}
}
}
</script>
+615
View File
@@ -0,0 +1,615 @@
<template lang="pug">
transition-group.vuecal__cell(
:class="cellClasses"
:name="`slide-fade--${transitionDirection}`"
tag="div"
:appear="options.transitions"
:style="cellStyles")
.vuecal__flex.vuecal__cell-content(
v-for="(split, i) in (splitsCount ? splits : 1)"
:key="options.transitions ? `${view.id}-${data.content}-${i}` : i"
:class="splitsCount && splitClasses(split)"
:data-split="splitsCount ? split.id : false"
column
tabindex="0"
:aria-label="data.content"
@focus="onCellFocus($event)"
@keypress.enter="onCellkeyPressEnter($event)"
@touchstart="!isDisabled && onCellTouchStart($event, splitsCount ? split.id : null)"
@mousedown="!isDisabled && onCellMouseDown($event, splitsCount ? split.id : null)"
@click="!isDisabled && onCellClick($event)"
@dblclick="!isDisabled && onCellDblClick($event)"
@contextmenu="!isDisabled && options.cellContextmenu && onCellContextMenu($event)"
@dragenter="!isDisabled && editEvents.drag && dnd && dnd.cellDragEnter($event, $data, data.startDate)"
@dragover="!isDisabled && editEvents.drag && dnd && dnd.cellDragOver($event, $data, data.startDate, splitsCount ? split.id : null)"
@dragleave="!isDisabled && editEvents.drag && dnd && dnd.cellDragLeave($event, $data, data.startDate)"
@drop="!isDisabled && editEvents.drag && dnd && dnd.cellDragDrop($event, $data, data.startDate, splitsCount ? split.id : null)")
.cell-time-labels(v-if="options.showTimeInCells && options.time && isWeekOrDayView && !allDay")
span.cell-time-label(v-for="(cell, i) in vuecal.timeCells" :key="i").
{{ cell.label }}
template(v-if="isWeekOrDayView && !allDay && specialHours.length")
.vuecal__special-hours(
v-for="(block, i) in specialHours"
:class="`vuecal__special-hours--day${block.day} ${block.class}`"
:style="`height: ${block.height}px;top: ${block.top}px`")
.special-hours-label(v-if="block.label" v-html="block.label")
slot(
name="cell-content"
:events="events"
:select-cell="$event => selectCell($event, true)"
:split="splitsCount ? split : false")
.vuecal__cell-events(
v-if="eventsCount && (isWeekOrDayView || (view.id === 'month' && options.eventsOnMonthView))")
event(
v-for="(event, j) in (splitsCount ? split.events : events)" :key="j"
:cell-formatted-date="data.formattedDate"
:event="event"
:all-day="allDay"
:cell-events="splitsCount ? split.events : events"
:overlaps="((splitsCount ? split.overlaps[event._eid] : cellOverlaps[event._eid]) || []).overlaps"
:event-position="((splitsCount ? split.overlaps[event._eid] : cellOverlaps[event._eid]) || []).position"
:overlaps-streak="splitsCount ? split.overlapsStreak : cellOverlapsStreak")
template(#event="{ event, view }")
slot(name="event" :view="view" :event="event")
.vuecal__now-line(
v-if="timelineVisible"
:style="`top: ${todaysTimePosition}px`"
:key="options.transitions ? `${view.id}-now-line` : 'now-line'"
:title="utils.date.formatTime(vuecal.now)")
</template>
<script>
import Event from './event.vue'
export default {
inject: ['vuecal', 'utils', 'modules', 'view', 'domEvents'],
components: { Event },
props: {
// Vue-cal main component options (props).
options: { type: Object, default: () => ({}) },
editEvents: { type: Object, required: true },
data: { type: Object, required: true },
cellSplits: { type: Array, default: () => [] },
minTimestamp: { type: [Number, null], default: null },
maxTimestamp: { type: [Number, null], default: null },
cellWidth: { type: [Number, Boolean], default: false },
allDay: { type: Boolean, default: false }
},
data: () => ({
cellOverlaps: {},
cellOverlapsStreak: 1, // Largest amount of simultaneous events in cell.
// On mouse down, save the time at cursor so it can be reused on cell focus event
// where there is no cursor coords.
timeAtCursor: null,
highlighted: false, // On event drag over.
highlightedSplit: null
}),
methods: {
getSplitAtCursor ({ target }) {
const targetIsSplit = target.classList.contains('vuecal__cell-split')
let split = targetIsSplit ? target : this.vuecal.findAncestor(target, 'vuecal__cell-split')
if (split) {
split = split.attributes['data-split'].value
// Convert to a numeric value if split id is a number.
if (parseInt(split).toString() === split.toString()) split = parseInt(split)
}
return split || null
},
splitClasses (split) {
return {
'vuecal__cell-split': true,
'vuecal__cell-split--highlighted': this.highlightedSplit === split.id,
[split.class]: !!split.class
}
},
checkCellOverlappingEvents () {
// If splits, checkCellOverlappingEvents() is called from within computed splits.
if (this.options.time && this.eventsCount && !this.splitsCount) {
if (this.eventsCount === 1) {
this.cellOverlaps = []
this.cellOverlapsStreak = 1
}
// If only 1 event remains re-init the overlaps.
else [this.cellOverlaps, this.cellOverlapsStreak] = this.utils.event.checkCellOverlappingEvents(this.events, this.options)
}
},
isDOMElementAnEvent (el) {
return this.vuecal.isDOMElementAnEvent(el)
},
/**
* Select a cell and go to narrower view on double click or single click according to vuecalProps option.
*/
selectCell (DOMEvent, force = false) {
// If splitting days, also return the clicked split on cell click when emitting event.
const split = this.splitsCount ? this.getSplitAtCursor(DOMEvent) : null
this.utils.cell.selectCell(force, this.timeAtCursor, split)
this.timeAtCursor = null
},
onCellkeyPressEnter (DOMEvent) {
if (!this.isSelected) this.onCellFocus(DOMEvent)
// If splitting days, also return the clicked split on cell keypress when emitting event.
const split = this.splitsCount ? this.getSplitAtCursor(DOMEvent) : null
this.utils.cell.keyPressEnterCell(this.timeAtCursor, split)
this.timeAtCursor = null
},
/**
* On cell focus, from tab key or from mousedown, highlight the cell and emit
* the cell-focus event with the date of the cell start when focusing from tab or
* the date & time at cursor if click/touch.
*/
onCellFocus (DOMEvent) {
if (!this.isSelected && !this.isDisabled) {
this.isSelected = this.data.startDate // Highlight the cell.
// If splitting days, also return the clicked split on cell focus when emitting event.
const split = this.splitsCount ? this.getSplitAtCursor(DOMEvent) : null
// Cell-focus event returns the cell start date (at midnight) if triggered from tab key,
// or cursor coords time if clicked.
const date = this.timeAtCursor || this.data.startDate
this.vuecal.$emit('cell-focus', split ? { date, split } : date)
}
},
onCellMouseDown (DOMEvent, split = null, touch = false) {
// Prevent a double mouse down on touch devices.
if ('ontouchstart' in window && !touch) return false
if (!this.isSelected) this.onCellFocus(DOMEvent)
const { clickHoldACell, focusAnEvent } = this.domEvents
// Reinit the click trigger on each mousedown.
// In some cases we explicitly set this flag to prevent the click event to trigger,
// and cancel event creation.
this.domEvents.cancelClickEventCreation = false
// Also reinit this var on each mousedown.
clickHoldACell.eventCreated = false
this.timeAtCursor = new Date(this.data.startDate)
const { minutes, cursorCoords: { y } } = this.vuecal.minutesAtCursor(DOMEvent)
this.timeAtCursor.setMinutes(minutes)
const mouseDownOnEvent = this.isDOMElementAnEvent(DOMEvent.target)
// Unfocus an event if any is focused and clicking on cell outside of an event.
if (!mouseDownOnEvent && focusAnEvent._eid) {
(this.view.events.find(e => e._eid === focusAnEvent._eid) || {}).focused = false
}
// Only if event creation is allowed and mousedown is on a cell (not on event).
if (this.editEvents.create && !mouseDownOnEvent) this.setUpEventCreation(DOMEvent, y)
},
setUpEventCreation (DOMEvent, startCursorY) {
// If dragToCreateEvent is true, start the event creation from dragging
// only on week and day views (doesn't make sense on month view).
if (this.options.dragToCreateEvent && ['week', 'day'].includes(this.view.id)) {
const { dragCreateAnEvent } = this.domEvents
dragCreateAnEvent.startCursorY = startCursorY
// If splitting days, store the clicked split to create an event in it from the global
// mousemove handler in index.vue.
dragCreateAnEvent.split = this.splitsCount ? this.getSplitAtCursor(DOMEvent) : null
// Save the time at cursor on initial mousedown.
dragCreateAnEvent.start = this.timeAtCursor
// If snapToTime, set the start to the closest intervaled number.
if (this.options.snapToTime) {
let timeMinutes = this.timeAtCursor.getHours() * 60 + this.timeAtCursor.getMinutes()
const plusHalfSnapTime = timeMinutes + this.options.snapToTime / 2
timeMinutes = plusHalfSnapTime - (plusHalfSnapTime % this.options.snapToTime)
dragCreateAnEvent.start.setHours(0, timeMinutes, 0, 0)
}
}
// If the cellClickHold option is true and not mousedown on an event, click & hold to create an event.
else if (this.options.cellClickHold && ['month', 'week', 'day'].includes(this.view.id)) {
this.setUpCellHoldTimer(DOMEvent)
}
},
// When click & holding a cell, and if allowed, set a timeout to create an event (can be cancelled).
setUpCellHoldTimer (DOMEvent) {
const { clickHoldACell } = this.domEvents
clickHoldACell.cellId = `${this.vuecal._.uid}_${this.data.formattedDate}`
// If splitting days, store the clicked split to create an event in it from the global
// mousemove handler in index.vue.
clickHoldACell.split = this.splitsCount ? this.getSplitAtCursor(DOMEvent) : null
clickHoldACell.timeoutId = setTimeout(() => {
if (clickHoldACell.cellId && !this.domEvents.cancelClickEventCreation) {
const { _eid } = this.utils.event.createAnEvent(
this.timeAtCursor,
null,
clickHoldACell.split ? { split: clickHoldACell.split } : {}
)
clickHoldACell.eventCreated = _eid
}
}, clickHoldACell.timeout)
},
onCellTouchStart (DOMEvent, split = null) {
// If not mousedown on an event.
this.onCellMouseDown(DOMEvent, split, true)
},
onCellClick (DOMEvent) {
if (!this.isDOMElementAnEvent(DOMEvent.target)) this.selectCell(DOMEvent)
},
onCellDblClick (DOMEvent) {
const date = new Date(this.data.startDate)
date.setMinutes(this.vuecal.minutesAtCursor(DOMEvent).minutes)
// If splitting days, also return the clicked split on cell dblclick when emitting event.
const split = this.splitsCount ? this.getSplitAtCursor(DOMEvent) : null
this.vuecal.$emit('cell-dblclick', split ? { date, split } : date)
if (this.options.dblclickToNavigate) this.vuecal.switchToNarrowerView()
},
onCellContextMenu (DOMEvent) {
DOMEvent.stopPropagation()
DOMEvent.preventDefault()
const date = new Date(this.data.startDate)
const { cursorCoords, minutes } = this.vuecal.minutesAtCursor(DOMEvent)
date.setMinutes(minutes)
// If splitting days, also return the clicked split on cell contextmenu when emitting event.
const split = this.splitsCount ? this.getSplitAtCursor(DOMEvent) : null
this.vuecal.$emit('cell-contextmenu', { date, ...cursorCoords, ...(split || {}), e: DOMEvent })
}
},
computed: {
// Drag & drop module.
dnd () {
return this.modules.dnd
},
nowInMinutes () {
return this.utils.date.dateToMinutes(this.vuecal.now)
},
isBeforeMinDate () {
return this.minTimestamp !== null && this.minTimestamp > this.data.endDate.getTime()
},
isAfterMaxDate () {
return this.maxTimestamp && this.maxTimestamp < this.data.startDate.getTime()
},
// Is the current cell disabled or not.
isDisabled () {
const { disableDays } = this.options
const { isYearsOrYearView } = this.vuecal
if (disableDays.length && disableDays.includes(this.data.formattedDate) && !isYearsOrYearView) return true
return this.isBeforeMinDate || this.isAfterMaxDate
},
// Is the current cell selected or not.
isSelected: {
get () {
let selected = false
const { selectedDate } = this.view
if (this.view.id === 'years') {
selected = selectedDate.getFullYear() === this.data.startDate.getFullYear()
}
else if (this.view.id === 'year') {
selected = (selectedDate.getFullYear() === this.data.startDate.getFullYear() &&
selectedDate.getMonth() === this.data.startDate.getMonth())
}
else selected = selectedDate.getTime() === this.data.startDate.getTime()
return selected
},
set (date) {
this.view.selectedDate = date
this.vuecal.$emit('update:selected-date', this.view.selectedDate)
}
},
// Cache result for performance.
isWeekOrDayView () {
return ['week', 'day'].includes(this.view.id)
},
transitionDirection () {
return this.vuecal.transitionDirection
},
specialHours () {
// this.data.specialHours is always an array, but may be empty.
return this.data.specialHours.map(block => {
let { from, to } = block
from = Math.max(from, this.options.timeFrom)
to = Math.min(to, this.options.timeTo)
return {
...block,
height: (to - from) * this.timeScale,
top: (from - this.options.timeFrom) * this.timeScale
}
})
},
events () {
const { startDate: cellStart, endDate: cellEnd } = this.data
let events = []
// Calculate events on month/week/day views or years/year if eventsCountOnYearView.
if (!(['years', 'year'].includes(this.view.id) && !this.options.eventsCountOnYearView)) {
// Means that when vuecal.view.events changes all the cells will be looking up new value. :/
// Also clone array to prevent modifying original.
events = this.view.events.slice(0)
if (this.view.id === 'month') {
events.push(...this.view.outOfScopeEvents)
}
// Only keep events in cell time range.
events = events.filter(e => this.utils.event.eventInRange(e, cellStart, cellEnd))
if (this.options.showAllDayEvents && this.view.id !== 'month') events = events.filter(e => !!e.allDay === this.allDay)
// From events in view, filter the ones that are out of `time-from`-`time-to` range in this cell.
if (this.options.time && this.isWeekOrDayView && !this.allDay) {
const { timeFrom, timeTo } = this.options
events = events.filter(e => {
const segment = (e.daysCount > 1 && e.segments[this.data.formattedDate]) || {}
const singleDayInRange = e.daysCount === 1 && e.startTimeMinutes < timeTo && e.endTimeMinutes > timeFrom
const multipleDayInRange = e.daysCount > 1 && (segment.startTimeMinutes < timeTo && segment.endTimeMinutes > timeFrom)
const recurrMultDayInRange = false // e.daysCount > 1 && e.repeat && recurringEventInRange(e, cellStart, cellEnd)
return (e.allDay || singleDayInRange || multipleDayInRange || recurrMultDayInRange)
})
}
// Position events with time in the timeline when there is a timeline and not in allDay slot.
if (this.options.time && this.isWeekOrDayView && !(this.options.showAllDayEvents && this.allDay)) {
// Sort events in chronological order.
events.sort((a, b) => a.start < b.start ? -1 : 1)
}
// If splits, checkCellOverlappingEvents() is called from within computed splits.
if (!this.cellSplits.length) this.$nextTick(this.checkCellOverlappingEvents)
}
return events
},
eventsCount () {
return this.events.length
},
splits () {
return this.cellSplits.map((item, i) => {
const events = this.events.filter(e => e.split === item.id)
const [overlaps, streak] = this.utils.event.checkCellOverlappingEvents(events.filter(e => !e.background && !e.allDay), this.options)
return {
...item,
overlaps,
overlapsStreak: streak,
events
}
})
},
splitsCount () {
return this.splits.length
},
cellClasses () {
return {
[this.data.class]: !!this.data.class,
'vuecal__cell--current': this.data.current, // E.g. Current year in years view.
'vuecal__cell--today': this.data.today,
'vuecal__cell--out-of-scope': this.data.outOfScope,
'vuecal__cell--before-min': this.isDisabled && this.isBeforeMinDate,
'vuecal__cell--after-max': this.isDisabled && this.isAfterMaxDate,
'vuecal__cell--disabled': this.isDisabled,
'vuecal__cell--selected': this.isSelected,
'vuecal__cell--highlighted': this.highlighted,
'vuecal__cell--has-splits': this.splitsCount,
'vuecal__cell--has-events': this.eventsCount
}
},
cellStyles () {
return {
// cellWidth is only applied when hiding weekdays on month and week views.
...(this.cellWidth ? { width: `${this.cellWidth}%` } : {})
}
},
timelineVisible () {
const { time, timeTo } = this.options
return this.data.today && this.isWeekOrDayView && time && !this.allDay && this.nowInMinutes <= timeTo
},
todaysTimePosition () {
// Skip the Maths if not relevant.
if (!this.data.today || !this.options.time) return
const minutesFromTop = this.nowInMinutes - this.options.timeFrom
return Math.round(minutesFromTop * this.timeScale)
},
timeScale () {
return this.options.timeCellHeight / this.options.timeStep
}
}
}
</script>
<style lang="scss">
.vuecal__cell {
position: relative;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
transition: 0.15s ease-in-out background-color;
// Cell modifiers.
// -------------------------------------------------
.vuecal__cells.month-view &,
.vuecal__cells.week-view & {
width: 14.2857%;
}
.vuecal--hide-weekends .vuecal__cells.month-view &,
.vuecal--hide-weekends .vuecal__cells.week-view & {
width: 20%;
}
.vuecal__cells.years-view & {width: 20%;}
.vuecal__cells.year-view & {width: 33.33%;}
.vuecal__cells.day-view & {flex: 1;}
.vuecal--overflow-x.vuecal--day-view & {width: auto;}
.vuecal--click-to-navigate &:not(&--disabled) {cursor: pointer;}
.vuecal--view-with-time &,
.vuecal--week-view.vuecal--no-time &:not(.vuecal__cell--has-splits),
.vuecal--day-view.vuecal--no-time &:not(.vuecal__cell--has-splits) {display: block;}
&.vuecal__cell--has-splits {
flex-direction: row;
display: flex;
}
&:before {
content: '';
position: absolute;
z-index: 0;
top: 0;
left: 0;
right: -1px;
bottom: -1px;
border: 1px solid rgba(196, 196, 196, 0.25);
}
.vuecal--overflow-x.vuecal--day-view &:before {bottom: 0;}
&--today,
&--current {
background-color: rgba(240, 240, 255, 0.4);
z-index: 1;
}
&--selected {
background-color: rgba(235, 255, 245, 0.4);
z-index: 2;
.vuecal--day-view & {background: none;}
}
&--out-of-scope {color: rgba(0, 0, 0, 0.25);}
&--disabled {color: rgba(0, 0, 0, 0.25);cursor: not-allowed;}
// Cells/splits get highlighted when dragging an event over it.
&--highlighted:not(.vuecal__cell--has-splits), &-split.vuecal__cell-split--highlighted {
background-color: rgba(0, 0, 0, 0.04);
// Drag over feedback must be fast. Then it can fade away with longer duration.
transition-duration: 5ms;
}
// -------------------------------------------------
&-content {
position: relative;
width: 100%;
height: 100%;
outline: none;
.vuecal--years-view &,
.vuecal--year-view &,
.vuecal--month-view & {justify-content: center;}
}
.cell-time-labels {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
}
.cell-time-label {
flex-grow: 1;
font-size: 0.8em;
opacity: 0.3;
line-height: 1.7;
}
// .cell-time-label:hover {opacity: 0.7;}
&-split {
display: flex;
flex-grow: 1;
flex-direction: column;
height: 100%;
position: relative;
transition: 0.15s ease-in-out background-color;
}
&-events {width: 100%;}
&-events-count {
position: absolute;
left: 50%;
top: 65%;
transform: translateX(-50%);
min-width: 12px;
height: 12px;
line-height: 12px;
padding: 0 3px;
background: #999;
color: #fff;
border-radius: 12px;
font-size: 10px;
box-sizing: border-box;
}
.vuecal__special-hours {
position: absolute;
left: 0;
right: 0;
box-sizing: border-box;
}
}
.vuecal--overflow-x.vuecal--week-view .vuecal__cell, .vuecal__cell-split {
overflow: hidden;
}
.vuecal__no-event {
padding-top: 1em;
color: #aaa;
justify-self: flex-start;
margin-bottom: auto; // Vertical align top within flex column and align center.
}
.vuecal__all-day .vuecal__no-event {display: none;}
.vuecal__now-line {
position: absolute;
left: 0;
width: 100%;
height: 0;
color: red;
border-top: 1px solid currentColor;
opacity: 0.6;
z-index: 1;
&:before {
content: "";
position: absolute;
top: -6px;
left: 0;
border: 5px solid transparent;
border-left-color: currentColor;
}
}
</style>
@@ -0,0 +1,426 @@
<template lang="pug">
.vuecal__event(
:class="eventClasses"
:style="eventStyles"
tabindex="0"
@focus="focusEvent"
@keypress.enter.stop="onEnterKeypress"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
@touchstart.stop="onTouchStart"
@mousedown="onMouseDown($event) /* Don't stop mousedown propagation. See in onMouseDown(). */"
@mouseup="onMouseUp"
@touchend="onMouseUp"
@touchmove="onTouchMove"
@dblclick="onDblClick"
:draggable="draggable"
@dragstart="draggable && onDragStart($event)"
@dragend="draggable && onDragEnd()")
.vuecal__event-delete(
v-if="vuecal.editEvents.delete && event.deletable"
@click.stop="deleteEvent"
@touchstart.stop="touchDeleteEvent") {{ vuecal.texts.deleteEvent }}
slot(name="event" :event="event" :view="view.id")
//- Force contenteditable="false" for new events without content.
.vuecal__event-resize-handle(
v-if="resizable"
contenteditable="false"
@mousedown.stop.prevent="onResizeHandleMouseDown"
@touchstart.stop.prevent="onResizeHandleMouseDown")
</template>
<script>
export default {
inject: ['vuecal', 'utils', 'modules', 'view', 'domEvents', 'editEvents'],
props: {
cellFormattedDate: { type: String, default: '' },
event: { type: Object, default: () => ({}) },
cellEvents: { type: Array, default: () => [] },
overlaps: { type: Array, default: () => [] },
// If multiple simultaneous events, the events are placed from left to right from the
// one starting first to the last. (See utils/event.js > checkCellOverlappingEvents)
eventPosition: { type: Number, default: 0 },
overlapsStreak: { type: Number, default: 0 },
allDay: { type: Boolean, default: false } // Is the event displayed in the all-day bar.
},
data: () => ({
// Event touch detection with 30px threshold.
touch: {
dragThreshold: 30, // px.
startX: 0,
startY: 0,
// Detect if the event touch start + touch end was a drag or a tap.
// If it was a drag, don't call the event-click handler.
dragged: false
}
}),
methods: {
/**
* On event mousedown.
* Do not prevent propagation to trigger cell mousedown which highlights the cell if not highlighted.
*/
onMouseDown (e, touch = false) {
// Prevent a double mouse down on touch devices.
if ('ontouchstart' in window && !touch) return false
const { clickHoldAnEvent, focusAnEvent, resizeAnEvent, dragAnEvent } = this.domEvents
// If the delete button is already out and event is on focus then delete event.
// Return true so the event-click function (if any) is not called.
if (focusAnEvent._eid === this.event._eid && clickHoldAnEvent._eid === this.event._eid) {
return true
}
// Focus the clicked event.
this.focusEvent()
clickHoldAnEvent._eid = null // Reinit click hold on each click.
// Show event delete button.
if (this.vuecal.editEvents.delete && this.event.deletable) {
clickHoldAnEvent.timeoutId = setTimeout(() => {
if (!resizeAnEvent._eid && !dragAnEvent._eid) {
clickHoldAnEvent._eid = this.event._eid
this.event.deleting = true
}
}, clickHoldAnEvent.timeout)
}
},
/**
* The mouseup handler is global (whole document) and initialized in index.vue on mounted.
* It handles the mouseup on cell, events, and everything.
* All mouseup on event, should be put there to avoid conflicts with other cases.
* This function is also called on touchend on the event.
*/
onMouseUp (e) {
// Don't allow mouseup to be fired on different event than mousedown for the onEventClick function.
if (this.domEvents.focusAnEvent._eid === this.event._eid && !this.touch.dragged) {
// This is used in the global mouseup handler.
this.domEvents.focusAnEvent.mousedUp = true
}
this.touch.dragged = false // After the touchend happens, reset the dragged flag.
},
onMouseEnter (e) {
e.preventDefault()
this.vuecal.emitWithEvent('event-mouse-enter', this.event)
},
onMouseLeave (e) {
e.preventDefault()
this.vuecal.emitWithEvent('event-mouse-leave', this.event)
},
// Detect if user taps on an event or drags it. If dragging, don't fire the event-click handler (if any).
onTouchMove (e) {
// Skip the maths if there is no event click handler.
if (typeof this.vuecal.onEventClick !== 'function') return
const { clientX, clientY } = e.touches[0]
const { startX, startY, dragThreshold } = this.touch
if (Math.abs(clientX - startX) > dragThreshold || Math.abs(clientY - startY) > dragThreshold) {
this.touch.dragged = true
}
},
onTouchStart (e) {
this.touch.startX = e.touches[0].clientX
this.touch.startY = e.touches[0].clientY
this.onMouseDown(e, true)
},
onEnterKeypress (e) {
if (typeof this.vuecal.onEventClick === 'function') return this.vuecal.onEventClick(this.event, e)
},
onDblClick (e) {
if (typeof this.vuecal.onEventDblclick === 'function') return this.vuecal.onEventDblclick(this.event, e)
},
onDragStart (e) {
this.dnd && this.dnd.eventDragStart(e, this.event)
},
onDragEnd () {
this.dnd && this.dnd.eventDragEnd(this.event)
},
onResizeHandleMouseDown () {
this.focusEvent()
this.domEvents.dragAnEvent._eid = null
this.domEvents.resizeAnEvent = Object.assign(this.domEvents.resizeAnEvent, {
_eid: this.event._eid,
start: (this.segment || this.event).start,
split: this.event.split || null,
segment: !!this.segment && this.utils.date.formatDateLite(this.segment.start),
originalEnd: new Date((this.segment || this.event).end),
originalEndTimeMinutes: this.event.endTimeMinutes
})
this.event.resizing = true
},
deleteEvent (touch = false) {
// Prevent a double mouse down on touch devices.
if ('ontouchstart' in window && !touch) return false
this.utils.event.deleteAnEvent(this.event)
},
touchDeleteEvent (event) {
this.deleteEvent(true)
},
cancelDeleteEvent () {
this.event.deleting = false
},
focusEvent () {
const { focusAnEvent } = this.domEvents
const focusedEvent = focusAnEvent._eid
// If event is already focus cancel refocusing.
if (focusedEvent === this.event._eid) return
// Unfocus previous event if any.
else if (focusedEvent) {
const event = this.view.events.find(e => e._eid === focusedEvent)
if (event) event.focused = false
}
// Cancel delete on previous event if any.
this.vuecal.cancelDelete()
this.vuecal.emitWithEvent('event-focus', this.event)
focusAnEvent._eid = this.event._eid
this.event.focused = true
}
},
computed: {
eventDimensions () {
const { startTimeMinutes, endTimeMinutes } = this.segment || this.event
// Top of event.
let minutesFromTop = startTimeMinutes - this.vuecal.timeFrom
const top = Math.max(Math.round(minutesFromTop * this.vuecal.timeCellHeight / this.vuecal.timeStep), 0)
// Bottom of event.
minutesFromTop = Math.min(endTimeMinutes, this.vuecal.timeTo) - this.vuecal.timeFrom
const bottom = Math.round(minutesFromTop * this.vuecal.timeCellHeight / this.vuecal.timeStep)
const height = Math.max(bottom - top, 5) // Min height is 5px.
return { top, height }
},
eventStyles () {
if (this.event.allDay || !this.vuecal.time || !this.event.endTimeMinutes || this.view.id === 'month' || this.allDay) return {}
let width = 100 / Math.min(this.overlaps.length + 1, this.overlapsStreak)
let left = (100 / (this.overlaps.length + 1)) * this.eventPosition
if (this.vuecal.minEventWidth && width < this.vuecal.minEventWidth) {
width = this.vuecal.minEventWidth
left = ((100 - this.vuecal.minEventWidth) / this.overlaps.length) * this.eventPosition
}
const { top, height } = this.eventDimensions
return {
top: `${top}px`,
height: `${height}px`,
width: `${width}%`,
left: (this.event.left && `${this.event.left}px`) || `${left}%`
}
},
// Don't rely on global variables otherwise whenever it would change all the events would be redrawn.
eventClasses () {
const { isFirstDay, isLastDay } = this.segment || {}
return {
[this.event.class]: !!this.event.class,
'vuecal__event--focus': this.event.focused,
'vuecal__event--resizing': this.event.resizing,
'vuecal__event--background': this.event.background,
'vuecal__event--deletable': this.event.deleting,
'vuecal__event--all-day': this.event.allDay,
// Only apply the dragging class on the event copy that is being dragged.
'vuecal__event--dragging': !this.event.draggingStatic && this.event.dragging,
// Only apply the static class on the event original that remains static while a copy is being dragged.
// Sometimes when dragging fast the static class would get stuck and events stays invisible,
// So if dragging is false disable the static class as well.
'vuecal__event--static': this.event.dragging && this.event.draggingStatic,
// Multiple days events.
'vuecal__event--multiple-days': !!this.segment,
'event-start': this.segment && isFirstDay && !isLastDay,
'event-middle': this.segment && !isFirstDay && !isLastDay,
'event-end': this.segment && isLastDay && !isFirstDay
}
},
// When multiple-day events, a segment is a portion of event spanning on 1 day.
segment () {
return (this.event.segments && this.event.segments[this.cellFormattedDate]) || null
},
draggable () {
const { draggable, background, daysCount } = this.event
return this.vuecal.editEvents.drag && draggable && !background && daysCount === 1
},
resizable () {
const { editEvents, time } = this.vuecal
return (editEvents.resize && this.event.resizable && time && !this.allDay &&
(!this.segment || (this.segment && this.segment.isLastDay)) && this.view.id !== 'month')
},
// Drag & drop module.
dnd () {
return this.modules.dnd
}
}
}
</script>
<style lang="scss">
.vuecal__event {
color: #666;
background-color: rgba(248, 248, 248, 0.8);
position: relative;
box-sizing: border-box;
left: 0;
width: 100%;
z-index: 1;
transition: box-shadow 0.3s, left 0.3s, width 0.3s;
overflow: hidden;// For sliding delete button.
// If nothing is shown inside, still make the event visible.
.vuecal--no-time & {min-height: 8px;}
.vuecal:not(.vuecal--dragging-event) &:hover {z-index: 2;}
// Reactivate user selection in events.
.vuecal__cell & * {user-select: text;}
.vuecal--view-with-time &:not(&--all-day) {position: absolute;}
.vuecal--view-with-time .vuecal__bg &--all-day {
position: absolute;
top: 0;
bottom: 0;
z-index: 0;
opacity: 0.6;
width: auto;
right: 0;
}
.vuecal--view-with-time .vuecal__all-day &--all-day {
position: relative;
left: 0;
}
&--background {z-index: 0;}
&--focus, &:focus {box-shadow: 1px 1px 6px rgba(0,0,0,0.2);z-index: 3;outline: none;}
&.vuecal__event--dragging {opacity: 0.7;}
&.vuecal__event--static {opacity: 0;transition: opacity 0.1s;}
}
// Firefox sets a half opacity already, so don't dim the element being dragged.
@-moz-document url-prefix() {
.vuecal__event.vuecal__event--dragging {opacity: 1;}
}
.vuecal__event-resize-handle {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1em;
background-color: rgba(255, 255, 255, 0.3);
opacity: 0;
transform: translateY(110%);
transition: 0.3s;
cursor: ns-resize;
.vuecal__event:hover &,
.vuecal__event:focus &,
.vuecal__event--focus &,
.vuecal__event--resizing & {opacity: 1;transform: translateY(0);}
.vuecal__event--dragging & {display: none;}
}
.vuecal__event-delete {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 1.4em;
line-height: 1.4em;
background-color: rgba(221, 51, 51, 0.85);
color: #fff;
z-index: 0;
cursor: pointer;
transform: translateY(-110%);
transition: 0.3s;
.vuecal__event & {user-select: none;}
.vuecal--full-height-delete & {
height: auto;
bottom: 0;
&:before {
content: '';
width: 1.7em;
height: 1.8em;
display: block;
background-image: url('data:image/svg+xml;utf8,<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M12 1.5a10.5 10.5 0 100 21 10.5 10.5 0 000-21zm5 14.1c.2 0 .2.2.2.2l-.1.3-1 1-.3.1h-.2L12 13.5l-3.5 3.6h-.3-.3l-1-1v-.4-.2l3.6-3.6-3.6-3.5A.4.4 0 017 8l1-1 .3-.2c.1 0 .2 0 .2.2l3.6 3.5L15.6 7l.2-.2c.1 0 .2 0 .3.2l1 1v.5L13.5 12z" fill="%23fff" opacity=".9"/></svg>');
}
}
.vuecal__event--deletable & {transform: translateY(0);z-index: 1;}
.vuecal__event--deletable.vuecal__event--dragging & {
opacity: 0;
transition: none;
}
}
.vuecal--month-view .vuecal__event-title {
font-size: 0.85em;
}
.vuecal--short-events .vuecal__event-title {
text-align: left;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0 3px;
}
.vuecal__event-title,
.vuecal__event-content {
hyphens: auto;
}
.vuecal__event-title--edit {
border-bottom: 1px solid transparent;
text-align: center;
transition: 0.3s;
color: inherit;
background-image: url('data:image/svg+xml;utf8,<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M442 150l-39 39-80-80 39-39q6-6 15-6t15 6l50 50q6 6 6 15t-6 15zM64 368l236-236 80 80-236 236H64v-80z" fill="%23000" opacity=".4"/></svg>');
background-repeat: no-repeat;
background-position: 120% 0.15em;
background-size: 0.4em;
outline: none;
width: 100%;
&:hover, &:focus {
border-color: rgba(0, 0, 0, 0.4);
background-position: 99% 0.15em;
background-size: 1.2em;
}
}
</style>
@@ -0,0 +1,284 @@
<template lang="pug">
.vuecal__header
.vuecal__flex.vuecal__menu(
v-if="!options.hideViewSelector"
role="tablist"
aria-label="Calendar views navigation")
template(v-for="(v, id) in viewProps.views" :key="id")
button.vuecal__view-btn(
v-if="v.enabled"
type="button"
:class="{ 'vuecal__view-btn--active': view.id === id, 'vuecal__view-btn--highlighted': highlightedControl === id }"
@dragenter="editEvents.drag && dnd && dnd.viewSelectorDragEnter($event, id, $data)"
@dragleave="editEvents.drag && dnd && dnd.viewSelectorDragLeave($event, id, $data)"
@click="switchView(id, null, true)"
:aria-label="`${v.label} view`") {{ v.label }}
.vuecal__title-bar(v-if="!options.hideTitleBar")
button.vuecal__arrow.vuecal__arrow--prev(
type="button"
:class="{ 'vuecal__arrow--highlighted': highlightedControl === 'previous' }"
@click="previous"
@dragenter="editEvents.drag && dnd && dnd.viewSelectorDragEnter($event, 'previous', $data)"
@dragleave="editEvents.drag && dnd && dnd.viewSelectorDragLeave($event, 'previous', $data)"
:aria-label="`Previous ${view.id}`")
slot(name="arrow-prev")
.vuecal__flex.vuecal__title(grow)
transition(:name="options.transitions ? `slide-fade--${transitionDirection}` : ''")
component(
:type="!!broaderView && 'button'"
:is="broaderView ? 'button' : 'span'"
:key="`${view.id}${view.startDate.toString()}`"
@click="!!broaderView && switchToBroaderView()"
:aria-label="!!broaderView && `Go to ${broaderView} view`")
slot(name="title")
button.vuecal__today-btn(
v-if="options.todayButton"
type="button"
:class="{ 'vuecal__today-btn--highlighted': highlightedControl === 'today' }"
@click="goToToday"
@dragenter="editEvents.drag && dnd && dnd.viewSelectorDragEnter($event, 'today', $data)"
@dragleave="editEvents.drag && dnd && dnd.viewSelectorDragLeave($event, 'today', $data)"
aria-label="Today")
slot(name="today-button")
button.vuecal__arrow.vuecal__arrow--next(
type="button"
:class="{ 'vuecal__arrow--highlighted': highlightedControl === 'next' }"
@click="next"
@dragenter="editEvents.drag && dnd && dnd.viewSelectorDragEnter($event, 'next', $data)"
@dragleave="editEvents.drag && dnd && dnd.viewSelectorDragLeave($event, 'next', $data)"
:aria-label="`Next ${view.id}`")
slot(name="arrow-next")
weekdays-headings(
v-if="viewProps.weekDaysInHeader"
:week-days="weekDays"
:transition-direction="transitionDirection"
:switch-to-narrower-view="switchToNarrowerView")
template(#weekday-heading="{ heading, view }" v-if="$slots['weekday-heading']")
slot(name="weekday-heading" :heading="heading" :view="view")
template(#split-label="{ split }" v-if="$slots['split-label']")
slot(name="split-label" :split="split" :view="view")
//- Sticky split-days headers on day view only.
transition(:name="`slide-fade--${transitionDirection}`")
.vuecal__flex.vuecal__split-days-headers(v-if="showDaySplits")
.day-split-header(v-for="(split, i) in daySplits" :key="i" :class="split.class || false")
slot(name="split-label" :split="split" :view="view.id") {{ split.label }}
</template>
<script>
import WeekdaysHeadings from './weekdays-headings.vue'
export default {
inject: ['vuecal', 'previous', 'next', 'switchView', 'updateSelectedDate', 'modules', 'view'],
components: { WeekdaysHeadings },
props: {
// Vuecal main component options (props).
options: { type: Object, default: () => ({}) },
editEvents: { type: Object, required: true },
hasSplits: { type: [Boolean, Number], default: false },
daySplits: { type: Array, default: () => [] },
viewProps: { type: Object, default: () => ({}) },
weekDays: { type: Array, default: () => [] },
switchToNarrowerView: { type: Function, default: () => {} }
},
data: () => ({
highlightedControl: null
}),
methods: {
goToToday () {
// Last midnight.
this.updateSelectedDate(new Date(new Date().setHours(0, 0, 0, 0)))
},
switchToBroaderView () {
this.transitionDirection = 'left'
if (this.broaderView) this.switchView(this.broaderView)
}
},
computed: {
transitionDirection: {
get () {
return this.vuecal.transitionDirection
},
set (direction) {
this.vuecal.transitionDirection = direction
}
},
broaderView () {
const { enabledViews } = this.vuecal
return enabledViews[enabledViews.indexOf(this.view.id) - 1]
},
showDaySplits () {
return this.view.id === 'day' && this.hasSplits && this.options.stickySplitLabels && !this.options.minSplitWidth
},
// Drag & drop module.
dnd () {
return this.modules.dnd
}
}
}
</script>
<style lang="scss">
.vuecal {
&__header button {
outline: none;
font-family: inherit;
}
&__menu {
padding: 0;
margin: 0;
list-style-type: none;
justify-content: center;
background-color: rgba(0, 0, 0, 0.02);
}
&__view-btn {
background: none;
border: none;
padding: 0.3em 1em;
height: 2.2em;
font-size: 1.3em;
border-bottom: 0 solid currentColor;
cursor: pointer;
color: inherit;
box-sizing: border-box;
transition: 0.2s;
&--active {
border-bottom-width: 2px;
background: rgba(255, 255, 255, 0.15);
}
}
&__title-bar {
background-color: rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
text-align: center;
justify-content: space-between;
font-size: 1.4em;
line-height: 1.3;
min-height: 2em;
.vuecal--xsmall & {font-size: 1.3em;}
}
&__title {
position: relative;
justify-content: center;
button {
cursor: pointer;
background: none;
border: none;
}
button.slide-fade--left-leave-active,
button.slide-fade--right-leave-active {width: 100%;}
}
&__today-btn {
position: relative;
align-items: center;
display: flex;
font-size: 0.8em;
background: none;
border: none;
span.default {
font-size: 0.8em;
padding: 3px 6px;
text-transform: uppercase;
cursor: pointer;
}
}
&__arrow {
cursor: pointer;
position: relative;
z-index: 1;
background: none;
border: none;
// Non-breakable spaces are added around the default angle icon to make it larger.
white-space: nowrap;
&--prev {margin-left: 0.6em;}
&--next {margin-right: 0.6em;}
i.angle {
display: inline-block;
border: solid currentColor;
border-width: 0 2px 2px 0;
padding: 0.25em;
transform: rotate(-45deg);
}
&--prev i.angle {border-width: 2px 0 0 2px;}
}
// Pulse header buttons when dragging over with an event.
&__arrow--highlighted,
&__today-btn--highlighted,
&__view-btn--highlighted {
position: relative;
background-color: rgba(0, 0, 0, 0.04);
// Prevent event bubbling on hover and move.
* {pointer-events: none;}
&:before, &:after {
content: '';
background-color: inherit;
animation: sonar 0.8s infinite ease-out;
position: absolute;
top: 50%;
left: 50%;
pointer-events: none;
}
&:before {
width: 3em;
height: 3em;
border-radius: 3em;
margin-top: -1.5em;
margin-left: -1.5em;
}
&:after {
animation-duration: 1.5s;
animation-delay: 0.1s;
width: 2.6em;
height: 2.6em;
border-radius: 2.6em;
margin-top: -1.3em;
margin-left: -1.3em;
}
}
}
// Animations.
//==================================//
@keyframes sonar {
0%, 20% {opacity: 1;}
100% {
transform: scale(2.5);
opacity: 0;
}
}
// Media queries.
//==================================//
@media screen and (max-width: 450px) {
.vuecal__title {font-size: 0.9em;}
.vuecal__view-btn {padding-left: 0.6em;padding-right: 0.6em;}
}
@media screen and (max-width: 350px) {
.vuecal__view-btn {font-size: 1.1em;}
}
</style>
@@ -0,0 +1,15 @@
{
"weekDays": ["الإثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة", "السبت", "الأحد"],
"months": ["يناير" , "فبراير" , "مارس" , "أبريل" , "مايو" , "يونيو" , "يوليو" , "أغسطس" , "سبتمبر" , "أكتوبر" , "نوفمبر" , " ديسمبر"],
"years": "سنوات",
"year": "سنة",
"month": "شهر",
"week": "أسبوع",
"day": "يوم",
"today": "اليوم",
"noEvent": "لا حدث",
"allDay": "طوال اليوم",
"deleteEvent": "حذف",
"createEvent": "إنشاء حدث",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Понеделник", "Вторник", "Сряда", "Четвъртък", "Петък", "Събота", "Неделя"],
"months": ["Януари", "Февруари", "Март", "Април", "Май", "Юни", "Юли", "Август", "Септември", "Октомври", "Ноември", "Декември"],
"years": "Години",
"year": "Година",
"month": "Месец",
"week": "Седмица",
"day": "Ден",
"today": "Днес",
"noEvent": "Няма събития",
"allDay": "Цял ден",
"deleteEvent": "Изтрий",
"createEvent": "Създай събитие",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["সোম", "মঙ্গল", "বুধ", "বৃহস্পতি", "শুক্র" , "শনি" , "রবি"],
"months": ["জানুয়ারি" , "ফেব্ুয়ারী" , "মার্চ", "এপ্রিল" , "মে" , "জুন" , "জুলাই" , "অগাস্ট" , "সেপ্টেম্বর" , "অক্টোবর", "নভেম্বর" , "ডিসেম্বর"],
"years": "বছর",
"year": "বছর",
"month": "মাস",
"week": "সপ্তাহ",
"day": "দিন",
"today": "আজ",
"noEvent": "কার্যসূচী",
"allDay": "সারাদিন",
"deleteEvent": "মুছুন",
"createEvent": "কার্যসূচী তৈরি করুন",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Ponedjeljak", "Utorak", "Srijeda", "Četvrtak", "Petak", "Subota", "Nedjelja"],
"months": ["Januar", "Februar", "Mart", "April", "Maj", "Jun", "Jul", "Avgust", "Septembar", "Oktobar", "Novembar", "Decembar"],
"years": "Godine",
"year": "Godina",
"month": "Mjesec",
"week": "Sedmica",
"day": "Dan",
"today": "Danas",
"noEvent": "Nema događaja",
"allDay": "Cijeli dan",
"deleteEvent": "Obriši",
"createEvent": "Kreiraj događaj",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,16 @@
{
"weekDays": ["Dilluns", "Dimarts", "Dimecres", "Dijous", "Divendres", "Dissabte", "Diumenge"],
"weekDaysShort": ["Dl", "Dt", "Dc", "Dj", "Dv", "Ds", "Dg"],
"months": ["Gener", "Febrer", "Març", "Abril", "Maig", "Juny", "Juliol", "Agost", "Setembre", "Octubre", "Novembre", "Desembre"],
"years": "Anys",
"year": "Any",
"month": "Mes",
"week": "Setmana",
"day": "Dia",
"today": "Avui",
"noEvent": "No hi ha esdeveniments",
"allDay": "Tot el dia",
"deleteEvent": "Eliminar",
"createEvent": "Crear un esdeveniment",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Pondělí", "Úterý", "Středa", "Čtvrtek", "Pátek", "Sobota", "Neděle"],
"months": ["Leden", "Únor", "Březen", "Duben", "Květen", "Červen", "Červenec", "Srpen", "Září", "Říjen", "Listopad", "Prosinec"],
"years": "Roky",
"year": "Rok",
"month": "Měsíc",
"week": "Týden",
"day": "Den",
"today": "Dnes",
"noEvent": "Bez událostí",
"allDay": "Celý den",
"deleteEvent": "Odstranit",
"createEvent": "Vytvořit událost",
"dateFormat": "dddd D. MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag", "Søndag"],
"months": ["Januar", "Februar", "Marts", "April", "Maj", "Juni", "Juli", "August", "September", "Oktober", "November", "December"],
"years": "År (flertal)",
"year": "År",
"month": "Måned",
"week": "Uge",
"day": "Dag",
"today": "I dag",
"noEvent": "Ingen begivenhed",
"allDay": "Hele dagen",
"deleteEvent": "Slet",
"createEvent": "Opret et event",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"],
"months": ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"],
"years": "Jahre",
"year": "Jahr",
"month": "Monat",
"week": "Woche",
"day": "Tag",
"today": "Heute",
"noEvent": "Keine Events",
"allDay": "Ganztägig",
"deleteEvent": "Löschen",
"createEvent": "Event erstellen",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,18 @@
{
"weekDays": ["Δευτέρα", "Τρίτη", "Τετάρτη", "Πέμπτη", "Παρασκευή", "Σάββατο", "Κυριακή"],
"months": ["Ιανουάριος", "Φεβρουάριος", "Μάρτιος", "Απρίλιος", "Μάιος", "Ιούνιος", "Ιούλιος", "Αύγουστος", "Σεπτέμβριος", "Οκτώβριος", "Νοέμβριος", "Δεκέμβριος"],
"monthsGenitive": ["Ιανουαρίου", "Φεβρουαρίου", "Μαρτίου", "Απριλίου", "Μαΐου", "Ιουνίου", "Ιουλίου", "Αυγούστου", "Σεπτεμβρίου", "Οκτωβρίου", "Νοεμβρίου", "Δεκεμβρίου"],
"years": "Έτη",
"year": "Έτος",
"month": "Μήνα",
"week": "Εβδομάδα",
"day": "Ημέρα",
"today": "Σήμερα",
"noEvent": "Κανένα συμβάν",
"allDay": "Ημερήσιο συμβάν",
"deleteEvent": "Διαγραφή",
"createEvent": "Δημιουργία συμβάντος",
"dateFormat": "dddd D MMMMG YYYY",
"am": "π.μ.",
"pm": "μ.μ."
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
"months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
"years": "Years",
"year": "Year",
"month": "Month",
"week": "Week",
"day": "Day",
"today": "Today",
"noEvent": "No Event",
"allDay": "All day",
"deleteEvent": "Delete",
"createEvent": "Create an event",
"dateFormat": "dddd MMMM D{S}, YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"],
"months": ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"],
"years": "Años",
"year": "Año",
"month": "Mes",
"week": "Semana",
"day": "Día",
"today": "Hoy",
"noEvent": "No hay evento",
"allDay": "Todo el día",
"deleteEvent": "Borrar",
"createEvent": "Crear un evento",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Esmaspäev", "Teisipäev", "Kolmapäev", "Neljapäev", "Reede", "Laupäev", "Pühapäev"],
"months": ["Jaanuar", "Veebruar", "Märts", "Aprill", "Mai", "Juuni", "Juuli", "August", "September", "Oktoober", "November", "Detsember"],
"years": "Aastad",
"year": "Aasta",
"month": "Kuu",
"week": "Nädal",
"day": "Päev",
"today": "Täna",
"noEvent": "Sündmus puudub",
"allDay": "Terve päev",
"deleteEvent": "Kustuta",
"createEvent": "Loo sündmus",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["دوشنبه", "سه شنبه", "چهار شنبه", "پنج شنبه", "جمعه", "شنبه", "یک شنبه"],
"months": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن", "ژوئیه", "اوت", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"],
"years": "سالها",
"year": "سال",
"month": "ماه",
"week": "هفته",
"day": "روز",
"today": "امروز",
"noEvent": "رویدادی نیست",
"allDay": "تمام روز",
"deleteEvent": "حذف",
"createEvent": "ایجاد یک رویداد",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"],
"months": ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"],
"years": "Années",
"year": "Année",
"month": "Mois",
"week": "Semaine",
"day": "Jour",
"today": "Aujourd'hui",
"noEvent": "Aucun événement",
"allDay": "Jour entier",
"deleteEvent": "Supprimer",
"createEvent": "Créer un événement",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["שני", "שלישי", "רביעי", "חמישי", "שישי", "שבת", "ראשון"],
"months": ["ינואר", "פברואר", "מרץ", "אפריל", "מאי", "יוני", "יולי", "אוגוסט", "ספטמבר", "אוקטובר", "נובמבר", "דצמבר"],
"years": "שנים",
"year": "שנה",
"month": "חודש",
"week": "שבוע",
"day": "יום",
"today": "היום",
"noEvent": "אין אירועים",
"allDay": "כל היום",
"deleteEvent": "מחיקה",
"createEvent": "צור אירוע",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Ponedjeljak", "Utorak", "Srijeda", "Četvrtak", "Petak", "Subota", "Nedjelja"],
"months": ["Siječanj", "Veljača", "Ožujak", "Travanj", "Svibanj", "Lipanj", "Srpanj", "Kolovoz", "Rujan", "Listopad", "Studeni", "Prosinac"],
"years": "Godine",
"year": "Godina",
"month": "Mjesec",
"week": "Tjedan",
"day": "Dan",
"today": "Današnji dan",
"noEvent": "Nema događaja",
"allDay": "Cijeli dan",
"deleteEvent": "Obriši",
"createEvent": "Kreiraj događaj",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Hétfo", "Kedd", "Szerda", "Csütörtök", "Péntek", "Szombat", "Vasárnap"],
"months": ["Január", "Február", "Március", "Április", "Május", "Június", "Július", "Augusztus", "Szeptember", "Október", "November", "December"],
"years": "Évek",
"year": "Év",
"month": "Hónap",
"week": "Hét",
"day": "Nap",
"today": "Mai nap",
"noEvent": "Nincs esemény",
"allDay": "Egész nap",
"deleteEvent": "Esemény törlese",
"createEvent": "Esemény létrehozása",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"],
"months": ["Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"],
"years": "Tahunan",
"year": "Tahun",
"month": "Bulan",
"week": "Minggu",
"day": "Hari",
"today": "Hari Ini",
"noEvent": "Tidak Ada Kegiatan",
"allDay": "Sepanjang Hari",
"deleteEvent": "Hapus",
"createEvent": "Tambah Kegiatan",
"dateFormat": "dddd, D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Mánudagur", "Þriðjudagur", "Miðvikudagur", "Fimmtudagur", "Föstudagur", "Laugardagur", "Sunnudagur"],
"months": ["Janúar", "Febrúar", "Mars", "Apríl", "Maí", "Júní", "Júlí", "Ágúst", "September", "Október", "Nóvember", "Desember"],
"years": "Ár",
"year": "Ár",
"month": "Mánuður",
"week": "Vika",
"day": "Dagur",
"today": "Í dag",
"noEvent": "Enginn atburður",
"allDay": "Allan daginn",
"deleteEvent": "Eyða",
"createEvent": "Búðu til viðburð",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Lunedì", "Martedì", "Mercoledì", "Giovedì", "Venerdì", "Sabato", "Domenica"],
"months": ["Gennaio", "Febbraio", "Marzo", "Aprile", "Maggio", "Giugno", "Luglio", "Agosto", "Settembre", "Ottobre", "Novembre", "Dicembre"],
"years": "Anni",
"year": "Anno",
"month": "Mese",
"week": "Settimana",
"day": "Giorno",
"today": "Oggi",
"noEvent": "Nessun evento",
"allDay": "Tutto il giorno",
"deleteEvent": "Cancella",
"createEvent": "Crea evento",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["月", "火", "水", "木", "金", "土", "日"],
"months": ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
"years": "年",
"year": "今年",
"month": "月",
"week": "週",
"day": "日",
"today": "今日",
"noEvent": "イベントなし",
"allDay": "終日",
"deleteEvent": "削除",
"createEvent": "イベント作成",
"dateFormat": "YYYY年 MMMM D日 (dddd)"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["ორშაბათი", "სამშაბათი", "ოთხშაბათი", "ხუთშაბათი", "პარასკევი", "შაბათი", "კვირა"],
"months": ["იანვარი", "თებერვალი", "მარტი", "აპრილი", "მაისი", "ივნისი", "ივლისი", "აგვისტო", "სექტემბერი", "ოქტომბერი", "ნოემბერი", "დეკემბერი"],
"years": "წლები",
"year": "წელი",
"month": "თვე",
"week": "კვირა",
"day": "დღე",
"today": "დღეს",
"noEvent": "ღონისძიება არ არის",
"allDay": "მთელი დღე",
"deleteEvent": "წაშლა",
"createEvent": "შექმენით ღონისძიება",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["월", "화", "수", "목", "금", "토", "일"],
"months": ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"],
"years": "년도",
"year": "연간",
"month": "월간",
"week": "주간",
"day": "일간",
"today": "오늘",
"noEvent": "일정 없음",
"allDay": "하루 종일",
"deleteEvent": "삭제",
"createEvent": "일정 추가",
"dateFormat": "YYYY년 MMMM D일 dddd요일"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Pirmadienis", "Antradienis", "Trečiadienis", "Ketvirtadienis", "Penktadienis", "Šeštadienis", "Sekmadienis"],
"months": ["Sausis", "Vasaris", "Kovas", "Balandis", "Gegužė", "Birželis", "Liepa", "Rugpjūtis", "Rugsėjis", "Spalis", "Lapkritis", "Gruodis"],
"years": "Metų pasirinkimas",
"year": "Metai",
"month": "Mėnesis",
"week": "Savaitė",
"day": "Diena",
"today": "Šiandien",
"noEvent": "Jokių įvykių",
"allDay": "Visa diena",
"deleteEvent": "Ištrinti",
"createEvent": "Sukurti įvykį",
"dateFormat": "dddd, D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Даваа", "Мягмар", "Лхавга", "Пүрэв", "Баасан", "Бямба", "Ням"],
"months": ["1-р сар", "2-р сар", "3-р сар", "4-р сар", "5-р сар", "6-р сар", "7-р сар", "8-р сар", "9-р сар", "10-р сар", "11-р сар", "12-р сар"],
"years": "Жилүүд",
"year": "Жил",
"month": "Сар",
"week": "Долоо хоног",
"day": "Өдөр",
"today": "Өнөөдөр",
"noEvent": "Тэмдэглэлгүй",
"allDay": "Бүх өдөр",
"deleteEvent": "Устгах",
"createEvent": "Шинэ тэмдэглэл",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Maandag", "Dinsdag", "Woensdag", "Donderdag", "Vrijdag", "Zaterdag", "Zondag"],
"months": ["Januari", "Februari", "Maart", "April", "Mei", "Juni", "Juli", "Augustus", "September", "Oktober", "November", "December"],
"years": "Jaren",
"year": "Jaar",
"month": "Maand",
"week": "Week",
"day": "Dag",
"today": "Vandaag",
"noEvent": "Geen afspraken",
"allDay": "Hele dag",
"deleteEvent": "Verwijderen",
"createEvent": "Nieuwe afspraak aanmaken",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag", "Søndag"],
"months": ["Januar", "Februar", "Mars", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Desember"],
"years": "Velg år",
"year": "År",
"month": "Måned",
"week": "Uke",
"day": "Dag",
"today": "Idag",
"noEvent": "Ingen hendelse",
"allDay": "Hele dagen",
"deleteEvent": "Ta bort",
"createEvent": "Ny hendelse",
"dateFormat": "dddd, D. MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Poniedziałek", "Wtorek", "Środa", "Czwartek", "Piątek", "Sobota", "Niedziela"],
"months": ["Styczeń", "Luty", "Marzec", "Kwiecień", "Maj", "Czerwiec", "Lipiec", "Sierpień", "Wrzesień", "Październik", "Listopad", "Grudzień"],
"years": "Lata",
"year": "Rok",
"month": "Miesiąc",
"week": "Tydzień",
"day": "Dzień",
"today": "Dzisiaj",
"noEvent": "Brak wydarzeń",
"allDay": "Cały dzień",
"deleteEvent": "Usuń",
"createEvent": "Utwórz wydarzenie",
"dateFormat": "dddd, D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Segunda-feira", "Terça-feira", "Quarta-feira", "Quinta-feira", "Sexta-feira", "Sábado", "Domingo"],
"months": ["Janeiro", "Fevereiro", "Março", "Abril", "Maio", "Junho", "Julho", "Agosto", "Setembro", "Outubro", "Novembro", "Dezembro"],
"years": "Anos",
"year": "Ano",
"month": "Mês",
"week": "Semana",
"day": "Dia",
"today": "Hoje",
"noEvent": "Sem eventos",
"allDay": "Dia inteiro",
"deleteEvent": "Remover",
"createEvent": "Criar um evento",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Luni", "Marți", "Miercuri", "Joi", "Vineri", "Sâmbăta", "Duminică"],
"months": ["Ianuarie", "Februarie", "Martie", "Aprilie", "Mai", "Iunie", "Iulie", "August", "Septembrie", "Octombrie", "Noiembrie", "Decembrie"],
"years": "Ani",
"year": "An",
"month": "Lună",
"week": "Săptămână",
"day": "Zi",
"today": "Azi",
"noEvent": "Nici o interacțiune",
"allDay": "Toată ziua",
"deleteEvent": "Șterge",
"createEvent": "Adaugă un eveniment",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,16 @@
{
"weekDays": ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"],
"weekDaysShort": ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
"months": ["Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"],
"years": "Годы",
"year": "Год",
"month": "Месяц",
"week": "Неделя",
"day": "День",
"today": "Сегодня",
"noEvent": "Нет событий",
"allDay": "Весь день",
"deleteEvent": "Удалить",
"createEvent": "Создать событие",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Pondelok", "Utorok", "Streda", "Štvrtok", "Piatok", "Sobota", "Nedeľa"],
"months": ["Január", "Február", "Marec", "Apríl", "Máj", "Jún", "Júl", "August", "September", "Október", "November", "December"],
"years": "Roky",
"year": "Rok",
"month": "Mesiac",
"week": "Týždeň",
"day": "Deň",
"today": "Dnes",
"noEvent": "Bez udalosti",
"allDay": "Celý deň",
"deleteEvent": "Odstrániť",
"createEvent": "Vytvoriť udalosť",
"dateFormat": "dddd D. MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Ponedeljek", "Torek", "Sreda", "Četrtek", "Petek", "Sobota", "Nedelja"],
"months": ["Januar", "Februar", "Marec", "April", "Maj", "Junij", "Julij", "Avgust", "September", "Oktober", "November", "December"],
"years": "Leta",
"year": "Leto",
"month": "Mesec",
"week": "Teden",
"day": "Dan",
"today": "Danes",
"noEvent": "Ni dogodkov",
"allDay": "Cel dan",
"deleteEvent": "Odstrani",
"createEvent": "Ustvari dogodek",
"dateFormat": "dddd MMMM D, YYYY"
}
@@ -0,0 +1,16 @@
{
"weekDays": ["E Hënë","E Martë","E Mërkurë","E Enjte","E Premte","E Shtunë","E Diel"],
"weekDaysShort": ["Hë","Ma","Mr","Enj","Pr","Sh","D"],
"months": ["Janar","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Shtator","Tetor","Nëntor","Dhjetor"],
"years": "Vitet",
"year": "Viti",
"month": "Muaji",
"week": "Java",
"day": "Dita",
"today": "Sot",
"noEvent": "Nuk ka event",
"allDay": "Tërë ditën",
"deleteEvent": "Fshijë",
"createEvent": "Krijo një event",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Ponedeljak", "Utorak", "Sreda", "Četvrtak", "Petak", "Subota", "Nedelja"],
"months": ["Januar", "Februar", "Mart", "April", "Maj", "Jun", "Jul", "Avgust", "Septembar", "Oktobar", "Novembar", "Decembar"],
"years": "Godine",
"year": "Godina",
"month": "Mesec",
"week": "Sedmica",
"day": "Dan",
"today": "Danas",
"noEvent": "Nema događaja",
"allDay": "Celi dan",
"deleteEvent": "Obriši",
"createEvent": "Kreiraj događaj",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag", "Söndag"],
"months": ["Januari", "Februari", "Mars", "April", "Maj", "Juni", "Juli", "Augusti", "September", "Oktober", "November", "December"],
"years": "År",
"year": "År",
"month": "Månad",
"week": "Vecka",
"day": "Dag",
"today": "Idag",
"noEvent": "Ingen händelse",
"allDay": "Heldag",
"deleteEvent": "Ta bort",
"createEvent": "Skapa händelse",
"dateFormat": "dddd den D MMMM YYYY"
}
@@ -0,0 +1,15 @@
{
"weekDays": ["Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi", "Pazar"],
"months": ["Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık"],
"years": "Yıllar",
"year": "Yıl",
"month": "Ay",
"week": "Hafta",
"day": "Gün",
"today": "Bugün",
"noEvent": "Etkinlik Yok",
"allDay": "Tüm gün",
"deleteEvent": "Sil",
"createEvent": "Etkinlik ekle",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,16 @@
{
"weekDays": ["Понеділок", "Вівторок", "Середа", "Четвер", "П'ятниця", "Субота", "Неділя"],
"months": ["Січень", "Лютий", "Березень", "Квітень", "Травень", "Червень", "Липень", "Серпень", "Вересень", "Жовтень", "Листопад", "Грудень"],
"weekDaysShort": ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Нд"],
"years": "Роки",
"year": "Рік",
"month": "Місяць",
"week": "Тиждень",
"day": "День",
"today": "Сьогодні",
"noEvent": "Немає подій",
"allDay": "Весь день",
"deleteEvent": "Видалити",
"createEvent": "Створити подію",
"dateFormat": "dddd D MMMM YYYY"
}
@@ -0,0 +1,16 @@
{
"weekDays": ["Thứ hai", "Thứ ba", "Thứ tư", "Thứ năm", "Thứ sáu", "Thứ bảy", "Chủ nhật"],
"weekDaysShort": ["T2", "T3", "T4", "T5", "T6", "T7", "CN"],
"months": ["Tháng 1", "Tháng 2", "Tháng 3", "Tháng 4", "Tháng 5", "Tháng 6", "Tháng 7", "Tháng 8", "Tháng 9", "Tháng 10", "Tháng 11", "Tháng 12"],
"years": "Năm",
"year": "Năm nay",
"month": "Tháng",
"week": "Tuần",
"day": "Ngày",
"today": "Hôm nay",
"noEvent": "NKhông có Event",
"allDay": "Cả ngày",
"deleteEvent": "Xóa",
"createEvent": "Tạo event",
"dateFormat": "dddd MMMM D YYYY"
}
@@ -0,0 +1,16 @@
{
"weekDays": ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"],
"weekDaysShort": ["一", "二", "三", "四", "五", "六", "日"],
"months": ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"],
"years": "年",
"year": "本年",
"month": "月",
"week": "周",
"day": "日",
"today": "今日",
"noEvent": "暂无活动",
"allDay": "整天",
"deleteEvent": "删除",
"createEvent": "新建活动",
"dateFormat": "YYYY MMMM D dddd"
}
@@ -0,0 +1,16 @@
{
"weekDays": ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"],
"weekDaysShort": ["一", "二", "三", "四", "五", "六", "日"],
"months": ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"],
"years": "年",
"year": "本年",
"month": "月",
"week": "周",
"day": "日",
"today": "今日",
"noEvent": "暫無活動",
"allDay": "整天",
"deleteEvent": "刪除",
"createEvent": "新建活動",
"dateFormat": "YYYY MMMM D dddd"
}
@@ -0,0 +1,10 @@
import { App } from 'vue'
import VueCal from './index.vue'
export default {
install: (app: App) => {
app.component('Vue-cal', VueCal)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,344 @@
/**
* Events drag and drop module.
*
* Note: Waiting for VS Code to support JavaScript private fields.
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Class_fields
* Meantime keep `_` for private.
*/
const holdOverTimeout = 800 // How long we should hold over an element before it reacts.
let changeViewTimeout = null
let pressPrevOrNextInterval = null
let viewBeforeDrag = { id: null, date: null } // To go back if cancelling.
let viewChanged = false
let cancelViewChange = true
let dragOverCell = { el: null, cell: null, timeout: null }
const dragging = {
_eid: null,
fromVueCal: null,
toVueCal: null
}
export const DragAndDrop = class {
_vuecal
constructor (vuecal) {
this._vuecal = vuecal
}
/**
* When click and drag an event the cursor can be anywhere in the event,
* when dropping the event, we need to subtract the cursor position in the event.
*
* @param {Object} e The associated DOM event.
*/
_getEventStart (e) {
const { timeStep, timeCellHeight, timeFrom, utils } = this._vuecal
let { y } = utils.cell.getPosition(e)
y -= e.dataTransfer.getData('cursor-grab-at') * 1
return Math.round(y * timeStep / parseInt(timeCellHeight) + timeFrom)
}
/**
* On drop, update the event start and end date directly into the event.
*
* @param {Object} e The associated DOM event.
* @param {Object} event The event being dragged.
* @param {Object} transferData The transfer data from the HTML5 dragging event.
* @param {Date} cellDate The hovered cell starting date.
*/
_updateEventStartEnd (e, event, transferData, cellDate) {
// If no duration calculate it from event.endTimeMinutes - event.startTimeMinutes
// before we modify the start and end.
const eventDuration = transferData.duration * 1 || (event.endTimeMinutes - event.startTimeMinutes)
// Force the start of the event at previous midnight minimum.
let startTimeMinutes = Math.max(this._getEventStart(e), 0)
// On drop, snap to time every X minutes if the option is on.
if (this._vuecal.snapToTime) {
const plusHalfSnapTime = (startTimeMinutes + this._vuecal.snapToTime / 2)
startTimeMinutes = plusHalfSnapTime - (plusHalfSnapTime % this._vuecal.snapToTime)
}
event.startTimeMinutes = startTimeMinutes
event.start = new Date(new Date(cellDate).setMinutes(startTimeMinutes))
// Force the end of the event at next midnight maximum.
event.endTimeMinutes = Math.min(startTimeMinutes + eventDuration, 24 * 60)
event.end = new Date(new Date(cellDate).setMinutes(event.endTimeMinutes))
}
/**
* On event drag start, only possible if editableEvent is true.
* /!\ This is using the native HTML5 drag & drop, not supported on touch devices.
*
* @param {Object} e The associated DOM event.
* @param {Object} event The event being dragged.
*/
eventDragStart (e, event) {
// Cancel the drag if event has draggable set to false and trying to drag a text selection.
if (e.target.nodeType === 3) return e.preventDefault()
e.dataTransfer.dropEffect = 'move'
// Transfer the event's data to the receiver (when successfully drag & dropping out of Vue Cal).
// Notice: in Firefox the drag is prevented if there is no dataTransfer.setData().
e.dataTransfer.setData('event', JSON.stringify(event))
// When click and drag an event the cursor can be anywhere in the event,
// when later dropping the event, we need to subtract the cursor position in the event.
e.dataTransfer.setData('cursor-grab-at', e.offsetY) // In pixels.
const { clickHoldAnEvent } = this._vuecal.domEvents
// Cancel any delete on dragStart (if held for too long). Don't drag an event with a visible delete button.
setTimeout(() => {
clickHoldAnEvent._eid = null
clearTimeout(clickHoldAnEvent.timeoutId)
event.deleting = false
}, 0)
this._vuecal.domEvents.dragAnEvent._eid = event._eid // Only for vuecal dragging-event class.
dragging._eid = event._eid
dragging.fromVueCal = this._vuecal._.uid
event.dragging = true
// Controls the CSS class of the static event that remains while a copy is being dragged.
// Thanks to this class, the event being dragged can have a different style.
setTimeout(() => (event.draggingStatic = true), 0)
viewChanged = false
viewBeforeDrag = { id: this._vuecal.view.id, date: this._vuecal.view.startDate }
cancelViewChange = true // Re-init the cancel view: should cancel unless a cell received the event.
}
/**
* On event drag end, when releasing the event.
*
* @param {Object} event The event being dragged.
*/
eventDragEnd (event) {
this._vuecal.domEvents.dragAnEvent._eid = null
dragging._eid = null
event.dragging = false
event.draggingStatic = false
// If an event is dragged from a Vue Cal instance and dropped in a different one, remove the
// event from the first one.
const { fromVueCal, toVueCal } = dragging
if (toVueCal && fromVueCal !== toVueCal) this._vuecal.utils.event.deleteAnEvent(event)
dragging.fromVueCal = null
dragging.toVueCal = null
// When dropping the event, cancel view change if no cell received the event (in cellDragDrop).
if (viewChanged && cancelViewChange && viewBeforeDrag.id) this._vuecal.switchView(viewBeforeDrag.id, viewBeforeDrag.date, true)
}
/**
* On cell/split enter with a dragging event.
* Highlight the cell, and if on `years`, `year`, `month` view,
* set a timer to go deeper on drag hold over this cell.
*
* @param {Object} e The associated DOM event.
* @param {Object} cell The cell component's $data.
* @param {Date} cellDate The hovered cell starting date.
*/
cellDragEnter (e, cell, cellDate) {
const target = e.currentTarget
// Cancel dragEnter event if hovering a child.
if (e.currentTarget.contains(e.relatedTarget)) return
if (target === dragOverCell.el || !target.className.includes('vuecal__cell-content')) return false
// Un-highlight the previous cell.
if (dragOverCell.el) dragOverCell.cell.highlighted = false
dragOverCell = { el: target, cell, timeout: clearTimeout(dragOverCell.timeout) }
cell.highlighted = true
// On `years`, `year` & `month` views, go to narrower view on drag and hold.
if (['years', 'year', 'month'].includes(this._vuecal.view.id)) {
dragOverCell.timeout = setTimeout(() => this._vuecal.switchToNarrowerView(cellDate), 2000)
}
}
/**
* On cell/split drag over, highlight the cell being hovered,
* Useful when starting to drag event on the same cell/split it's in.
* Warning: This is fired repeatedly as long as you stay over this cell/split.
*
* @param {Object} e The associated DOM event.
* @param {Object} cell The cell component's $data.
* @param {Date} cellDate The hovered cell starting date.
* @param {Number|String} split The optional split being hovered if any.
*/
cellDragOver (e, cell, cellDate, split) {
e.preventDefault()
cell.highlighted = true
if (split || split === 0) cell.highlightedSplit = split
}
/**
* When event drag leaves a cell/split.
* Remove the cell/split highlighted state.
* Warning: cell dragleave event happens AFTER another cell dragenter!
*
* @param {Object} e The associated DOM event.
* @param {Object} cell The cell component's $data.
*/
cellDragLeave (e, cell) {
e.preventDefault()
if (e.currentTarget.contains(e.relatedTarget)) return
cell.highlightedSplit = false
// Only cancel the timer if leaving the current cell to no other one.
// If leaving this cell to enter another, a cancel is done in cellDragEnter,
// and a new timer is started.
if (dragOverCell.cell === cell) {
clearTimeout(dragOverCell.timeout)
dragOverCell = { el: null, cell: null, timeout: null }
cell.highlighted = false
}
}
/**
* On successful event drop into a cell/split.
* Change the event start and end time and remove the event dragging state
* and cell/split highlighted state.
*
* @param {Object} e The associated DOM event.
* @param {Object} cell The cell component's $data.
* @param {Date} cellDate The hovered cell starting date.
* @param {Number|String} split The optional split being dropped into, if any.
*/
cellDragDrop (e, cell, cellDate, split) {
// Needed to prevent navigation to the text set in dataTransfer from eventDragStart().
e.preventDefault()
clearTimeout(dragOverCell.timeout)
dragOverCell = { el: null, cell: null, timeout: null }
const transferData = JSON.parse(e.dataTransfer.getData('event') || '{}')
let event, addToView
// If the event is not coming from this Vue Cal it means that we are accepting a new event.
// So create the event in this Vue Cal.
if (dragging.fromVueCal !== this._vuecal._.uid) {
// Removing the _eid is mandatory! It prevents the event to be duplicated when drag and
// dropping to another calendar then back to the original place.
const { _eid, start, end, duration, ...cleanTransferData } = transferData
// Note: createAnEvent adds the event to the view.
event = this._vuecal.utils.event.createAnEvent(cellDate, duration, { ...cleanTransferData, split })
}
else {
// Find the dragged event from its _eid in the view or mutableEvents array.
// If dragging the event to another day, the event is not in the view array but in the
// mutableEvents one.
event = this._vuecal.view.events.find(evt => evt._eid === dragging._eid)
if (!event) {
event = this._vuecal.mutableEvents.find(evt => evt._eid === dragging._eid)
addToView = !!event
}
// Case where events are fetched from the backend and removed from the array when not in the view.
// So it won't be found in the mutableEvents array.
if (!event) {
const duration = transferData.endTimeMinutes - transferData.startTimeMinutes
// Pass exactly the same event as it was before the view change (same _eid as well) except dates.
const { start, end, ...cleanTransferData } = transferData
event = this._vuecal.utils.event.createAnEvent(cellDate, duration, { ...cleanTransferData, split })
// Note: createAnEvent adds the event to the view.
}
}
const { start: oldDate, split: oldSplit } = event
this._updateEventStartEnd(e, event, transferData, cellDate)
// Only add the event to view after the start and end are modified otherwise
// it would be filtered out from the function if not in range.
if (addToView) this._vuecal.addEventsToView([event])
event.dragging = false
if (split || split === 0) event.split = split
cell.highlighted = false
cell.highlightedSplit = null
cancelViewChange = false
dragging.toVueCal = this._vuecal._.uid
// Emit `event-drop` & `event-change` events and return the updated event.
const params = {
event: this._vuecal.cleanupEvent(event),
oldDate,
newDate: event.start,
...((split || split === 0) && { oldSplit, newSplit: split }),
originalEvent: this._vuecal.cleanupEvent(transferData),
external: !dragging.fromVueCal // If external event, not coming from any Vue Cal.
}
this._vuecal.$emit('event-drop', params)
this._vuecal.$emit('event-change', { event: params.event, originalEvent: params.originalEvent })
// Sometimes the event dragend does not trigger (?!), so manually trigger it if it didn't.
setTimeout(() => {
if (dragging._eid) this.eventDragEnd(event)
}, 300)
}
/**
* On drag enter on a view button or on today, prev & next buttons.
* Sets a highlighted state on the hovered button, and go to requested view.
*
* @param {Object} e The associated DOM event.
* @param {String} id The id of the header element being hovered. One of:
* previous, next, today, years, year, month, week, day.
* @param {Object} headerData The header component's $data.
*/
viewSelectorDragEnter (e, id, headerData) {
if (e.currentTarget.contains(e.relatedTarget)) return
headerData.highlightedControl = id
clearTimeout(changeViewTimeout)
changeViewTimeout = setTimeout(() => {
if (['previous', 'next'].includes(id)) {
this._vuecal[id]()
// Keep pressing on previous or next button until user goes away.
clearInterval(pressPrevOrNextInterval)
pressPrevOrNextInterval = setInterval(this._vuecal[id], holdOverTimeout)
}
else if (id === 'today') {
clearInterval(pressPrevOrNextInterval)
let viewId
if (this._vuecal.view.id.includes('year')) {
// If hovering today from a year or years view go to narrower view from month view.
viewId = this._vuecal.enabledViews.filter(view => !view.includes('year'))[0]
}
this._vuecal.switchView(viewId || this._vuecal.view.id, new Date(new Date().setHours(0, 0, 0, 0)), true)
}
else this._vuecal.switchView(id, null, true)
viewChanged = true
}, holdOverTimeout)
}
/**
* On drag leave on a view button or on today, prev & next buttons.
* Removes the highlighted state on the hovered button, and cancel the timer to
* go to the requested view.
*
* @param {Object} e The associated DOM event.
* @param {String} id The id of the header element being hovered. One of:
* previous, next, today, years, year, month, week, day.
* @param {Object} headerData The header component's $data.
*/
viewSelectorDragLeave (e, id, headerData) {
if (e.currentTarget.contains(e.relatedTarget)) return
// Only cancel the timer if leaving the current nav button to no other one.
// If leaving this nav button to enter another, a cancel is done in viewSelectorDragEnter,
// and a new timer is started.
if (headerData.highlightedControl === id) {
headerData.highlightedControl = null
if (changeViewTimeout) changeViewTimeout = clearTimeout(changeViewTimeout)
if (pressPrevOrNextInterval) pressPrevOrNextInterval = clearInterval(pressPrevOrNextInterval)
}
}
}
@@ -0,0 +1,76 @@
// Global structure.
// --------------------------------------------------------
* {margin: 0;padding: 0;}
html {scroll-behavior: smooth;}
#app {overflow-x: hidden;}
.w-app {
font: 15px 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #fff !important;
color: #2c3e50;
margin-top: 8em;
padding-top: 12em;
}
.page-container {
width: 100%;
max-width: $page-max-width;
margin-left: auto;
margin-right: auto;
}
// Shared classes & styles.
// --------------------------------------------------------
a {
text-decoration: none;
color: $primary;
&[name] {
position: relative;
top: -4em;
display: block;
}
}
.scrollable {
height: 250px;
overflow-y: scroll;
padding-right: 1.8em;
}
.footer {
font-size: 0.9em;
font-style: italic;
margin: 3em 0 3.5em;
.w-icon {
font-size: 1.2em;
&.heart {transition: 1s ease-out;cursor: pointer;}
&.heart:hover {animation: pulse 1.8s ease-out infinite;}
}
}
@keyframes pulse {
0%, 20%, 30%, 35%, 45%, 100% {transform: scale(1);}
25%, 40% {transform: scale(1.3);}
}
// Media queries.
// --------------------------------------------------------
@media screen and (max-width: ($page-max-width + (2 * $container-padding-md))) {
:root {--container-padding: #{$container-padding-md};}
.page-container {
padding-left: var(--container-padding);
padding-right: var(--container-padding);
}
}
@media screen and (max-width: (500 + (2 * $container-padding-sm))) {
:root {--container-padding: #{$container-padding-sm};}
}
@@ -0,0 +1,16 @@
$primary: #42b983;
$page-max-width: 1200px;
$container-padding-md: 20px;
$container-padding-sm: 12px;
@mixin backdrop-blur($blur: 4px, $fallback-color: rgba(255, 255, 255, 0.9), $blur-color: rgba(255, 255, 255, 0.6)) {
background-color: $fallback-color; // Slightly transparent fallback by default.
// If backdrop support: very transparent and blurred.
@supports ((-webkit-backdrop-filter: none) or (backdrop-filter: none)) {
& {
background-color: $blur-color;
backdrop-filter: blur($blur);
}
}
}
@@ -0,0 +1,2 @@
@import './variables';
@import './base';
@@ -0,0 +1,323 @@
$time-column-width: 3em;
$week-numbers-width: 3em;
$time-column-width-12: 4em; // twelve-hour clock shows am/pm.
$weekdays-headings-height: 2.8em;
$weekdays-headings-height-with-splits: 3.4em;
$splits-headers-height: 1.5em;
.vuecal {
height: 100%;
box-shadow: 0 0 0 1px inset rgba(0, 0, 0, 0.08);
// Disable user selection everywhere except in events.
* {user-select: none;}
// On touch device, disable everywhere except the event title if editable.
&--has-touch *:not(.vuecal__event-title--edit) {
-webkit-touch-callout: none;
user-select: none;
}
// General.
//==================================//
.clickable {cursor: pointer;}
&--resizing-event, &--drag-creating-event {cursor: ns-resize;}
&--dragging-event {
cursor: move;
cursor: -webkit-grabbing;
cursor: grabbing;
}
.dragging-helper {
position: absolute;
width: 60px;
height: 40px;
background: rgba(138, 190, 230, 0.8);
border: 1px solid #61a9e0;
z-index: 10;
// transition: 0.2s transform;
}
&--xsmall {font-size: 0.9em;}
&__flex {
display: flex;
flex-direction: row;
&[column] {
flex-direction: column;
flex: 1 1 auto;
}
&[grow] {
flex: 1 1 auto;
width: 100%;
}
&[wrap] {
flex-wrap: wrap;
}
}
// Calendar header.
//==================================//
&__split-days-headers {
&.slide-fade--right-leave-active {display: none;}
.vuecal--week-numbers.vuecal--month-view & {margin-left: $week-numbers-width;}
.vuecal--day-view:not(.vuecal--overflow-x) & {margin-left: $time-column-width;height: 2.2em;}
.vuecal--day-view.vuecal--twelve-hour:not(.vuecal--overflow-x) & {margin-left: $time-column-width-12;}
.day-split-header {
display: flex;
flex-grow: 1;
flex-basis: 0; // Evenly distribute.
justify-content: center;
align-items: center;
height: 100%;
}
.vuecal--day-view.vuecal--overflow-x.vuecal--sticky-split-labels .day-split-header {height: $splits-headers-height;}
}
// Calendar body.
//==================================//
&__body {
position: relative;
overflow: hidden;
}
&__all-day {
min-height: 1.7em;
margin-bottom: -1px;
flex-shrink: 0;
&-text {
width: $time-column-width;
box-sizing: border-box;
color: #999;
padding-right: 2px;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
border-bottom: 1px solid #ddd;
hyphens: auto;
span {
font-size: 0.85em;
text-align: right;
line-height: 1.1;
}
}
.vuecal--twelve-hour & > span {width: $time-column-width-12;}
}
&__bg {
overflow: auto;
overflow-x: hidden; // Prevent horizontal scroll bar while transitioning.
-webkit-overflow-scrolling: touch;
min-height: 60px;
position: relative;
width: 100%;
// margin-bottom: 1px;
}
&--no-time &__bg {
display: flex;
flex: 1 1 auto;
overflow: auto;
}
&__week-numbers {
width: $week-numbers-width;
flex-shrink: 0 !important;
.vuecal__week-number-cell {
opacity: 0.4;
font-size: 0.9em;
align-items: center;
justify-items: center;
justify-content: center;
}
}
&__scrollbar-check {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: scroll;
visibility: hidden;
z-index: -1;
& div {height: 120%;}
}
&__time-column {
width: $time-column-width;
height: 100%;
flex-shrink: 0;
.vuecal--twelve-hour & {
width: $time-column-width-12;
font-size: 0.9em;
}
.vuecal--overflow-x.vuecal--week-view & {
margin-top: $weekdays-headings-height;
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3);
}
.vuecal--overflow-x.vuecal--week-view.vuecal--sticky-split-labels & {
margin-top: $weekdays-headings-height-with-splits;
}
.vuecal--overflow-x.vuecal--day-view.vuecal--sticky-split-labels & {margin-top: $splits-headers-height;}
.vuecal__time-cell {
color: #999;
text-align: right;
padding-right: 2px;
font-size: 0.9em;
}
.vuecal__time-cell-line:before {
content: '';
position: absolute;
left: 0;
right: 0;
border-top: 1px solid rgba(196, 196, 196, 0.3);
}
}
// Calendar cells.
//==================================//
&__cells {
margin: 0 1px 1px 0;
.vuecal--overflow-x.vuecal--day-view & {margin: 0;}
.vuecal--events-on-month-view.vuecal--short-events & {width: 99.9%;}
.vuecal--overflow-x.vuecal--day-view &,
.vuecal--overflow-x.vuecal--week-view & {
flex-wrap: nowrap;
overflow: auto;
}
}
}
// Transitions.
//==================================//
.slide-fade--left-enter-active, .slide-fade--left-leave-active,
.slide-fade--right-enter-active, .slide-fade--right-leave-active {
transition: 0.25s ease-out;
}
.slide-fade--left-enter-from,
.slide-fade--right-leave-to {
transform: translateX(-15px);
opacity: 0;
}
.slide-fade--left-leave-to,
.slide-fade--right-enter-from {
transform: translateX(15px);
opacity: 0;
}
.slide-fade--left-leave-active,
.slide-fade--right-leave-active {position: absolute !important;height: 100%;}
.vuecal__title-bar .slide-fade--left-leave-active,
.vuecal__title-bar .slide-fade--right-leave-active {left: 0;right: 0;height: auto;}
.vuecal__heading .slide-fade--left-leave-active,
.vuecal__heading .slide-fade--right-leave-active {display: flex;align-items: center;}
// Themes.
//==================================//
.vuecal--green-theme {
.vuecal__menu, .vuecal__cell-events-count {background-color: #42b983;color: #fff;}
.vuecal__title-bar {background-color: #e4f5ef;}
.vuecal__cell--today, .vuecal__cell--current {background-color: rgba(240, 240, 255, 0.4);}
&:not(.vuecal--day-view) .vuecal__cell--selected {background-color: rgba(235, 255, 245, 0.4);}
.vuecal__cell--selected:before {border-color: rgba(66, 185, 131, 0.5);}
.vuecal__cell--highlighted:not(.vuecal__cell--has-splits),
.vuecal__cell-split--highlighted {background-color: rgba(195, 255, 225, 0.5);}
.vuecal__arrow--highlighted,
.vuecal__today-btn--highlighted,
.vuecal__view-btn--highlighted {background-color: rgba(136, 236, 191, 0.25);}
}
.vuecal--blue-theme {
.vuecal__menu, .vuecal__cell-events-count {background-color: rgba(66, 163, 185, 0.8);color: #fff;}
.vuecal__title-bar {background-color: rgba(0, 165, 188, 0.3);}
.vuecal__cell--today, .vuecal__cell--current {background-color: rgba(240, 240, 255, 0.4);}
&:not(.vuecal--day-view) .vuecal__cell--selected {background-color: rgba(235, 253, 255, 0.4);}
.vuecal__cell--selected:before {border-color: rgba(115, 191, 204, 0.5);}
.vuecal__cell--highlighted:not(.vuecal__cell--has-splits),
.vuecal__cell-split--highlighted {background-color: rgba(0, 165, 188, 0.06);}
.vuecal__arrow--highlighted,
.vuecal__today-btn--highlighted,
.vuecal__view-btn--highlighted {background-color: rgba(66, 163, 185, 0.2);}
}
// Rounded cells.
.vuecal--rounded-theme {
.vuecal__weekdays-headings {border: none;}
.vuecal__cell, .vuecal__cell:before {background: none;border: none;}
.vuecal__cell--out-of-scope {opacity: 0.4;}
.vuecal__cell-content {
width: 30px;
height: 30px;
flex-grow: 0;
border: 1px solid transparent;
border-radius: 30px;
color: #333;
}
&.vuecal--day-view .vuecal__cell-content {width: auto;background: none;}
&.vuecal--year-view .vuecal__cell {width: 33.33%;}
&.vuecal--year-view .vuecal__cell-content {width: 85px;}
&.vuecal--years-view .vuecal__cell-content {width: 52px;}
.vuecal__cell {background-color: transparent !important;}
&.vuecal--green-theme {
&:not(.vuecal--day-view) .vuecal__cell-content {background-color: #f1faf7;}
&:not(.vuecal--day-view) .vuecal__cell--today .vuecal__cell-content {background-color: #42b983;color: #fff;}
.vuecal--day-view .vuecal__cell--today:before {background-color: rgba(66, 185, 131, 0.05);}
&:not(.vuecal--day-view) .vuecal__cell--selected .vuecal__cell-content {border-color: #42b983;}
.vuecal__cell--highlighted:not(.vuecal__cell--has-splits),
.vuecal__cell-split--highlighted {background-color: rgba(195, 255, 225, 0.5);}
}
&.vuecal--blue-theme {
&:not(.vuecal--day-view) .vuecal__cell-content {background-color: rgba(100, 182, 255, 0.2);}
&:not(.vuecal--day-view) .vuecal__cell--today .vuecal__cell-content {background-color: #8fb7e4;color: #fff;}
.vuecal--day-view .vuecal__cell--today:before {background-color: rgba(143, 183, 228, 0.1);}
&:not(.vuecal--day-view) .vuecal__cell--selected .vuecal__cell-content {border-color: #61a9e0;}
.vuecal__cell--highlighted:not(.vuecal__cell--has-splits),
.vuecal__cell-split--highlighted {background-color: rgba(0, 165, 188, 0.06);}
}
}
.vuecal--date-picker {
.vuecal__title-bar {font-size: 1.2em;}
.vuecal__heading {height: 2.2em;font-weight: 500;opacity: 0.4;}
.vuecal__weekdays-headings {border: none;}
.vuecal__body {margin-left: 1px;}
.vuecal__cell, .vuecal__cell:before {background: none;border: none;}
.vuecal__cell-content {
height: 26px;
flex-grow: 0;
border: 1px solid transparent;
border-radius: 25px;
transition: background-color 0.2s cubic-bezier(0.39, 0.58, 0.57, 1);
}
&.vuecal--years-view .vuecal__cell-content {flex: 0;padding: 0 4px;height: 24px;}
&.vuecal--year-view .vuecal__cell-content {flex: 0;padding: 0 15px;}
&.vuecal--month-view .vuecal__cell-content {width: 26px;}
&:not(.vuecal--day-view) .vuecal__cell-content:hover {background-color: rgba(0, 0, 0, 0.1);}
&:not(.vuecal--day-view) .vuecal__cell--selected .vuecal__cell-content {background-color: #42b982;color: #fff;}
&:not(.vuecal--day-view) .vuecal__cell--current .vuecal__cell-content,
&:not(.vuecal--day-view) .vuecal__cell--today .vuecal__cell-content {border-color: #42b982;}
}
@@ -0,0 +1,94 @@
/**
* Cell Utils.
*
* Waiting for VS Code to support JavaScript private fields.
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Class_fields
* Meantime keep `_` for private.
*/
const minutesInADay = 24 * 60
export default class CellUtils {
_vuecal = null
constructor (vuecal) {
this._vuecal = vuecal
}
/**
* Select a cell and go to narrower view on double click or single click according to vuecalProps option.
*
* @param {Boolean} force Force switching to narrower view.
* @param {Date} date The selected cell date at the exact time where it was clicked (through cursor coords).
* @param {Integer} split The selected cell split if any.
*/
selectCell = (force = false, date, split) => {
// Cell-click event returns a date and time at cursor position.
this._vuecal.$emit('cell-click', split ? { date, split } : date)
// Switch to narrower view.
if (this._vuecal.clickToNavigate || force) this._vuecal.switchToNarrowerView()
// Handle double click manually for touch devices.
else if (this._vuecal.dblclickToNavigate && 'ontouchstart' in window) {
this._vuecal.domEvents.dblTapACell.taps++
setTimeout(() => (this._vuecal.domEvents.dblTapACell.taps = 0), this._vuecal.domEvents.dblTapACell.timeout)
if (this._vuecal.domEvents.dblTapACell.taps >= 2) {
this._vuecal.domEvents.dblTapACell.taps = 0
this._vuecal.switchToNarrowerView()
this._vuecal.$emit('cell-dblclick', split ? { date, split } : date)
}
}
}
/**
* Select a cell and go to narrower view on enter.
*
* @param {Boolean} force Force switching to narrower view.
* @param {Date} date The selected cell date at the exact time where it was clicked (through cursor coords).
* @param {Integer} split The selected cell split if any.
*/
keyPressEnterCell = (date, split) => {
// Cell-key-press-enter event returns a date and time at cursor position.
this._vuecal.$emit('cell-keypress-enter', split ? { date, split } : date)
// Switch to narrower view.
this._vuecal.switchToNarrowerView()
}
/**
* Get the coordinates of the mouse cursor from the cells wrapper referential (`ref="cells"`).
*
* @todo Cache bounding box & update it on resize.
* @param {Object} e the native DOM event object.
* @return {Object} containing { x: {Number}, y: {Number} }
*/
getPosition = e => {
const { left, top } = this._vuecal.cellsEl.getBoundingClientRect()
const { clientX, clientY } = 'ontouchstart' in window && e.touches ? e.touches[0] : e
return { x: clientX - left, y: clientY - top }
}
/**
* Get the number of minutes from the top to the mouse cursor.
* Returns a constrained time between 0 and 24 * 60.
*
* @param {Object} e the native DOM event object.
* @return {Object} containing { minutes: {Number}, cursorCoords: { x: {Number}, y: {Number} } }
*/
minutesAtCursor = e => {
let minutes = 0
let cursorCoords = { x: 0, y: 0 }
const { timeStep, timeCellHeight, timeFrom } = this._vuecal.$props
if (typeof e === 'number') minutes = e
else if (typeof e === 'object') {
cursorCoords = this.getPosition(e)
minutes = Math.round(cursorCoords.y * timeStep / parseInt(timeCellHeight) + timeFrom)
}
return { minutes: Math.max(Math.min(minutes, minutesInADay), 0), cursorCoords }
}
}
@@ -0,0 +1,376 @@
/**
* Date Utils & prototypes.
*
* Waiting for VS Code to support JavaScript private fields.
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Class_fields
* Meantime keep `_` for private.
*/
let now, todayDate, todayF, self
let _dateObject = {}
let _timeObject = {}
export default class DateUtils {
texts = {}
constructor (texts, noPrototypes = false) {
self = this // For use in Date prototypes.
this._texts = texts
// Add prototypes ASAP - only once.
if (!noPrototypes && Date && !Date.prototype.addDays) this._initDatePrototypes()
// @todo: This would be nicer, but how to set Date.noPrototypes ASAP only if user wants?
// if (Date.noPrototypes) delete Date.noPrototypes
// else this._initDatePrototypes()
}
_initDatePrototypes () {
/* eslint-disable no-extend-native */
Date.prototype.addDays = function (days) { return self.addDays(this, days) }
Date.prototype.subtractDays = function (days) { return self.subtractDays(this, days) }
Date.prototype.addHours = function (hours) { return self.addHours(this, hours) }
Date.prototype.subtractHours = function (hours) { return self.subtractHours(this, hours) }
Date.prototype.addMinutes = function (minutes) { return self.addMinutes(this, minutes) }
Date.prototype.subtractMinutes = function (minutes) { return self.subtractMinutes(this, minutes) }
Date.prototype.getWeek = function () { return self.getWeek(this) }
Date.prototype.isToday = function () { return self.isToday(this) }
Date.prototype.isLeapYear = function () { return self.isLeapYear(this) }
Date.prototype.format = function (format = 'YYYY-MM-DD') { return self.formatDate(this, format) }
Date.prototype.formatTime = function (format = 'HH:mm') { return self.formatTime(this, format) }
/* eslint-enable no-extend-native */
}
removePrototypes () {
delete Date.prototype.addDays
delete Date.prototype.subtractDays
delete Date.prototype.addHours
delete Date.prototype.subtractHours
delete Date.prototype.addMinutes
delete Date.prototype.subtractMinutes
delete Date.prototype.getWeek
delete Date.prototype.isToday
delete Date.prototype.isLeapYear
delete Date.prototype.format
delete Date.prototype.formatTime
}
updateTexts (texts) {
this._texts = texts
}
// Cache Today's date (to a maximum) for better isToday() performances. Formatted without leading 0.
// We still need to update Today's date when Today changes without page refresh.
_todayFormatted () {
if (todayDate !== (new Date()).getDate()) {
now = new Date()
todayDate = now.getDate()
todayF = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`
}
return todayF
}
// UTILITIES.
// ====================================================================
addDays (date, days) {
const d = new Date(date.valueOf())
d.setDate(d.getDate() + days)
return d
}
subtractDays (date, days) {
const d = new Date(date.valueOf())
d.setDate(d.getDate() - days)
return d
}
addHours (date, hours) {
const d = new Date(date.valueOf())
d.setHours(d.getHours() + hours)
return d
}
subtractHours (date, hours) {
const d = new Date(date.valueOf())
d.setHours(d.getHours() - hours)
return d
}
addMinutes (date, minutes) {
const d = new Date(date.valueOf())
d.setMinutes(d.getMinutes() + minutes)
return d
}
subtractMinutes (date, minutes) {
const d = new Date(date.valueOf())
d.setMinutes(d.getMinutes() - minutes)
return d
}
getWeek (date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
}
isToday (date) {
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` === this._todayFormatted()
}
isLeapYear (date) {
const year = date.getFullYear()
return !(year % 400) || (year % 100 && !(year % 4))
}
// Returns today if it's FirstDayOfWeek (Monday or Sunday) or previous FirstDayOfWeek otherwise.
getPreviousFirstDayOfWeek (date = null, weekStartsOnSunday) {
const prevFirstDayOfWeek = (date && new Date(date.valueOf())) || new Date()
const dayModifier = weekStartsOnSunday ? 7 : 6
prevFirstDayOfWeek.setDate(prevFirstDayOfWeek.getDate() - (prevFirstDayOfWeek.getDay() + dayModifier) % 7)
return prevFirstDayOfWeek
}
/**
* Converts a string to a Javascript Date object. If a Date object is passed, return it as is.
*
* @param {String | Date} date the string to convert to Date.
* @return {Date} the equivalent Javascript Date object.
*/
stringToDate (date) {
if (date instanceof Date) return date
// Regexp way is less performant: https://jsperf.com/string-to-date-regexp-vs-new-date
// const [, y, m, d, h = 0, min = 0] = date.match(/(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2}))?/)
// return new Date(y, parseInt(m) - 1, d, h, min)
if (date.length === 10) date += ' 00:00'
return new Date(date.replace(/-/g, '/')) // replace '-' with '/' for Safari.
}
/**
* Simply takes a Date and returns the associated time in minutes (sum of hours + minutes).
*
* @param {Date} date the JavaScript Date to extract minutes from.
* @return {Number} the number of minutes (total of hours plus minutes).
*/
dateToMinutes = date => date.getHours() * 60 + date.getMinutes()
/**
* Count the number of days this date range spans onto.
* E.g. countDays(2019-11-02 18:00, 2019-11-03 02:00) = 2
*
* @param {String | Date} start the start date
* @param {String | Date} end the end date
* @return {Integer} The number of days this date range involves
*/
countDays (start, end) {
// replace '-' with '/' for Safari.
if (typeof start === 'string') start = start.replace(/-/g, '/')
if (typeof end === 'string') end = end.replace(/-/g, '/')
// Set start & end at midnight then compare the delta. Don't modify the original dates.
start = (new Date(start)).setHours(0, 0, 0, 0)
// Set end at midnight plus 1 sec, so Math.ceil will round it up to a full day.
end = (new Date(end)).setHours(0, 0, 1, 0)
// Remove the potential daylight saving delta.
const timezoneDiffMs = (new Date(end).getTimezoneOffset() - new Date(start).getTimezoneOffset()) * 60 * 1000
return Math.ceil((end - start - timezoneDiffMs) / (24 * 3600 * 1000))
}
/**
* Take 2 dates and check if within the same time step (useful in overlapping events).
*
* @return {Boolean} `true` if their time is included in the same time step,
* this means these 2 dates are very close.
*/
datesInSameTimeStep (date1, date2, timeStep) {
return Math.abs(date1.getTime() - date2.getTime()) <= timeStep * 60 * 1000
}
// ====================================================================
// FORMATTERS.
// ====================================================================
/**
* Formats a date/time to the given format and returns the formatted string.
*
* @param {Date} date a JavaScript Date object to format.
* @param {String} format the wanted format.
* @param {Object} texts Optional: the localized texts object to override the vue-cal one in this._texts.
* This becomes useful when showing multiple instances with different languages,
* like in the documentation page.
* @return {String} the formatted date.
*/
formatDate (date, format = 'YYYY-MM-DD', texts = null) {
if (!texts) texts = this._texts
if (!format) format = 'YYYY-MM-DD' // Allows passing null for default format.
if (format === 'YYYY-MM-DD') return this.formatDateLite(date)
// Reinit the date and time object on each function call.
_dateObject = {}
_timeObject = {}
// Each keyword is a function to load the dateObject or timeObject on demand: no wasted resource.
const dateObj = {
YYYY: () => this._hydrateDateObject(date, texts).YYYY,
YY: () => this._hydrateDateObject(date, texts).YY(),
M: () => this._hydrateDateObject(date, texts).M,
MM: () => this._hydrateDateObject(date, texts).MM(),
MMM: () => this._hydrateDateObject(date, texts).MMM(),
MMMM: () => this._hydrateDateObject(date, texts).MMMM(),
MMMMG: () => this._hydrateDateObject(date, texts).MMMMG(),
D: () => this._hydrateDateObject(date, texts).D,
DD: () => this._hydrateDateObject(date, texts).DD(),
S: () => this._hydrateDateObject(date, texts).S(),
d: () => this._hydrateDateObject(date, texts).d,
dd: () => this._hydrateDateObject(date, texts).dd(),
ddd: () => this._hydrateDateObject(date, texts).ddd(),
dddd: () => this._hydrateDateObject(date, texts).dddd(),
HH: () => this._hydrateTimeObject(date, texts).HH,
H: () => this._hydrateTimeObject(date, texts).H,
hh: () => this._hydrateTimeObject(date, texts).hh,
h: () => this._hydrateTimeObject(date, texts).h,
am: () => this._hydrateTimeObject(date, texts).am,
AM: () => this._hydrateTimeObject(date, texts).AM,
mm: () => this._hydrateTimeObject(date, texts).mm,
m: () => this._hydrateTimeObject(date, texts).m
}
return format.replace(/(\{[a-zA-Z]+\}|[a-zA-Z]+)/g, (m, contents) => {
const result = dateObj[contents.replace(/\{|\}/g, '')]
return result !== undefined ? result() : contents
})
}
// More performant function to convert a Date to `YYYY-MM-DD` formatted string only.
formatDateLite (date) {
const m = date.getMonth() + 1
const d = date.getDate()
return `${date.getFullYear()}-${m < 10 ? '0' : ''}${m}-${d < 10 ? '0' : ''}${d}`
}
/**
* Formats a time (from Date or number of mins) to the given format and returns the formatted string.
*
* @param {Date | Number} date a JavaScript Date object or a time in minutes.
* @param {String} format the wanted format.
* @param {Object} texts Optional: the localized texts object to override the vue-cal one in this._texts.
* This becomes useful when showing multiple instances with different languages,
* like in the documentation page.
* @param {Boolean} round if time is 23:59:59, rounds up to 24:00 for formatting only.
* @return {String} the formatted time.
*/
formatTime (date, format = 'HH:mm', texts = null, round = false) {
let shouldRound = false
if (round) {
const [h, m, s] = [date.getHours(), date.getMinutes(), date.getSeconds()]
if ((h + m + s) === (23 + 59 + 59)) shouldRound = true
}
if (date instanceof Date && format === 'HH:mm') return shouldRound ? '24:00' : this.formatTimeLite(date)
_timeObject = {} // Reinit the time object on each function call.
if (!texts) texts = this._texts
const timeObj = this._hydrateTimeObject(date, texts)
const formatted = format.replace(/(\{[a-zA-Z]+\}|[a-zA-Z]+)/g, (m, contents) => {
const result = timeObj[contents.replace(/\{|\}/g, '')]
return result !== undefined ? result : contents
})
// Round 23:59:59 to 24:00. For 12-hour format there is nothing to replace: as both are 12am.
// Also don't return `24:00` straight away as the user format may be different.
return shouldRound ? formatted.replace('23:59', '24:00') : formatted
}
/**
* Formats a time to 'HH:mm' from a Date and returns the formatted string.
*
* @param {Date} date a JavaScript Date object to format.
* @return {String} the formatted time.
*/
formatTimeLite (date) {
const h = date.getHours()
const m = date.getMinutes()
return `${(h < 10 ? '0' : '') + h}:${(m < 10 ? '0' : '') + m}`
}
_nth (d) {
if (d > 3 && d < 21) return 'th'
switch (d % 10) {
case 1: return 'st'
case 2: return 'nd'
case 3: return 'rd'
default: return 'th'
}
}
_hydrateDateObject (date, texts) {
if (_dateObject.D) return _dateObject
const YYYY = date.getFullYear()
const M = date.getMonth() + 1
const D = date.getDate()
const day = date.getDay() // Day of the week.
const dayNumber = (day - 1 + 7) % 7 // Day of the week. 0 to 6 with 6 = Sunday.
// Some of this props are functions, to only calculate on demand.
_dateObject = {
// Year.
YYYY, // 2019.
YY: () => YYYY.toString().substring(2), // 19.
// Month.
M, // 1 to 12.
MM: () => (M < 10 ? '0' : '') + M, // 01 to 12.
MMM: () => texts.months[M - 1].substring(0, 3), // Jan to Dec.
MMMM: () => texts.months[M - 1], // January to December.
MMMMG: () => (texts.monthsGenitive || texts.months)[M - 1], // January to December in genitive form (Greek...)
// Day.
D, // 1 to 31.
DD: () => (D < 10 ? '0' : '') + D, // 01 to 31.
S: () => this._nth(D), // st, nd, rd, th.
// Day of the week.
d: dayNumber + 1, // 1 to 7 with 7 = Sunday.
dd: () => texts.weekDays[dayNumber][0], // M to S.
ddd: () => texts.weekDays[dayNumber].substr(0, 3), // Mon to Sun.
dddd: () => texts.weekDays[dayNumber] // Monday to Sunday.
}
return _dateObject
}
_hydrateTimeObject (date, texts) {
if (_timeObject.am) return _timeObject
let H, m
if (date instanceof Date) {
H = date.getHours()
m = date.getMinutes()
}
else {
H = Math.floor(date / 60)
m = Math.floor(date % 60)
}
const h = H % 12 ? H % 12 : 12
const am = (texts || { am: 'am', pm: 'pm' })[H === 24 || H < 12 ? 'am' : 'pm']
_timeObject = {
H,
h,
HH: (H < 10 ? '0' : '') + H,
hh: (h < 10 ? '0' : '') + h,
am,
AM: am.toUpperCase(),
m,
mm: (m < 10 ? '0' : '') + m
}
return _timeObject
}
// ====================================================================
}
@@ -0,0 +1,426 @@
/**
* Events Utils.
*
* Waiting for VS Code to support JavaScript private fields.
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Class_fields
* Meantime keep `_` for private.
*/
const defaultEventDuration = 2 // In hours.
const minutesInADay = 24 * 60 // Don't do the maths every time.
let ud
// Event overlaps: only for the current view, recreated on view change.
let _cellOverlaps, _comparisonArray
// This is an approximation, it will not work with DLS time.
// const approxDayMilliseconds = minutesInADay * 60 * 1000
// This is an approximate minimum we can get in a year. Purposely stay below 365 but close.
// const minYearMilliseconds = 364 * approxDayMilliseconds // Don't do the maths every time.
export default class EventUtils {
_vuecal = null
eventDefaults = {
_eid: null,
start: '', // Externally given formatted date & time or Date object.
startTimeMinutes: 0,
end: '', // Externally given formatted date & time or Date object.
endTimeMinutes: 0,
title: '',
content: '',
background: false,
allDay: false,
segments: null,
repeat: null,
daysCount: 1,
deletable: true,
deleting: false,
titleEditable: true,
resizable: true,
resizing: false,
draggable: true,
dragging: false,
draggingStatic: false, // Controls the CSS class of the static clone while dragging.
focused: false,
class: ''
}
constructor (vuecal, dateUtils) {
this._vuecal = vuecal
ud = dateUtils
}
/**
* Create an event at the given date and time, and allow overriding
* event attributes through the eventOptions object.
*
* @param {Date | String} dateTime The date and time of the new event start.
* @param {Number} duration the event duration in minutes.
* @param {Object} eventOptions some options to override the `eventDefaults` - optional.
*/
createAnEvent (dateTime, duration, eventOptions) {
if (typeof dateTime === 'string') dateTime = ud.stringToDate(dateTime)
if (!(dateTime instanceof Date)) return false
const startTimeMinutes = ud.dateToMinutes(dateTime)
duration = duration * 1 || defaultEventDuration * 60
const endTimeMinutes = startTimeMinutes + duration
const end = ud.addMinutes(new Date(dateTime), duration)
// Automatically add the required endTimeMinutes when passing an end.
if (eventOptions.end) {
if (typeof eventOptions.end === 'string') eventOptions.end = ud.stringToDate(eventOptions.end)
eventOptions.endTimeMinutes = ud.dateToMinutes(eventOptions.end)
}
const event = {
...this.eventDefaults,
_eid: `${this._vuecal._.uid}_${this._vuecal.eventIdIncrement++}`,
start: dateTime,
startTimeMinutes,
end,
endTimeMinutes,
segments: null,
...eventOptions
}
// If the onEventCreate() function is given as a parameter to vue-cal:
// 1. give it access to the created event & the deleteAnEvent() function.
// 2. Prevent creation of the event if this function returns false.
if (typeof this._vuecal.onEventCreate === 'function') {
if (!this._vuecal.onEventCreate(event, () => this.deleteAnEvent(event))) return
}
// Check if event is a multiple day event and update days count.
if (event.startDateF !== event.endDateF) {
event.daysCount = ud.countDays(event.start, event.end)
}
// Add event to the mutableEvents array.
this._vuecal.mutableEvents.push(event)
// Add the new event to the current view.
// The event may have been edited on the fly to become a multiple-day event,
// the method addEventsToView makes sure the segments are created.
this._vuecal.addEventsToView([event])
this._vuecal.emitWithEvent('event-create', event)
this._vuecal.$emit('event-change', { event: this._vuecal.cleanupEvent(event), originalEvent: null })
return event
}
/**
* Add an event segment (= day) to a multiple-day event.
*
* @param {Object} e the multiple-day event to add segment in.
*/
addEventSegment (e) {
// If event was previously single-day, event.segments = null,
// so first create the first segment (first day).
if (!e.segments) {
e.segments = {}
e.segments[ud.formatDateLite(e.start)] = {
start: e.start,
startTimeMinutes: e.startTimeMinutes,
endTimeMinutes: minutesInADay,
isFirstDay: true,
isLastDay: false
}
}
// Modify the last segment - which will not stay the last one after this function.
const previousSegment = e.segments[ud.formatDateLite(e.end)]
// previousSegment might not exist when dragging too fast, prevent errors.
if (previousSegment) {
previousSegment.isLastDay = false
previousSegment.endTimeMinutes = minutesInADay
}
else {
// @todo: when moving fast might lose the previousSegment.
// Trying to update it would then result in an error, but do nothing would create a visual bug.
}
// Create the new last segment.
const start = ud.addDays(e.end, 1)
const formattedDate = ud.formatDateLite(start)
start.setHours(0, 0, 0, 0)
e.segments[formattedDate] = {
start,
startTimeMinutes: 0,
endTimeMinutes: e.endTimeMinutes,
isFirstDay: false,
isLastDay: true
}
e.end = ud.addMinutes(start, e.endTimeMinutes)
e.daysCount = Object.keys(e.segments).length
return formattedDate
}
/**
* Remove an event segment (= day) from a multiple-day event.
*
* @param {Object} e the multiple-day event to remove segments from.
*/
removeEventSegment (e) {
let segmentsCount = Object.keys(e.segments).length
if (segmentsCount <= 1) return ud.formatDateLite(e.end)
// Remove the last segment.
delete e.segments[ud.formatDateLite(e.end)]
segmentsCount--
const end = ud.subtractDays(e.end, 1)
const formattedDate = ud.formatDateLite(end)
const previousSegment = e.segments[formattedDate]
// If no more segments, reset the segments attribute to null.
if (!segmentsCount) e.segments = null
// previousSegment might not exist when dragging too fast, prevent errors.
else if (previousSegment) {
// Modify the new last segment.
previousSegment.isLastDay = true
previousSegment.endTimeMinutes = e.endTimeMinutes
}
else {
// @todo: when moving fast might lose the previousSegment.
// Trying to update it would then result in an error, but do nothing would create a visual bug.
}
e.daysCount = segmentsCount || 1
e.end = end
return formattedDate
}
/**
* Create 1 segment per day of the given event, but only within the current view.
* (It won't create segments for all the days in view that are not in the event!)
*
* An event segment is a piece of event per day that contains more day-specific data.
*
* @param {Object} e the multiple-day event to create segments for.
* @param {Date} viewStartDate the starting date of the view.
* @param {Date} viewEndDate the ending date of the view.
*/
createEventSegments (e, viewStartDate, viewEndDate) {
const viewStartTimestamp = viewStartDate.getTime()
const viewEndTimestamp = viewEndDate.getTime()
let eventStart = e.start.getTime()
let eventEnd = e.end.getTime()
let repeatedEventStartFound = false
let timestamp, end, eventStartAtMidnight
// @todo: I don't think we still need that:
// Removing 1 sec when ending at 00:00, so that we don't create a segment for nothing on last day.
if (!e.end.getHours() && !e.end.getMinutes()) eventEnd -= 1000
e.segments = {}
// The goal is to create 1 segment per day in the event, but only within the current view.
if (!e.repeat) { // Simple case first.
timestamp = Math.max(viewStartTimestamp, eventStart)
end = Math.min(viewEndTimestamp, eventEnd)
}
else {
// Start at the beginning of the range, and end at soonest between `repeat.until` if any or range end.
// This range will most likely be too large (e.g. whole week) and we need to narrow it
// down in the while loop below.
// We must not create unused segments, it would break the render or result in weird behaviors.
timestamp = viewStartTimestamp
end = Math.min(
viewEndTimestamp,
e.repeat.until ? ud.stringToDate(e.repeat.until).getTime() : viewEndTimestamp
)
}
while (timestamp <= end) {
let createSegment = false
// Be careful not to simply add 24 hours!
// In case of DLS, that would cause the event to never end and browser to hang.
// So use `addDays(1)` instead.
const nextMidnight = ud.addDays(new Date(timestamp), 1).setHours(0, 0, 0, 0)
let isFirstDay, isLastDay, start, formattedDate
if (e.repeat) {
const tmpDate = new Date(timestamp)
const tmpDateFormatted = ud.formatDateLite(tmpDate)
// If the current day in loop is a known date of the repeated event (in `e.occurrences`),
// then we found the first day of the event repetition, now update the `eventStart` and
// the end of the loop at current day + event days count.
if (repeatedEventStartFound || (e.occurrences && e.occurrences[tmpDateFormatted])) {
if (!repeatedEventStartFound) {
eventStart = e.occurrences[tmpDateFormatted].start
eventStartAtMidnight = new Date(eventStart).setHours(0, 0, 0, 0)
eventEnd = e.occurrences[tmpDateFormatted].end
}
repeatedEventStartFound = true
createSegment = true
}
isFirstDay = timestamp === eventStartAtMidnight
isLastDay = tmpDateFormatted === ud.formatDateLite(new Date(eventEnd))
start = new Date(isFirstDay ? eventStart : timestamp)
formattedDate = ud.formatDateLite(start)
// We want to find any potential other repetition of event in same range.
if (isLastDay) repeatedEventStartFound = false
}
else {
createSegment = true
isFirstDay = timestamp === eventStart
isLastDay = end === eventEnd && nextMidnight > end
start = isFirstDay ? e.start : new Date(timestamp)
formattedDate = ud.formatDateLite(isFirstDay ? e.start : start)
}
if (createSegment) {
e.segments[formattedDate] = {
start,
startTimeMinutes: isFirstDay ? e.startTimeMinutes : 0,
endTimeMinutes: isLastDay ? e.endTimeMinutes : minutesInADay,
isFirstDay,
isLastDay
}
}
timestamp = nextMidnight
}
return e
}
/**
* Delete an event.
*
* @param {Object} event the calendar event to delete.
*/
deleteAnEvent (event) {
this._vuecal.emitWithEvent('event-delete', event)
// Delete the event globally.
this._vuecal.mutableEvents = this._vuecal.mutableEvents.filter(e => e._eid !== event._eid)
// Delete the event from the current view.
// checkCellOverlappingEvents() will be re-run automatically from the cell computed events.
this._vuecal.view.events = this._vuecal.view.events.filter(e => e._eid !== event._eid)
}
// EVENT OVERLAPS.
// ===================================================================
// Will recalculate all the overlaps of the current cell OR split.
// cellEvents will contain only the current split events if in a split.
checkCellOverlappingEvents (cellEvents, options) {
_comparisonArray = cellEvents.slice(0)
_cellOverlaps = {}
// Can't filter background events before calling this function otherwise
// when an event is changed to background it would not update its previous overlaps.
cellEvents.forEach(e => {
// For performance, never compare the current event in the next loops.
// The array is smaller and smaller as we loop.
_comparisonArray.shift()
if (!_cellOverlaps[e._eid]) _cellOverlaps[e._eid] = { overlaps: [], start: e.start, position: 0 }
_cellOverlaps[e._eid].position = 0
_comparisonArray.forEach(e2 => {
if (!_cellOverlaps[e2._eid]) _cellOverlaps[e2._eid] = { overlaps: [], start: e2.start, position: 0 }
const eventIsInRange = this.eventInRange(e2, e.start, e.end)
const eventsInSameTimeStep = options.overlapsPerTimeStep ? ud.datesInSameTimeStep(e.start, e2.start, options.timeStep) : 1
// Add to the overlaps array if overlapping.
if (!e.background && !e.allDay && !e2.background && !e2.allDay && eventIsInRange && eventsInSameTimeStep) {
_cellOverlaps[e._eid].overlaps.push(e2._eid)
_cellOverlaps[e._eid].overlaps = [...new Set(_cellOverlaps[e._eid].overlaps)] // Dedupe, most performant way.
_cellOverlaps[e2._eid].overlaps.push(e._eid)
_cellOverlaps[e2._eid].overlaps = [...new Set(_cellOverlaps[e2._eid].overlaps)] // Dedupe, most performant way.
_cellOverlaps[e2._eid].position++
}
// Remove from the overlaps array if not overlapping or if 1 of the 2 events is background or all-day long.
else {
let pos1, pos2
if ((pos1 = (_cellOverlaps[e._eid] || { overlaps: [] }).overlaps.indexOf(e2._eid)) > -1) _cellOverlaps[e._eid].overlaps.splice(pos1, 1)
if ((pos2 = (_cellOverlaps[e2._eid] || { overlaps: [] }).overlaps.indexOf(e._eid)) > -1) _cellOverlaps[e2._eid].overlaps.splice(pos2, 1)
_cellOverlaps[e2._eid].position--
}
})
})
// Overlaps streak is the longest horizontal set of simultaneous events.
// This is determining the width of events in a streak.
// e.g. 3 overlapping events in a cell:
// ___ ___
// | 1 | |_2_| 1 overlaps 2 & 3; 2 & 3 don't overlap;
// | | ___ => streak = 2; each width = 50% not 33%.
// |___| |_3_|
let longestStreak = 0
for (const id in _cellOverlaps) {
const item = _cellOverlaps[id]
// Calculate the position of each event in current streak (determines the CSS left property).
const overlapsRow = item.overlaps.map(id2 => ({ id: id2, start: _cellOverlaps[id2].start }))
overlapsRow.push({ id, start: item.start })
overlapsRow.sort((a, b) => a.start < b.start ? -1 : (a.start > b.start ? 1 : (a.id > b.id ? -1 : 1)))
item.position = overlapsRow.findIndex(e => e.id === id)
longestStreak = Math.max(this.getOverlapsStreak(item, _cellOverlaps), longestStreak)
}
return [_cellOverlaps, longestStreak]
}
/**
* Overlaps streak is the longest horizontal set of simultaneous events.
* This is determining the width of each events in this streak.
* E.g. 3 overlapping events [1, 2, 3]; 1 overlaps 2 & 3; 2 & 3 don't overlap;
* => streak = 2; each width = 50% not 33%.
*
* @param {Object} event The current event we are checking among all the events of the current cell.
* @param {Object} cellOverlaps An indexed array of all the events overlaps for the current cell.
* @return {Number} The number of simultaneous event for this event.
*/
getOverlapsStreak (event, cellOverlaps = {}) {
let streak = event.overlaps.length + 1
let removeFromStreak = []
event.overlaps.forEach(id => {
if (!removeFromStreak.includes(id)) {
const overlapsWithoutSelf = event.overlaps.filter(id2 => id2 !== id)
overlapsWithoutSelf.forEach(id3 => {
if (!cellOverlaps[id3].overlaps.includes(id)) removeFromStreak.push(id3)
})
}
})
removeFromStreak = [...new Set(removeFromStreak)] // Dedupe, most performant way.
streak -= removeFromStreak.length
return streak
}
/**
* Tells whether an event is in a given date range, even partially.
*
* @param {Object} event The event to test.
* @param {Date} start The start of range date object.
* @param {Date} end The end of range date object.
* @return {Boolean} true if in range, even partially.
*/
eventInRange (event, start, end) {
// Check if all-day or timeless event (if date but no time there won't be a `:` in event.start).
if (event.allDay || !this._vuecal.time) {
// Get the date and discard the time if any, then check it's within the date range.
const startTimestamp = new Date(event.start).setHours(0, 0, 0, 0)
const endTimestamp = new Date(event.end).setHours(23, 59, 0, 0)
return (endTimestamp >= new Date(start).setHours(0, 0, 0, 0) &&
startTimestamp <= new Date(end).setHours(0, 0, 0, 0))
}
const startTimestamp = event.start.getTime()
const endTimestamp = event.end.getTime()
return startTimestamp < end.getTime() && endTimestamp > start.getTime()
}
}
@@ -0,0 +1,188 @@
<template lang="pug">
//- weekdays-headings are on week view only.
.vuecal__flex.vuecal__weekdays-headings
template(v-for="(heading, i) in headings" :key="i")
.vuecal__flex.vuecal__heading(
v-if="!heading.hide"
:class="{ today: heading.today, clickable: cellHeadingsClickable }"
:style="weekdayCellStyles"
@click="view.id === 'week' && selectCell(heading.date, $event)"
@dblclick="view.id === 'week' && vuecal.dblclickToNavigate && switchToNarrowerView()")
transition(:name="`slide-fade--${transitionDirection}`" :appear="vuecal.transitions")
.vuecal__flex(column :key="vuecal.transitions ? `${i}-${heading.dayOfMonth}` : false")
.vuecal__flex.weekday-label(grow)
slot(name="weekday-heading" :heading="cleanupHeading(heading)" :view="view")
//- For small/xsmall option. 3 media queries also truncate weekdays.
span.full {{ heading.full }}
span.small {{ heading.small }}
span.xsmall {{ heading.xsmall }}
span(v-if="heading.dayOfMonth") &nbsp;{{ heading.dayOfMonth }}
.vuecal__flex.vuecal__split-days-headers(
v-if="vuecal.hasSplits && vuecal.stickySplitLabels"
grow)
.day-split-header(v-for="(split, i) in vuecal.daySplits" :key="i" :class="split.class || false")
slot(name="split-label" :split="split" :view="view") {{ split.label }}
</template>
<script>
export default {
inject: ['vuecal', 'utils', 'view'],
props: {
transitionDirection: { type: String, default: 'right' },
weekDays: { type: Array, default: () => [] },
switchToNarrowerView: { type: Function, default: () => {} }
},
methods: {
selectCell (date, DOMEvent) {
if (date.getTime() !== this.view.selectedDate.getTime()) {
this.view.selectedDate = date
}
this.utils.cell.selectCell(false, date, DOMEvent)
},
cleanupHeading: heading => ({
label: heading.full,
date: heading.date,
...(heading.today ? { today: heading.today } : {})
})
},
computed: {
headings () {
if (!['month', 'week'].includes(this.view.id)) return []
let todayFound = false
const headings = this.weekDays.map((cell, i) => {
const date = this.utils.date.addDays(this.view.startDate, this.vuecal.startWeekOnSunday ? i - 1 : i)
return {
hide: cell.hide,
full: cell.label,
// If defined in i18n file, weekDaysShort overrides default truncation of
// week days when does not fit on screen or with small/xsmall options.
small: cell.short || cell.label.substr(0, 3),
xsmall: cell.short || cell.label.substr(0, 1),
// Only for week view.
...(this.view.id === 'week' ? {
dayOfMonth: date.getDate(),
date,
today: !todayFound && this.utils.date.isToday(date) && !todayFound++
} : {})
}
})
return headings
},
cellWidth () {
return 100 / (7 - this.weekDays.reduce((total, day) => total + day.hide, 0))
},
weekdayCellStyles () {
return {
...(this.vuecal.hideWeekdays.length ? { width: `${this.cellWidth}%` } : {})
}
},
cellHeadingsClickable () {
return this.view.id === 'week' && (this.vuecal.clickToNavigate || this.vuecal.dblclickToNavigate)
}
}
}
</script>
<style lang="scss">
$time-column-width: 3em;
$time-column-width-12: 4em; // twelve-hour clock shows am/pm.
$week-numbers-width: 3em;
$weekdays-headings-height: 2.8em;
$weekdays-headings-height-with-splits: 3.4em;
.vuecal {
&__weekdays-headings {
border-bottom: 1px solid #ddd;
margin-bottom: -1px;
.vuecal--week-numbers & {padding-left: $week-numbers-width;}
.vuecal--view-with-time & {padding-left: $time-column-width;}
.vuecal--view-with-time.vuecal--twelve-hour & {
font-size: 0.9em;
padding-left: $time-column-width-12;
}
.vuecal--overflow-x.vuecal--view-with-time & {padding-left: 0;}
}
&__heading {
width: 100%;
height: $weekdays-headings-height;
font-weight: 400;
justify-content: center;
text-align: center;
align-items: center;
position: relative;
overflow: hidden;
> .vuecal__flex {width: 100%;height: 100%;align-items: initial !important;} // For Vue transition.
.vuecal--sticky-split-labels & {height: $weekdays-headings-height-with-splits;}
.vuecal--month-view &, .vuecal--week-view &, .vuecal--day-view & {width: 14.2857%;}
.vuecal--hide-weekends.vuecal--month-view &,
.vuecal--hide-weekends.vuecal--week-view &,
.vuecal--hide-weekends.vuecal--day-view & {width: 20%;}
.vuecal--years-view & {width: 20%;}
.vuecal--year-view & {width: 33.33%;}
.weekday-label {
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
}
.vuecal--small & .small, .vuecal--xsmall & .xsmall {display: block;}
.small, .xsmall,
.vuecal--small & .full, .vuecal--small & .xsmall,
.vuecal--xsmall & .full, .vuecal--xsmall & .small {display: none;}
}
.vuecal__split-days-headers {align-items: center;}
}
// Media queries.
//==================================//
@media screen and (max-width: 550px) {
.vuecal__heading {
line-height: 1.2;
.small,
.vuecal--small & .small,
.vuecal--xsmall & .xsmall {display: block;}
.full, .xsmall,
.vuecal--small & .full, .vuecal--small & .xsmall,
.vuecal--xsmall & .full, .vuecal--xsmall & .small {display: none;}
.vuecal--overflow-x & .full,
.vuecal--small.vuecal--overflow-x & .small,
.vuecal--xsmall.vuecal--overflow-x & .xsmall {display: block;}
.vuecal--overflow-x & .small, .vuecal--overflow-x & .xsmall,
.vuecal--small.vuecal--overflow-x & .full, .vuecal--small.vuecal--overflow-x & .xsmall,
.vuecal--xsmall.vuecal--overflow-x & .full, .vuecal--xsmall.vuecal--overflow-x & .small {display: none;}
}
}
@media screen and (max-width: 450px) {
.vuecal__heading {
.xsmall,
.vuecal--small & .xsmall,
.vuecal--xsmall & .xsmall {display: block;}
.full, .small,
.vuecal--small & .full, .vuecal--small & .small,
.vuecal--xsmall & .full, .vuecal--xsmall & .small {display: none;}
.vuecal--small.vuecal--overflow-x & .small,
.vuecal--xsmall.vuecal--overflow-x & .xsmall {display: block;}
.vuecal--small.vuecal--overflow-x & .full, .vuecal--small.vuecal--overflow-x & .xsmall,
.vuecal--xsmall.vuecal--overflow-x & .full, .vuecal--xsmall.vuecal--overflow-x & .small {display: none;}
}
}
</style>