Refactor event and selection handling to a separate module.
This commit is contained in:
parent
474b9fd497
commit
1edb3f0e85
698
calendar.js
698
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 = `
|
||||
<div class="ec-modal-backdrop" part="backdrop" hidden>
|
||||
<div class="ec-modal" role="dialog" aria-modal="true" aria-labelledby="ec-modal-title">
|
||||
<form class="ec-form" novalidate>
|
||||
<header class="ec-header">
|
||||
<h2 id="ec-modal-title">Event</h2>
|
||||
</header>
|
||||
<div class="ec-body">
|
||||
<label class="ec-field">
|
||||
<span>Title</span>
|
||||
<input type="text" name="title" autocomplete="off" required />
|
||||
</label>
|
||||
<div class="ec-row">
|
||||
<label class="ec-field">
|
||||
<span>Start day</span>
|
||||
<input type="date" name="startDate" />
|
||||
</label>
|
||||
<label class="ec-field">
|
||||
<span>Duration</span>
|
||||
<select name="duration">
|
||||
<option value="15">15 minutes</option>
|
||||
<option value="30">30 minutes</option>
|
||||
<option value="45">45 minutes</option>
|
||||
<option value="60" selected>1 hour</option>
|
||||
<option value="90">1.5 hours</option>
|
||||
<option value="120">2 hours</option>
|
||||
<option value="180">3 hours</option>
|
||||
<option value="240">4 hours</option>
|
||||
<option value="480">8 hours</option>
|
||||
<option value="720">12 hours</option>
|
||||
<option value="1440">Full day</option>
|
||||
<option value="2880">2 days</option>
|
||||
<option value="4320">3 days</option>
|
||||
<option value="10080">7 days</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ec-row ec-time-row">
|
||||
<label class="ec-field">
|
||||
<span>Start time</span>
|
||||
<input type="time" name="startTime" step="300" />
|
||||
</label>
|
||||
<div></div>
|
||||
</div>
|
||||
<div class="ec-color-swatches">
|
||||
${Array.from({ length: 8 }, (_, i) => `
|
||||
<input class="swatch event-color-${i}" type="radio" name="colorId" value="${i}">
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<footer class="ec-footer">
|
||||
<button type="button" class="ec-btn" data-action="cancel">Cancel</button>
|
||||
<button type="submit" class="ec-btn primary">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
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', () => {
|
||||
|
592
event-manager.js
Normal file
592
event-manager.js
Normal file
@ -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 = `
|
||||
<div class="ec-modal-backdrop" part="backdrop" hidden>
|
||||
<div class="ec-modal" role="dialog" aria-modal="true" aria-labelledby="ec-modal-title">
|
||||
<form class="ec-form" novalidate>
|
||||
<header class="ec-header">
|
||||
<h2 id="ec-modal-title">Event</h2>
|
||||
</header>
|
||||
<div class="ec-body">
|
||||
<label class="ec-field">
|
||||
<span>Title</span>
|
||||
<input type="text" name="title" autocomplete="off" required />
|
||||
</label>
|
||||
<div class="ec-row">
|
||||
<label class="ec-field">
|
||||
<span>Start day</span>
|
||||
<input type="date" name="startDate" />
|
||||
</label>
|
||||
<label class="ec-field">
|
||||
<span>Duration</span>
|
||||
<select name="duration">
|
||||
<option value="15">15 minutes</option>
|
||||
<option value="30">30 minutes</option>
|
||||
<option value="45">45 minutes</option>
|
||||
<option value="60" selected>1 hour</option>
|
||||
<option value="90">1.5 hours</option>
|
||||
<option value="120">2 hours</option>
|
||||
<option value="180">3 hours</option>
|
||||
<option value="240">4 hours</option>
|
||||
<option value="480">8 hours</option>
|
||||
<option value="720">12 hours</option>
|
||||
<option value="1440">Full day</option>
|
||||
<option value="2880">2 days</option>
|
||||
<option value="4320">3 days</option>
|
||||
<option value="10080">7 days</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ec-row ec-time-row">
|
||||
<label class="ec-field">
|
||||
<span>Start time</span>
|
||||
<input type="time" name="startTime" step="300" />
|
||||
</label>
|
||||
<div></div>
|
||||
</div>
|
||||
<div class="ec-color-swatches">
|
||||
${Array.from({ length: 8 }, (_, i) => `
|
||||
<input class="swatch event-color-${i}" type="radio" name="colorId" value="${i}">
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<footer class="ec-footer">
|
||||
<button type="button" class="ec-btn" data-action="cancel">Cancel</button>
|
||||
<button type="submit" class="ec-btn primary">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user