diff --git a/calendar.js b/calendar.js
index 590d07a..98256d2 100644
--- a/calendar.js
+++ b/calendar.js
@@ -12,9 +12,10 @@ import {
addDaysStr,
getLocalizedWeekdayNames,
getLocalizedMonthName,
- formatDateRange
- ,lunarPhaseSymbol
+ formatDateRange,
+ lunarPhaseSymbol
} from './date-utils.js'
+import { EventManager } from './event-manager.js'
class InfiniteCalendar {
constructor(config = {}) {
@@ -27,9 +28,8 @@ class InfiniteCalendar {
this.weekend = [true, false, false, false, false, false, true]
- // Event storage
- this.events = new Map() // Map of date strings to arrays of events
- this.eventIdCounter = 1
+ // Initialize event manager
+ this.eventManager = new EventManager(this)
this.viewport = document.getElementById('calendar-viewport')
this.content = document.getElementById('calendar-content')
@@ -42,26 +42,14 @@ class InfiniteCalendar {
this.visibleWeeks = new Map()
this.baseDate = new Date(2024, 0, 1) // 2024 begins with Monday
- // unified selection state (single or range)
- this.selStart = null
- this.selEnd = null
- this.isDragging = false
- this.dragAnchor = null
-
- // DnD state for events
- this.dragEventState = null // { mode: 'move'|'resize-left'|'resize-right', id, originWeek, originStartIdx, originEndIdx, pointerStartX, pointerStartY, startDate, endDate }
-
this.init()
- }
-
- init() {
+ } init() {
this.createHeader()
this.setupScrollListener()
this.setupJogwheel()
this.setupYearScroll()
this.setupSelectionInput()
this.setupCurrentDate()
- this.setupEventDialog()
this.setupInitialView()
}
@@ -97,7 +85,7 @@ class InfiniteCalendar {
const todayDateElement = document.getElementById('today-date')
todayDateElement.addEventListener('click', () => this.goToToday())
- if (this.config.select_days > 1) this.setupGlobalDragHandlers()
+ // Day selection drag functionality is handled through cell event handlers in EventManager
updateDate()
setInterval(updateDate, 1000)
}
@@ -345,35 +333,35 @@ class InfiniteCalendar {
cell.addEventListener('mousedown', e => {
e.preventDefault()
e.stopPropagation()
- this.startDrag(dateStr)
+ this.eventManager.startDrag(dateStr)
})
cell.addEventListener('touchstart', e => {
e.preventDefault()
e.stopPropagation()
- this.startDrag(dateStr)
+ this.eventManager.startDrag(dateStr)
})
cell.addEventListener('mouseenter', () => {
- if (this.isDragging) this.updateDrag(dateStr)
+ if (this.eventManager.isDragging) this.eventManager.updateDrag(dateStr)
})
cell.addEventListener('mouseup', e => {
e.stopPropagation()
- if (this.isDragging) this.endDrag(dateStr)
+ if (this.eventManager.isDragging) this.eventManager.endDrag(dateStr)
})
cell.addEventListener('touchmove', e => {
- if (this.isDragging) {
+ if (this.eventManager.isDragging) {
e.preventDefault()
const touch = e.touches[0]
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY)
if (elementBelow && elementBelow.closest('.cell[data-date]')) {
const cellBelow = elementBelow.closest('.cell[data-date]')
const touchDateStr = cellBelow.dataset.date
- if (touchDateStr) this.updateDrag(touchDateStr)
+ if (touchDateStr) this.eventManager.updateDrag(touchDateStr)
}
}
})
cell.addEventListener('touchend', e => {
e.stopPropagation()
- if (this.isDragging) this.endDrag(dateStr)
+ if (this.eventManager.isDragging) this.eventManager.endDrag(dateStr)
})
}
@@ -464,419 +452,7 @@ class InfiniteCalendar {
this.scrollToTarget(top, { smooth: true })
}
- // -------- Selection --------
-
- clampRange(anchorStr, otherStr) {
- if (this.config.select_days <= 1) return [otherStr, otherStr]
- const limit = this.config.select_days
- const forward = fromLocalString(otherStr) >= fromLocalString(anchorStr)
- const span = daysInclusive(anchorStr, otherStr)
- if (span <= limit) {
- const a = [anchorStr, otherStr].sort()
- return [a[0], a[1]]
- }
- if (forward) return [anchorStr, addDaysStr(anchorStr, limit - 1)]
- return [addDaysStr(anchorStr, -(limit - 1)), anchorStr]
- }
-
- setSelection(aStr, bStr) {
- const [start, end] = this.clampRange(aStr, bStr)
- this.selStart = start
- this.selEnd = end
- this.applySelectionToVisible()
- this.selectedDateInput.value = formatDateRange(fromLocalString(start), fromLocalString(end))
- }
-
- clearSelection() {
- this.selStart = null
- this.selEnd = null
- for (const [, weekEl] of this.visibleWeeks) {
- weekEl.querySelectorAll('.cell.selected').forEach(c => c.classList.remove('selected'))
- }
- this.selectedDateInput.value = ''
- }
-
- applySelectionToVisible() {
- for (const [, weekEl] of this.visibleWeeks) {
- const cells = weekEl.querySelectorAll('.cell[data-date]')
- for (const cell of cells) {
- if (!this.selStart || !this.selEnd) {
- cell.classList.remove('selected')
- continue
- }
- const ds = cell.dataset.date
- const inRange = ds >= this.selStart && ds <= this.selEnd
- cell.classList.toggle('selected', inRange)
- }
- }
- }
-
- setupGlobalDragHandlers() {
- document.addEventListener('mouseup', () => {
- if (!this.isDragging) return
- this.isDragging = false
- document.body.style.cursor = 'default'
- })
- document.addEventListener('touchend', () => {
- if (!this.isDragging) return
- this.isDragging = false
- document.body.style.cursor = 'default'
- })
- document.addEventListener('touchmove', e => {
- if (!this.isDragging) return
- e.preventDefault()
- const touch = e.touches[0]
- const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY)
- if (elementBelow && elementBelow.closest('.cell[data-date]')) {
- const cellBelow = elementBelow.closest('.cell[data-date]')
- const touchDateStr = cellBelow.dataset.date
- if (touchDateStr) this.updateDrag(touchDateStr)
- }
- }, { passive: false })
- document.addEventListener('selectstart', e => {
- if (this.isDragging) e.preventDefault()
- })
- document.addEventListener('contextmenu', e => {
- if (this.isDragging) e.preventDefault()
- })
- }
-
- startDrag(dateStr) {
- if (this.config.select_days === 0) return
- this.isDragging = true
- this.dragAnchor = dateStr
- this.setSelection(dateStr, dateStr)
- }
-
- updateDrag(dateStr) {
- if (!this.isDragging) return
- this.setSelection(this.dragAnchor, dateStr)
- document.body.style.cursor = 'default'
- }
-
- endDrag(dateStr) {
- if (!this.isDragging) return
- this.isDragging = false
- this.setSelection(this.dragAnchor, dateStr)
- document.body.style.cursor = 'default'
- if (this.selStart && this.selEnd) {
- setTimeout(() => this.showEventDialog('create'), 50)
- }
- }
-
- // -------- Event Management (overlay-based) --------
-
- // Build dialog DOM once
- setupEventDialog() {
- const tpl = document.createElement('template')
- tpl.innerHTML = `
-
`
-
- document.body.appendChild(tpl.content)
- this.eventModal = document.querySelector('.ec-modal-backdrop')
- this.eventForm = this.eventModal.querySelector('form.ec-form')
- this.eventTitleInput = this.eventForm.elements['title']
- this.eventStartDateInput = this.eventForm.elements['startDate']
- this.eventStartTimeInput = this.eventForm.elements['startTime']
- this.eventDurationInput = this.eventForm.elements['duration']
- this.eventTimeRow = this.eventForm.querySelector('.ec-time-row')
- this.eventColorInputs = Array.from(this.eventForm.querySelectorAll('input[name="colorId"]'))
- // duration change toggles time visibility
- this.eventDurationInput.addEventListener('change', () => this.updateTimeVisibilityByDuration())
- // color selection visual state
- this.eventColorInputs.forEach(radio => {
- radio.addEventListener('change', () => {
- const swatches = this.eventForm.querySelectorAll('.ec-color-swatches .swatch')
- swatches.forEach(s => s.classList.toggle('selected', s.checked))
- })
- })
-
- this.eventForm.addEventListener('submit', e => {
- e.preventDefault()
- const data = this.readEventForm()
- if (!data.title.trim()) return
- if (this._dialogMode === 'create') {
- const computed = this.computeDatesFromForm(data)
- this.createEvent({
- title: data.title.trim(),
- startDate: computed.startDate,
- endDate: computed.endDate,
- colorId: data.colorId,
- startTime: data.startTime,
- durationMinutes: data.duration
- })
- this.clearSelection()
- } else if (this._dialogMode === 'edit' && this._editingEventId != null) {
- const computed = this.computeDatesFromForm(data)
- this.applyEventEdit(this._editingEventId, { ...data, ...computed })
- }
- this.hideEventDialog()
- })
-
- this.eventForm.querySelector('[data-action="cancel"]').addEventListener('click', () => {
- this.hideEventDialog()
- if (this._dialogMode === 'create') this.clearSelection()
- })
-
- this.eventModal.addEventListener('click', e => {
- if (e.target === this.eventModal) this.hideEventDialog()
- })
- document.addEventListener('keydown', e => {
- if (this.eventModal.hidden) return
- if (e.key === 'Escape') {
- this.hideEventDialog()
- if (this._dialogMode === 'create') this.clearSelection()
- }
- })
- }
-
- showEventDialog(mode, opts = {}) {
- this._dialogMode = mode
- this._editingEventId = null
-
- if (mode === 'create') {
- // Defaults for new event
- this.eventTitleInput.value = ''
- this.eventStartTimeInput.value = '09:00'
- // start date defaults
- this.eventStartDateInput.value = this.selStart || toLocalString(new Date())
- // duration defaults from selection (full days) or 60 min
- if (this.selStart && this.selEnd) {
- const days = daysInclusive(this.selStart, this.selEnd)
- this.setDurationValue(days * 1440)
- } else {
- this.setDurationValue(60)
- }
- // suggest least-used color across range
- const suggested = this.selectEventColorId(this.selStart, this.selEnd)
- this.eventColorInputs.forEach(r => r.checked = Number(r.value) === suggested)
- this.updateTimeVisibilityByDuration()
- } else if (mode === 'edit') {
- const ev = this.getEventById(opts.id)
- if (!ev) return
- this._editingEventId = ev.id
- this.eventTitleInput.value = ev.title || ''
- this.eventStartDateInput.value = ev.startDate
- if (ev.startDate !== ev.endDate) {
- const days = daysInclusive(ev.startDate, ev.endDate)
- this.setDurationValue(days * 1440)
- } else {
- this.setDurationValue(ev.durationMinutes || 60)
- }
- this.eventStartTimeInput.value = ev.startTime || '09:00'
- this.eventColorInputs.forEach(r => r.checked = Number(r.value) === (ev.colorId ?? 0))
- this.updateTimeVisibilityByDuration()
- }
- this.eventModal.hidden = false
- // simple focus
- setTimeout(() => this.eventTitleInput.focus(), 0)
- }
-
- toggleTimeRow(show) {
- if (!this.eventTimeRow) return
- this.eventTimeRow.style.display = show ? '' : 'none'
- }
-
- updateTimeVisibilityByDuration() {
- const minutes = Number(this.eventDurationInput.value || 0)
- const isFullDayOrMore = minutes >= 1440
- this.toggleTimeRow(!isFullDayOrMore)
- }
-
- hideEventDialog() {
- this.eventModal.hidden = true
- }
-
- readEventForm() {
- const colorId = Number(this.eventForm.querySelector('input[name="colorId"]:checked')?.value ?? 0)
- const timeRowVisible = this.eventTimeRow && this.eventTimeRow.style.display !== 'none'
- return {
- title: this.eventTitleInput.value,
- startDate: this.eventStartDateInput.value,
- startTime: timeRowVisible ? (this.eventStartTimeInput.value || '09:00') : null,
- duration: timeRowVisible ? Math.max(15, Number(this.eventDurationInput.value) || 60) : null,
- colorId
- }
- }
-
- setDurationValue(minutes) {
- const v = String(minutes)
- const exists = Array.from(this.eventDurationInput.options).some(o => o.value === v)
- if (!exists) {
- const opt = document.createElement('option')
- opt.value = v
- const days = Math.floor(minutes / 1440)
- opt.textContent = days >= 1 ? `${days} day${days > 1 ? 's' : ''}` : `${minutes} minutes`
- this.eventDurationInput.appendChild(opt)
- }
- this.eventDurationInput.value = v
- }
-
- computeDatesFromForm(data) {
- const minutes = Number(this.eventDurationInput.value || 0)
- if (minutes >= 1440) {
- const days = Math.max(1, Math.floor(minutes / 1440))
- return { startDate: data.startDate, endDate: addDaysStr(data.startDate, days - 1) }
- }
- return { startDate: data.startDate, endDate: data.startDate }
- }
-
- createEvent(eventData) {
- const singleDay = eventData.startDate === eventData.endDate
- const event = {
- id: this.eventIdCounter++,
- title: eventData.title,
- startDate: eventData.startDate,
- endDate: eventData.endDate,
- colorId: eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
- startTime: singleDay ? (eventData.startTime || '09:00') : null,
- durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null
- }
-
- const startDate = new Date(fromLocalString(event.startDate))
- const endDate = new Date(fromLocalString(event.endDate))
-
- for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
- const dateStr = toLocalString(d)
- if (!this.events.has(dateStr)) this.events.set(dateStr, [])
- this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate })
- }
-
- this.refreshEvents()
- }
-
- applyEventEdit(eventId, data) {
- const current = this.getEventById(eventId)
- if (!current) return
- const newStart = data.startDate || current.startDate
- const newEnd = data.endDate || current.endDate
- const datesChanged = (newStart !== current.startDate) || (newEnd !== current.endDate)
- if (datesChanged) {
- const multi = daysInclusive(newStart, newEnd) > 1
- const payload = {
- ...current,
- title: data.title.trim(),
- colorId: data.colorId,
- startDate: newStart,
- endDate: newEnd,
- startTime: multi ? null : (data.startTime ?? current.startTime),
- durationMinutes: multi ? null : (data.duration ?? current.durationMinutes)
- }
- this.updateEventDatesAndReindex(eventId, payload)
- this.refreshEvents()
- return
- }
- // No date change: update in place across instances
- for (const [, list] of this.events) {
- for (let i = 0; i < list.length; i++) {
- if (list[i].id === eventId) {
- const isMulti = list[i].startDate !== list[i].endDate
- list[i] = {
- ...list[i],
- title: data.title.trim(),
- colorId: data.colorId,
- startTime: isMulti ? null : data.startTime,
- durationMinutes: isMulti ? null : data.duration
- }
- }
- }
- }
- this.refreshEvents()
- }
-
- getEventById(id) {
- for (const [, list] of this.events) {
- const found = list.find(e => e.id === id)
- if (found) return found
- }
- return null
- }
-
- selectEventColorId(startDateStr, endDateStr) {
- // Count frequency of each color used on the date range
- const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0]
- const startDate = new Date(fromLocalString(startDateStr))
- const endDate = new Date(fromLocalString(endDateStr))
-
- for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
- const dateStr = toLocalString(d)
- const dayEvents = this.events.get(dateStr) || []
- for (const event of dayEvents) {
- if (event.colorId >= 0 && event.colorId < 8) {
- colorCounts[event.colorId]++
- }
- }
- }
-
- // Find the color with the lowest count
- // For equal counts, prefer the lowest color number
- let minCount = colorCounts[0]
- let selectedColor = 0
-
- for (let colorId = 1; colorId < 8; colorId++) {
- if (colorCounts[colorId] < minCount) {
- minCount = colorCounts[colorId]
- selectedColor = colorId
- }
- }
-
- return selectedColor
- }
+ // -------- Event Rendering (overlay-based) --------
refreshEvents() {
for (const [, weekEl] of this.visibleWeeks) {
@@ -904,10 +480,10 @@ class InfiniteCalendar {
while (overlay.firstChild) overlay.removeChild(overlay.firstChild)
- const weekEvents = new Map()
+ const weekEvents = new Map()
for (const cell of cells) {
const dateStr = cell.dataset.date
- const events = this.events.get(dateStr) || []
+ const events = this.eventManager.events.get(dateStr) || []
for (const ev of events) {
if (!weekEvents.has(ev.id)) {
weekEvents.set(ev.id, {
@@ -926,8 +502,8 @@ class InfiniteCalendar {
}
// If dragging, hide the original of the dragged event and inject preview if it intersects this week
- if (this.dragPreview && this.dragPreview.id != null) {
- const pv = this.dragPreview
+ if (this.eventManager.dragPreview && this.eventManager.dragPreview.id != null) {
+ const pv = this.eventManager.dragPreview
// Remove original entries of the dragged event for this week to prevent ghosts
if (weekEvents.has(pv.id)) weekEvents.delete(pv.id)
// Determine week range
@@ -951,7 +527,7 @@ class InfiniteCalendar {
if (eIdx === -1) eIdx = cells.length - 1
// Build/override entry
- const baseEv = this.getEventById(pv.id)
+ const baseEv = this.eventManager.getEventById(pv.id)
if (baseEv) {
const entry = {
...baseEv,
@@ -1020,7 +596,7 @@ class InfiniteCalendar {
}
// Create the spans
- for (const w of spans) this.createOverlaySpan(overlay, w, weekEl)
+ for (const w of spans) this.createOverlaySpan(overlay, w, weekEl)
}
createOverlaySpan(overlay, w, weekEl) {
@@ -1031,13 +607,13 @@ class InfiniteCalendar {
span.textContent = w.title
span.title = `${w.title} (${w.startDate === w.endDate ? w.startDate : w.startDate + ' - ' + w.endDate})`
span.dataset.eventId = String(w.id)
- if (this.dragEventState && this.dragEventState.id === w.id) span.classList.add('dragging')
+ if (this.eventManager.dragEventState && this.eventManager.dragEventState.id === w.id) span.classList.add('dragging')
// Click opens edit if not dragging
span.addEventListener('click', e => {
e.stopPropagation()
- if (this.dragEventState || this.justDragged) return
- this.showEventDialog('edit', { id: w.id })
+ if (this.eventManager.dragEventState || this.eventManager.justDragged) return
+ this.eventManager.showEventDialog('edit', { id: w.id })
})
// Add resize handles
@@ -1054,7 +630,7 @@ class InfiniteCalendar {
ev.stopPropagation()
const point = ev.touches ? ev.touches[0] : ev
const hitAtStart = this.getDateUnderPointer(point.clientX, point.clientY)
- this.dragEventState = {
+ this.eventManager.dragEventState = {
mode,
id: w.id,
originWeek: weekEl,
@@ -1076,19 +652,19 @@ class InfiniteCalendar {
else if (anchorDate > w.endDate) anchorOffset = spanDays - 1
else anchorOffset = daysInclusive(w.startDate, anchorDate) - 1
}
- this.dragEventState.anchorOffset = anchorOffset
- this.dragEventState.originSpanDays = spanDays
- this.dragEventState.originalStartDate = w.startDate
- this.dragEventState.originalEndDate = w.endDate
+ this.eventManager.dragEventState.anchorOffset = anchorOffset
+ this.eventManager.dragEventState.originSpanDays = spanDays
+ this.eventManager.dragEventState.originalStartDate = w.startDate
+ this.eventManager.dragEventState.originalEndDate = w.endDate
// capture pointer to ensure we receive the up even if cursor leaves element
- if (this.dragEventState.usingPointer && span.setPointerCapture && ev.pointerId != null) {
+ if (this.eventManager.dragEventState.usingPointer && span.setPointerCapture && ev.pointerId != null) {
try { span.setPointerCapture(ev.pointerId) } catch {}
}
- this.dragEventState.element = span
- this.dragEventState.currentOverlay = overlay
- this._eventDragMoved = false
+ this.eventManager.dragEventState.element = span
+ this.eventManager.dragEventState.currentOverlay = overlay
+ this.eventManager._eventDragMoved = false
span.classList.add('dragging')
- this.installGlobalEventDragHandlers()
+ this.eventManager.installGlobalEventDragHandlers()
}
// Mouse
left.addEventListener('mousedown', e => onPointerDown('resize-left', e))
@@ -1114,140 +690,6 @@ class InfiniteCalendar {
overlay.appendChild(span)
}
- installGlobalEventDragHandlers() {
- if (this._installedEventDrag) return
- this._installedEventDrag = true
- this._onMouseMoveEventDrag = e => this.onEventDragMove(e)
- this._onMouseUpEventDrag = e => this.onEventDragEnd(e)
- document.addEventListener('mousemove', this._onMouseMoveEventDrag)
- document.addEventListener('mouseup', this._onMouseUpEventDrag)
- // touch
- this._onTouchMoveEventDrag = e => this.onEventDragMove(e)
- this._onTouchEndEventDrag = e => this.onEventDragEnd(e)
- document.addEventListener('touchmove', this._onTouchMoveEventDrag, { passive: false })
- document.addEventListener('touchend', this._onTouchEndEventDrag)
- // window-level safety to prevent stuck drags
- this._onWindowMouseUpEventDrag = e => this.onEventDragEnd(e)
- this._onWindowBlurEventDrag = () => this.onEventDragEnd()
- window.addEventListener('mouseup', this._onWindowMouseUpEventDrag)
- window.addEventListener('blur', this._onWindowBlurEventDrag)
- // pointer events
- this._onPointerMoveEventDrag = e => this.onEventDragMove(e)
- this._onPointerUpEventDrag = e => this.onEventDragEnd(e)
- this._onPointerCancelEventDrag = e => this.onEventDragEnd(e)
- window.addEventListener('pointermove', this._onPointerMoveEventDrag)
- window.addEventListener('pointerup', this._onPointerUpEventDrag)
- window.addEventListener('pointercancel', this._onPointerCancelEventDrag)
- }
-
- removeGlobalEventDragHandlers() {
- if (!this._installedEventDrag) return
- document.removeEventListener('mousemove', this._onMouseMoveEventDrag)
- document.removeEventListener('mouseup', this._onMouseUpEventDrag)
- document.removeEventListener('touchmove', this._onTouchMoveEventDrag)
- document.removeEventListener('touchend', this._onTouchEndEventDrag)
- window.removeEventListener('mouseup', this._onWindowMouseUpEventDrag)
- window.removeEventListener('blur', this._onWindowBlurEventDrag)
- window.removeEventListener('pointermove', this._onPointerMoveEventDrag)
- window.removeEventListener('pointerup', this._onPointerUpEventDrag)
- window.removeEventListener('pointercancel', this._onPointerCancelEventDrag)
- this._installedEventDrag = false
- }
-
- onEventDragMove(e) {
- if (!this.dragEventState) return
- if (this.dragEventState.usingPointer && !(e && e.type && e.type.startsWith('pointer'))) return
- const { pointerStartX } = this.dragEventState
- if (e && e.cancelable) e.preventDefault()
- const pt = e.touches ? e.touches[0] : e
- const hit = pt ? this.getDateUnderPointer(pt.clientX, pt.clientY) : null
- if (hit && hit.date) {
- const [s, en] = this.computeTentativeRangeFromPointer(hit.date)
- this.dragPreview = { id: this.dragEventState.id, startDate: s, endDate: en }
- } else {
- this.dragPreview = null
- }
- this._eventDragMoved = true
- this.forceUpdateVisibleWeeks()
- }
-
- onEventDragEnd(e) {
- if (!this.dragEventState) return
- if (this.dragEventState.usingPointer && e && !(e.type && e.type.startsWith('pointer'))) {
- // Ignore mouse/touch ups while using pointer stream
- return
- }
- const st = this.dragEventState
- const weekEl = st.originWeek
- const getPoint = ev => (ev && ev.changedTouches && ev.changedTouches[0]) ? ev.changedTouches[0] : (ev && ev.touches ? ev.touches[0] : ev)
- const pt = getPoint(e)
- let startDateStr = this.dragPreview?.startDate
- let endDateStr = this.dragPreview?.endDate
- // If no preview strings were set, derive from pointer now
- if (!startDateStr || !endDateStr) {
- const drop = pt ? this.getDateUnderPointer(pt.clientX, pt.clientY) : null
- if (drop && drop.date) {
- const pair = this.computeTentativeRangeFromPointer(drop.date)
- startDateStr = pair[0]
- endDateStr = pair[1]
- } else {
- // Fallback: keep original
- startDateStr = st.startDate
- endDateStr = st.endDate
- }
- }
-
- // Apply transformation: move or resize.
- const ev = this.getEventById(st.id)
- if (ev) {
- const updated = { ...ev }
- if (st.mode === 'move') {
- const spanDays = daysInclusive(ev.startDate, ev.endDate)
- updated.startDate = startDateStr
- updated.endDate = addDaysStr(startDateStr, spanDays - 1)
- } else {
- // Resize left/right updates start or end date
- if (startDateStr <= endDateStr) {
- updated.startDate = startDateStr
- updated.endDate = endDateStr
- }
- }
-
- // If now spans more than 1 day, force full-day semantics
- let [ns, ne] = this.normalizeDateOrder(updated.startDate, updated.endDate)
- updated.startDate = ns
- updated.endDate = ne
- const multi = daysInclusive(updated.startDate, updated.endDate) > 1
- if (multi) {
- updated.startTime = null
- updated.durationMinutes = null
- } else {
- // Single-day: ensure we have a time window
- if (!updated.startTime) updated.startTime = '09:00'
- if (!updated.durationMinutes) updated.durationMinutes = 60
- }
-
- this.updateEventDatesAndReindex(ev.id, updated)
- }
-
- // Cleanup
- // No need to directly manipulate DOM; we re-render
- // release pointer capture if any
- try {
- if (st.usingPointer && st.element && st.element.releasePointerCapture && e && e.pointerId != null) {
- st.element.releasePointerCapture(e.pointerId)
- }
- } catch {}
- this.dragEventState = null
- this.justDragged = !!this._eventDragMoved
- this._eventDragMoved = false
- this.removeGlobalEventDragHandlers()
- this.forceUpdateVisibleWeeks()
- // Clear justDragged after microtask so subsequent click isn't fired as edit
- setTimeout(() => { this.justDragged = false }, 0)
- this.dragPreview = null
- }
-
getDateUnderPointer(clientX, clientY) {
const el = document.elementFromPoint(clientX, clientY)
if (!el) return null
@@ -1273,78 +715,6 @@ class InfiniteCalendar {
const cell = cells[col]
return cell ? { weekEl, overlay, col, date: cell.dataset.date } : null
}
-
- computeTentativeRangeFromPointer(dropDateStr) {
- const st = this.dragEventState
- if (!st) return [null, null]
- const anchorOffset = st.anchorOffset || 0
- const spanDays = st.originSpanDays || daysInclusive(st.startDate, st.endDate)
- let startStr = st.startDate
- let endStr = st.endDate
- if (st.mode === 'move') {
- startStr = addDaysStr(dropDateStr, -anchorOffset)
- endStr = addDaysStr(startStr, spanDays - 1)
- } else if (st.mode === 'resize-left') {
- startStr = dropDateStr
- endStr = st.originalEndDate || st.endDate
- } else if (st.mode === 'resize-right') {
- startStr = st.originalStartDate || st.startDate
- endStr = dropDateStr
- }
- const [ns, ne] = this.normalizeDateOrder(startStr, endStr)
- return [ns, ne]
- }
-
- computeSpanIndicesForWeek(cells, startStr, endStr) {
- if (!cells || cells.length !== 7) return null
- if (!startStr || !endStr) return null
- const sIdx = cells.findIndex(c => c.dataset.date >= startStr)
- const eIdx = (() => {
- let idx = -1
- for (let i = 0; i < cells.length; i++) {
- if (cells[i].dataset.date <= endStr) idx = i
- }
- return idx
- })()
- if (sIdx === -1 || eIdx === -1 || sIdx > 6 || eIdx < 0) return null
- const start = Math.max(0, sIdx)
- const end = Math.min(6, eIdx)
- if (start > end) return null
- return [start, end]
- }
-
- // (preview functions removed; moving actual element during drag)
- normalizeDateOrder(aStr, bStr) {
- if (!aStr) return [bStr, bStr]
- if (!bStr) return [aStr, aStr]
- return aStr <= bStr ? [aStr, bStr] : [bStr, aStr]
- }
-
- updateEventDatesAndReindex(eventId, updated) {
- // Remove old instances
- for (const [date, list] of this.events) {
- const idx = list.findIndex(e => e.id === eventId)
- if (idx !== -1) list.splice(idx, 1)
- if (list.length === 0) this.events.delete(date)
- }
- // Re-add across new range
- const start = new Date(fromLocalString(updated.startDate))
- const end = new Date(fromLocalString(updated.endDate))
- const base = {
- id: updated.id,
- title: updated.title,
- colorId: updated.colorId,
- startDate: updated.startDate,
- endDate: updated.endDate,
- startTime: updated.startTime,
- durationMinutes: updated.durationMinutes
- }
- for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
- const ds = toLocalString(d)
- if (!this.events.has(ds)) this.events.set(ds, [])
- this.events.get(ds).push({ ...base, isSpanning: start < end })
- }
- }
}
document.addEventListener('DOMContentLoaded', () => {
diff --git a/event-manager.js b/event-manager.js
new file mode 100644
index 0000000..ebf7c0d
--- /dev/null
+++ b/event-manager.js
@@ -0,0 +1,592 @@
+// event-manager.js — Event creation, editing, drag/drop, and selection logic
+import {
+ toLocalString,
+ fromLocalString,
+ daysInclusive,
+ addDaysStr,
+ formatDateRange
+} from './date-utils.js'
+
+export class EventManager {
+ constructor(calendar) {
+ this.calendar = calendar
+ this.events = new Map() // Map of date strings to arrays of events
+ this.eventIdCounter = 1
+
+ // Selection state
+ this.selStart = null
+ this.selEnd = null
+ this.isDragging = false
+ this.dragAnchor = null
+
+ // Event drag state
+ this.dragEventState = null
+ this.dragPreview = null
+ this.justDragged = false
+ this._eventDragMoved = false
+ this._installedEventDrag = false
+
+ this.setupEventDialog()
+ }
+
+ // -------- Selection Logic --------
+
+ clampRange(anchorStr, otherStr) {
+ if (this.calendar.config.select_days <= 1) return [otherStr, otherStr]
+ const limit = this.calendar.config.select_days
+ const forward = fromLocalString(otherStr) >= fromLocalString(anchorStr)
+ const span = daysInclusive(anchorStr, otherStr)
+ if (span <= limit) {
+ const a = [anchorStr, otherStr].sort()
+ return [a[0], a[1]]
+ }
+ if (forward) return [anchorStr, addDaysStr(anchorStr, limit - 1)]
+ return [addDaysStr(anchorStr, -(limit - 1)), anchorStr]
+ }
+
+ setSelection(aStr, bStr) {
+ const [start, end] = this.clampRange(aStr, bStr)
+ this.selStart = start
+ this.selEnd = end
+ this.applySelectionToVisible()
+ this.calendar.selectedDateInput.value = formatDateRange(fromLocalString(start), fromLocalString(end))
+ }
+
+ clearSelection() {
+ this.selStart = null
+ this.selEnd = null
+ for (const [, weekEl] of this.calendar.visibleWeeks) {
+ weekEl.querySelectorAll('.cell.selected').forEach(c => c.classList.remove('selected'))
+ }
+ this.calendar.selectedDateInput.value = ''
+ }
+
+ applySelectionToVisible() {
+ for (const [, weekEl] of this.calendar.visibleWeeks) {
+ const cells = weekEl.querySelectorAll('.cell[data-date]')
+ for (const cell of cells) {
+ if (!this.selStart || !this.selEnd) {
+ cell.classList.remove('selected')
+ continue
+ }
+ const ds = cell.dataset.date
+ const inRange = ds >= this.selStart && ds <= this.selEnd
+ cell.classList.toggle('selected', inRange)
+ }
+ }
+ }
+
+ startDrag(dateStr) {
+ if (this.calendar.config.select_days === 0) return
+ this.isDragging = true
+ this.dragAnchor = dateStr
+ this.setSelection(dateStr, dateStr)
+ }
+
+ updateDrag(dateStr) {
+ if (!this.isDragging) return
+ this.setSelection(this.dragAnchor, dateStr)
+ document.body.style.cursor = 'default'
+ }
+
+ endDrag(dateStr) {
+ if (!this.isDragging) return
+ this.isDragging = false
+ this.setSelection(this.dragAnchor, dateStr)
+ document.body.style.cursor = 'default'
+ if (this.selStart && this.selEnd) {
+ setTimeout(() => this.showEventDialog('create'), 50)
+ }
+ }
+
+ // -------- Event Management --------
+
+ createEvent(eventData) {
+ const singleDay = eventData.startDate === eventData.endDate
+ const event = {
+ id: this.eventIdCounter++,
+ title: eventData.title,
+ startDate: eventData.startDate,
+ endDate: eventData.endDate,
+ colorId: eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
+ startTime: singleDay ? (eventData.startTime || '09:00') : null,
+ durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null
+ }
+
+ const startDate = new Date(fromLocalString(event.startDate))
+ const endDate = new Date(fromLocalString(event.endDate))
+
+ for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
+ const dateStr = toLocalString(d)
+ if (!this.events.has(dateStr)) this.events.set(dateStr, [])
+ this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate })
+ }
+
+ this.calendar.forceUpdateVisibleWeeks()
+ }
+
+ getEventById(id) {
+ for (const [, list] of this.events) {
+ const found = list.find(e => e.id === id)
+ if (found) return found
+ }
+ return null
+ }
+
+ selectEventColorId(startDateStr, endDateStr) {
+ const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0]
+ const startDate = new Date(fromLocalString(startDateStr))
+ const endDate = new Date(fromLocalString(endDateStr))
+
+ for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
+ const dateStr = toLocalString(d)
+ const dayEvents = this.events.get(dateStr) || []
+ for (const event of dayEvents) {
+ if (event.colorId >= 0 && event.colorId < 8) {
+ colorCounts[event.colorId]++
+ }
+ }
+ }
+
+ let minCount = colorCounts[0]
+ let selectedColor = 0
+
+ for (let colorId = 1; colorId < 8; colorId++) {
+ if (colorCounts[colorId] < minCount) {
+ minCount = colorCounts[colorId]
+ selectedColor = colorId
+ }
+ }
+
+ return selectedColor
+ }
+
+ applyEventEdit(eventId, data) {
+ const current = this.getEventById(eventId)
+ if (!current) return
+ const newStart = data.startDate || current.startDate
+ const newEnd = data.endDate || current.endDate
+ const datesChanged = (newStart !== current.startDate) || (newEnd !== current.endDate)
+ if (datesChanged) {
+ const multi = daysInclusive(newStart, newEnd) > 1
+ const payload = {
+ ...current,
+ title: data.title.trim(),
+ colorId: data.colorId,
+ startDate: newStart,
+ endDate: newEnd,
+ startTime: multi ? null : (data.startTime ?? current.startTime),
+ durationMinutes: multi ? null : (data.duration ?? current.durationMinutes)
+ }
+ this.updateEventDatesAndReindex(eventId, payload)
+ this.calendar.forceUpdateVisibleWeeks()
+ return
+ }
+ // No date change: update in place across instances
+ for (const [, list] of this.events) {
+ for (let i = 0; i < list.length; i++) {
+ if (list[i].id === eventId) {
+ const isMulti = list[i].startDate !== list[i].endDate
+ list[i] = {
+ ...list[i],
+ title: data.title.trim(),
+ colorId: data.colorId,
+ startTime: isMulti ? null : data.startTime,
+ durationMinutes: isMulti ? null : data.duration
+ }
+ }
+ }
+ }
+ this.calendar.forceUpdateVisibleWeeks()
+ }
+
+ updateEventDatesAndReindex(eventId, updated) {
+ // Remove old instances
+ for (const [date, list] of this.events) {
+ const idx = list.findIndex(e => e.id === eventId)
+ if (idx !== -1) list.splice(idx, 1)
+ if (list.length === 0) this.events.delete(date)
+ }
+ // Re-add across new range
+ const start = new Date(fromLocalString(updated.startDate))
+ const end = new Date(fromLocalString(updated.endDate))
+ const base = {
+ id: updated.id,
+ title: updated.title,
+ colorId: updated.colorId,
+ startDate: updated.startDate,
+ endDate: updated.endDate,
+ startTime: updated.startTime,
+ durationMinutes: updated.durationMinutes
+ }
+ for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
+ const ds = toLocalString(d)
+ if (!this.events.has(ds)) this.events.set(ds, [])
+ this.events.get(ds).push({ ...base, isSpanning: start < end })
+ }
+ }
+
+ // -------- Event Dialog --------
+
+ setupEventDialog() {
+ const tpl = document.createElement('template')
+ tpl.innerHTML = `
+ `
+
+ document.body.appendChild(tpl.content)
+ this.eventModal = document.querySelector('.ec-modal-backdrop')
+ this.eventForm = this.eventModal.querySelector('form.ec-form')
+ this.eventTitleInput = this.eventForm.elements['title']
+ this.eventStartDateInput = this.eventForm.elements['startDate']
+ this.eventStartTimeInput = this.eventForm.elements['startTime']
+ this.eventDurationInput = this.eventForm.elements['duration']
+ this.eventTimeRow = this.eventForm.querySelector('.ec-time-row')
+ this.eventColorInputs = Array.from(this.eventForm.querySelectorAll('input[name="colorId"]'))
+
+ // Duration change toggles time visibility
+ this.eventDurationInput.addEventListener('change', () => this.updateTimeVisibilityByDuration())
+
+ // Color selection visual state
+ this.eventColorInputs.forEach(radio => {
+ radio.addEventListener('change', () => {
+ const swatches = this.eventForm.querySelectorAll('.ec-color-swatches .swatch')
+ swatches.forEach(s => s.classList.toggle('selected', s.checked))
+ })
+ })
+
+ this.eventForm.addEventListener('submit', e => {
+ e.preventDefault()
+ const data = this.readEventForm()
+ if (!data.title.trim()) return
+ if (this._dialogMode === 'create') {
+ const computed = this.computeDatesFromForm(data)
+ this.createEvent({
+ title: data.title.trim(),
+ startDate: computed.startDate,
+ endDate: computed.endDate,
+ colorId: data.colorId,
+ startTime: data.startTime,
+ durationMinutes: data.duration
+ })
+ this.clearSelection()
+ } else if (this._dialogMode === 'edit' && this._editingEventId != null) {
+ const computed = this.computeDatesFromForm(data)
+ this.applyEventEdit(this._editingEventId, { ...data, ...computed })
+ }
+ this.hideEventDialog()
+ })
+
+ this.eventForm.querySelector('[data-action="cancel"]').addEventListener('click', () => {
+ this.hideEventDialog()
+ if (this._dialogMode === 'create') this.clearSelection()
+ })
+
+ this.eventModal.addEventListener('click', e => {
+ if (e.target === this.eventModal) this.hideEventDialog()
+ })
+
+ document.addEventListener('keydown', e => {
+ if (this.eventModal.hidden) return
+ if (e.key === 'Escape') {
+ this.hideEventDialog()
+ if (this._dialogMode === 'create') this.clearSelection()
+ }
+ })
+ }
+
+ showEventDialog(mode, opts = {}) {
+ this._dialogMode = mode
+ this._editingEventId = null
+
+ if (mode === 'create') {
+ this.eventTitleInput.value = ''
+ this.eventStartTimeInput.value = '09:00'
+ this.eventStartDateInput.value = this.selStart || toLocalString(new Date())
+ if (this.selStart && this.selEnd) {
+ const days = daysInclusive(this.selStart, this.selEnd)
+ this.setDurationValue(days * 1440)
+ } else {
+ this.setDurationValue(60)
+ }
+ const suggested = this.selectEventColorId(this.selStart, this.selEnd)
+ this.eventColorInputs.forEach(r => r.checked = Number(r.value) === suggested)
+ this.updateTimeVisibilityByDuration()
+ } else if (mode === 'edit') {
+ const ev = this.getEventById(opts.id)
+ if (!ev) return
+ this._editingEventId = ev.id
+ this.eventTitleInput.value = ev.title || ''
+ this.eventStartDateInput.value = ev.startDate
+ if (ev.startDate !== ev.endDate) {
+ const days = daysInclusive(ev.startDate, ev.endDate)
+ this.setDurationValue(days * 1440)
+ } else {
+ this.setDurationValue(ev.durationMinutes || 60)
+ }
+ this.eventStartTimeInput.value = ev.startTime || '09:00'
+ this.eventColorInputs.forEach(r => r.checked = Number(r.value) === (ev.colorId ?? 0))
+ this.updateTimeVisibilityByDuration()
+ }
+ this.eventModal.hidden = false
+ setTimeout(() => this.eventTitleInput.focus(), 0)
+ }
+
+ hideEventDialog() {
+ this.eventModal.hidden = true
+ }
+
+ toggleTimeRow(show) {
+ if (!this.eventTimeRow) return
+ this.eventTimeRow.style.display = show ? '' : 'none'
+ }
+
+ updateTimeVisibilityByDuration() {
+ const minutes = Number(this.eventDurationInput.value || 0)
+ const isFullDayOrMore = minutes >= 1440
+ this.toggleTimeRow(!isFullDayOrMore)
+ }
+
+ readEventForm() {
+ const colorId = Number(this.eventForm.querySelector('input[name="colorId"]:checked')?.value ?? 0)
+ const timeRowVisible = this.eventTimeRow && this.eventTimeRow.style.display !== 'none'
+ return {
+ title: this.eventTitleInput.value,
+ startDate: this.eventStartDateInput.value,
+ startTime: timeRowVisible ? (this.eventStartTimeInput.value || '09:00') : null,
+ duration: timeRowVisible ? Math.max(15, Number(this.eventDurationInput.value) || 60) : null,
+ colorId
+ }
+ }
+
+ setDurationValue(minutes) {
+ const v = String(minutes)
+ const exists = Array.from(this.eventDurationInput.options).some(o => o.value === v)
+ if (!exists) {
+ const opt = document.createElement('option')
+ opt.value = v
+ const days = Math.floor(minutes / 1440)
+ opt.textContent = days >= 1 ? `${days} day${days > 1 ? 's' : ''}` : `${minutes} minutes`
+ this.eventDurationInput.appendChild(opt)
+ }
+ this.eventDurationInput.value = v
+ }
+
+ computeDatesFromForm(data) {
+ const minutes = Number(this.eventDurationInput.value || 0)
+ if (minutes >= 1440) {
+ const days = Math.max(1, Math.floor(minutes / 1440))
+ return { startDate: data.startDate, endDate: addDaysStr(data.startDate, days - 1) }
+ }
+ return { startDate: data.startDate, endDate: data.startDate }
+ }
+
+ // -------- Event Drag & Drop --------
+
+ installGlobalEventDragHandlers() {
+ if (this._installedEventDrag) return
+ this._installedEventDrag = true
+ this._onMouseMoveEventDrag = e => this.onEventDragMove(e)
+ this._onMouseUpEventDrag = e => this.onEventDragEnd(e)
+ document.addEventListener('mousemove', this._onMouseMoveEventDrag)
+ document.addEventListener('mouseup', this._onMouseUpEventDrag)
+
+ this._onTouchMoveEventDrag = e => this.onEventDragMove(e)
+ this._onTouchEndEventDrag = e => this.onEventDragEnd(e)
+ document.addEventListener('touchmove', this._onTouchMoveEventDrag, { passive: false })
+ document.addEventListener('touchend', this._onTouchEndEventDrag)
+
+ this._onPointerMoveEventDrag = e => this.onEventDragMove(e)
+ this._onPointerUpEventDrag = e => this.onEventDragEnd(e)
+ this._onPointerCancelEventDrag = e => this.onEventDragEnd(e)
+ window.addEventListener('pointermove', this._onPointerMoveEventDrag)
+ window.addEventListener('pointerup', this._onPointerUpEventDrag)
+ window.addEventListener('pointercancel', this._onPointerCancelEventDrag)
+
+ this._onWindowMouseUpEventDrag = e => this.onEventDragEnd(e)
+ this._onWindowBlurEventDrag = () => this.onEventDragEnd()
+ window.addEventListener('mouseup', this._onWindowMouseUpEventDrag)
+ window.addEventListener('blur', this._onWindowBlurEventDrag)
+ }
+
+ removeGlobalEventDragHandlers() {
+ if (!this._installedEventDrag) return
+ document.removeEventListener('mousemove', this._onMouseMoveEventDrag)
+ document.removeEventListener('mouseup', this._onMouseUpEventDrag)
+ document.removeEventListener('touchmove', this._onTouchMoveEventDrag)
+ document.removeEventListener('touchend', this._onTouchEndEventDrag)
+ window.removeEventListener('pointermove', this._onPointerMoveEventDrag)
+ window.removeEventListener('pointerup', this._onPointerUpEventDrag)
+ window.removeEventListener('pointercancel', this._onPointerCancelEventDrag)
+ window.removeEventListener('mouseup', this._onWindowMouseUpEventDrag)
+ window.removeEventListener('blur', this._onWindowBlurEventDrag)
+ this._installedEventDrag = false
+ }
+
+ onEventDragMove(e) {
+ if (!this.dragEventState) return
+ if (this.dragEventState.usingPointer && !(e && e.type && e.type.startsWith('pointer'))) return
+
+ if (e && e.cancelable) e.preventDefault()
+ const pt = e.touches ? e.touches[0] : e
+ const hit = pt ? this.calendar.getDateUnderPointer(pt.clientX, pt.clientY) : null
+ if (hit && hit.date) {
+ const [s, en] = this.computeTentativeRangeFromPointer(hit.date)
+ this.dragPreview = { id: this.dragEventState.id, startDate: s, endDate: en }
+ } else {
+ this.dragPreview = null
+ }
+ this._eventDragMoved = true
+ this.calendar.forceUpdateVisibleWeeks()
+ }
+
+ onEventDragEnd(e) {
+ if (!this.dragEventState) return
+ if (this.dragEventState.usingPointer && e && !(e.type && e.type.startsWith('pointer'))) {
+ return
+ }
+
+ const st = this.dragEventState
+
+ let startDateStr = this.dragPreview?.startDate
+ let endDateStr = this.dragPreview?.endDate
+
+ if (!startDateStr || !endDateStr) {
+ const getPoint = ev => (ev && ev.changedTouches && ev.changedTouches[0]) ? ev.changedTouches[0] : (ev && ev.touches ? ev.touches[0] : ev)
+ const pt = getPoint(e)
+ const drop = pt ? this.calendar.getDateUnderPointer(pt.clientX, pt.clientY) : null
+ if (drop && drop.date) {
+ const pair = this.computeTentativeRangeFromPointer(drop.date)
+ startDateStr = pair[0]
+ endDateStr = pair[1]
+ } else {
+ startDateStr = st.startDate
+ endDateStr = st.endDate
+ }
+ }
+
+ const ev = this.getEventById(st.id)
+ if (ev) {
+ const updated = { ...ev }
+ if (st.mode === 'move') {
+ const spanDays = daysInclusive(ev.startDate, ev.endDate)
+ updated.startDate = startDateStr
+ updated.endDate = addDaysStr(startDateStr, spanDays - 1)
+ } else {
+ if (startDateStr <= endDateStr) {
+ updated.startDate = startDateStr
+ updated.endDate = endDateStr
+ }
+ }
+
+ let [ns, ne] = this.normalizeDateOrder(updated.startDate, updated.endDate)
+ updated.startDate = ns
+ updated.endDate = ne
+ const multi = daysInclusive(updated.startDate, updated.endDate) > 1
+ if (multi) {
+ updated.startTime = null
+ updated.durationMinutes = null
+ } else {
+ if (!updated.startTime) updated.startTime = '09:00'
+ if (!updated.durationMinutes) updated.durationMinutes = 60
+ }
+
+ this.updateEventDatesAndReindex(ev.id, updated)
+ }
+
+ try {
+ if (st.usingPointer && st.element && st.element.releasePointerCapture && e && e.pointerId != null) {
+ st.element.releasePointerCapture(e.pointerId)
+ }
+ } catch {}
+
+ this.dragEventState = null
+ this.justDragged = !!this._eventDragMoved
+ this._eventDragMoved = false
+ this.removeGlobalEventDragHandlers()
+ this.calendar.forceUpdateVisibleWeeks()
+ setTimeout(() => { this.justDragged = false }, 0)
+ this.dragPreview = null
+ }
+
+ computeTentativeRangeFromPointer(dropDateStr) {
+ const st = this.dragEventState
+ if (!st) return [null, null]
+ const anchorOffset = st.anchorOffset || 0
+ const spanDays = st.originSpanDays || daysInclusive(st.startDate, st.endDate)
+ let startStr = st.startDate
+ let endStr = st.endDate
+ if (st.mode === 'move') {
+ startStr = addDaysStr(dropDateStr, -anchorOffset)
+ endStr = addDaysStr(startStr, spanDays - 1)
+ } else if (st.mode === 'resize-left') {
+ startStr = dropDateStr
+ endStr = st.originalEndDate || st.endDate
+ } else if (st.mode === 'resize-right') {
+ startStr = st.originalStartDate || st.startDate
+ endStr = dropDateStr
+ }
+ const [ns, ne] = this.normalizeDateOrder(startStr, endStr)
+ return [ns, ne]
+ }
+
+ normalizeDateOrder(aStr, bStr) {
+ if (!aStr) return [bStr, bStr]
+ if (!bStr) return [aStr, aStr]
+ return aStr <= bStr ? [aStr, bStr] : [bStr, aStr]
+ }
+}