diff --git a/calendar.js b/calendar.js index 68a9bc1..590d07a 100644 --- a/calendar.js +++ b/calendar.js @@ -48,6 +48,9 @@ class InfiniteCalendar { 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() } @@ -798,7 +801,27 @@ class InfiniteCalendar { } applyEventEdit(eventId, data) { - // Update all instances of this event across dates + 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) { @@ -861,6 +884,17 @@ class InfiniteCalendar { } } + forceUpdateVisibleWeeks() { + // Force complete re-render of all visible weeks by clearing overlays first + for (const [, weekEl] of this.visibleWeeks) { + const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay') + if (overlay) { + while (overlay.firstChild) overlay.removeChild(overlay.firstChild) + } + this.addEventsToWeek(weekEl) + } + } + addEventsToWeek(weekEl) { const daysGrid = weekEl._daysGrid || weekEl.querySelector('.days-grid') const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay') @@ -870,7 +904,7 @@ 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) || [] @@ -891,6 +925,47 @@ 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 + // 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})/) @@ -945,22 +1020,331 @@ class InfiniteCalendar { } // Create the spans - for (const w of spans) this.createOverlaySpan(overlay, w) + for (const w of spans) this.createOverlaySpan(overlay, w, weekEl) } - createOverlaySpan(overlay, w) { + 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() + if (this.dragEventState || 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) => { + ev.preventDefault() + ev.stopPropagation() + const point = ev.touches ? ev.touches[0] : ev + const hitAtStart = this.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() + } + // 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) } + + 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 + // Fast path: directly find the cell under the pointer + const directCell = el.closest && el.closest('.cell[data-date]') + if (directCell) { + const weekEl = directCell.closest('.week-row') + return weekEl ? { weekEl, overlay: (weekEl._overlay || weekEl.querySelector('.week-overlay')), col: -1, date: directCell.dataset.date } : null + } + const weekEl = el.closest && el.closest('.week-row') + if (!weekEl) return null + const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay') + const daysGrid = weekEl._daysGrid || weekEl.querySelector('.days-grid') + if (!overlay || !daysGrid) return null + const rect = overlay.getBoundingClientRect() + if (rect.width <= 0) return null + const colWidth = rect.width / 7 + let col = Math.floor((clientX - rect.left) / colWidth) + if (clientX < rect.left) col = 0 + if (clientX > rect.right) col = 6 + col = Math.max(0, Math.min(6, col)) + const cells = Array.from(daysGrid.querySelectorAll('.cell[data-date]')) + const cell = cells[col] + return cell ? { weekEl, overlay, col, date: cell.dataset.date } : null + } + + computeTentativeRangeFromPointer(dropDateStr) { + const st = this.dragEventState + if (!st) return [null, null] + const anchorOffset = st.anchorOffset || 0 + const spanDays = st.originSpanDays || daysInclusive(st.startDate, st.endDate) + let startStr = st.startDate + let endStr = st.endDate + if (st.mode === 'move') { + startStr = addDaysStr(dropDateStr, -anchorOffset) + endStr = addDaysStr(startStr, spanDays - 1) + } else if (st.mode === 'resize-left') { + startStr = dropDateStr + endStr = st.originalEndDate || st.endDate + } else if (st.mode === 'resize-right') { + startStr = st.originalStartDate || st.startDate + endStr = dropDateStr + } + const [ns, ne] = this.normalizeDateOrder(startStr, endStr) + return [ns, ne] + } + + computeSpanIndicesForWeek(cells, startStr, endStr) { + if (!cells || cells.length !== 7) return null + if (!startStr || !endStr) return null + const sIdx = cells.findIndex(c => c.dataset.date >= startStr) + const eIdx = (() => { + let idx = -1 + for (let i = 0; i < cells.length; i++) { + if (cells[i].dataset.date <= endStr) idx = i + } + return idx + })() + if (sIdx === -1 || eIdx === -1 || sIdx > 6 || eIdx < 0) return null + const start = Math.max(0, sIdx) + const end = Math.min(6, eIdx) + if (start > end) return null + return [start, end] + } + + // (preview functions removed; moving actual element during drag) + normalizeDateOrder(aStr, bStr) { + if (!aStr) return [bStr, bStr] + if (!bStr) return [aStr, aStr] + return aStr <= bStr ? [aStr, bStr] : [bStr, aStr] + } + + updateEventDatesAndReindex(eventId, updated) { + // Remove old instances + for (const [date, list] of this.events) { + const idx = list.findIndex(e => e.id === eventId) + if (idx !== -1) list.splice(idx, 1) + if (list.length === 0) this.events.delete(date) + } + // Re-add across new range + const start = new Date(fromLocalString(updated.startDate)) + const end = new Date(fromLocalString(updated.endDate)) + const base = { + id: updated.id, + title: updated.title, + colorId: updated.colorId, + startDate: updated.startDate, + endDate: updated.endDate, + startTime: updated.startTime, + durationMinutes: updated.durationMinutes + } + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + const ds = toLocalString(d) + if (!this.events.has(ds)) this.events.set(ds, []) + this.events.get(ds).push({ ...base, isSpanning: start < end }) + } + } } document.addEventListener('DOMContentLoaded', () => { diff --git a/events.css b/events.css index 1286421..cb168a2 100644 --- a/events.css +++ b/events.css @@ -16,6 +16,8 @@ justify-content: center; pointer-events: auto; /* clickable despite overlay having none */ z-index: 1; + position: relative; + cursor: grab; } /* Selection styles */ @@ -24,3 +26,44 @@ box-shadow: 0 0 .1em var(--muted) inset; } .cell.selected .event { opacity: .7 } + +/* Dragging state */ +.event-span.dragging { + opacity: .9; + cursor: grabbing; + z-index: 4; +} + +/* Resize handles */ +.event-span .resize-handle { + position: absolute; + top: 0; + bottom: 0; + width: 6px; + background: transparent; + z-index: 2; +} +.event-span .resize-handle.left { + left: 0; + cursor: ew-resize; +} +.event-span .resize-handle.right { + right: 0; + cursor: ew-resize; +} + +/* Live preview ghost while dragging */ +.event-preview { + pointer-events: none; + opacity: .6; + outline: 2px dashed currentColor; + outline-offset: -2px; + border-radius: .4em; + display: flex; + align-items: center; + justify-content: center; + font-size: clamp(.45em, 1.8vh, .75em); + line-height: 1; + height: 100%; + z-index: 3; +}