Port to Vue. Also implements plenty of new functionality.
This commit is contained in:
2025-08-22 21:34:33 +00:00
parent e4c960b0ca
commit 86a06acccb
35 changed files with 4137 additions and 1717 deletions
+620
View File
@@ -0,0 +1,620 @@
<template>
<div class="week-overlay">
<div
v-for="span in eventSpans"
:key="span.id"
class="event-span"
:class="[`event-color-${span.colorId}`]"
: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 { toLocalString, fromLocalString, daysInclusive, addDaysStr } from '@/utils/date'
const props = defineProps({
week: {
type: Object,
required: true,
},
})
const emit = defineEmits(['event-click'])
const store = useCalendarStore()
// Local drag state
const dragState = ref(null)
const justDragged = ref(false)
// Generate repeat occurrences for a specific date
function generateRepeatOccurrencesForDate(targetDateStr) {
const occurrences = []
// Get all events from the store and check for repeating ones
for (const [, eventList] of store.events) {
for (const baseEvent of eventList) {
if (!baseEvent.isRepeating || baseEvent.repeat === 'none') {
continue
}
const targetDate = new Date(fromLocalString(targetDateStr))
const baseStartDate = new Date(fromLocalString(baseEvent.startDate))
const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
if (baseEvent.repeat === 'weeks') {
const repeatWeekdays = baseEvent.repeatWeekdays
if (targetDate < baseStartDate) continue
const maxOccurrences =
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
if (maxOccurrences === 0) continue
const interval = baseEvent.repeatInterval || 1
const msPerDay = 24 * 60 * 60 * 1000
// Determine if targetDate lies within some occurrence span. We look backwards up to spanDays to find a start day.
let occStart = null
for (let back = 0; back <= spanDays; back++) {
const cand = new Date(targetDate)
cand.setDate(cand.getDate() - back)
if (cand < baseStartDate) break
const daysDiff = Math.floor((cand - baseStartDate) / msPerDay)
const weeksDiff = Math.floor(daysDiff / 7)
if (weeksDiff % interval !== 0) continue
if (repeatWeekdays[cand.getDay()]) {
// candidate start must produce span covering targetDate
const candEnd = new Date(cand)
candEnd.setDate(candEnd.getDate() + spanDays)
if (targetDate <= candEnd) {
occStart = cand
break
}
}
}
if (!occStart) continue
// Skip base occurrence if this is within its span (base already physically stored)
if (occStart.getTime() === baseStartDate.getTime()) continue
// Compute occurrence index (number of previous start days)
let occIdx = 0
const cursor = new Date(baseStartDate)
while (cursor < occStart && occIdx < maxOccurrences) {
const cDaysDiff = Math.floor((cursor - baseStartDate) / msPerDay)
const cWeeksDiff = Math.floor(cDaysDiff / 7)
if (cWeeksDiff % interval === 0 && repeatWeekdays[cursor.getDay()]) occIdx++
cursor.setDate(cursor.getDate() + 1)
}
if (occIdx >= maxOccurrences) continue
const occEnd = new Date(occStart)
occEnd.setDate(occStart.getDate() + spanDays)
const occStartStr = toLocalString(occStart)
const occEndStr = toLocalString(occEnd)
occurrences.push({
...baseEvent,
id: `${baseEvent.id}_repeat_${occIdx}_${occStart.getDay()}`,
startDate: occStartStr,
endDate: occEndStr,
isRepeatOccurrence: true,
repeatIndex: occIdx,
})
continue
} else {
// Handle other repeat types (months)
let intervalsPassed = 0
const timeDiff = targetDate - baseStartDate
if (baseEvent.repeat === 'months') {
intervalsPassed =
(targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
(targetDate.getMonth() - baseStartDate.getMonth())
} else {
continue
}
const interval = baseEvent.repeatInterval || 1
if (intervalsPassed < 0 || intervalsPassed % interval !== 0) continue
// Check a few occurrences around the target date
const maxOccurrences =
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
if (maxOccurrences === 0) continue
const i = intervalsPassed
if (i >= maxOccurrences) continue
const currentStart = new Date(baseStartDate)
currentStart.setMonth(baseStartDate.getMonth() + i)
const currentEnd = new Date(currentStart)
currentEnd.setDate(currentStart.getDate() + spanDays)
// If target day lies within base (i===0) we skip because base is stored already
if (i === 0) {
// only skip if targetDate within base span
if (targetDate >= baseStartDate && targetDate <= baseEndDate) continue
}
const currentStartStr = toLocalString(currentStart)
const currentEndStr = toLocalString(currentEnd)
if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) {
occurrences.push({
...baseEvent,
id: `${baseEvent.id}_repeat_${i}`,
startDate: currentStartStr,
endDate: currentEndStr,
isRepeatOccurrence: true,
repeatIndex: i,
})
}
}
}
}
return occurrences
}
// Extract original event ID from repeat occurrence ID
function getOriginalEventId(eventId) {
if (typeof eventId === 'string' && eventId.includes('_repeat_')) {
return eventId.split('_repeat_')[0]
}
return eventId
}
// Handle event click
function handleEventClick(span) {
if (justDragged.value) return
// Emit the actual span id (may include repeat suffix) so edit dialog knows occurrence context
emit('event-click', span.id)
}
// Handle event pointer down for dragging
function handleEventPointerDown(span, event) {
// Don't start drag if clicking on resize handle
if (event.target.classList.contains('resize-handle')) return
event.stopPropagation()
// Do not preventDefault here to allow click unless drag threshold is passed
// Get the date under the pointer
const hit = getDateUnderPointer(event.clientX, event.clientY, event.currentTarget)
const anchorDate = hit ? hit.date : span.startDate
startLocalDrag(
{
id: span.id,
mode: 'move',
pointerStartX: event.clientX,
pointerStartY: event.clientY,
anchorDate,
startDate: span.startDate,
endDate: span.endDate,
},
event,
)
}
// Handle resize handle pointer down
function handleResizePointerDown(span, mode, event) {
event.stopPropagation()
// Start drag from the current edge; anchorDate not needed for resize
startLocalDrag(
{
id: span.id,
mode,
pointerStartX: event.clientX,
pointerStartY: event.clientY,
anchorDate: null,
startDate: span.startDate,
endDate: span.endDate,
},
event,
)
}
// Get date under pointer coordinates
function getDateUnderPointer(clientX, clientY, targetEl) {
// First try to find a day cell directly under the pointer
let element = document.elementFromPoint(clientX, clientY)
// If we hit an event element, temporarily hide it and try again
const hiddenElements = []
while (element && element.classList.contains('event-span')) {
element.style.pointerEvents = 'none'
hiddenElements.push(element)
element = document.elementFromPoint(clientX, clientY)
}
// Restore pointer events for hidden elements
hiddenElements.forEach((el) => (el.style.pointerEvents = 'auto'))
if (element) {
// Look for a day cell with data-date attribute
const dayElement = element.closest('[data-date]')
if (dayElement && dayElement.dataset.date) {
return { date: dayElement.dataset.date }
}
// Also check if we're over a week element and can calculate position
const weekElement = element.closest('.week-row')
if (weekElement) {
const rect = weekElement.getBoundingClientRect()
const relativeX = clientX - rect.left
const dayWidth = rect.width / 7
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
const daysGrid = weekElement.querySelector('.days-grid')
if (daysGrid && daysGrid.children[dayIndex]) {
const dayEl = daysGrid.children[dayIndex]
const date = dayEl?.dataset?.date
if (date) return { date }
}
}
}
// Fallback: try to find the week overlay and calculate position
const overlayEl = targetEl?.closest('.week-overlay')
const weekElement = overlayEl ? overlayEl.parentElement : null
if (!weekElement) {
// If we're outside this week, try to find any week element under the pointer
const allWeekElements = document.querySelectorAll('.week-row')
let bestWeek = null
let bestDistance = Infinity
for (const week of allWeekElements) {
const rect = week.getBoundingClientRect()
if (clientY >= rect.top && clientY <= rect.bottom) {
const distance = Math.abs(clientY - (rect.top + rect.height / 2))
if (distance < bestDistance) {
bestDistance = distance
bestWeek = week
}
}
}
if (bestWeek) {
const rect = bestWeek.getBoundingClientRect()
const relativeX = clientX - rect.left
const dayWidth = rect.width / 7
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
const daysGrid = bestWeek.querySelector('.days-grid')
if (daysGrid && daysGrid.children[dayIndex]) {
const dayEl = daysGrid.children[dayIndex]
const date = dayEl?.dataset?.date
if (date) return { date }
}
}
return null
}
const rect = weekElement.getBoundingClientRect()
const relativeX = clientX - rect.left
const dayWidth = rect.width / 7
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
if (props.week.days[dayIndex]) {
return { date: props.week.days[dayIndex].date }
}
return null
}
// 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
}
dragState.value = {
...init,
anchorOffset,
originSpanDays: spanDays,
eventMoved: false,
}
// 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 to avoid text selection and other interference
evt.preventDefault()
window.addEventListener('pointermove', onDragPointerMove, { passive: false })
window.addEventListener('pointerup', onDragPointerUp, { passive: false })
window.addEventListener('pointercancel', onDragPointerUp, { passive: false })
}
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
applyRangeDuringDrag(st, ns, ne)
}
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
dragState.value = null
window.removeEventListener('pointermove', onDragPointerMove)
window.removeEventListener('pointerup', onDragPointerUp)
window.removeEventListener('pointercancel', onDragPointerUp)
if (moved) {
justDragged.value = true
setTimeout(() => {
justDragged.value = false
}, 120)
}
}
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) {
let ev = store.getEventById(st.id)
let isRepeatOccurrence = false
let baseId = st.id
let repeatIndex = 0
let grabbedWeekday = null
// If not found (repeat occurrences aren't stored) parse synthetic id
if (!ev && typeof st.id === 'string' && st.id.includes('_repeat_')) {
const [bid, suffix] = st.id.split('_repeat_')
baseId = bid
ev = store.getEventById(baseId)
if (ev) {
const parts = suffix.split('_')
repeatIndex = parseInt(parts[0], 10) || 0
grabbedWeekday = parts.length > 1 ? parseInt(parts[1], 10) : null
isRepeatOccurrence = repeatIndex >= 0
}
}
if (!ev) return
const mode = st.mode === 'resize-left' || st.mode === 'resize-right' ? st.mode : 'move'
if (isRepeatOccurrence) {
if (repeatIndex === 0) {
store.setEventRange(baseId, startDate, endDate, { mode })
} else {
if (!st.splitNewBaseId) {
const newId = store.splitRepeatSeries(
baseId,
repeatIndex,
startDate,
endDate,
grabbedWeekday,
)
if (newId) {
st.splitNewBaseId = newId
st.id = newId
st.startDate = startDate
st.endDate = endDate
}
} else {
store.setEventRange(st.splitNewBaseId, startDate, endDate, { mode })
}
}
} else {
store.setEventRange(st.id, startDate, endDate, { mode })
}
}
// Calculate event spans for this week
const eventSpans = computed(() => {
const spans = []
const weekEvents = new Map()
// Collect events from all days in this week, including repeat occurrences
props.week.days.forEach((day, dayIndex) => {
// Get base events for this day
day.events.forEach((event) => {
if (!weekEvents.has(event.id)) {
weekEvents.set(event.id, {
...event,
startIdx: dayIndex,
endIdx: dayIndex,
})
} else {
const existing = weekEvents.get(event.id)
existing.endIdx = dayIndex
}
})
// Generate repeat occurrences for this day
const repeatOccurrences = generateRepeatOccurrencesForDate(day.date)
repeatOccurrences.forEach((event) => {
if (!weekEvents.has(event.id)) {
weekEvents.set(event.id, {
...event,
startIdx: dayIndex,
endIdx: dayIndex,
})
} else {
const existing = weekEvents.get(event.id)
existing.endIdx = dayIndex
}
})
})
// Convert to array and sort
const eventArray = Array.from(weekEvents.values())
eventArray.sort((a, b) => {
// Sort by span length (longer first)
const spanA = a.endIdx - a.startIdx
const spanB = b.endIdx - b.startIdx
if (spanA !== spanB) return spanB - spanA
// Then by start position
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
// Then by start time if available
const timeA = a.startTime ? timeToMinutes(a.startTime) : 0
const timeB = b.startTime ? timeToMinutes(b.startTime) : 0
if (timeA !== timeB) return timeA - timeB
// Fallback to ID
return String(a.id).localeCompare(String(b.id))
})
// Assign rows to avoid overlaps
const rowsLastEnd = []
eventArray.forEach((event) => {
let placedRow = 0
while (placedRow < rowsLastEnd.length && !(event.startIdx > rowsLastEnd[placedRow])) {
placedRow++
}
if (placedRow === rowsLastEnd.length) {
rowsLastEnd.push(-1)
}
rowsLastEnd[placedRow] = event.endIdx
event.row = placedRow + 1
})
return eventArray
})
function timeToMinutes(timeStr) {
if (!timeStr) return 0
const [hours, minutes] = timeStr.split(':').map(Number)
return hours * 60 + minutes
}
</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: 0.2em;
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>