Compare commits

...

8 Commits

Author SHA1 Message Date
Leo Vasanko
57aefc5b4c Much simpler undo/redo handling, bugs fixed and less code. 2025-08-27 13:54:10 -06:00
Leo Vasanko
45939939f2 Implement event/date search bar 2025-08-27 11:15:27 -06:00
Leo Vasanko
9183ffe873 Adjust year limits from 1582 when Gregorian calendar was first introduced to 3000 which ought to be enough. 2025-08-27 11:15:09 -06:00
Leo Vasanko
8f092b5653 Event resize/move bugs and UX. 2025-08-27 09:42:23 -06:00
Leo Vasanko
cfb1b2ce5a Use hard ends on event bars when they cross between weeks. 2025-08-27 09:15:08 -06:00
Leo Vasanko
6f4ff06047 Refactor modules for consistency 2025-08-27 09:09:19 -06:00
Leo Vasanko
eb3b5a2aa4 Fix recurrent event splitting being broken after prior refactoring. 2025-08-27 08:51:36 -06:00
Leo Vasanko
5a0d6804bc Mouse/touch event handling improvement
- prefer passive handlers
- fix event moving on touch
- faster selection updates
2025-08-27 08:39:26 -06:00
15 changed files with 927 additions and 660 deletions

View File

@ -22,10 +22,18 @@ import { shallowRef } from 'vue'
const eventDialogRef = shallowRef(null)
function openCreateEventDialog(eventData) {
if (!eventDialogRef.value) return
// Capture baseline before dialog opens (new event creation flow)
try {
calendarStore.$history?._baselineIfNeeded?.(true)
} catch {}
const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount }
setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30)
}
function openEditEventDialog(eventClickPayload) {
// Capture baseline before editing existing event
try {
calendarStore.$history?._baselineIfNeeded?.(true)
} catch {}
eventDialogRef.value?.openEditDialog(eventClickPayload)
}
const viewport = ref(null)
@ -161,7 +169,27 @@ function measureFromProbe() {
}
}
const { getWeekIndex, getFirstDayForVirtualWeek, goToToday, handleHeaderYearChange } = vwm
const {
getWeekIndex,
getFirstDayForVirtualWeek,
goToToday,
handleHeaderYearChange,
scrollToWeekCentered,
} = vwm
// Reference date for search: center of the current viewport (virtual week at vertical midpoint)
const centerVisibleWeek = computed(() => {
const midRow = (scrollTop.value + viewportHeight.value / 2) / rowHeight.value
return Math.floor(midRow) + minVirtualWeek.value
})
const centerVisibleDateStr = computed(() => {
try {
const d = getFirstDayForVirtualWeek(centerVisibleWeek.value)
return toLocalString(d, DEFAULT_TZ)
} catch {
return calendarStore.today
}
})
// createWeek logic moved to virtualWeeks plugin
@ -248,7 +276,7 @@ function onGlobalTouchMove(e) {
if (!isDragging.value) return
const t = e.touches && e.touches[0]
if (!t) return
e.preventDefault()
if (e.cancelable) e.preventDefault()
const dateStr = getDateUnderPoint(t.clientX, t.clientY)
if (dateStr) updateDrag(dateStr)
}
@ -397,153 +425,24 @@ const handleEventClick = (payload) => {
openEditEventDialog(payload)
}
// ------------------------------
// Event Search (Ctrl/Cmd+F)
// ------------------------------
const searchOpen = ref(false)
const searchQuery = ref('')
const searchResults = ref([]) // [{ id, title, startDate }]
const searchIndex = ref(0)
const searchInputRef = ref(null)
function isEditableElement(el) {
if (!el) return false
const tag = el.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable) return true
return false
}
function buildSearchResults() {
const q = searchQuery.value.trim().toLowerCase()
if (!q) {
searchResults.value = []
searchIndex.value = 0
return
}
const out = []
for (const ev of calendarStore.events.values()) {
const title = (ev.title || '').trim()
if (!title) continue
if (title.toLowerCase().includes(q)) {
out.push({ id: ev.id, title: title, startDate: ev.startDate })
}
}
out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0))
searchResults.value = out
if (searchIndex.value >= out.length) searchIndex.value = 0
}
watch(searchQuery, buildSearchResults)
watch(
() => calendarStore.eventsMutation,
() => {
if (searchOpen.value && searchQuery.value.trim()) buildSearchResults()
},
)
function openSearch(prefill = '') {
searchOpen.value = true
if (prefill) searchQuery.value = prefill
nextTick(() => {
if (searchInputRef.value) {
searchInputRef.value.focus()
searchInputRef.value.select()
}
})
buildSearchResults()
}
function closeSearch() {
searchOpen.value = false
}
function navigateSearch(delta) {
const n = searchResults.value.length
if (!n) return
searchIndex.value = (searchIndex.value + delta + n) % n
scrollToCurrentResult()
}
function scrollToCurrentResult() {
const cur = searchResults.value[searchIndex.value]
if (!cur) return
// Scroll so week containing event is near top (offset 2 weeks for context)
function scrollToEventStart(startDate, smooth = true) {
try {
const dateObj = fromLocalString(cur.startDate, DEFAULT_TZ)
const dateObj = fromLocalString(startDate, DEFAULT_TZ)
const weekIndex = getWeekIndex(dateObj)
const offsetWeeks = 2
const targetScrollWeek = Math.max(minVirtualWeek.value, weekIndex - offsetWeeks)
const newScrollTop = (targetScrollWeek - minVirtualWeek.value) * rowHeight.value
setScrollTop(newScrollTop, 'search-jump')
scheduleWindowUpdate('search-jump')
scrollToWeekCentered(weekIndex, 'search-jump', smooth)
} catch {}
}
function activateCurrentResult() {
scrollToCurrentResult()
function handleHeaderSearchPreview(result) {
if (!result) return
scrollToEventStart(result.startDate, true)
}
function handleGlobalFind(e) {
if (!(e.ctrlKey || e.metaKey)) return
const k = e.key
if (k === 'f' || k === 'F') {
if (isEditableElement(e.target)) return
e.preventDefault()
if (!searchOpen.value) openSearch('')
else {
// If already open, select input text for quick overwrite
nextTick(() => {
if (searchInputRef.value) {
searchInputRef.value.focus()
searchInputRef.value.select()
function handleHeaderSearchActivate(result) {
if (!result) return
scrollToEventStart(result.startDate, true)
// Open edit dialog for the event
const ev = calendarStore.getEventById(result.id)
if (ev) openEditEventDialog({ id: ev.id, event: ev })
}
})
}
}
// While open: Enter confirms current selection & closes dialog
if (searchOpen.value && (k === 'Enter' || k === 'Return')) {
e.preventDefault()
activateCurrentResult()
closeSearch()
}
}
function handleSearchKeydown(e) {
if (!searchOpen.value) return
if (e.key === 'Escape') {
e.preventDefault()
closeSearch()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
navigateSearch(1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
navigateSearch(-1)
} else if (e.key === 'Enter') {
// Enter inside input: activate current and close
e.preventDefault()
activateCurrentResult()
closeSearch()
}
}
onMounted(() => {
document.addEventListener('keydown', handleGlobalFind, { passive: false })
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleGlobalFind)
})
// Ensure focus when (re)opening via reactive watch (catches programmatic toggles too)
watch(
() => searchOpen.value,
(v) => {
if (v) {
nextTick(() => {
if (searchInputRef.value) {
searchInputRef.value.focus()
searchInputRef.value.select()
}
})
}
},
)
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
// We explicitly avoid locale detection; rely solely on characters present.
@ -569,19 +468,22 @@ watch(
},
)
// Event changes
// Event changes (optimized): react to mutation counter & targeted range payload
watch(
() => calendarStore.events,
() => {
refreshEvents('events')
},
() => refreshEvents('events'),
{ deep: true },
)
// Reflect selection & events by rebuilding day objects in-place
watch(
() => [selection.value.startDate, selection.value.dayCount],
() => refreshEvents('selection'),
([start, count]) => {
const hasSel = !!start && !!count && count > 0
const end = hasSel ? addDaysStr(start, count, DEFAULT_TZ) : null
for (const w of visibleWeeks.value)
for (const d of w.days) d.isSelected = hasSel && d.date >= start && d.date < end
},
)
// Rebuild if viewport height changes (e.g., resize)
@ -596,7 +498,12 @@ window.addEventListener('resize', () => {
<div class="calendar-view-root" :dir="rtl && 'rtl'">
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
<div class="wrap">
<HeaderControls @go-to-today="goToToday" />
<HeaderControls
:reference-date="centerVisibleDateStr"
@go-to-today="goToToday"
@search-preview="handleHeaderSearchPreview"
@search-activate="handleHeaderSearchActivate"
/>
<CalendarHeader
:scroll-top="scrollTop"
:row-height="rowHeight"
@ -644,33 +551,6 @@ window.addEventListener('resize', () => {
</div>
</div>
<EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" />
<!-- Event Search Overlay -->
<div v-if="searchOpen" class="event-search" @keydown.capture="handleSearchKeydown">
<div class="search-row">
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
placeholder="Search events..."
aria-label="Search events"
autofocus
/>
<button type="button" @click="closeSearch" title="Close (Esc)"></button>
</div>
<ul class="results" v-if="searchResults.length">
<li
v-for="(r, i) in searchResults"
:key="r.id"
:class="{ active: i === searchIndex }"
@click="((searchIndex = i), activateCurrentResult(), closeSearch())"
>
<span class="title">{{ r.title }}</span>
<span class="date">{{ r.startDate }}</span>
</li>
</ul>
<div v-else class="no-results">{{ searchQuery ? 'No matches' : 'Type to search' }}</div>
<div class="hint">Enter to go, Esc to close, / to browse</div>
</div>
</div>
</div>
</template>
@ -772,93 +652,4 @@ header h1 {
height: var(--row-h);
pointer-events: none;
}
/* Search overlay */
.event-search {
position: fixed;
top: 0.75rem;
inset-inline-end: 0.75rem;
z-index: 1200;
background: color-mix(in srgb, var(--panel) 90%, transparent);
backdrop-filter: blur(0.75em);
-webkit-backdrop-filter: blur(0.75em);
color: var(--ink);
padding: 0.75rem 0.75rem 0.6rem 0.75rem;
border-radius: 0.6rem;
width: min(28rem, 80vw);
box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.35);
border: 1px solid color-mix(in srgb, var(--muted) 40%, transparent);
font-size: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.event-search .search-row {
display: flex;
gap: 0.4rem;
}
.event-search input[type='text'] {
flex: 1;
padding: 0.45rem 0.6rem;
border-radius: 0.4rem;
border: 1px solid color-mix(in srgb, var(--muted) 40%, transparent);
background: color-mix(in srgb, var(--panel) 85%, transparent);
color: inherit;
}
.event-search button {
background: color-mix(in srgb, var(--accent, #4b7) 70%, transparent);
color: var(--ink, #111);
border: 0;
border-radius: 0.4rem;
padding: 0.45rem 0.6rem;
cursor: pointer;
}
.event-search button:disabled {
opacity: 0.4;
cursor: default;
}
.event-search .results {
list-style: none;
margin: 0;
padding: 0;
max-height: 14rem;
overflow: auto;
border: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
border-radius: 0.4rem;
}
.event-search .results li {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.4rem 0.55rem;
cursor: pointer;
font-size: 0.85rem;
line-height: 1.2;
}
.event-search .results li.active {
background: color-mix(in srgb, var(--accent, #4b7) 45%, transparent);
color: var(--ink, #111);
font-weight: 600;
}
.event-search .results li:hover:not(.active) {
background: color-mix(in srgb, var(--panel) 70%, transparent);
}
.event-search .results .title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-search .results .date {
opacity: 0.6;
font-family: monospace;
}
.event-search .no-results {
padding: 0.25rem 0.1rem;
opacity: 0.7;
}
.event-search .hint {
opacity: 0.55;
font-size: 0.7rem;
}
</style>

View File

@ -57,7 +57,7 @@ function shouldRotateMonth(label) {
@mousedown="handleDayMouseDown(day.date)"
@mouseenter="handleDayMouseEnter(day.date)"
@mouseup="handleDayMouseUp(day.date)"
@touchstart="handleDayTouchStart(day.date)"
@touchstart.passive="handleDayTouchStart(day.date)"
/>
<EventOverlay :week="props.week" @event-click="handleEventClick" />
</div>

View File

@ -22,6 +22,8 @@ const props = defineProps({
const emit = defineEmits(['clear-selection'])
const calendarStore = useCalendarStore()
// Track baseline signature when dialog opens to decide if we need an undo snapshot on close
let dialogBaselineSig = null
const showDialog = ref(false)
// Anchoring: element of the DayCell representing the event's start date.
@ -29,7 +31,7 @@ const anchorElement = ref(null)
const dialogMode = ref('create') // 'create' or 'edit'
const editingEventId = ref(null)
const unsavedCreateId = ref(null)
const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occurrenceDate }
const occurrenceContext = ref(null) // { baseId, n }
const initialWeekday = ref(null)
const title = computed({
get() {
@ -208,7 +210,8 @@ function resolveAnchorFromDate(dateStr) {
}
function openCreateDialog(selectionData = null) {
calendarStore.$history?.beginCompound()
// Pre-change snapshot (before creating stub event)
calendarStore.$history?.push?.()
if (unsavedCreateId.value && !eventSaved.value) {
if (calendarStore.events?.has(unsavedCreateId.value)) {
calendarStore.deleteEvent(unsavedCreateId.value)
@ -272,6 +275,7 @@ function openCreateDialog(selectionData = null) {
// anchor to the starting day cell
anchorElement.value = resolveAnchorFromDate(start)
showDialog.value = true
// (Pre snapshot already taken before stub creation)
nextTick(() => {
if (titleInput.value) {
@ -284,7 +288,6 @@ function openCreateDialog(selectionData = null) {
}
function openEditDialog(payload) {
calendarStore.$history?.beginCompound()
if (
dialogMode.value === 'create' &&
unsavedCreateId.value &&
@ -304,17 +307,13 @@ function openEditDialog(payload) {
const baseId = payload.id
let n = payload.n || 0
let weekday = null
let occurrenceDate = null
const event = calendarStore.getEventById(baseId)
if (!event) return
if (event.recur && n >= 0) {
const occStr = getOccurrenceDate(event, n, DEFAULT_TZ)
if (occStr) {
occurrenceDate = fromLocalString(occStr, DEFAULT_TZ)
weekday = occurrenceDate.getDay()
}
if (occStr) weekday = fromLocalString(occStr, DEFAULT_TZ).getDay()
}
dialogMode.value = 'edit'
editingEventId.value = baseId
@ -344,14 +343,16 @@ function openEditDialog(payload) {
if (event.recur) {
if (event.recur.freq === 'weeks' && n >= 0) {
occurrenceContext.value = { baseId, occurrenceIndex: n, weekday, occurrenceDate }
occurrenceContext.value = { baseId, n }
} else if (event.recur.freq === 'months' && n > 0) {
occurrenceContext.value = { baseId, occurrenceIndex: n, weekday: null, occurrenceDate }
occurrenceContext.value = { baseId, n }
}
}
// anchor to base event start date
anchorElement.value = resolveAnchorFromDate(event.startDate)
showDialog.value = true
// Pre-change snapshot (only once when dialog opens)
calendarStore.$history?.push?.()
nextTick(() => {
if (titleInput.value) {
@ -364,7 +365,6 @@ function openEditDialog(payload) {
}
function closeDialog() {
calendarStore.$history?.endCompound()
showDialog.value = false
}
@ -396,13 +396,11 @@ function saveEvent() {
unsavedCreateId.value = null
}
if (dialogMode.value === 'create') emit('clear-selection')
calendarStore.$history?.endCompound()
closeDialog()
}
function deleteEventAll() {
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
calendarStore.$history?.endCompound()
closeDialog()
}
@ -412,14 +410,12 @@ function deleteEventOne() {
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
calendarStore.deleteFirstOccurrence(editingEventId.value)
}
calendarStore.$history?.endCompound()
closeDialog()
}
function deleteEventFrom() {
if (!occurrenceContext.value) return
calendarStore.deleteFromOccurrence(occurrenceContext.value)
calendarStore.$history?.endCompound()
closeDialog()
}
@ -435,8 +431,6 @@ watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => {
})
watch(showDialog, (val, oldVal) => {
if (oldVal && !val) {
// Closed (cancel, escape, outside click) -> end compound session
calendarStore.$history?.endCompound()
if (dialogMode.value === 'create' && unsavedCreateId.value && !eventSaved.value) {
if (calendarStore.events?.has(unsavedCreateId.value)) {
calendarStore.deleteEvent(unsavedCreateId.value)
@ -471,11 +465,13 @@ const isLastOccurrence = computed(() => {
if (!event || !event.recur) return false
if (event.recur.count === 'unlimited' || recurrenceOccurrences.value === 0) return false
const totalCount = parseInt(event.recur.count, 10) || 0
return occurrenceContext.value.occurrenceIndex === totalCount - 1
return occurrenceContext.value.n === totalCount - 1
})
const formattedOccurrenceShort = computed(() => {
if (occurrenceContext.value?.occurrenceDate) {
return formatDateShort(occurrenceContext.value.occurrenceDate)
if (occurrenceContext.value?.n != null) {
const ev = calendarStore.getEventById(editingEventId.value)
const occStr = ev ? getOccurrenceDate(ev, occurrenceContext.value.n, DEFAULT_TZ) : null
if (occStr) return formatDateShort(fromLocalString(occStr, DEFAULT_TZ))
}
if (isRepeatingBaseEdit.value && editingEventId.value) {
const ev = calendarStore.getEventById(editingEventId.value)
@ -487,8 +483,10 @@ const formattedOccurrenceShort = computed(() => {
})
const headerDateShort = computed(() => {
if (occurrenceContext.value?.occurrenceDate) {
return formatDateShort(occurrenceContext.value.occurrenceDate)
if (occurrenceContext.value?.n != null) {
const ev = calendarStore.getEventById(editingEventId.value)
const occStr = ev ? getOccurrenceDate(ev, occurrenceContext.value.n, DEFAULT_TZ) : null
if (occStr) return formatDateShort(fromLocalString(occStr, DEFAULT_TZ))
}
if (editingEventId.value) {
const ev = calendarStore.getEventById(editingEventId.value)

View File

@ -11,7 +11,10 @@
:key="span.id + '-' + (span.n != null ? span.n : 0)"
class="event-span"
dir="auto"
:class="[`event-color-${span.colorId}`]"
:class="[
`event-color-${span.colorId}`,
{ 'cont-prev': span.hasPrevWeek, 'cont-next': span.hasNextWeek },
]"
:data-id="span.id"
:data-n="span.n != null ? span.n : 0"
:style="{
@ -23,10 +26,12 @@
>
<span class="event-title">{{ span.title }}</span>
<div
v-if="!span.hasPrevWeek"
class="resize-handle left"
@pointerdown="handleResizePointerDown(span, 'resize-left', $event)"
></div>
<div
v-if="!span.hasNextWeek"
class="resize-handle right"
@pointerdown="handleResizePointerDown(span, 'resize-right', $event)"
></div>
@ -58,8 +63,21 @@ const eventSegments = computed(() => {
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)
if (!spanMap.has(key)) {
// Track min/max nDay (offset inside the occurrence) to know if the span is clipped by week boundaries
spanMap.set(key, {
...ev,
startIdx: di,
endIdx: di,
minNDay: ev.nDay,
maxNDay: ev.nDay,
})
} else {
const sp = spanMap.get(key)
sp.endIdx = Math.max(sp.endIdx, di)
if (ev.nDay < sp.minNDay) sp.minNDay = ev.nDay
if (ev.nDay > sp.maxNDay) sp.maxNDay = ev.nDay
}
})
})
const spans = Array.from(spanMap.values())
@ -67,6 +85,26 @@ const eventSegments = computed(() => {
spans.forEach((sp) => {
sp.startDate = props.week.days[sp.startIdx].date
sp.endDate = props.week.days[sp.endIdx].date
// Determine if this span actually continues beyond the visible week on either side.
// If it begins on the first day column but its first occurrence day offset (minNDay) > 0, the event started earlier.
sp.hasPrevWeek = sp.startIdx === 0 && sp.minNDay > 0
// If it ends on the last day column but we have not yet reached the final day of the occurrence (maxNDay < days-1), it continues.
if (sp.days != null) {
sp.hasNextWeek = sp.endIdx === 6 && sp.maxNDay < sp.days - 1
} else {
sp.hasNextWeek = false
}
// Compute full occurrence start/end (may extend beyond visible week)
if (sp.minNDay != null) {
sp.occurrenceStartDate = addDaysStr(sp.startDate, -sp.minNDay)
if (sp.days != null) {
sp.occurrenceEndDate = addDaysStr(sp.occurrenceStartDate, sp.days - 1)
} else {
// Fallback: approximate using maxNDay if days unknown
const total = (sp.maxNDay || 0) + 1
sp.occurrenceEndDate = addDaysStr(sp.occurrenceStartDate, total - 1)
}
}
})
// Sort so longer multi-day first, then earlier, then id for stability
spans.sort((a, b) => {
@ -181,7 +219,10 @@ function handleEventPointerDown(span, event) {
if (event.target.classList.contains('resize-handle')) return
event.stopPropagation()
const baseId = span.id
let anchorDate = span.startDate
// Use full occurrence boundaries for drag logic (not clipped week portion)
const fullStart = span.occurrenceStartDate || span.startDate
const fullEnd = span.occurrenceEndDate || span.endDate
let anchorDate = fullStart
try {
const spanDays = daysInclusive(span.startDate, span.endDate)
const targetEl = event.currentTarget
@ -193,7 +234,8 @@ function handleEventPointerDown(span, event) {
if (!isFinite(dayIndex)) dayIndex = 0
if (dayIndex < 0) dayIndex = 0
if (dayIndex >= spanDays) dayIndex = spanDays - 1
anchorDate = addDaysStr(span.startDate, dayIndex)
const absoluteOffset = (span.minNDay || 0) + dayIndex
anchorDate = addDaysStr(fullStart, absoluteOffset)
}
} catch (e) {}
startLocalDrag(
@ -204,8 +246,9 @@ function handleEventPointerDown(span, event) {
pointerStartX: event.clientX,
pointerStartY: event.clientY,
anchorDate,
startDate: span.startDate,
endDate: span.endDate,
startDate: fullStart,
endDate: fullEnd,
n: span.n,
},
event,
)
@ -214,6 +257,8 @@ function handleEventPointerDown(span, event) {
function handleResizePointerDown(span, mode, event) {
event.stopPropagation()
const baseId = span.id
const fullStart = span.occurrenceStartDate || span.startDate
const fullEnd = span.occurrenceEndDate || span.endDate
startLocalDrag(
{
id: baseId,
@ -222,8 +267,9 @@ function handleResizePointerDown(span, mode, event) {
pointerStartX: event.clientX,
pointerStartY: event.clientY,
anchorDate: null,
startDate: span.startDate,
endDate: span.endDate,
startDate: fullStart,
endDate: fullEnd,
n: span.n,
},
event,
)
@ -268,7 +314,46 @@ function startLocalDrag(init, evt) {
realizedId: null,
}
store.$history?.beginCompound()
// If history is empty (no baseline), create a baseline snapshot BEFORE any movement mutations
try {
const isResize = init.mode === 'resize-left' || init.mode === 'resize-right'
// Move: only baseline if history empty. Resize: force baseline (so undo returns to pre-resize) but only once.
store.$history?._baselineIfNeeded?.(isResize)
const evs = []
if (store.events instanceof Map) {
for (const [id, ev] of store.events) {
evs.push({
id,
start: ev.startDate,
days: ev.days,
title: ev.title,
color: ev.colorId,
recur: ev.recur
? {
f: ev.recur.freq,
i: ev.recur.interval,
c: ev.recur.count,
w: Array.isArray(ev.recur.weekdays) ? ev.recur.weekdays.join('') : null,
}
: null,
})
}
}
console.debug(
isResize ? '[history] pre-resize baseline snapshot' : '[history] pre-drag baseline snapshot',
{
mode: init.mode,
events: evs,
weekend: store.weekend,
firstDay: store.config?.first_day,
},
)
} catch {}
// Enter drag suppression (prevent intermediate pushes)
try {
store.$history?._beginDrag?.()
} catch {}
if (evt.currentTarget && evt.pointerId !== undefined) {
try {
@ -278,9 +363,7 @@ function startLocalDrag(init, evt) {
}
}
if (!(evt.pointerType === 'touch')) {
evt.preventDefault()
}
if (evt.cancelable) evt.preventDefault()
window.addEventListener('pointermove', onDragPointerMove, { passive: false })
window.addEventListener('pointerup', onDragPointerUp, { passive: false })
@ -325,7 +408,7 @@ function onDragPointerMove(e) {
if (st.mode === 'move') {
if (st.n && st.n > 0) {
if (!st.realizedId) {
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne)
const newId = store.splitMoveVirtualOccurrence(st.id, ns, ne, st.n)
if (newId) {
st.realizedId = newId
st.id = newId
@ -357,7 +440,7 @@ function onDragPointerMove(e) {
if (!st.realizedId) {
const initialStart = ns
const initialEnd = ne
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, initialStart, initialEnd)
const newId = store.splitMoveVirtualOccurrence(st.id, initialStart, initialEnd, st.n)
if (newId) {
st.realizedId = newId
st.id = newId
@ -409,38 +492,34 @@ function onDragPointerUp(e) {
justDragged.value = false
}, 120)
}
store.$history?.endCompound()
// End drag suppression regardless; no post snapshot (pre-only model)
try {
store.$history?._endDrag?.()
} catch {}
}
function computeTentativeRangeFromPointer(st, dropDateStr) {
const min = (a, b) => (a < b ? a : b)
const max = (a, b) => (a > b ? a : b)
function computeTentativeRangeFromPointer(st, current) {
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)
const ns = addDaysStr(current, -anchorOffset)
const ne = addDaysStr(ns, spanDays - 1)
return [ns, ne]
}
if (st.mode === 'resize-left') return [min(st.endDate, current), st.endDate]
if (st.mode === 'resize-right') return [st.startDate, max(st.startDate, current)]
function normalizeDateOrder(aStr, bStr) {
if (!aStr) return [bStr, bStr]
if (!bStr) return [aStr, aStr]
return aStr <= bStr ? [aStr, bStr] : [bStr, aStr]
return [st.startDate, st.endDate]
}
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)
store.splitMoveVirtualOccurrence(st.id, startDate, endDate, st.n)
return
}
store.setEventRange(st.id, startDate, endDate, { mode: st.mode })
@ -486,6 +565,18 @@ function applyRangeDuringDrag(st, startDate, endDate) {
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;
}
.event-span.cont-prev {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.event-span.cont-next {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
/* Inner title wrapper ensures proper ellipsis within flex/grid constraints */
@ -510,6 +601,7 @@ function applyRangeDuringDrag(st, startDate, endDate) {
background: transparent;
z-index: 2;
cursor: ew-resize;
touch-action: none; /* Allow touch resizing without scroll */
}
.event-span .resize-handle.left {

View File

@ -1,6 +1,13 @@
<template>
<div class="header-controls-wrapper">
<Transition name="header-controls" appear>
<div v-if="isVisible" class="header-controls">
<EventSearch
ref="eventSearchRef"
:reference-date="referenceDate"
@activate="handleSearchActivate"
@preview="(r) => emit('search-preview', r)"
/>
<div class="today-date" @click="goToToday">{{ todayString }}</div>
<button
type="button"
@ -43,12 +50,14 @@
>
</button>
</div>
</template>
<script setup>
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { computed, ref, onMounted, onBeforeUnmount, defineExpose, nextTick } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import { formatTodayString } from '@/utils/date'
import EventSearch from '@/components/Search.vue'
import SettingsDialog from '@/components/SettingsDialog.vue'
const calendarStore = useCalendarStore()
@ -58,7 +67,8 @@ const todayString = computed(() => {
return formatTodayString(d)
})
const emit = defineEmits(['go-to-today'])
const emit = defineEmits(['go-to-today', 'search-activate', 'search-preview'])
const { referenceDate = null } = defineProps({ referenceDate: { type: String, default: null } })
function goToToday() {
// Emit the event so the parent can handle the viewport scrolling logic
@ -68,6 +78,8 @@ function goToToday() {
// Screen size detection and visibility toggle
const isVisible = ref(false)
// Track if we auto-opened due to a find (Ctrl/Cmd+F)
const autoOpenedForSearch = ref(false)
function checkScreenSize() {
const isSmallScreen = window.innerHeight < 600
@ -77,30 +89,88 @@ function checkScreenSize() {
function toggleVisibility() {
isVisible.value = !isVisible.value
if (!isVisible.value) autoOpenedForSearch.value = false
}
// Settings dialog integration
const settingsDialog = ref(null)
function openSettings() {
// Capture baseline before opening settings
try {
calendarStore.$history?._baselineIfNeeded?.(true)
} catch {}
settingsDialog.value?.open()
}
// Search component ref exposure
const eventSearchRef = ref(null)
function focusSearch(selectAll = true) {
eventSearchRef.value?.focusSearch(selectAll)
}
function isEditableElement(el) {
if (!el) return false
const tag = el.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
}
defineExpose({ focusSearch })
function handleGlobalFind(e) {
if (!(e.ctrlKey || e.metaKey)) return
if (e.key === 'f' || e.key === 'F') {
if (isEditableElement(e.target)) return
e.preventDefault()
if (!isVisible.value) {
isVisible.value = true
autoOpenedForSearch.value = true
} else {
autoOpenedForSearch.value = false
}
// Defer focus until after transition renders input
nextTick(() => requestAnimationFrame(() => focusSearch(true)))
}
}
function handleSearchActivate(r) {
emit('search-activate', r)
// Auto close only if we auto-opened for search shortcut
if (autoOpenedForSearch.value) {
isVisible.value = false
}
autoOpenedForSearch.value = false
}
onMounted(() => {
checkScreenSize()
window.addEventListener('resize', checkScreenSize)
document.addEventListener('keydown', handleGlobalFind, { passive: false })
})
onBeforeUnmount(() => {
window.removeEventListener('resize', checkScreenSize)
document.removeEventListener('keydown', handleGlobalFind)
})
</script>
<style scoped>
.header-controls-wrapper {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.4rem 0.5rem 0 0.5rem;
}
.header-controls {
display: flex;
justify-content: end;
align-items: center;
margin-inline-end: 2rem;
gap: 0.75rem;
width: 100%;
padding-inline-end: 2rem;
}
.header-controls :deep(.search-bar) {
flex: 1 1 clamp(14rem, 40vw, 30rem);
max-width: clamp(18rem, 40vw, 30rem);
min-width: 12rem;
margin-inline-end: auto;
}
.toggle-btn {
position: fixed;
@ -201,6 +271,7 @@ onBeforeUnmount(() => {
}
.today-date {
font-size: 1.5em;
white-space: pre-line;
text-align: center;
margin-inline-end: 2rem;

381
src/components/Search.vue Normal file
View File

@ -0,0 +1,381 @@
<template>
<div class="search-bar" @keydown="onContainerKey">
<input
ref="searchInputRef"
v-model="searchQuery"
type="search"
placeholder="Date or event..."
aria-label="Search date and events"
@keydown="handleSearchKeydown"
/>
<ul
v-if="searchQuery.trim() && searchResults.length"
class="search-dropdown"
role="listbox"
:aria-activedescendant="activeResultId"
>
<li
v-for="(r, i) in searchResults"
:key="r.id"
:id="'sr-' + r.id"
:class="{ active: i === searchIndex }"
role="option"
@mousedown.prevent="selectResult(i)"
>
<span class="title">{{ r.title }}</span>
<span class="date">{{ r.startDate }}</span>
</li>
</ul>
<div v-else-if="searchQuery.trim() && !searchResults.length" class="search-empty">
No matches
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick, computed, defineExpose } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import {
fromLocalString,
DEFAULT_TZ,
monthAbbr,
getLocalizedMonthName,
toLocalString,
getMondayOfISOWeek,
formatTodayString,
makeTZDate,
} from '@/utils/date'
import { addDays } from 'date-fns'
import { getDate as getRecurDate, getNearestOccurrence } from '@/utils/events'
import * as dateFns from 'date-fns'
const emit = defineEmits(['activate', 'preview'])
const props = defineProps({ referenceDate: { type: String, default: null } })
const calendarStore = useCalendarStore()
const searchQuery = ref('')
const searchResults = ref([])
const searchIndex = ref(0)
const searchInputRef = ref(null)
function buildSearchResults() {
const raw = searchQuery.value.trim()
const q = raw.toLowerCase()
if (!q) {
searchResults.value = []
searchIndex.value = 0
return
}
const listAll = raw === '*'
const out = []
// Reference date: prefer viewport anchor (date-only) else 'now'. Normalize to midnight local.
let refStr = props.referenceDate || calendarStore.today || calendarStore.now
// If it's full ISO (with time), slice date portion.
if (refStr.includes('T')) refStr = refStr.slice(0, 10)
const nowDate = fromLocalString(refStr, DEFAULT_TZ)
for (const ev of calendarStore.events.values()) {
const title = (ev.title || '').trim()
if (!title) continue
if (!(listAll || title.toLowerCase().includes(q))) continue
let displayStart = ev.startDate
if (ev.recur) {
const nearest = getNearestOccurrence(ev, refStr, DEFAULT_TZ)
if (nearest && nearest.dateStr) displayStart = nearest.dateStr
}
out.push({ id: ev.id, title, startDate: displayStart })
}
out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0))
// Inject Go To Date option if query matches a date pattern (first item)
const gotoDateStr = parseGoToDateCandidate(raw)
if (gotoDateStr) {
const dateObj = fromLocalString(gotoDateStr, DEFAULT_TZ)
const label = formatTodayString(dateObj).replace(/\n+/g, ' ')
out.unshift({ id: '__goto__' + gotoDateStr, title: label, startDate: gotoDateStr, _goto: true })
}
searchResults.value = out
if (searchIndex.value >= out.length) searchIndex.value = 0
}
watch(searchQuery, buildSearchResults)
watch(
() => calendarStore.events,
() => {
if (searchQuery.value.trim()) buildSearchResults()
},
{ deep: true },
)
watch(
() => props.referenceDate,
() => {
if (searchQuery.value.trim()) buildSearchResults()
},
)
function focusSearch(selectAll = true) {
nextTick(() => {
if (searchInputRef.value) {
searchInputRef.value.focus()
if (selectAll) searchInputRef.value.select()
}
})
}
function navigate(delta) {
const n = searchResults.value.length
if (!n) return
searchIndex.value = (searchIndex.value + delta + n) % n
const r = searchResults.value[searchIndex.value]
if (r) emit('preview', r)
}
function selectResult(idx) {
searchIndex.value = idx
const r = searchResults.value[searchIndex.value]
if (r) {
emit('activate', r)
// Clear query after activation (auto-close handled by parent visibility)
searchQuery.value = ''
}
}
function handleSearchKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault()
navigate(1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
navigate(-1)
} else if (e.key === 'Enter') {
e.preventDefault()
selectResult(searchIndex.value)
} else if (e.key === 'Escape') {
if (searchQuery.value) {
searchQuery.value = ''
e.preventDefault()
}
}
}
function onContainerKey(e) {
/* capture for list navigation if needed */
}
const activeResultId = computed(() => {
const r = searchResults.value[searchIndex.value]
return r ? 'sr-' + r.id : null
})
defineExpose({ focusSearch })
function parseGoToDateCandidate(input) {
const s = input.trim()
if (!s) return null
const today = new Date()
const currentYear = today.getFullYear()
const localized = Array.from({ length: 12 }, (_, i) => getLocalizedMonthName(i))
function monthFromToken(tok) {
if (!tok) return null
const t = tok.toLowerCase()
if (/^\d{1,2}$/.test(t)) {
const n = +t
return n >= 1 && n <= 12 ? n : null
}
for (let i = 0; i < 12; i++) {
const ab = monthAbbr[i]
if (t === ab || t === ab.slice(0, 3)) return i + 1
}
for (let i = 0; i < 12; i++) {
const full = localized[i].toLowerCase()
if (t === full || full.startsWith(t)) return i + 1
}
return null
}
// ISO full date or year-month (defaults day=1)
let mIsoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (mIsoFull) {
const y = +mIsoFull[1],
m = +mIsoFull[2],
d = +mIsoFull[3]
const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ)
return toLocalString(dt, DEFAULT_TZ)
}
let mIsoMonth = s.match(/^(\d{4})[-/](\d{2})$/)
if (mIsoMonth) {
const y = +mIsoMonth[1],
m = +mIsoMonth[2]
const dt = makeTZDate(y, m - 1, 1, DEFAULT_TZ)
return toLocalString(dt, DEFAULT_TZ)
}
// ISO week
const mWeek = s.match(/^(\d{4})-W(\d{1,2})$/i)
if (mWeek) {
const wy = +mWeek[1],
w = +mWeek[2]
if (w >= 1 && w <= 53) {
const jan4 = new Date(Date.UTC(wy, 0, 4))
const target = addDays(jan4, (w - 1) * 7)
const monday = getMondayOfISOWeek(target)
return toLocalString(monday, DEFAULT_TZ)
}
return null
}
// Dotted: day.month[.year] or day.month. (trailing dot) or day.month.year.
let d = null,
m = null,
y = null
let mDot = s.match(/^(\d{1,2})\.([A-Za-z]+|\d{1,2})(?:\.(\d{4}))?\.?$/)
if (mDot) {
d = +mDot[1]
m = monthFromToken(mDot[2])
y = mDot[3] ? +mDot[3] : currentYear
} else {
// Slash month/day(/year) (month accepts names); year optional -> current year
let mUSFull = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/)
if (mUSFull) {
m = monthFromToken(mUSFull[1])
d = +mUSFull[2]
y = +mUSFull[3]
} else {
let mUSShort = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})$/)
if (mUSShort) {
m = monthFromToken(mUSShort[1])
d = +mUSShort[2]
y = currentYear
}
}
}
// Free-form with spaces: tokens containing month names and numbers
if (!y && !m && !d) {
const tokens = s.split(/[ ,]+/).filter(Boolean)
if (tokens.length >= 2 && tokens.length <= 3) {
// Prefer a token with letters as month over numeric month
let monthIdx = tokens.findIndex((t) => /[a-zA-Z]/.test(t) && monthFromToken(t) != null)
if (monthIdx === -1) monthIdx = tokens.findIndex((t) => monthFromToken(t) != null)
if (monthIdx !== -1) {
m = monthFromToken(tokens[monthIdx])
const others = tokens.filter((_t, i) => i !== monthIdx)
let dayExplicit = false
for (const rawTok of others) {
const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '') // trim punctuation
if (!tok) continue
if (/^\d+$/.test(tok)) {
const num = +tok
if (num > 100) {
y = num
} else if (!d) {
d = num
dayExplicit = true
}
} else if (!y && /^\d{4}[.,;:]?$/.test(tok)) {
// salvage year with trailing punctuation
const num = parseInt(tok, 10)
if (num > 1000) y = num
}
}
if (!y) y = currentYear
// Only default day=1 if user didn't provide any day-ish numeric token
if (!d && !dayExplicit) d = 1
}
}
}
if (y != null && m != null && d != null) {
if (y < 1000 || y > 9999 || m < 1 || m > 12 || d < 1 || d > 31) return null
const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ)
return toLocalString(dt, DEFAULT_TZ)
}
return null
}
</script>
<style scoped>
.search-bar {
position: relative;
min-width: 14rem;
flex: 1 1 clamp(14rem, 40vw, 30rem);
max-width: clamp(18rem, 40vw, 30rem);
min-width: 12rem;
}
.search-bar input {
width: 100%;
padding: 0.32rem 0.5rem;
border-radius: 0.45rem;
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
background: color-mix(in srgb, var(--panel) 88%, transparent);
font: inherit;
font-size: 0.8rem;
line-height: 1.1;
color: var(--ink);
outline: none;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background 0.2s;
}
.search-bar input:focus-visible {
border-color: color-mix(in srgb, var(--accent, #4b7) 70%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #4b7) 45%, transparent);
background: color-mix(in srgb, var(--panel) 95%, transparent);
}
.search-bar input::-webkit-search-cancel-button {
cursor: pointer;
}
.search-dropdown {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
right: 0;
z-index: 1400;
list-style: none;
margin: 0;
padding: 0.2rem;
background: color-mix(in srgb, var(--panel) 92%, transparent);
backdrop-filter: blur(0.6em);
-webkit-backdrop-filter: blur(0.6em);
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
border-radius: 0.55rem;
max-height: 16rem;
overflow: auto;
box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.3);
font-size: 0.8rem;
}
.search-dropdown li {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
border-radius: 0.4rem;
}
.search-dropdown li.active {
background: color-mix(in srgb, var(--accent, #4b7) 45%, transparent);
color: var(--ink, #111);
font-weight: 600;
}
.search-dropdown li:hover:not(.active) {
background: color-mix(in srgb, var(--panel) 70%, transparent);
}
.search-dropdown .title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-dropdown .date {
opacity: 0.6;
font-family: monospace;
}
.search-empty {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
right: 0;
padding: 0.45rem 0.6rem;
background: color-mix(in srgb, var(--panel) 92%, transparent);
backdrop-filter: blur(0.6em);
-webkit-backdrop-filter: blur(0.6em);
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
border-radius: 0.55rem;
box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.3);
font-size: 0.7rem;
opacity: 0.65;
pointer-events: none;
text-align: center;
}
</style>

View File

@ -3,7 +3,7 @@ import './assets/calendar.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { calendarHistory } from '@/plugins/calendarHistory'
import { history } from '@/plugins/history'
import App from './App.vue'
@ -12,7 +12,7 @@ const app = createApp(App)
const pinia = createPinia()
// Order: persistence first so snapshots recorded by undo reflect already-hydrated state
pinia.use(piniaPluginPersistedstate)
pinia.use(calendarHistory)
pinia.use(history)
app.use(pinia)
app.mount('#app')

View File

@ -1,200 +0,0 @@
// Custom lightweight undo/redo specifically for calendar store with Map support
// Adds store.$history = { undo(), redo(), canUndo, canRedo, clear(), pushManual() }
// Wraps action calls to create history entries only for meaningful mutations.
function deepCloneCalendarState(raw) {
// We only need to snapshot keys we care about; omit volatile fields
const { today, events, config, weekend } = raw
return {
today,
weekend: Array.isArray(weekend) ? [...weekend] : weekend,
config: JSON.parse(JSON.stringify(config)),
events: new Map([...events].map(([k, v]) => [k, { ...v }])),
}
}
function restoreCalendarState(store, snap) {
store.today = snap.today
store.weekend = Array.isArray(snap.weekend) ? [...snap.weekend] : snap.weekend
store.config = JSON.parse(JSON.stringify(snap.config))
store.events = new Map([...snap.events].map(([k, v]) => [k, { ...v }]))
store.eventsMutation = (store.eventsMutation + 1) % 1_000_000_000
}
export function calendarHistory({ store }) {
if (store.$id !== 'calendar') return
const max = 100 // history depth limit
const history = [] // past states
let pointer = -1 // index of current state in history
let isRestoring = false
let lastSerialized = null
// Compound editing session (e.g. event dialog) flags
let compoundActive = false
let compoundBaseSig = null
let compoundChanged = false
function serializeForComparison() {
const evCount = store.events instanceof Map ? store.events.size : 0
const em = store.eventsMutation || 0
return `${em}|${evCount}|${store.today}|${JSON.stringify(store.config)}`
}
function pushSnapshot() {
if (isRestoring) return
const sig = serializeForComparison()
if (sig === lastSerialized) return
// Drop any redo branch
if (pointer < history.length - 1) history.splice(pointer + 1)
history.push(deepCloneCalendarState(store))
if (history.length > max) history.shift()
pointer = history.length - 1
lastSerialized = sig
bumpIndicators()
// console.log('[history] pushed', pointer, sig)
}
function bumpIndicators() {
if (typeof store.historyTick === 'number') {
store.historyTick = (store.historyTick + 1) % 1_000_000_000
}
if (typeof store.historyCanUndo === 'boolean') {
store.historyCanUndo = pointer > 0
}
if (typeof store.historyCanRedo === 'boolean') {
store.historyCanRedo = pointer >= 0 && pointer < history.length - 1
}
}
function markPotentialChange() {
if (isRestoring) return
if (compoundActive) {
const sig = serializeForComparison()
if (sig !== compoundBaseSig) compoundChanged = true
return
}
pushSnapshot()
}
function beginCompound() {
if (compoundActive) return
compoundActive = true
compoundBaseSig = serializeForComparison()
compoundChanged = false
}
function endCompound() {
if (!compoundActive) return
const finalSig = serializeForComparison()
const changed = compoundChanged || finalSig !== compoundBaseSig
compoundActive = false
compoundBaseSig = null
if (changed) pushSnapshot()
else bumpIndicators() // session ended without change still refresh flags
}
function undo() {
// Ensure any active compound changes are finalized before moving back
if (compoundActive) endCompound()
else {
// If current state differs from last snapshot, push it so redo can restore it
const curSig = serializeForComparison()
if (curSig !== lastSerialized) pushSnapshot()
}
if (pointer <= 0) return
pointer--
isRestoring = true
try {
restoreCalendarState(store, history[pointer])
lastSerialized = serializeForComparison()
} finally {
isRestoring = false
}
bumpIndicators()
}
function redo() {
if (compoundActive) endCompound()
else {
const curSig = serializeForComparison()
if (curSig !== lastSerialized) pushSnapshot()
}
if (pointer >= history.length - 1) return
pointer++
isRestoring = true
try {
restoreCalendarState(store, history[pointer])
lastSerialized = serializeForComparison()
} finally {
isRestoring = false
}
bumpIndicators()
}
function clear() {
history.length = 0
pointer = -1
lastSerialized = null
bumpIndicators()
}
// Wrap selected mutating actions to push snapshot AFTER they run if state changed.
const actionNames = [
'createEvent',
'deleteEvent',
'deleteFirstOccurrence',
'deleteSingleOccurrence',
'deleteFromOccurrence',
'setEventRange',
'splitMoveVirtualOccurrence',
'splitRepeatSeries',
'_terminateRepeatSeriesAtIndex',
'toggleHolidays',
'initializeHolidays',
]
for (const name of actionNames) {
if (typeof store[name] === 'function') {
const original = store[name].bind(store)
store[name] = (...args) => {
const beforeSig = serializeForComparison()
const result = original(...args)
const afterSig = serializeForComparison()
if (afterSig !== beforeSig) markPotentialChange()
return result
}
}
}
// Capture direct property edits (e.g., deep field edits signaled via touchEvents())
store.$subscribe((mutation, _state) => {
if (mutation.storeId !== 'calendar') return
markPotentialChange()
})
// Initial snapshot after hydration (next microtask to let persistence load)
Promise.resolve().then(() => pushSnapshot())
store.$history = {
undo,
redo,
clear,
pushManual: pushSnapshot,
beginCompound,
endCompound,
flush() {
pushSnapshot()
},
get canUndo() {
return pointer > 0
},
get canRedo() {
return pointer >= 0 && pointer < history.length - 1
},
get compoundActive() {
return compoundActive
},
_debug() {
return { pointer, length: history.length }
},
}
}

View File

@ -1,57 +0,0 @@
// Pinia plugin to ensure calendar store keeps Map for events after undo/redo snapshots
export function calendarUndoNormalize({ store }) {
if (store.$id !== 'calendar') return
function fixEvents() {
const evs = store.events
if (evs instanceof Map) return
// If serialized form { __map: true, data: [...] }
if (evs && evs.__map && Array.isArray(evs.data)) {
store.events = new Map(evs.data)
return
}
// If an array of [k,v]
if (Array.isArray(evs) && evs.every((x) => Array.isArray(x) && x.length === 2)) {
store.events = new Map(evs)
return
}
// If plain object, convert own enumerable props
if (evs && typeof evs === 'object') {
store.events = new Map(Object.entries(evs))
}
}
// Patch undo/redo if present (after pinia-undo is installed)
const patchFns = ['undo', 'redo']
for (const fn of patchFns) {
if (typeof store[fn] === 'function') {
const original = store[fn].bind(store)
store[fn] = (...args) => {
console.log(`[calendar history] ${fn} invoked`)
const beforeType = store.events && store.events.constructor && store.events.constructor.name
const out = original(...args)
const afterRawType =
store.events && store.events.constructor && store.events.constructor.name
fixEvents()
const finalType = store.events && store.events.constructor && store.events.constructor.name
let size = null
try {
if (store.events instanceof Map) size = store.events.size
else if (Array.isArray(store.events)) size = store.events.length
} catch {}
console.log(
`[calendar history] ${fn} types: before=${beforeType} afterRaw=${afterRawType} final=${finalType} size=${size}`,
)
return out
}
}
}
// Also watch all mutations (includes direct assigns and action commits)
store.$subscribe(() => {
fixEvents()
})
// Initial sanity
fixEvents()
}

110
src/plugins/history.js Normal file
View File

@ -0,0 +1,110 @@
// Minimal undo/redo with explicit snapshot triggers.
// API: store.$history = { push(), undo(), redo(), clear(), canUndo, canRedo, _baselineIfNeeded(), _beginDrag(), _endDrag() }
function cloneEvent(ev) {
if (!ev || typeof ev !== 'object') return ev
const c = { ...ev }
if (c.recur && typeof c.recur === 'object') {
c.recur = {
...c.recur,
weekdays: Array.isArray(c.recur.weekdays) ? [...c.recur.weekdays] : c.recur.weekdays,
}
}
return c
}
function cloneState(store) {
const events = new Map()
for (const [k, ev] of store.events) events.set(k, cloneEvent(ev))
return {
today: store.today,
weekend: [...store.weekend],
config: JSON.parse(JSON.stringify(store.config)),
events,
}
}
function restoreState(store, snap) {
store.today = snap.today
store.weekend = [...snap.weekend]
store.config = JSON.parse(JSON.stringify(snap.config))
const events = new Map()
for (const [k, ev] of snap.events) events.set(k, cloneEvent(ev))
store.events = events
}
function same(a, b) {
if (!a || !b) return false
if (a.today !== b.today) return false
if (JSON.stringify(a.config) !== JSON.stringify(b.config)) return false
if (JSON.stringify(a.weekend) !== JSON.stringify(b.weekend)) return false
if (a.events.size !== b.events.size) return false
for (const [k, ev] of a.events) {
const other = b.events.get(k)
if (!other || JSON.stringify(ev) !== JSON.stringify(other)) return false
}
return true
}
export function history({ store }) {
if (store.$id !== 'calendar') return
const undoStack = []
const redoStack = []
const maxHistory = 50
function push() {
const snap = cloneState(store)
const last = undoStack[undoStack.length - 1]
if (last && same(snap, last)) return
undoStack.push(snap)
redoStack.length = 0
if (undoStack.length > maxHistory) undoStack.shift()
updateIndicators()
}
function undo() {
if (!undoStack.length) return
redoStack.push(cloneState(store))
restoreState(store, undoStack.pop())
updateIndicators()
}
function redo() {
if (!redoStack.length) return
undoStack.push(cloneState(store))
restoreState(store, redoStack.pop())
updateIndicators()
}
function clear() {
undoStack.length = redoStack.length = 0
updateIndicators()
}
function updateIndicators() {
store.historyCanUndo = undoStack.length > 0
store.historyCanRedo = redoStack.length > 0
store.historyTick = (store.historyTick + 1) % 1000000
}
// No initial snapshot: caller decides when to baseline.
store.$history = {
undo,
redo,
clear,
push,
get canUndo() {
return undoStack.length > 0
},
get canRedo() {
return redoStack.length > 0
},
// Drag lifecycle helpers used by EventOverlay.vue
_baselineIfNeeded(force = false) {
// Force: always push (resize, explicit dialogs). Non-force: rely on duplicate detection.
if (force) return push()
push()
},
_beginDrag() {},
_endDrag() {},
}
}

View File

@ -124,7 +124,6 @@ function createMomentumDrag({
return
}
applyDragPosition(e.touches[0].clientY, reasonDragTouch)
e.preventDefault()
}
function handlePointerDown(e) {
if (e.button !== undefined && e.button !== 0) return
@ -158,7 +157,7 @@ function createMomentumDrag({
window.addEventListener('touchmove', onTouchMove, { passive: false })
window.addEventListener('touchend', endDrag, { passive: false })
window.addEventListener('touchcancel', endDrag, { passive: false })
e.preventDefault()
if (e.cancelable) e.preventDefault()
}
function onPointerLockChange() {
const lockedEl = document.pointerLockElement

View File

@ -285,10 +285,24 @@ export function createVirtualWeekManager({
}
function goToToday() {
const top = addDays(new Date(calendarStore.now), -21)
const targetWeekIndex = getWeekIndex(top)
const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
if (setScrollTopFn) setScrollTopFn(newScrollTop, 'go-to-today')
const todayDate = new Date(calendarStore.now)
const targetWeekIndex = getWeekIndex(todayDate)
scrollToWeekCentered(targetWeekIndex, 'go-to-today', true)
}
function scrollToWeekCentered(weekIndex, reason = 'center-scroll', smooth = true) {
if (weekIndex == null || !isFinite(weekIndex)) return
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
const baseTop = (weekIndex - minVirtualWeek.value) * rowHeight.value
// Center: subtract half viewport minus half row height
let newScrollTop = baseTop - (viewportHeight.value / 2 - rowHeight.value / 2)
newScrollTop = Math.max(0, Math.min(newScrollTop, maxScroll))
if (smooth && viewport.value && typeof viewport.value.scrollTo === 'function') {
viewport.value.scrollTo({ top: newScrollTop, behavior: 'smooth' })
} else if (setScrollTopFn) {
setScrollTopFn(newScrollTop, reason)
scheduleWindowUpdate(reason)
}
}
function handleHeaderYearChange({ scrollTop }) {
@ -308,6 +322,7 @@ export function createVirtualWeekManager({
getWeekIndex,
getFirstDayForVirtualWeek,
goToToday,
scrollToWeekCentered,
handleHeaderYearChange,
attachScroll,
}

View File

@ -7,6 +7,7 @@ import {
DEFAULT_TZ,
} from '@/utils/date'
import { differenceInCalendarDays, addDays } from 'date-fns'
import { getDate } from '@/utils/events'
import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays'
export const useCalendarStore = defineStore('calendar', {
@ -14,9 +15,6 @@ export const useCalendarStore = defineStore('calendar', {
today: toLocalString(new Date(), DEFAULT_TZ),
now: new Date().toISOString(),
events: new Map(),
// Lightweight mutation counter so views can rebuild in a throttled / idle way
// without tracking deep reactivity on every event object.
eventsMutation: 0,
// Incremented internally by history plugin to force reactive updates for canUndo/canRedo
historyTick: 0,
historyCanUndo: false,
@ -117,10 +115,7 @@ export const useCalendarStore = defineStore('calendar', {
return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36)
},
notifyEventsChanged() {
// Bump simple counter (wrapping to avoid overflow in extreme long sessions)
this.eventsMutation = (this.eventsMutation + 1) % 1_000_000_000
},
notifyEventsChanged() {},
touchEvents() {
this.notifyEventsChanged()
},
@ -208,30 +203,30 @@ export const useCalendarStore = defineStore('calendar', {
},
deleteSingleOccurrence(ctx) {
const { baseId, occurrenceIndex } = ctx || {}
if (occurrenceIndex == null) return
const { baseId, n } = ctx || {}
if (n == null) return
const base = this.getEventById(baseId)
if (!base) return
if (!base.recur) {
if (occurrenceIndex === 0) this.deleteEvent(baseId)
if (n === 0) this.deleteEvent(baseId)
return
}
if (occurrenceIndex === 0) {
if (n === 0) {
this.deleteFirstOccurrence(baseId)
return
}
const snapshot = { ...base }
snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null
if (base.recur.count === occurrenceIndex + 1) {
base.recur.count = occurrenceIndex
if (base.recur.count === n + 1) {
base.recur.count = n
return
}
base.recur.count = occurrenceIndex
base.recur.count = n
const originalNumeric =
snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10)
let remainingCount = 'unlimited'
if (originalNumeric !== Infinity) {
const rem = originalNumeric - (occurrenceIndex + 1)
const rem = originalNumeric - (n + 1)
if (rem <= 0) return
remainingCount = String(rem)
}
@ -253,14 +248,14 @@ export const useCalendarStore = defineStore('calendar', {
},
deleteFromOccurrence(ctx) {
const { baseId, occurrenceIndex } = ctx
const { baseId, n } = ctx
const base = this.getEventById(baseId)
if (!base || !base.recur) return
if (occurrenceIndex === 0) {
if (n === 0) {
this.deleteEvent(baseId)
return
}
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
this._terminateRepeatSeriesAtIndex(baseId, n)
this.notifyEventsChanged()
},
@ -295,10 +290,16 @@ export const useCalendarStore = defineStore('calendar', {
this.notifyEventsChanged()
},
splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) {
// Split a recurring series at occurrence index n, moving that occurrence (n) into its own new series
splitMoveVirtualOccurrence(baseId, newStartStr, newEndStr, n) {
const base = this.events.get(baseId)
if (!base || !base.recur) return
const originalCountRaw = base.recur.count
// Derive occurrence date from n (first occurrence n=0 is base.startDate)
let occurrenceDateStr = null
if (n === 0) occurrenceDateStr = base.startDate
else occurrenceDateStr = getDate(base, n, DEFAULT_TZ)
if (!occurrenceDateStr) return
const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ)
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
// If series effectively has <=1 occurrence, treat as simple move (no split) and flatten
@ -316,7 +317,8 @@ export const useCalendarStore = defineStore('calendar', {
this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move', rotatePattern: true })
return baseId
}
if (occurrenceDate <= baseStart) {
// First occurrence: just move the event
if (n === 0) {
this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' })
return baseId
}
@ -408,19 +410,21 @@ export const useCalendarStore = defineStore('calendar', {
}
}
this.notifyEventsChanged()
// NOTE: Do NOT push a history snapshot here; wait until drag pointer up.
// Mid-drag snapshots create extra undo points. Final snapshot occurs in EventOverlay on pointerup.
return newId
},
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, _newEndStr) {
splitRepeatSeries(baseId, n, newStartStr, _newEndStr) {
const base = this.events.get(baseId)
if (!base || !base.recur) return null
const originalCountRaw = base.recur.count
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
this._terminateRepeatSeriesAtIndex(baseId, n)
let newSeriesCount = 'unlimited'
if (originalCountRaw !== 'unlimited') {
const originalNum = parseInt(originalCountRaw, 10)
if (!isNaN(originalNum)) {
const remaining = originalNum - occurrenceIndex
const remaining = originalNum - n
newSeriesCount = String(Math.max(1, remaining))
}
}

View File

@ -23,8 +23,9 @@ const monthAbbr = [
'nov',
'dec',
]
const MIN_YEAR = 1000
const MAX_YEAR = 9999
// We get scrolling issues if the virtual view is bigger than that
const MIN_YEAR = 1582
const MAX_YEAR = 3000
// Core helpers ------------------------------------------------------------
/**

View File

@ -139,6 +139,68 @@ export function getDate(event, n, timeZone = DEFAULT_TZ) {
return null
}
/**
* Return nearest occurrence (past or future) relative to a reference date (date-string yyyy-MM-dd).
* Falls back to first/last when reference lies before first or after last (bounded by cap).
* Returns { n, dateStr } or null if no recurrence / invalid.
*/
export function getNearestOccurrence(event, referenceDateStr, timeZone = DEFAULT_TZ, cap = 5000) {
if (!event) return null
if (!event.recur) return { n: 0, dateStr: event.startDate }
const { recur } = event
if (!recur || !['weeks', 'months'].includes(recur.freq)) return { n: 0, dateStr: event.startDate }
const refDate = fromLocalString(referenceDateStr, timeZone)
const baseDate = fromLocalString(event.startDate, timeZone)
if (refDate <= baseDate) return { n: 0, dateStr: event.startDate }
const maxCount = recur.count === 'unlimited' ? cap : Math.min(parseInt(recur.count, 10) || 0, cap)
if (maxCount <= 0) return null
let low = 0
let high = maxCount - 1
let candidateGE = null
while (low <= high) {
const mid = (low + high) >> 1
const midStr = getDate(event, mid, timeZone)
if (!midStr) {
// invalid mid (should rarely happen) shrink high
high = mid - 1
continue
}
const midDate = fromLocalString(midStr, timeZone)
if (midDate >= refDate) {
candidateGE = { n: mid, dateStr: midStr, date: midDate }
high = mid - 1
} else {
low = mid + 1
}
}
let candidateLT = null
if (candidateGE) {
const prevN = candidateGE.n - 1
if (prevN >= 0) {
const prevStr = getDate(event, prevN, timeZone)
if (prevStr) {
candidateLT = { n: prevN, dateStr: prevStr, date: fromLocalString(prevStr, timeZone) }
}
}
} else {
// All occurrences earlier than ref
const lastN = maxCount - 1
const lastStr = getDate(event, lastN, timeZone)
if (lastStr)
candidateLT = { n: lastN, dateStr: lastStr, date: fromLocalString(lastStr, timeZone) }
}
if (candidateGE && candidateLT) {
const diffGE = candidateGE.date - refDate
const diffLT = refDate - candidateLT.date
return diffLT <= diffGE
? { n: candidateLT.n, dateStr: candidateLT.dateStr }
: { n: candidateGE.n, dateStr: candidateGE.dateStr }
}
if (candidateGE) return { n: candidateGE.n, dateStr: candidateGE.dateStr }
if (candidateLT) return { n: candidateLT.n, dateStr: candidateLT.dateStr }
return null
}
export function buildDayEvents(events, dateStr, timeZone = DEFAULT_TZ) {
const date = fromLocalString(dateStr, timeZone)
const out = []