Creating events now requires a double tap or double tap with drag on the second tap, to prevent erratic clicks. Bugfixes on event handling and some unrelated comment changes.

This commit is contained in:
Leo Vasanko 2025-08-24 19:49:32 -06:00
parent 898ec2df00
commit 9a4d1c7196
4 changed files with 144 additions and 92 deletions

View File

@ -1,12 +1,14 @@
<script setup> <script setup>
const props = defineProps({ const props = defineProps({
day: Object, day: Object,
dragging: { type: Boolean, default: false },
}) })
</script> </script>
<template> <template>
<div <div
class="cell" class="cell"
:style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'"
:class="[ :class="[
props.day.monthClass, props.day.monthClass,
{ {
@ -37,7 +39,6 @@ const props = defineProps({
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
user-select: none; user-select: none;
touch-action: none;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: flex-start; align-items: flex-start;

View File

@ -47,10 +47,33 @@ const selection = ref({ startDate: null, dayCount: 0 })
const isDragging = ref(false) const isDragging = ref(false)
const dragAnchor = ref(null) const dragAnchor = ref(null)
// Double-tap state const DOUBLE_TAP_DELAY = 300
const lastTapTime = ref(0) const pendingTap = ref({ date: null, time: 0, type: null })
const lastTapDate = ref(null) const suppressMouseUntil = ref(0)
const DOUBLE_TAP_DELAY = 300 // milliseconds
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 minVirtualWeek = computed(() => {
const date = new Date(calendarStore.minYear, 0, 1) const date = new Date(calendarStore.minYear, 0, 1)
@ -153,29 +176,24 @@ function createWeek(virtualWeek) {
const dateStr = toLocalString(cur, DEFAULT_TZ) const dateStr = toLocalString(cur, DEFAULT_TZ)
const storedEvents = [] const storedEvents = []
// Find all non-repeating events that occur on this date
for (const ev of calendarStore.events.values()) { for (const ev of calendarStore.events.values()) {
if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) { if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) {
storedEvents.push(ev) storedEvents.push(ev)
} }
} }
// Build day events starting with stored (base/spanning) then virtual occurrences
const dayEvents = [...storedEvents] const dayEvents = [...storedEvents]
// Expand repeating events into per-day occurrences (including virtual spans) when they cover this date.
for (const base of repeatingBases) { for (const base of repeatingBases) {
// If the current date falls within the base event's original span, include the base // Base event's original span: include it directly as occurrence index 0.
// 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.
if (dateStr >= base.startDate && dateStr <= base.endDate) { if (dateStr >= base.startDate && dateStr <= base.endDate) {
dayEvents.push({ dayEvents.push({
...base, ...base,
// Mark explicit recurrence index for consistency with virtual occurrences
_recurrenceIndex: 0, _recurrenceIndex: 0,
_baseId: base.id, _baseId: base.id,
}) })
continue continue
} }
// Check if any virtual occurrence spans this date
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ) const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart)) const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
@ -183,19 +201,16 @@ function createWeek(virtualWeek) {
let occurrenceFound = false 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++) { for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
const candidateStart = addDays(currentDate, -offset) const candidateStart = addDays(currentDate, -offset)
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ) const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ) const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
if (occurrenceIndex !== null) { if (occurrenceIndex !== null) {
// Calculate the end date of this occurrence
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ) const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
// Check if this occurrence spans through the current date
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) { if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
// Create virtual occurrence (if not already created)
const virtualId = base.id + '_v_' + candidateStartStr const virtualId = base.id + '_v_' + candidateStartStr
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId) 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 let holiday = null
if (calendarStore.config.holidays.enabled) { if (calendarStore.config.holidays.enabled) {
calendarStore._ensureHolidaysInitialized?.() calendarStore._ensureHolidaysInitialized?.()
@ -308,21 +321,13 @@ function clearSelection() {
selection.value = { startDate: null, dayCount: 0 } 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) { function startDrag(dateStr) {
dateStr = normalizeDate(dateStr)
if (calendarStore.config.select_days === 0) return if (calendarStore.config.select_days === 0) return
isDragging.value = true isDragging.value = true
dragAnchor.value = dateStr dragAnchor.value = dateStr
selection.value = { startDate: dateStr, dayCount: 1 } selection.value = { startDate: dateStr, dayCount: 1 }
addGlobalTouchListeners()
} }
function updateDrag(dateStr) { function updateDrag(dateStr) {
@ -338,6 +343,88 @@ function endDrag(dateStr) {
selection.value = { startDate, dayCount } 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) { function calculateSelection(anchorStr, otherStr) {
const limit = calendarStore.config.select_days const limit = calendarStore.config.select_days
const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ) const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
@ -395,59 +482,33 @@ onBeforeUnmount(() => {
} }
}) })
const handleDayMouseDown = (dateStr) => { const handleDayMouseDown = (d) => {
// Check for double tap - only start drag on second tap d = normalizeDate(d)
if (isDoubleTap(dateStr)) { if (Date.now() < suppressMouseUntil.value) return
startDrag(dateStr) if (registerTap(d, 'mouse')) startDrag(d)
}
} }
const handleDayMouseEnter = (d) => updateDrag(normalizeDate(d))
const handleDayMouseEnter = (dateStr) => { const handleDayMouseUp = (d) => {
if (isDragging.value) { d = normalizeDate(d)
updateDrag(dateStr) if (Date.now() < suppressMouseUntil.value && !isDragging.value) return
} if (!isDragging.value) return
} endDrag(d)
const ev = createEventFromSelection()
const handleDayMouseUp = (dateStr) => { if (ev) {
if (isDragging.value) {
endDrag(dateStr)
const eventData = createEventFromSelection()
if (eventData) {
clearSelection() clearSelection()
emit('create-event', eventData) emit('create-event', ev)
}
} }
} }
const handleDayTouchStart = (d) => {
const handleDayTouchStart = (dateStr) => { d = normalizeDate(d)
// Check for double tap - only start drag on second tap suppressMouseUntil.value = Date.now() + 800
if (isDoubleTap(dateStr)) { if (registerTap(d, 'touch')) startDrag(d)
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 handleEventClick = (payload) => { const handleEventClick = (payload) => {
emit('edit-event', payload) emit('edit-event', payload)
} }
// Handle year change emitted from CalendarHeader: scroll to computed target position
const handleHeaderYearChange = ({ scrollTop: st }) => { const handleHeaderYearChange = ({ scrollTop: st }) => {
const maxScroll = contentHeight.value - viewportHeight.value const maxScroll = contentHeight.value - viewportHeight.value
const clamped = Math.max(0, Math.min(st, isFinite(maxScroll) ? maxScroll : st)) const clamped = Math.max(0, Math.min(st, isFinite(maxScroll) ? maxScroll : st))
@ -458,7 +519,7 @@ const handleHeaderYearChange = ({ scrollTop: st }) => {
function openSettings() { function openSettings() {
settingsDialog.value?.open() settingsDialog.value?.open()
} }
// Preserve approximate top visible date when first_day changes // Keep roughly same visible date when first_day setting changes.
watch( watch(
() => calendarStore.config.first_day, () => calendarStore.config.first_day,
() => { () => {
@ -504,13 +565,12 @@ watch(
v-for="week in visibleWeeks" v-for="week in visibleWeeks"
:key="week.virtualWeek" :key="week.virtualWeek"
:week="week" :week="week"
:dragging="isDragging"
:style="{ top: week.top + 'px' }" :style="{ top: week.top + 'px' }"
@day-mousedown="handleDayMouseDown" @day-mousedown="handleDayMouseDown"
@day-mouseenter="handleDayMouseEnter" @day-mouseenter="handleDayMouseEnter"
@day-mouseup="handleDayMouseUp" @day-mouseup="handleDayMouseUp"
@day-touchstart="handleDayTouchStart" @day-touchstart="handleDayTouchStart"
@day-touchmove="handleDayTouchMove"
@day-touchend="handleDayTouchEnd"
@event-click="handleEventClick" @event-click="handleEventClick"
/> />
<!-- Month labels positioned absolutely --> <!-- Month labels positioned absolutely -->

View File

@ -2,17 +2,13 @@
import CalendarDay from './CalendarDay.vue' import CalendarDay from './CalendarDay.vue'
import EventOverlay from './EventOverlay.vue' import EventOverlay from './EventOverlay.vue'
const props = defineProps({ const props = defineProps({ week: Object, dragging: { type: Boolean, default: false } })
week: Object,
})
const emit = defineEmits([ const emit = defineEmits([
'day-mousedown', 'day-mousedown',
'day-mouseenter', 'day-mouseenter',
'day-mouseup', 'day-mouseup',
'day-touchstart', 'day-touchstart',
'day-touchmove',
'day-touchend',
'event-click', 'event-click',
]) ])
@ -32,13 +28,7 @@ const handleDayTouchStart = (dateStr) => {
emit('day-touchstart', dateStr) emit('day-touchstart', dateStr)
} }
const handleDayTouchMove = (dateStr) => { // touchmove & touchend handled globally in CalendarView
emit('day-touchmove', dateStr)
}
const handleDayTouchEnd = (dateStr) => {
emit('day-touchend', dateStr)
}
const handleEventClick = (payload) => { const handleEventClick = (payload) => {
emit('event-click', payload) emit('event-click', payload)
@ -53,12 +43,11 @@ const handleEventClick = (payload) => {
v-for="day in props.week.days" v-for="day in props.week.days"
:key="day.date" :key="day.date"
:day="day" :day="day"
:dragging="props.dragging"
@mousedown="handleDayMouseDown(day.date)" @mousedown="handleDayMouseDown(day.date)"
@mouseenter="handleDayMouseEnter(day.date)" @mouseenter="handleDayMouseEnter(day.date)"
@mouseup="handleDayMouseUp(day.date)" @mouseup="handleDayMouseUp(day.date)"
@touchstart="handleDayTouchStart(day.date)" @touchstart="handleDayTouchStart(day.date)"
@touchmove="handleDayTouchMove(day.date)"
@touchend="handleDayTouchEnd(day.date)"
/> />
<EventOverlay :week="props.week" @event-click="handleEventClick" /> <EventOverlay :week="props.week" @event-click="handleEventClick" />
</div> </div>

View File

@ -165,8 +165,10 @@ function startLocalDrag(init, evt) {
} }
} }
// Prevent default to avoid text selection and other interference // 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() evt.preventDefault()
}
window.addEventListener('pointermove', onDragPointerMove, { passive: false }) window.addEventListener('pointermove', onDragPointerMove, { passive: false })
window.addEventListener('pointerup', onDragPointerUp, { passive: false }) window.addEventListener('pointerup', onDragPointerUp, { passive: false })