From 1edb3f0e8590596e8827e2fc78772ea4e83f97d8 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 20 Aug 2025 21:04:27 -0600 Subject: [PATCH] Refactor event and selection handling to a separate module. --- calendar.js | 698 +++-------------------------------------------- event-manager.js | 592 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 626 insertions(+), 664 deletions(-) create mode 100644 event-manager.js 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] + } +}