From b8b8575c6db38690754a3a7628c0b525c02cf8c1 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 20 Aug 2025 21:34:08 -0600 Subject: [PATCH] More event refactoring, cleanup. --- calendar.js | 218 +--------------------- event-manager.js | 461 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 361 insertions(+), 318 deletions(-) diff --git a/calendar.js b/calendar.js index b367770..c632c7d 100644 --- a/calendar.js +++ b/calendar.js @@ -8,7 +8,6 @@ import { fromLocalString, mondayIndex, pad, - daysInclusive, addDaysStr, getLocalizedWeekdayNames, getLocalizedMonthName, @@ -472,222 +471,7 @@ class InfiniteCalendar { } 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.eventManager.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.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 - 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.eventManager.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.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.eventManager.dragEventState || this.eventManager.justDragged) return - this.eventManager.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) => { - ev.preventDefault() - ev.stopPropagation() - const point = ev.touches ? ev.touches[0] : ev - const hitAtStart = this.getDateUnderPointer(point.clientX, point.clientY) - this.eventManager.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.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.eventManager.dragEventState.usingPointer && span.setPointerCapture && ev.pointerId != null) { - try { span.setPointerCapture(ev.pointerId) } catch {} - } - this.eventManager.dragEventState.element = span - this.eventManager.dragEventState.currentOverlay = overlay - this.eventManager._eventDragMoved = false - span.classList.add('dragging') - this.eventManager.installGlobalEventDragHandlers() - } - // Mouse - left.addEventListener('mousedown', e => onPointerDown('resize-left', e)) - right.addEventListener('mousedown', e => onPointerDown('resize-right', e)) - span.addEventListener('mousedown', e => { - if ((e.target).classList && (e.target).classList.contains('resize-handle')) return - onPointerDown('move', e) - }) - // Pointer (preferred) - 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 - 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) + this.eventManager.addEventsToWeek(weekEl) } getDateUnderPointer(clientX, clientY) { diff --git a/event-manager.js b/event-manager.js index ebf7c0d..6f5a2fe 100644 --- a/event-manager.js +++ b/event-manager.js @@ -125,6 +125,63 @@ export class EventManager { 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) @@ -242,38 +299,31 @@ export class EventManager { Title -
+ + -
- -
-
${Array.from({ length: 8 }, (_, i) => ` @@ -292,14 +342,16 @@ export class EventManager { 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.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"]')) - // Duration change toggles time visibility - this.eventDurationInput.addEventListener('change', () => this.updateTimeVisibilityByDuration()) + // 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 => { @@ -313,20 +365,24 @@ export class EventManager { e.preventDefault() const data = this.readEventForm() if (!data.title.trim()) return + if (this._dialogMode === 'create') { - const computed = this.computeDatesFromForm(data) - this.createEvent({ + this.createEventWithRepeat({ title: data.title.trim(), - startDate: computed.startDate, - endDate: computed.endDate, + startDate: this.selStart, + endDate: this.selEnd, colorId: data.colorId, - startTime: data.startTime, - durationMinutes: data.duration + repeat: data.repeat, + repeatCount: data.repeatCount }) this.clearSelection() } else if (this._dialogMode === 'edit' && this._editingEventId != null) { - const computed = this.computeDatesFromForm(data) - this.applyEventEdit(this._editingEventId, { ...data, ...computed }) + this.applyEventEdit(this._editingEventId, { + title: data.title.trim(), + colorId: data.colorId, + repeat: data.repeat, + repeatCount: data.repeatCount + }) } this.hideEventDialog() }) @@ -355,32 +411,20 @@ export class EventManager { 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) - } + 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) - 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.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.updateTimeVisibilityByDuration() } this.eventModal.hidden = false setTimeout(() => this.eventTitleInput.focus(), 0) @@ -390,51 +434,16 @@ export class EventManager { 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, + repeat: this.eventRepeatInput.value, + repeatCount: this.eventRepeatCountInput.value, 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() { @@ -481,8 +490,26 @@ export class EventManager { 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 + + // 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) @@ -490,7 +517,6 @@ export class EventManager { } else { this.dragPreview = null } - this._eventDragMoved = true this.calendar.forceUpdateVisibleWeeks() } @@ -555,11 +581,24 @@ export class EventManager { } catch {} this.dragEventState = null + + // Only set justDragged if we actually moved and dragged this.justDragged = !!this._eventDragMoved + this._eventDragMoved = false this.removeGlobalEventDragHandlers() - this.calendar.forceUpdateVisibleWeeks() - setTimeout(() => { this.justDragged = false }, 0) + + // 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 } @@ -589,4 +628,224 @@ export class EventManager { 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) + } }