// 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] } }