Files
calendar/src/components/EventOverlay.vue
T
LeoVasanko 45b95fc67c Mouse/touch event handling improvement
- prefer passive handlers
- fix event moving on touch
- faster selection updates
2025-08-27 20:04:29 +00:00

524 lines
15 KiB
Vue

<template>
<div class="week-overlay" :style="{ gridColumn: '1 / -1' }" ref="weekOverlayRef">
<div
v-for="seg in eventSegments"
:key="'seg-' + seg.startIdx + '-' + seg.endIdx"
:class="['segment-grid', { compress: isSegmentCompressed(seg) }]"
:style="segmentStyle(seg)"
>
<div
v-for="span in seg.events"
:key="span.id + '-' + (span.n != null ? span.n : 0)"
class="event-span"
dir="auto"
:class="[`event-color-${span.colorId}`]"
:data-id="span.id"
:data-n="span.n != null ? span.n : 0"
:style="{
gridColumn: `${span.startIdxRel + 1} / ${span.endIdxRel + 2}`,
gridRow: `${span.row}`,
}"
@click="handleEventClick(span)"
@pointerdown="handleEventPointerDown(span, $event)"
>
<span class="event-title">{{ span.title }}</span>
<div
class="resize-handle left"
@pointerdown="handleResizePointerDown(span, 'resize-left', $event)"
></div>
<div
class="resize-handle right"
@pointerdown="handleResizePointerDown(span, 'resize-right', $event)"
></div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import { daysInclusive, addDaysStr } from '@/utils/date'
const props = defineProps({
week: { type: Object, required: true },
})
const emit = defineEmits(['event-click'])
const store = useCalendarStore()
// Drag state
const dragState = ref(null)
const justDragged = ref(false)
const weekOverlayRef = ref(null)
const segmentCompression = ref({}) // key -> boolean
// Build event segments: each segment is a contiguous day range with at least one bridging event between any adjacent days within it.
const eventSegments = computed(() => {
// Construct spans across the week
const spanMap = new Map()
props.week.days.forEach((day, di) => {
day.events.forEach((ev) => {
const key = ev.id + '|' + (ev.n ?? 0)
if (!spanMap.has(key)) spanMap.set(key, { ...ev, startIdx: di, endIdx: di })
else spanMap.get(key).endIdx = Math.max(spanMap.get(key).endIdx, di)
})
})
const spans = Array.from(spanMap.values())
// Derive span start/end date strings from week day indices (removes need for per-day stored endDate)
spans.forEach((sp) => {
sp.startDate = props.week.days[sp.startIdx].date
sp.endDate = props.week.days[sp.endIdx].date
})
// Sort so longer multi-day first, then earlier, then id for stability
spans.sort((a, b) => {
const la = a.endIdx - a.startIdx
const lb = b.endIdx - b.startIdx
if (la !== lb) return lb - la
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
const ca = a.colorId != null ? a.colorId : 0
const cb = b.colorId != null ? b.colorId : 0
if (ca !== cb) return ca - cb
return String(a.id).localeCompare(String(b.id))
})
// Identify breaks
const breaks = []
for (let d = 0; d < 6; d++) {
const bridged = spans.some((sp) => sp.startIdx <= d && sp.endIdx >= d + 1)
if (!bridged) breaks.push(d)
}
const rawSegments = []
let segStart = 0
for (const b of breaks) {
rawSegments.push([segStart, b])
segStart = b + 1
}
rawSegments.push([segStart, 6])
const segments = rawSegments.map(([s, e]) => {
const evs = spans.filter((sp) => sp.startIdx >= s && sp.endIdx <= e)
// Row packing in this segment (gap fill)
const rows = [] // each row: intervals
function fits(row, a, b) {
return row.every((iv) => b < iv.start || a > iv.end)
}
function addInterval(row, a, b) {
let inserted = false
for (let i = 0; i < row.length; i++) {
if (b < row[i].start) {
row.splice(i, 0, { start: a, end: b })
inserted = true
break
}
}
if (!inserted) row.push({ start: a, end: b })
}
evs.forEach((ev) => {
let placed = false
for (let r = 0; r < rows.length; r++) {
if (fits(rows[r], ev.startIdx, ev.endIdx)) {
addInterval(rows[r], ev.startIdx, ev.endIdx)
ev.row = r + 1
placed = true
break
}
}
if (!placed) {
rows.push([{ start: ev.startIdx, end: ev.endIdx }])
ev.row = rows.length
}
ev.startIdxRel = ev.startIdx - s
ev.endIdxRel = ev.endIdx - s
})
return { startIdx: s, endIdx: e, events: evs, rowsCount: rows.length }
})
return segments
})
function segmentStyle(seg) {
return { gridColumn: `${seg.startIdx + 1} / ${seg.endIdx + 2}` }
}
function segmentKey(seg) {
return seg.startIdx + '-' + seg.endIdx
}
function isSegmentCompressed(seg) {
return !!segmentCompression.value[segmentKey(seg)]
}
function recomputeCompression() {
const el = weekOverlayRef.value
if (!el) return
const available = el.clientHeight || 0
if (!available) return
const cs = getComputedStyle(el)
const fontSize = parseFloat(cs.fontSize) || 16
const baseRowPx = fontSize * 1.5 // desired row height (matches CSS 1.5em)
const marginTop = 0 // already applied outside height
const usable = Math.max(0, available - marginTop)
const nextMap = {}
for (const seg of eventSegments.value) {
const desired = (seg.rowsCount || 1) * baseRowPx
nextMap[segmentKey(seg)] = desired > usable
}
segmentCompression.value = nextMap
}
watch(eventSegments, () => nextTick(() => recomputeCompression()))
onMounted(() => {
nextTick(() => recomputeCompression())
window.addEventListener('resize', recomputeCompression)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', recomputeCompression)
})
function handleEventClick(span) {
if (justDragged.value) return
emit('event-click', { id: span.id, n: span.n != null ? span.n : 0 })
}
function handleEventPointerDown(span, event) {
if (event.target.classList.contains('resize-handle')) return
event.stopPropagation()
const baseId = span.id
let anchorDate = span.startDate
try {
const spanDays = daysInclusive(span.startDate, span.endDate)
const targetEl = event.currentTarget
if (targetEl && spanDays > 0) {
const rect = targetEl.getBoundingClientRect()
const relX = event.clientX - rect.left
const dayWidth = rect.width / spanDays
let dayIndex = Math.floor(relX / dayWidth)
if (!isFinite(dayIndex)) dayIndex = 0
if (dayIndex < 0) dayIndex = 0
if (dayIndex >= spanDays) dayIndex = spanDays - 1
anchorDate = addDaysStr(span.startDate, dayIndex)
}
} catch (e) {}
startLocalDrag(
{
id: baseId,
originalId: span.id,
mode: 'move',
pointerStartX: event.clientX,
pointerStartY: event.clientY,
anchorDate,
startDate: span.startDate,
endDate: span.endDate,
},
event,
)
}
function handleResizePointerDown(span, mode, event) {
event.stopPropagation()
const baseId = span.id
startLocalDrag(
{
id: baseId,
originalId: span.id,
mode,
pointerStartX: event.clientX,
pointerStartY: event.clientY,
anchorDate: null,
startDate: span.startDate,
endDate: span.endDate,
},
event,
)
}
// Local drag handling
function startLocalDrag(init, evt) {
const spanDays = daysInclusive(init.startDate, init.endDate)
let anchorOffset = 0
if (init.mode === 'move' && init.anchorDate) {
if (init.anchorDate < init.startDate) anchorOffset = 0
else if (init.anchorDate > init.endDate) anchorOffset = spanDays - 1
else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1
}
let originalWeekday = null
let originalPattern = null
if (init.mode === 'move') {
try {
originalWeekday = new Date(init.startDate + 'T00:00:00').getDay()
const baseEv = store.getEventById(init.id)
if (
baseEv &&
baseEv.recur &&
baseEv.recur.freq === 'weeks' &&
Array.isArray(baseEv.recur.weekdays)
) {
originalPattern = [...baseEv.recur.weekdays]
}
} catch {}
}
dragState.value = {
...init,
anchorOffset,
originSpanDays: spanDays,
eventMoved: false,
tentativeStart: init.startDate,
tentativeEnd: init.endDate,
originalWeekday,
originalPattern,
realizedId: null,
}
store.$history?.beginCompound()
if (evt.currentTarget && evt.pointerId !== undefined) {
try {
evt.currentTarget.setPointerCapture(evt.pointerId)
} catch (e) {
console.warn('Could not set pointer capture:', e)
}
}
if (evt.cancelable) evt.preventDefault()
window.addEventListener('pointermove', onDragPointerMove, { passive: false })
window.addEventListener('pointerup', onDragPointerUp, { passive: false })
window.addEventListener('pointercancel', onDragPointerUp, { passive: false })
}
// Determine date under pointer: traverse DOM to find day cell carrying data-date attribute
function getDateUnderPointer(x, y, el) {
for (let cur = el; cur; cur = cur.parentElement)
if (cur.dataset?.date) return { date: cur.dataset.date }
const overlayEl = weekOverlayRef.value
const container = overlayEl?.parentElement // .days-grid
if (container) {
for (const d of container.querySelectorAll('[data-date]')) {
const { left, right, top, bottom } = d.getBoundingClientRect()
if (y >= top && y <= bottom && x >= left && x <= right) return { date: d.dataset.date }
}
}
return null
}
function onDragPointerMove(e) {
const st = dragState.value
if (!st) return
const dx = e.clientX - st.pointerStartX
const dy = e.clientY - st.pointerStartY
const distance = Math.sqrt(dx * dx + dy * dy)
if (!st.eventMoved && distance < 5) return
st.eventMoved = true
const hitEl = document.elementFromPoint(e.clientX, e.clientY)
const hit = getDateUnderPointer(e.clientX, e.clientY, hitEl)
if (!hit || !hit.date) return
const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date)
if (!ns || !ne) return
// Only proceed if changed
if (ns === st.tentativeStart && ne === st.tentativeEnd) return
st.tentativeStart = ns
st.tentativeEnd = ne
if (st.mode === 'move') {
if (st.n && st.n > 0) {
if (!st.realizedId) {
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne)
if (newId) {
st.realizedId = newId
st.id = newId
// converted to standalone event
} else {
return
}
} else {
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
}
} else {
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
}
if (st.originalPattern && st.originalWeekday != null) {
try {
const currentWeekday = new Date(ns + 'T00:00:00').getDay()
const shift = currentWeekday - st.originalWeekday
const rotated = store._rotateWeekdayPattern([...st.originalPattern], shift)
const ev = store.getEventById(st.id)
if (ev && ev.recur && ev.recur.freq === 'weeks') {
ev.recur.weekdays = rotated
store.touchEvents()
}
} catch {}
}
} else if (!(st.n && st.n > 0)) {
applyRangeDuringDrag({ id: st.id, mode: st.mode, startDate: ns, endDate: ne }, ns, ne)
} else if (st.n && st.n > 0 && (st.mode === 'resize-left' || st.mode === 'resize-right')) {
if (!st.realizedId) {
const initialStart = ns
const initialEnd = ne
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, initialStart, initialEnd)
if (newId) {
st.realizedId = newId
st.id = newId
// converted
} else return
}
const rotate = st.mode === 'resize-left'
store.setEventRange(st.id, ns, ne, { mode: st.mode, rotatePattern: rotate })
}
}
function onDragPointerUp(e) {
const st = dragState.value
if (!st) return
if (e.target && e.pointerId !== undefined) {
try {
e.target.releasePointerCapture(e.pointerId)
} catch (err) {
// Ignore errors - capture might not have been set
}
}
const moved = !!st.eventMoved
const finalStart = st.tentativeStart
const finalEnd = st.tentativeEnd
dragState.value = null
window.removeEventListener('pointermove', onDragPointerMove)
window.removeEventListener('pointerup', onDragPointerUp)
window.removeEventListener('pointercancel', onDragPointerUp)
if (moved) {
// Apply final mutation if virtual (we deferred) or if non-virtual no further change (rare)
if (st.n && st.n > 0) {
applyRangeDuringDrag(
{
id: st.id,
mode: st.mode,
startDate: finalStart,
endDate: finalEnd,
},
finalStart,
finalEnd,
)
}
justDragged.value = true
setTimeout(() => {
justDragged.value = false
}, 120)
}
store.$history?.endCompound()
}
function computeTentativeRangeFromPointer(st, dropDateStr) {
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.endDate
} else if (st.mode === 'resize-right') {
startStr = st.startDate
endStr = dropDateStr
}
return normalizeDateOrder(startStr, endStr)
}
function normalizeDateOrder(aStr, bStr) {
if (!aStr) return [bStr, bStr]
if (!bStr) return [aStr, aStr]
return aStr <= bStr ? [aStr, bStr] : [bStr, aStr]
}
function applyRangeDuringDrag(st, startDate, endDate) {
if (st.n && st.n > 0) {
if (st.mode !== 'move') return // no resize for virtual occurrence
// Split-move: occurrence being dragged treated as first of new series
store.splitMoveVirtualOccurrence(st.id, st.startDate, startDate, endDate)
return
}
store.setEventRange(st.id, startDate, endDate, { mode: st.mode })
}
</script>
<style scoped>
.week-overlay {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-top: 1.8em;
pointer-events: none;
}
.segment-grid {
display: grid;
gap: 2px;
align-content: start;
pointer-events: none;
overflow: hidden;
grid-auto-columns: 1fr;
grid-auto-rows: 1.5em;
}
.segment-grid.compress {
grid-auto-rows: 1fr;
}
.event-span {
padding: 0.1em 0.3em;
border-radius: 1em;
font-size: clamp(0.45em, 1.8vh, 0.75em);
font-weight: 600;
cursor: grab;
pointer-events: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1;
display: flex;
align-items: center;
position: relative;
user-select: none;
z-index: 1;
text-align: center;
/* Ensure touch pointer events aren't turned into a scroll gesture; needed for reliable drag on mobile */
touch-action: none;
}
/* Inner title wrapper ensures proper ellipsis within flex/grid constraints */
.event-title {
display: block;
flex: 1 1 0%;
min-width: 0;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
pointer-events: none;
}
/* Resize handles */
.event-span .resize-handle {
position: absolute;
top: 0;
bottom: 0;
width: 6px;
background: transparent;
z-index: 2;
cursor: ew-resize;
touch-action: none; /* Allow touch resizing without scroll */
}
.event-span .resize-handle.left {
inset-inline-start: 0;
}
.event-span .resize-handle.right {
inset-inline-end: 0;
}
</style>