|
|
|
|
@@ -47,10 +47,33 @@ const selection = ref({ startDate: null, dayCount: 0 })
|
|
|
|
|
const isDragging = ref(false)
|
|
|
|
|
const dragAnchor = ref(null)
|
|
|
|
|
|
|
|
|
|
// Double-tap state
|
|
|
|
|
const lastTapTime = ref(0)
|
|
|
|
|
const lastTapDate = ref(null)
|
|
|
|
|
const DOUBLE_TAP_DELAY = 300 // milliseconds
|
|
|
|
|
const DOUBLE_TAP_DELAY = 300
|
|
|
|
|
const pendingTap = ref({ date: null, time: 0, type: null })
|
|
|
|
|
const suppressMouseUntil = ref(0)
|
|
|
|
|
|
|
|
|
|
function normalizeDate(val) {
|
|
|
|
|
if (typeof val === 'string') return val
|
|
|
|
|
if (val && typeof val === 'object') {
|
|
|
|
|
if (val.date) return String(val.date)
|
|
|
|
|
if (val.startDate) return String(val.startDate)
|
|
|
|
|
}
|
|
|
|
|
return String(val)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function registerTap(rawDate, type) {
|
|
|
|
|
const dateStr = normalizeDate(rawDate)
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
const prev = pendingTap.value
|
|
|
|
|
const delta = now - prev.time
|
|
|
|
|
const isDouble =
|
|
|
|
|
prev.date === dateStr && prev.type === type && delta <= DOUBLE_TAP_DELAY && delta >= 35
|
|
|
|
|
if (isDouble) {
|
|
|
|
|
pendingTap.value = { date: null, time: 0, type: null }
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
pendingTap.value = { date: dateStr, time: now, type }
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const minVirtualWeek = computed(() => {
|
|
|
|
|
const date = new Date(calendarStore.minYear, 0, 1)
|
|
|
|
|
@@ -153,29 +176,24 @@ function createWeek(virtualWeek) {
|
|
|
|
|
const dateStr = toLocalString(cur, DEFAULT_TZ)
|
|
|
|
|
const storedEvents = []
|
|
|
|
|
|
|
|
|
|
// Find all non-repeating events that occur on this date
|
|
|
|
|
for (const ev of calendarStore.events.values()) {
|
|
|
|
|
if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) {
|
|
|
|
|
storedEvents.push(ev)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Build day events starting with stored (base/spanning) then virtual occurrences
|
|
|
|
|
const dayEvents = [...storedEvents]
|
|
|
|
|
// Expand repeating events into per-day occurrences (including virtual spans) when they cover this date.
|
|
|
|
|
for (const base of repeatingBases) {
|
|
|
|
|
// If the current date falls within the base event's original span, include the base
|
|
|
|
|
// event itself as occurrence index 0. Previously this was skipped which caused the
|
|
|
|
|
// first (n=0) occurrence of repeating events to be missing from the calendar.
|
|
|
|
|
// Base event's original span: include it directly as occurrence index 0.
|
|
|
|
|
if (dateStr >= base.startDate && dateStr <= base.endDate) {
|
|
|
|
|
dayEvents.push({
|
|
|
|
|
...base,
|
|
|
|
|
// Mark explicit recurrence index for consistency with virtual occurrences
|
|
|
|
|
_recurrenceIndex: 0,
|
|
|
|
|
_baseId: base.id,
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if any virtual occurrence spans this date
|
|
|
|
|
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
|
|
|
|
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
|
|
|
|
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
|
|
|
|
|
@@ -183,19 +201,16 @@ function createWeek(virtualWeek) {
|
|
|
|
|
|
|
|
|
|
let occurrenceFound = false
|
|
|
|
|
|
|
|
|
|
// Walk backwards within span to find occurrence start
|
|
|
|
|
// Walk backwards within the base span to locate a matching virtual occurrence start.
|
|
|
|
|
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
|
|
|
|
|
const candidateStart = addDays(currentDate, -offset)
|
|
|
|
|
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
|
|
|
|
|
|
|
|
|
|
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
|
|
|
|
|
if (occurrenceIndex !== null) {
|
|
|
|
|
// Calculate the end date of this occurrence
|
|
|
|
|
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
|
|
|
|
|
|
|
|
|
|
// Check if this occurrence spans through the current date
|
|
|
|
|
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
|
|
|
|
|
// Create virtual occurrence (if not already created)
|
|
|
|
|
const virtualId = base.id + '_v_' + candidateStartStr
|
|
|
|
|
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
|
|
|
|
|
|
|
|
|
|
@@ -232,8 +247,6 @@ function createWeek(virtualWeek) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get holiday info once per day
|
|
|
|
|
// Ensure holidays initialized lazily
|
|
|
|
|
let holiday = null
|
|
|
|
|
if (calendarStore.config.holidays.enabled) {
|
|
|
|
|
calendarStore._ensureHolidaysInitialized?.()
|
|
|
|
|
@@ -308,21 +321,13 @@ function clearSelection() {
|
|
|
|
|
selection.value = { startDate: null, dayCount: 0 }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isDoubleTap(dateStr) {
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
const isDouble = lastTapDate.value === dateStr && now - lastTapTime.value <= DOUBLE_TAP_DELAY
|
|
|
|
|
|
|
|
|
|
lastTapTime.value = now
|
|
|
|
|
lastTapDate.value = dateStr
|
|
|
|
|
|
|
|
|
|
return isDouble
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startDrag(dateStr) {
|
|
|
|
|
dateStr = normalizeDate(dateStr)
|
|
|
|
|
if (calendarStore.config.select_days === 0) return
|
|
|
|
|
isDragging.value = true
|
|
|
|
|
dragAnchor.value = dateStr
|
|
|
|
|
selection.value = { startDate: dateStr, dayCount: 1 }
|
|
|
|
|
addGlobalTouchListeners()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateDrag(dateStr) {
|
|
|
|
|
@@ -338,6 +343,88 @@ function endDrag(dateStr) {
|
|
|
|
|
selection.value = { startDate, dayCount }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function finalizeDragAndCreate() {
|
|
|
|
|
if (!isDragging.value) return
|
|
|
|
|
isDragging.value = false
|
|
|
|
|
const eventData = createEventFromSelection()
|
|
|
|
|
if (eventData) {
|
|
|
|
|
clearSelection()
|
|
|
|
|
emit('create-event', eventData)
|
|
|
|
|
}
|
|
|
|
|
removeGlobalTouchListeners()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDateUnderPoint(x, y) {
|
|
|
|
|
const el = document.elementFromPoint(x, y)
|
|
|
|
|
let cur = el
|
|
|
|
|
while (cur) {
|
|
|
|
|
if (cur.dataset && cur.dataset.date) return cur.dataset.date
|
|
|
|
|
cur = cur.parentElement
|
|
|
|
|
}
|
|
|
|
|
return getDateFromCoordinates(x, y)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onGlobalTouchMove(e) {
|
|
|
|
|
if (!isDragging.value) return
|
|
|
|
|
const t = e.touches && e.touches[0]
|
|
|
|
|
if (!t) return
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
const dateStr = getDateUnderPoint(t.clientX, t.clientY)
|
|
|
|
|
if (dateStr) updateDrag(dateStr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onGlobalTouchEnd(e) {
|
|
|
|
|
if (!isDragging.value) {
|
|
|
|
|
removeGlobalTouchListeners()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const t = (e.changedTouches && e.changedTouches[0]) || (e.touches && e.touches[0])
|
|
|
|
|
if (t) {
|
|
|
|
|
const dateStr = getDateUnderPoint(t.clientX, t.clientY)
|
|
|
|
|
if (dateStr) {
|
|
|
|
|
const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
|
|
|
|
|
selection.value = { startDate, dayCount }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
finalizeDragAndCreate()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addGlobalTouchListeners() {
|
|
|
|
|
window.addEventListener('touchmove', onGlobalTouchMove, { passive: false })
|
|
|
|
|
window.addEventListener('touchend', onGlobalTouchEnd, { passive: false })
|
|
|
|
|
window.addEventListener('touchcancel', onGlobalTouchEnd, { passive: false })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeGlobalTouchListeners() {
|
|
|
|
|
window.removeEventListener('touchmove', onGlobalTouchMove)
|
|
|
|
|
window.removeEventListener('touchend', onGlobalTouchEnd)
|
|
|
|
|
window.removeEventListener('touchcancel', onGlobalTouchEnd)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback hit-test if elementFromPoint doesn't find a day cell (e.g., moving between rows).
|
|
|
|
|
function getDateFromCoordinates(clientX, clientY) {
|
|
|
|
|
if (!viewport.value) return null
|
|
|
|
|
const vpRect = viewport.value.getBoundingClientRect()
|
|
|
|
|
const yOffset = clientY - vpRect.top + viewport.value.scrollTop
|
|
|
|
|
if (yOffset < 0) return null
|
|
|
|
|
const rowIndex = Math.floor(yOffset / rowHeight.value)
|
|
|
|
|
const virtualWeek = minVirtualWeek.value + rowIndex
|
|
|
|
|
if (virtualWeek < minVirtualWeek.value || virtualWeek > maxVirtualWeek.value) return null
|
|
|
|
|
const sampleWeek = viewport.value.querySelector('.week-row')
|
|
|
|
|
if (!sampleWeek) return null
|
|
|
|
|
const labelEl = sampleWeek.querySelector('.week-label')
|
|
|
|
|
const jogwheelWidth = 48
|
|
|
|
|
const wrRect = sampleWeek.getBoundingClientRect()
|
|
|
|
|
const labelRight = labelEl ? labelEl.getBoundingClientRect().right : wrRect.left
|
|
|
|
|
const daysAreaRight = wrRect.right - jogwheelWidth
|
|
|
|
|
const daysWidth = daysAreaRight - labelRight
|
|
|
|
|
if (clientX < labelRight || clientX > daysAreaRight) return null
|
|
|
|
|
const col = Math.min(6, Math.max(0, Math.floor(((clientX - labelRight) / daysWidth) * 7)))
|
|
|
|
|
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
|
|
|
|
|
const targetDate = addDays(firstDay, col)
|
|
|
|
|
return toLocalString(targetDate, DEFAULT_TZ)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function calculateSelection(anchorStr, otherStr) {
|
|
|
|
|
const limit = calendarStore.config.select_days
|
|
|
|
|
const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
|
|
|
|
|
@@ -395,59 +482,33 @@ onBeforeUnmount(() => {
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const handleDayMouseDown = (dateStr) => {
|
|
|
|
|
// Check for double tap - only start drag on second tap
|
|
|
|
|
if (isDoubleTap(dateStr)) {
|
|
|
|
|
startDrag(dateStr)
|
|
|
|
|
const handleDayMouseDown = (d) => {
|
|
|
|
|
d = normalizeDate(d)
|
|
|
|
|
if (Date.now() < suppressMouseUntil.value) return
|
|
|
|
|
if (registerTap(d, 'mouse')) startDrag(d)
|
|
|
|
|
}
|
|
|
|
|
const handleDayMouseEnter = (d) => updateDrag(normalizeDate(d))
|
|
|
|
|
const handleDayMouseUp = (d) => {
|
|
|
|
|
d = normalizeDate(d)
|
|
|
|
|
if (Date.now() < suppressMouseUntil.value && !isDragging.value) return
|
|
|
|
|
if (!isDragging.value) return
|
|
|
|
|
endDrag(d)
|
|
|
|
|
const ev = createEventFromSelection()
|
|
|
|
|
if (ev) {
|
|
|
|
|
clearSelection()
|
|
|
|
|
emit('create-event', ev)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDayMouseEnter = (dateStr) => {
|
|
|
|
|
if (isDragging.value) {
|
|
|
|
|
updateDrag(dateStr)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDayMouseUp = (dateStr) => {
|
|
|
|
|
if (isDragging.value) {
|
|
|
|
|
endDrag(dateStr)
|
|
|
|
|
const eventData = createEventFromSelection()
|
|
|
|
|
if (eventData) {
|
|
|
|
|
clearSelection()
|
|
|
|
|
emit('create-event', eventData)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDayTouchStart = (dateStr) => {
|
|
|
|
|
// Check for double tap - only start drag on second tap
|
|
|
|
|
if (isDoubleTap(dateStr)) {
|
|
|
|
|
startDrag(dateStr)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDayTouchMove = (dateStr) => {
|
|
|
|
|
if (isDragging.value) {
|
|
|
|
|
updateDrag(dateStr)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDayTouchEnd = (dateStr) => {
|
|
|
|
|
if (isDragging.value) {
|
|
|
|
|
endDrag(dateStr)
|
|
|
|
|
const eventData = createEventFromSelection()
|
|
|
|
|
if (eventData) {
|
|
|
|
|
clearSelection()
|
|
|
|
|
emit('create-event', eventData)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const handleDayTouchStart = (d) => {
|
|
|
|
|
d = normalizeDate(d)
|
|
|
|
|
suppressMouseUntil.value = Date.now() + 800
|
|
|
|
|
if (registerTap(d, 'touch')) startDrag(d)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleEventClick = (payload) => {
|
|
|
|
|
emit('edit-event', payload)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle year change emitted from CalendarHeader: scroll to computed target position
|
|
|
|
|
const handleHeaderYearChange = ({ scrollTop: st }) => {
|
|
|
|
|
const maxScroll = contentHeight.value - viewportHeight.value
|
|
|
|
|
const clamped = Math.max(0, Math.min(st, isFinite(maxScroll) ? maxScroll : st))
|
|
|
|
|
@@ -458,7 +519,7 @@ const handleHeaderYearChange = ({ scrollTop: st }) => {
|
|
|
|
|
function openSettings() {
|
|
|
|
|
settingsDialog.value?.open()
|
|
|
|
|
}
|
|
|
|
|
// Preserve approximate top visible date when first_day changes
|
|
|
|
|
// Keep roughly same visible date when first_day setting changes.
|
|
|
|
|
watch(
|
|
|
|
|
() => calendarStore.config.first_day,
|
|
|
|
|
() => {
|
|
|
|
|
@@ -504,13 +565,12 @@ watch(
|
|
|
|
|
v-for="week in visibleWeeks"
|
|
|
|
|
:key="week.virtualWeek"
|
|
|
|
|
:week="week"
|
|
|
|
|
:dragging="isDragging"
|
|
|
|
|
:style="{ top: week.top + 'px' }"
|
|
|
|
|
@day-mousedown="handleDayMouseDown"
|
|
|
|
|
@day-mouseenter="handleDayMouseEnter"
|
|
|
|
|
@day-mouseup="handleDayMouseUp"
|
|
|
|
|
@day-touchstart="handleDayTouchStart"
|
|
|
|
|
@day-touchmove="handleDayTouchMove"
|
|
|
|
|
@day-touchend="handleDayTouchEnd"
|
|
|
|
|
@event-click="handleEventClick"
|
|
|
|
|
/>
|
|
|
|
|
<!-- Month labels positioned absolutely -->
|
|
|
|
|
|