Partial support for dragging events around.
This commit is contained in:
parent
8cf97f4c9f
commit
474b9fd497
392
calendar.js
392
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', () => {
|
||||
|
43
events.css
43
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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user