852 lines
30 KiB
JavaScript
852 lines
30 KiB
JavaScript
// 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()
|
|
}
|
|
|
|
createEventWithRepeat(eventData) {
|
|
const { repeat, repeatCount, ...baseEventData } = eventData
|
|
|
|
if (repeat === 'none') {
|
|
// Single event
|
|
this.createEvent(baseEventData)
|
|
return
|
|
}
|
|
|
|
// Calculate dates for repeating events
|
|
const startDate = new Date(fromLocalString(baseEventData.startDate))
|
|
const endDate = new Date(fromLocalString(baseEventData.endDate))
|
|
const spanDays = Math.floor((endDate - startDate) / (24 * 60 * 60 * 1000))
|
|
|
|
const maxOccurrences = repeatCount === 'unlimited' ? 104 : parseInt(repeatCount, 10) // Cap unlimited at ~2 years
|
|
const dates = []
|
|
|
|
for (let i = 0; i < maxOccurrences; i++) {
|
|
const currentStart = new Date(startDate)
|
|
|
|
switch (repeat) {
|
|
case 'daily':
|
|
currentStart.setDate(startDate.getDate() + i)
|
|
break
|
|
case 'weekly':
|
|
currentStart.setDate(startDate.getDate() + i * 7)
|
|
break
|
|
case 'biweekly':
|
|
currentStart.setDate(startDate.getDate() + i * 14)
|
|
break
|
|
case 'monthly':
|
|
currentStart.setMonth(startDate.getMonth() + i)
|
|
break
|
|
case 'yearly':
|
|
currentStart.setFullYear(startDate.getFullYear() + i)
|
|
break
|
|
}
|
|
|
|
const currentEnd = new Date(currentStart)
|
|
currentEnd.setDate(currentStart.getDate() + spanDays)
|
|
|
|
dates.push({
|
|
startDate: toLocalString(currentStart),
|
|
endDate: toLocalString(currentEnd)
|
|
})
|
|
}
|
|
|
|
// Create events for all dates
|
|
dates.forEach(({ startDate, endDate }) => {
|
|
this.createEvent({
|
|
...baseEventData,
|
|
startDate,
|
|
endDate
|
|
})
|
|
})
|
|
}
|
|
|
|
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>
|
|
<label class="ec-field">
|
|
<span>Repeat</span>
|
|
<select name="repeat">
|
|
<option value="none">No repeat</option>
|
|
<option value="daily">Daily</option>
|
|
<option value="weekly">Weekly</option>
|
|
<option value="biweekly">Every 2 weeks</option>
|
|
<option value="monthly">Monthly</option>
|
|
<option value="yearly">Yearly</option>
|
|
</select>
|
|
</label>
|
|
<div class="ec-repeat-count-row" style="display: none;">
|
|
<label class="ec-field">
|
|
<span>Number of occurrences</span>
|
|
<select name="repeatCount">
|
|
<option value="2">2 times</option>
|
|
<option value="3">3 times</option>
|
|
<option value="4">4 times</option>
|
|
<option value="5">5 times</option>
|
|
<option value="10">10 times</option>
|
|
<option value="52">52 times (1 year)</option>
|
|
<option value="unlimited">Unlimited</option>
|
|
</select>
|
|
</label>
|
|
</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.eventRepeatInput = this.eventForm.elements['repeat']
|
|
this.eventRepeatCountInput = this.eventForm.elements['repeatCount']
|
|
this.eventRepeatCountRow = this.eventForm.querySelector('.ec-repeat-count-row')
|
|
this.eventColorInputs = Array.from(this.eventForm.querySelectorAll('input[name="colorId"]'))
|
|
|
|
// Repeat change toggles repeat count visibility
|
|
this.eventRepeatInput.addEventListener('change', () => {
|
|
const showRepeatCount = this.eventRepeatInput.value !== 'none'
|
|
this.eventRepeatCountRow.style.display = showRepeatCount ? 'block' : 'none'
|
|
})
|
|
|
|
// 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') {
|
|
this.createEventWithRepeat({
|
|
title: data.title.trim(),
|
|
startDate: this.selStart,
|
|
endDate: this.selEnd,
|
|
colorId: data.colorId,
|
|
repeat: data.repeat,
|
|
repeatCount: data.repeatCount
|
|
})
|
|
this.clearSelection()
|
|
} else if (this._dialogMode === 'edit' && this._editingEventId != null) {
|
|
this.applyEventEdit(this._editingEventId, {
|
|
title: data.title.trim(),
|
|
colorId: data.colorId,
|
|
repeat: data.repeat,
|
|
repeatCount: data.repeatCount
|
|
})
|
|
}
|
|
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.eventRepeatInput.value = 'none'
|
|
this.eventRepeatCountInput.value = '5'
|
|
this.eventRepeatCountRow.style.display = 'none'
|
|
const suggested = this.selectEventColorId(this.selStart, this.selEnd)
|
|
this.eventColorInputs.forEach(r => r.checked = Number(r.value) === suggested)
|
|
} else if (mode === 'edit') {
|
|
const ev = this.getEventById(opts.id)
|
|
if (!ev) return
|
|
this._editingEventId = ev.id
|
|
this.eventTitleInput.value = ev.title || ''
|
|
this.eventRepeatInput.value = ev.repeat || 'none'
|
|
this.eventRepeatCountInput.value = ev.repeatCount || '5'
|
|
this.eventRepeatCountRow.style.display = (ev.repeat && ev.repeat !== 'none') ? 'block' : 'none'
|
|
this.eventColorInputs.forEach(r => r.checked = Number(r.value) === (ev.colorId ?? 0))
|
|
}
|
|
this.eventModal.hidden = false
|
|
setTimeout(() => this.eventTitleInput.focus(), 0)
|
|
}
|
|
|
|
hideEventDialog() {
|
|
this.eventModal.hidden = true
|
|
}
|
|
|
|
readEventForm() {
|
|
const colorId = Number(this.eventForm.querySelector('input[name="colorId"]:checked')?.value ?? 0)
|
|
return {
|
|
title: this.eventTitleInput.value,
|
|
repeat: this.eventRepeatInput.value,
|
|
repeatCount: this.eventRepeatCountInput.value,
|
|
colorId
|
|
}
|
|
}
|
|
|
|
// -------- 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
|
|
|
|
const pt = e.touches ? e.touches[0] : e
|
|
|
|
// Check if we've moved far enough to consider this a real drag
|
|
if (!this._eventDragMoved) {
|
|
const dx = pt.clientX - this.dragEventState.pointerStartX
|
|
const dy = pt.clientY - this.dragEventState.pointerStartY
|
|
const distance = Math.sqrt(dx * dx + dy * dy)
|
|
const minDragDistance = 5 // pixels
|
|
|
|
if (distance < minDragDistance) {
|
|
return // Don't start dragging yet
|
|
}
|
|
// Only prevent default when we actually start dragging
|
|
if (e && e.cancelable) e.preventDefault()
|
|
this._eventDragMoved = true
|
|
} else {
|
|
// Already dragging, continue to prevent default
|
|
if (e && e.cancelable) e.preventDefault()
|
|
}
|
|
|
|
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.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
|
|
|
|
// Only set justDragged if we actually moved and dragged
|
|
this.justDragged = !!this._eventDragMoved
|
|
|
|
this._eventDragMoved = false
|
|
this.removeGlobalEventDragHandlers()
|
|
|
|
// Only update visible weeks if we actually dragged
|
|
if (this.justDragged) {
|
|
this.calendar.forceUpdateVisibleWeeks()
|
|
}
|
|
|
|
// Clear justDragged flag after a short delay to allow click events to process
|
|
if (this.justDragged) {
|
|
setTimeout(() => {
|
|
this.justDragged = false
|
|
}, 100)
|
|
}
|
|
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]
|
|
}
|
|
|
|
addEventsToWeek(weekEl) {
|
|
const daysGrid = weekEl._daysGrid || weekEl.querySelector('.days-grid')
|
|
const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay')
|
|
if (!daysGrid || !overlay) return
|
|
|
|
const cells = Array.from(daysGrid.querySelectorAll('.cell[data-date]'))
|
|
|
|
while (overlay.firstChild) overlay.removeChild(overlay.firstChild)
|
|
|
|
const weekEvents = new Map()
|
|
for (const cell of cells) {
|
|
const dateStr = cell.dataset.date
|
|
const events = this.events.get(dateStr) || []
|
|
for (const ev of events) {
|
|
if (!weekEvents.has(ev.id)) {
|
|
weekEvents.set(ev.id, {
|
|
...ev,
|
|
startDateInWeek: dateStr,
|
|
endDateInWeek: dateStr,
|
|
startIdx: cells.indexOf(cell),
|
|
endIdx: cells.indexOf(cell)
|
|
})
|
|
} else {
|
|
const w = weekEvents.get(ev.id)
|
|
w.endDateInWeek = dateStr
|
|
w.endIdx = cells.indexOf(cell)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// 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
|
|
const weekStart = cells[0]?.dataset?.date
|
|
const weekEnd = cells[cells.length - 1]?.dataset?.date
|
|
if (weekStart && weekEnd) {
|
|
const s = pv.startDate
|
|
const e = pv.endDate
|
|
// Intersect preview with this week
|
|
const startInWeek = s <= weekEnd && e >= weekStart ? (s < weekStart ? weekStart : s) : null
|
|
const endInWeek = s <= weekEnd && e >= weekStart ? (e > weekEnd ? weekEnd : e) : null
|
|
if (startInWeek && endInWeek) {
|
|
// Compute indices
|
|
let sIdx = cells.findIndex(c => c.dataset.date === startInWeek)
|
|
if (sIdx === -1) sIdx = cells.findIndex(c => c.dataset.date > startInWeek)
|
|
if (sIdx === -1) sIdx = 0
|
|
let eIdx = -1
|
|
for (let i = 0; i < cells.length; i++) {
|
|
if (cells[i].dataset.date <= endInWeek) eIdx = i
|
|
}
|
|
if (eIdx === -1) eIdx = cells.length - 1
|
|
|
|
// Build/override entry
|
|
const baseEv = this.getEventById(pv.id)
|
|
if (baseEv) {
|
|
const entry = {
|
|
...baseEv,
|
|
startDateInWeek: startInWeek,
|
|
endDateInWeek: endInWeek,
|
|
startIdx: sIdx,
|
|
endIdx: eIdx
|
|
}
|
|
weekEvents.set(pv.id, entry)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const timeToMin = t => {
|
|
if (typeof t !== 'string') return 1e9
|
|
const m = t.match(/^(\d{2}):(\d{2})/)
|
|
if (!m) return 1e9
|
|
return Number(m[1]) * 60 + Number(m[2])
|
|
}
|
|
|
|
const spans = Array.from(weekEvents.values()).sort((a, b) => {
|
|
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
|
|
// Prefer longer spans to be placed first for packing
|
|
const aLen = a.endIdx - a.startIdx
|
|
const bLen = b.endIdx - b.startIdx
|
|
if (aLen !== bLen) return bLen - aLen
|
|
// Within the same day and same span length, order by start time
|
|
const at = timeToMin(a.startTime)
|
|
const bt = timeToMin(b.startTime)
|
|
if (at !== bt) return at - bt
|
|
// Stable fallback by id
|
|
return (a.id || 0) - (b.id || 0)
|
|
})
|
|
|
|
const rowsLastEnd = []
|
|
for (const w of spans) {
|
|
let placedRow = 0
|
|
while (placedRow < rowsLastEnd.length && !(w.startIdx > rowsLastEnd[placedRow])) placedRow++
|
|
if (placedRow === rowsLastEnd.length) rowsLastEnd.push(-1)
|
|
rowsLastEnd[placedRow] = w.endIdx
|
|
w._row = placedRow + 1
|
|
}
|
|
|
|
const numRows = Math.max(1, rowsLastEnd.length)
|
|
|
|
// Decide between "comfortable" layout (with gaps, not stretched)
|
|
// and "compressed" layout (fractional rows, no gaps) based on fit.
|
|
const cs = getComputedStyle(overlay)
|
|
const overlayHeight = overlay.getBoundingClientRect().height
|
|
const marginTopPx = parseFloat(cs.marginTop) || 0
|
|
const available = Math.max(0, overlayHeight - marginTopPx)
|
|
const baseEm = parseFloat(cs.fontSize) || 16
|
|
const rowPx = 1.2 * baseEm // preferred row height ~ 1.2em
|
|
const gapPx = 0.2 * baseEm // preferred gap ~ .2em
|
|
const needed = numRows * rowPx + (numRows - 1) * gapPx
|
|
|
|
if (needed <= available) {
|
|
// Comfortable: keep gaps and do not stretch rows to fill
|
|
overlay.style.gridTemplateRows = `repeat(${numRows}, ${rowPx}px)`
|
|
overlay.style.rowGap = `${gapPx}px`
|
|
} else {
|
|
// Compressed: use fractional rows so everything fits; remove gaps
|
|
overlay.style.gridTemplateRows = `repeat(${numRows}, 1fr)`
|
|
overlay.style.rowGap = '0'
|
|
}
|
|
|
|
// Create the spans
|
|
for (const w of spans) this.createOverlaySpan(overlay, w, weekEl)
|
|
}
|
|
|
|
createOverlaySpan(overlay, w, weekEl) {
|
|
const span = document.createElement('div')
|
|
span.className = `event-span event-color-${w.colorId}`
|
|
span.style.gridColumn = `${w.startIdx + 1} / ${w.endIdx + 2}`
|
|
span.style.gridRow = `${w._row}`
|
|
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')
|
|
|
|
// Click opens edit if not dragging
|
|
span.addEventListener('click', e => {
|
|
e.stopPropagation()
|
|
|
|
// Only block if we actually dragged (moved the mouse)
|
|
if (this.justDragged) return
|
|
|
|
this.showEventDialog('edit', { id: w.id })
|
|
})
|
|
|
|
// Add resize handles
|
|
const left = document.createElement('div')
|
|
left.className = 'resize-handle left'
|
|
const right = document.createElement('div')
|
|
right.className = 'resize-handle right'
|
|
span.appendChild(left)
|
|
span.appendChild(right)
|
|
|
|
// Pointer down handlers
|
|
const onPointerDown = (mode, ev) => {
|
|
// Prevent duplicate handling if we already have a drag state
|
|
if (this.dragEventState) return
|
|
|
|
// Don't prevent default immediately - let click events through
|
|
ev.stopPropagation()
|
|
const point = ev.touches ? ev.touches[0] : ev
|
|
const hitAtStart = this.calendar.getDateUnderPointer(point.clientX, point.clientY)
|
|
this.dragEventState = {
|
|
mode,
|
|
id: w.id,
|
|
originWeek: weekEl,
|
|
originStartIdx: w.startIdx,
|
|
originEndIdx: w.endIdx,
|
|
pointerStartX: point.clientX,
|
|
pointerStartY: point.clientY,
|
|
startDate: w.startDate,
|
|
endDate: w.endDate,
|
|
usingPointer: ev.type && ev.type.startsWith('pointer')
|
|
}
|
|
// compute anchor offset within the event based on where the pointer is
|
|
const spanDays = daysInclusive(w.startDate, w.endDate)
|
|
let anchorOffset = 0
|
|
if (hitAtStart && hitAtStart.date) {
|
|
const anchorDate = hitAtStart.date
|
|
// clamp anchorDate to within event span
|
|
if (anchorDate < w.startDate) anchorOffset = 0
|
|
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
|
|
// capture pointer to ensure we receive the up even if cursor leaves element
|
|
if (this.dragEventState.usingPointer && span.setPointerCapture && ev.pointerId != null) {
|
|
try { span.setPointerCapture(ev.pointerId) } catch {}
|
|
}
|
|
this.dragEventState.element = span
|
|
this.dragEventState.currentOverlay = overlay
|
|
this._eventDragMoved = false
|
|
span.classList.add('dragging')
|
|
this.installGlobalEventDragHandlers()
|
|
}
|
|
|
|
// Use pointer events (supported by all modern browsers)
|
|
left.addEventListener('pointerdown', e => onPointerDown('resize-left', e))
|
|
right.addEventListener('pointerdown', e => onPointerDown('resize-right', e))
|
|
span.addEventListener('pointerdown', e => {
|
|
if ((e.target).classList && (e.target).classList.contains('resize-handle')) return
|
|
onPointerDown('move', e)
|
|
})
|
|
|
|
// Touch support (for compatibility with older mobile browsers)
|
|
left.addEventListener('touchstart', e => onPointerDown('resize-left', e), { passive: false })
|
|
right.addEventListener('touchstart', e => onPointerDown('resize-right', e), { passive: false })
|
|
span.addEventListener('touchstart', e => {
|
|
if ((e.target).classList && (e.target).classList.contains('resize-handle')) return
|
|
onPointerDown('move', e)
|
|
}, { passive: false })
|
|
overlay.appendChild(span)
|
|
}
|
|
}
|