473 lines
14 KiB
Vue
473 lines
14 KiB
Vue
<template>
|
|
<div class="week-overlay">
|
|
<div
|
|
v-for="span in eventSpans"
|
|
:key="span.id"
|
|
class="event-span"
|
|
:class="[`event-color-${span.colorId}`]"
|
|
:data-id="span.id"
|
|
:data-n="span._recurrenceIndex != null ? span._recurrenceIndex : 0"
|
|
:style="{
|
|
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 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>
|
|
</template>
|
|
<script setup>
|
|
import { computed, ref } 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)
|
|
|
|
// Consolidate already-provided day.events into contiguous spans (no recurrence generation)
|
|
const eventSpans = computed(() => {
|
|
const weekEvents = new Map()
|
|
props.week.days.forEach((day, dayIndex) => {
|
|
day.events.forEach((ev) => {
|
|
const key = ev.id
|
|
if (!weekEvents.has(key)) {
|
|
weekEvents.set(key, { ...ev, startIdx: dayIndex, endIdx: dayIndex })
|
|
} else {
|
|
const ref = weekEvents.get(key)
|
|
ref.endIdx = Math.max(ref.endIdx, dayIndex)
|
|
}
|
|
})
|
|
})
|
|
const arr = Array.from(weekEvents.values())
|
|
arr.sort((a, b) => {
|
|
const spanA = a.endIdx - a.startIdx
|
|
const spanB = b.endIdx - b.startIdx
|
|
if (spanA !== spanB) return spanB - spanA
|
|
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
|
|
// For one-day events that are otherwise equal, sort by color (0 first)
|
|
if (spanA === 0 && spanB === 0 && a.startIdx === b.startIdx) {
|
|
const colorA = a.colorId || 0
|
|
const colorB = b.colorId || 0
|
|
if (colorA !== colorB) return colorA - colorB
|
|
}
|
|
return String(a.id).localeCompare(String(b.id))
|
|
})
|
|
// Assign non-overlapping rows
|
|
const rowsLastEnd = []
|
|
arr.forEach((ev) => {
|
|
let row = 0
|
|
while (row < rowsLastEnd.length && !(ev.startIdx > rowsLastEnd[row])) row++
|
|
if (row === rowsLastEnd.length) rowsLastEnd.push(-1)
|
|
rowsLastEnd[row] = ev.endIdx
|
|
ev.row = row + 1
|
|
})
|
|
return arr
|
|
})
|
|
|
|
function handleEventClick(span) {
|
|
if (justDragged.value) return
|
|
// Emit composite payload: base id (without virtual marker), instance id, occurrence index (data-n)
|
|
const idStr = span.id
|
|
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
|
|
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
|
|
emit('event-click', {
|
|
id: baseId,
|
|
instanceId: span.id,
|
|
occurrenceIndex: span._recurrenceIndex != null ? span._recurrenceIndex : 0,
|
|
})
|
|
}
|
|
|
|
function handleEventPointerDown(span, event) {
|
|
if (event.target.classList.contains('resize-handle')) return
|
|
event.stopPropagation()
|
|
const idStr = span.id
|
|
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
|
|
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
|
|
const isVirtual = hasVirtualMarker
|
|
// Determine which day within the span was grabbed so we maintain relative position
|
|
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) {
|
|
// Fallback to startDate if any calculation fails
|
|
}
|
|
startLocalDrag(
|
|
{
|
|
id: baseId,
|
|
originalId: span.id,
|
|
isVirtual,
|
|
mode: 'move',
|
|
pointerStartX: event.clientX,
|
|
pointerStartY: event.clientY,
|
|
anchorDate,
|
|
startDate: span.startDate,
|
|
endDate: span.endDate,
|
|
},
|
|
event,
|
|
)
|
|
}
|
|
|
|
function handleResizePointerDown(span, mode, event) {
|
|
event.stopPropagation()
|
|
const idStr = span.id
|
|
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
|
|
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
|
|
const isVirtual = hasVirtualMarker
|
|
startLocalDrag(
|
|
{
|
|
id: baseId,
|
|
originalId: span.id,
|
|
isVirtual,
|
|
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
|
|
}
|
|
|
|
// Capture original repeating pattern & weekday (for weekly repeats) so we can rotate relative to original
|
|
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.isRepeating &&
|
|
baseEv.repeat === 'weeks' &&
|
|
Array.isArray(baseEv.repeatWeekdays)
|
|
) {
|
|
originalPattern = [...baseEv.repeatWeekdays]
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
dragState.value = {
|
|
...init,
|
|
anchorOffset,
|
|
originSpanDays: spanDays,
|
|
eventMoved: false,
|
|
tentativeStart: init.startDate,
|
|
tentativeEnd: init.endDate,
|
|
originalWeekday,
|
|
originalPattern,
|
|
realizedId: null, // for virtual occurrence converted to real during drag
|
|
}
|
|
|
|
// Begin compound history session (single snapshot after drag completes)
|
|
store.$history?.beginCompound()
|
|
|
|
// Capture pointer events globally
|
|
if (evt.currentTarget && evt.pointerId !== undefined) {
|
|
try {
|
|
evt.currentTarget.setPointerCapture(evt.pointerId)
|
|
} catch (e) {
|
|
console.warn('Could not set pointer capture:', e)
|
|
}
|
|
}
|
|
|
|
// Prevent default for mouse/pen to avoid text selection. For touch we skip so the user can still scroll.
|
|
if (!(evt.pointerType === 'touch')) {
|
|
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) {
|
|
let cur = el
|
|
while (cur) {
|
|
if (cur.dataset && cur.dataset.date) {
|
|
return { date: cur.dataset.date }
|
|
}
|
|
cur = cur.parentElement
|
|
}
|
|
// Fallback: elementFromPoint scan
|
|
const probe = document.elementFromPoint(x, y)
|
|
let p = probe
|
|
while (p) {
|
|
if (p.dataset && p.dataset.date) return { date: p.dataset.date }
|
|
p = p.parentElement
|
|
}
|
|
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 we can't find a date, don't update the range but keep the drag active
|
|
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.isVirtual) {
|
|
// On first movement convert virtual occurrence into a real new event (split series)
|
|
if (!st.realizedId) {
|
|
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne)
|
|
if (newId) {
|
|
st.realizedId = newId
|
|
st.id = newId
|
|
st.isVirtual = false
|
|
} else {
|
|
return
|
|
}
|
|
} else {
|
|
// Subsequent moves: update range without rotating pattern automatically
|
|
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
|
|
}
|
|
} else {
|
|
// Normal non-virtual move; rotate handled in setEventRange
|
|
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
|
|
}
|
|
// Manual rotation relative to original pattern (keeps pattern anchored to initially grabbed weekday)
|
|
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.repeat === 'weeks') {
|
|
ev.repeatWeekdays = rotated
|
|
store.touchEvents()
|
|
}
|
|
} catch {}
|
|
}
|
|
} else if (!st.isVirtual) {
|
|
// Resizes on real events update immediately
|
|
applyRangeDuringDrag(
|
|
{ id: st.id, isVirtual: st.isVirtual, mode: st.mode, startDate: ns, endDate: ne },
|
|
ns,
|
|
ne,
|
|
)
|
|
} else if (st.isVirtual && (st.mode === 'resize-left' || st.mode === 'resize-right')) {
|
|
// For virtual occurrence resize: convert to real once, then adjust range
|
|
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
|
|
st.isVirtual = false
|
|
} else return
|
|
}
|
|
// Apply range change; rotate if left edge moved and weekday changed
|
|
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
|
|
|
|
// Release pointer capture if it was set
|
|
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.isVirtual) {
|
|
applyRangeDuringDrag(
|
|
{
|
|
id: st.id,
|
|
isVirtual: st.isVirtual,
|
|
mode: st.mode,
|
|
startDate: finalStart,
|
|
endDate: finalEnd,
|
|
},
|
|
finalStart,
|
|
finalEnd,
|
|
)
|
|
}
|
|
justDragged.value = true
|
|
setTimeout(() => {
|
|
justDragged.value = false
|
|
}, 120)
|
|
}
|
|
// End compound session (snapshot if changed)
|
|
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.isVirtual) {
|
|
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;
|
|
pointer-events: none;
|
|
z-index: 15;
|
|
display: grid;
|
|
/* Prevent content from expanding tracks beyond container width */
|
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
grid-auto-rows: minmax(0, 1.5em);
|
|
|
|
row-gap: 0.05em;
|
|
margin-top: 1.8em;
|
|
align-content: start;
|
|
}
|
|
|
|
.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;
|
|
height: 100%;
|
|
width: 100%;
|
|
max-width: 100%;
|
|
box-sizing: border-box;
|
|
z-index: 1;
|
|
text-align: center;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
.event-span .resize-handle.left {
|
|
left: 0;
|
|
}
|
|
|
|
.event-span .resize-handle.right {
|
|
right: 0;
|
|
}
|
|
</style>
|