Compare commits
8 Commits
ecae48fd85
...
57aefc5b4c
Author | SHA1 | Date | |
---|---|---|---|
![]() |
57aefc5b4c | ||
![]() |
45939939f2 | ||
![]() |
9183ffe873 | ||
![]() |
8f092b5653 | ||
![]() |
cfb1b2ce5a | ||
![]() |
6f4ff06047 | ||
![]() |
eb3b5a2aa4 | ||
![]() |
5a0d6804bc |
@ -22,10 +22,18 @@ import { shallowRef } from 'vue'
|
|||||||
const eventDialogRef = shallowRef(null)
|
const eventDialogRef = shallowRef(null)
|
||||||
function openCreateEventDialog(eventData) {
|
function openCreateEventDialog(eventData) {
|
||||||
if (!eventDialogRef.value) return
|
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 }
|
const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount }
|
||||||
setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30)
|
setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30)
|
||||||
}
|
}
|
||||||
function openEditEventDialog(eventClickPayload) {
|
function openEditEventDialog(eventClickPayload) {
|
||||||
|
// Capture baseline before editing existing event
|
||||||
|
try {
|
||||||
|
calendarStore.$history?._baselineIfNeeded?.(true)
|
||||||
|
} catch {}
|
||||||
eventDialogRef.value?.openEditDialog(eventClickPayload)
|
eventDialogRef.value?.openEditDialog(eventClickPayload)
|
||||||
}
|
}
|
||||||
const viewport = ref(null)
|
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
|
// createWeek logic moved to virtualWeeks plugin
|
||||||
|
|
||||||
@ -248,7 +276,7 @@ function onGlobalTouchMove(e) {
|
|||||||
if (!isDragging.value) return
|
if (!isDragging.value) return
|
||||||
const t = e.touches && e.touches[0]
|
const t = e.touches && e.touches[0]
|
||||||
if (!t) return
|
if (!t) return
|
||||||
e.preventDefault()
|
if (e.cancelable) e.preventDefault()
|
||||||
const dateStr = getDateUnderPoint(t.clientX, t.clientY)
|
const dateStr = getDateUnderPoint(t.clientX, t.clientY)
|
||||||
if (dateStr) updateDrag(dateStr)
|
if (dateStr) updateDrag(dateStr)
|
||||||
}
|
}
|
||||||
@ -397,154 +425,25 @@ const handleEventClick = (payload) => {
|
|||||||
openEditEventDialog(payload)
|
openEditEventDialog(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------
|
function scrollToEventStart(startDate, smooth = true) {
|
||||||
// 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)
|
|
||||||
try {
|
try {
|
||||||
const dateObj = fromLocalString(cur.startDate, DEFAULT_TZ)
|
const dateObj = fromLocalString(startDate, DEFAULT_TZ)
|
||||||
const weekIndex = getWeekIndex(dateObj)
|
const weekIndex = getWeekIndex(dateObj)
|
||||||
const offsetWeeks = 2
|
scrollToWeekCentered(weekIndex, 'search-jump', smooth)
|
||||||
const targetScrollWeek = Math.max(minVirtualWeek.value, weekIndex - offsetWeeks)
|
|
||||||
const newScrollTop = (targetScrollWeek - minVirtualWeek.value) * rowHeight.value
|
|
||||||
setScrollTop(newScrollTop, 'search-jump')
|
|
||||||
scheduleWindowUpdate('search-jump')
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
function activateCurrentResult() {
|
function handleHeaderSearchPreview(result) {
|
||||||
scrollToCurrentResult()
|
if (!result) return
|
||||||
|
scrollToEventStart(result.startDate, true)
|
||||||
}
|
}
|
||||||
|
function handleHeaderSearchActivate(result) {
|
||||||
function handleGlobalFind(e) {
|
if (!result) return
|
||||||
if (!(e.ctrlKey || e.metaKey)) return
|
scrollToEventStart(result.startDate, true)
|
||||||
const k = e.key
|
// Open edit dialog for the event
|
||||||
if (k === 'f' || k === 'F') {
|
const ev = calendarStore.getEventById(result.id)
|
||||||
if (isEditableElement(e.target)) return
|
if (ev) openEditEventDialog({ id: ev.id, event: ev })
|
||||||
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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 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.
|
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
||||||
// We explicitly avoid locale detection; rely solely on characters present.
|
// We explicitly avoid locale detection; rely solely on characters present.
|
||||||
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
|
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
|
||||||
@ -569,19 +468,22 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event changes
|
// Event changes (optimized): react to mutation counter & targeted range payload
|
||||||
watch(
|
watch(
|
||||||
() => calendarStore.events,
|
() => calendarStore.events,
|
||||||
() => {
|
() => refreshEvents('events'),
|
||||||
refreshEvents('events')
|
|
||||||
},
|
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reflect selection & events by rebuilding day objects in-place
|
// Reflect selection & events by rebuilding day objects in-place
|
||||||
watch(
|
watch(
|
||||||
() => [selection.value.startDate, selection.value.dayCount],
|
() => [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)
|
// Rebuild if viewport height changes (e.g., resize)
|
||||||
@ -596,7 +498,12 @@ window.addEventListener('resize', () => {
|
|||||||
<div class="calendar-view-root" :dir="rtl && 'rtl'">
|
<div class="calendar-view-root" :dir="rtl && 'rtl'">
|
||||||
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<HeaderControls @go-to-today="goToToday" />
|
<HeaderControls
|
||||||
|
:reference-date="centerVisibleDateStr"
|
||||||
|
@go-to-today="goToToday"
|
||||||
|
@search-preview="handleHeaderSearchPreview"
|
||||||
|
@search-activate="handleHeaderSearchActivate"
|
||||||
|
/>
|
||||||
<CalendarHeader
|
<CalendarHeader
|
||||||
:scroll-top="scrollTop"
|
:scroll-top="scrollTop"
|
||||||
:row-height="rowHeight"
|
:row-height="rowHeight"
|
||||||
@ -644,33 +551,6 @@ window.addEventListener('resize', () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -772,93 +652,4 @@ header h1 {
|
|||||||
height: var(--row-h);
|
height: var(--row-h);
|
||||||
pointer-events: none;
|
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>
|
</style>
|
||||||
|
@ -57,7 +57,7 @@ function shouldRotateMonth(label) {
|
|||||||
@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.passive="handleDayTouchStart(day.date)"
|
||||||
/>
|
/>
|
||||||
<EventOverlay :week="props.week" @event-click="handleEventClick" />
|
<EventOverlay :week="props.week" @event-click="handleEventClick" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -22,6 +22,8 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['clear-selection'])
|
const emit = defineEmits(['clear-selection'])
|
||||||
|
|
||||||
const calendarStore = useCalendarStore()
|
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)
|
const showDialog = ref(false)
|
||||||
// Anchoring: element of the DayCell representing the event's start date.
|
// 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 dialogMode = ref('create') // 'create' or 'edit'
|
||||||
const editingEventId = ref(null)
|
const editingEventId = ref(null)
|
||||||
const unsavedCreateId = 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 initialWeekday = ref(null)
|
||||||
const title = computed({
|
const title = computed({
|
||||||
get() {
|
get() {
|
||||||
@ -208,7 +210,8 @@ function resolveAnchorFromDate(dateStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateDialog(selectionData = null) {
|
function openCreateDialog(selectionData = null) {
|
||||||
calendarStore.$history?.beginCompound()
|
// Pre-change snapshot (before creating stub event)
|
||||||
|
calendarStore.$history?.push?.()
|
||||||
if (unsavedCreateId.value && !eventSaved.value) {
|
if (unsavedCreateId.value && !eventSaved.value) {
|
||||||
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
||||||
calendarStore.deleteEvent(unsavedCreateId.value)
|
calendarStore.deleteEvent(unsavedCreateId.value)
|
||||||
@ -272,6 +275,7 @@ function openCreateDialog(selectionData = null) {
|
|||||||
// anchor to the starting day cell
|
// anchor to the starting day cell
|
||||||
anchorElement.value = resolveAnchorFromDate(start)
|
anchorElement.value = resolveAnchorFromDate(start)
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
|
// (Pre snapshot already taken before stub creation)
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (titleInput.value) {
|
if (titleInput.value) {
|
||||||
@ -284,7 +288,6 @@ function openCreateDialog(selectionData = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEditDialog(payload) {
|
function openEditDialog(payload) {
|
||||||
calendarStore.$history?.beginCompound()
|
|
||||||
if (
|
if (
|
||||||
dialogMode.value === 'create' &&
|
dialogMode.value === 'create' &&
|
||||||
unsavedCreateId.value &&
|
unsavedCreateId.value &&
|
||||||
@ -304,17 +307,13 @@ function openEditDialog(payload) {
|
|||||||
const baseId = payload.id
|
const baseId = payload.id
|
||||||
let n = payload.n || 0
|
let n = payload.n || 0
|
||||||
let weekday = null
|
let weekday = null
|
||||||
let occurrenceDate = null
|
|
||||||
|
|
||||||
const event = calendarStore.getEventById(baseId)
|
const event = calendarStore.getEventById(baseId)
|
||||||
if (!event) return
|
if (!event) return
|
||||||
|
|
||||||
if (event.recur && n >= 0) {
|
if (event.recur && n >= 0) {
|
||||||
const occStr = getOccurrenceDate(event, n, DEFAULT_TZ)
|
const occStr = getOccurrenceDate(event, n, DEFAULT_TZ)
|
||||||
if (occStr) {
|
if (occStr) weekday = fromLocalString(occStr, DEFAULT_TZ).getDay()
|
||||||
occurrenceDate = fromLocalString(occStr, DEFAULT_TZ)
|
|
||||||
weekday = occurrenceDate.getDay()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
dialogMode.value = 'edit'
|
dialogMode.value = 'edit'
|
||||||
editingEventId.value = baseId
|
editingEventId.value = baseId
|
||||||
@ -344,14 +343,16 @@ function openEditDialog(payload) {
|
|||||||
|
|
||||||
if (event.recur) {
|
if (event.recur) {
|
||||||
if (event.recur.freq === 'weeks' && n >= 0) {
|
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) {
|
} 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
|
// anchor to base event start date
|
||||||
anchorElement.value = resolveAnchorFromDate(event.startDate)
|
anchorElement.value = resolveAnchorFromDate(event.startDate)
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
|
// Pre-change snapshot (only once when dialog opens)
|
||||||
|
calendarStore.$history?.push?.()
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (titleInput.value) {
|
if (titleInput.value) {
|
||||||
@ -364,7 +365,6 @@ function openEditDialog(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
calendarStore.$history?.endCompound()
|
|
||||||
showDialog.value = false
|
showDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -396,13 +396,11 @@ function saveEvent() {
|
|||||||
unsavedCreateId.value = null
|
unsavedCreateId.value = null
|
||||||
}
|
}
|
||||||
if (dialogMode.value === 'create') emit('clear-selection')
|
if (dialogMode.value === 'create') emit('clear-selection')
|
||||||
calendarStore.$history?.endCompound()
|
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEventAll() {
|
function deleteEventAll() {
|
||||||
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
|
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
|
||||||
calendarStore.$history?.endCompound()
|
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -412,14 +410,12 @@ function deleteEventOne() {
|
|||||||
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
|
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
|
||||||
calendarStore.deleteFirstOccurrence(editingEventId.value)
|
calendarStore.deleteFirstOccurrence(editingEventId.value)
|
||||||
}
|
}
|
||||||
calendarStore.$history?.endCompound()
|
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEventFrom() {
|
function deleteEventFrom() {
|
||||||
if (!occurrenceContext.value) return
|
if (!occurrenceContext.value) return
|
||||||
calendarStore.deleteFromOccurrence(occurrenceContext.value)
|
calendarStore.deleteFromOccurrence(occurrenceContext.value)
|
||||||
calendarStore.$history?.endCompound()
|
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -435,8 +431,6 @@ watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => {
|
|||||||
})
|
})
|
||||||
watch(showDialog, (val, oldVal) => {
|
watch(showDialog, (val, oldVal) => {
|
||||||
if (oldVal && !val) {
|
if (oldVal && !val) {
|
||||||
// Closed (cancel, escape, outside click) -> end compound session
|
|
||||||
calendarStore.$history?.endCompound()
|
|
||||||
if (dialogMode.value === 'create' && unsavedCreateId.value && !eventSaved.value) {
|
if (dialogMode.value === 'create' && unsavedCreateId.value && !eventSaved.value) {
|
||||||
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
||||||
calendarStore.deleteEvent(unsavedCreateId.value)
|
calendarStore.deleteEvent(unsavedCreateId.value)
|
||||||
@ -471,11 +465,13 @@ const isLastOccurrence = computed(() => {
|
|||||||
if (!event || !event.recur) return false
|
if (!event || !event.recur) return false
|
||||||
if (event.recur.count === 'unlimited' || recurrenceOccurrences.value === 0) return false
|
if (event.recur.count === 'unlimited' || recurrenceOccurrences.value === 0) return false
|
||||||
const totalCount = parseInt(event.recur.count, 10) || 0
|
const totalCount = parseInt(event.recur.count, 10) || 0
|
||||||
return occurrenceContext.value.occurrenceIndex === totalCount - 1
|
return occurrenceContext.value.n === totalCount - 1
|
||||||
})
|
})
|
||||||
const formattedOccurrenceShort = computed(() => {
|
const formattedOccurrenceShort = computed(() => {
|
||||||
if (occurrenceContext.value?.occurrenceDate) {
|
if (occurrenceContext.value?.n != null) {
|
||||||
return formatDateShort(occurrenceContext.value.occurrenceDate)
|
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) {
|
if (isRepeatingBaseEdit.value && editingEventId.value) {
|
||||||
const ev = calendarStore.getEventById(editingEventId.value)
|
const ev = calendarStore.getEventById(editingEventId.value)
|
||||||
@ -487,8 +483,10 @@ const formattedOccurrenceShort = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const headerDateShort = computed(() => {
|
const headerDateShort = computed(() => {
|
||||||
if (occurrenceContext.value?.occurrenceDate) {
|
if (occurrenceContext.value?.n != null) {
|
||||||
return formatDateShort(occurrenceContext.value.occurrenceDate)
|
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) {
|
if (editingEventId.value) {
|
||||||
const ev = calendarStore.getEventById(editingEventId.value)
|
const ev = calendarStore.getEventById(editingEventId.value)
|
||||||
|
@ -11,7 +11,10 @@
|
|||||||
:key="span.id + '-' + (span.n != null ? span.n : 0)"
|
:key="span.id + '-' + (span.n != null ? span.n : 0)"
|
||||||
class="event-span"
|
class="event-span"
|
||||||
dir="auto"
|
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-id="span.id"
|
||||||
:data-n="span.n != null ? span.n : 0"
|
:data-n="span.n != null ? span.n : 0"
|
||||||
:style="{
|
:style="{
|
||||||
@ -23,10 +26,12 @@
|
|||||||
>
|
>
|
||||||
<span class="event-title">{{ span.title }}</span>
|
<span class="event-title">{{ span.title }}</span>
|
||||||
<div
|
<div
|
||||||
|
v-if="!span.hasPrevWeek"
|
||||||
class="resize-handle left"
|
class="resize-handle left"
|
||||||
@pointerdown="handleResizePointerDown(span, 'resize-left', $event)"
|
@pointerdown="handleResizePointerDown(span, 'resize-left', $event)"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
|
v-if="!span.hasNextWeek"
|
||||||
class="resize-handle right"
|
class="resize-handle right"
|
||||||
@pointerdown="handleResizePointerDown(span, 'resize-right', $event)"
|
@pointerdown="handleResizePointerDown(span, 'resize-right', $event)"
|
||||||
></div>
|
></div>
|
||||||
@ -58,8 +63,21 @@ const eventSegments = computed(() => {
|
|||||||
props.week.days.forEach((day, di) => {
|
props.week.days.forEach((day, di) => {
|
||||||
day.events.forEach((ev) => {
|
day.events.forEach((ev) => {
|
||||||
const key = ev.id + '|' + (ev.n ?? 0)
|
const key = ev.id + '|' + (ev.n ?? 0)
|
||||||
if (!spanMap.has(key)) spanMap.set(key, { ...ev, startIdx: di, endIdx: di })
|
if (!spanMap.has(key)) {
|
||||||
else spanMap.get(key).endIdx = Math.max(spanMap.get(key).endIdx, di)
|
// 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())
|
const spans = Array.from(spanMap.values())
|
||||||
@ -67,6 +85,26 @@ const eventSegments = computed(() => {
|
|||||||
spans.forEach((sp) => {
|
spans.forEach((sp) => {
|
||||||
sp.startDate = props.week.days[sp.startIdx].date
|
sp.startDate = props.week.days[sp.startIdx].date
|
||||||
sp.endDate = props.week.days[sp.endIdx].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
|
// Sort so longer multi-day first, then earlier, then id for stability
|
||||||
spans.sort((a, b) => {
|
spans.sort((a, b) => {
|
||||||
@ -181,7 +219,10 @@ function handleEventPointerDown(span, event) {
|
|||||||
if (event.target.classList.contains('resize-handle')) return
|
if (event.target.classList.contains('resize-handle')) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const baseId = span.id
|
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 {
|
try {
|
||||||
const spanDays = daysInclusive(span.startDate, span.endDate)
|
const spanDays = daysInclusive(span.startDate, span.endDate)
|
||||||
const targetEl = event.currentTarget
|
const targetEl = event.currentTarget
|
||||||
@ -193,7 +234,8 @@ function handleEventPointerDown(span, event) {
|
|||||||
if (!isFinite(dayIndex)) dayIndex = 0
|
if (!isFinite(dayIndex)) dayIndex = 0
|
||||||
if (dayIndex < 0) dayIndex = 0
|
if (dayIndex < 0) dayIndex = 0
|
||||||
if (dayIndex >= spanDays) dayIndex = spanDays - 1
|
if (dayIndex >= spanDays) dayIndex = spanDays - 1
|
||||||
anchorDate = addDaysStr(span.startDate, dayIndex)
|
const absoluteOffset = (span.minNDay || 0) + dayIndex
|
||||||
|
anchorDate = addDaysStr(fullStart, absoluteOffset)
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
startLocalDrag(
|
startLocalDrag(
|
||||||
@ -204,8 +246,9 @@ function handleEventPointerDown(span, event) {
|
|||||||
pointerStartX: event.clientX,
|
pointerStartX: event.clientX,
|
||||||
pointerStartY: event.clientY,
|
pointerStartY: event.clientY,
|
||||||
anchorDate,
|
anchorDate,
|
||||||
startDate: span.startDate,
|
startDate: fullStart,
|
||||||
endDate: span.endDate,
|
endDate: fullEnd,
|
||||||
|
n: span.n,
|
||||||
},
|
},
|
||||||
event,
|
event,
|
||||||
)
|
)
|
||||||
@ -214,6 +257,8 @@ function handleEventPointerDown(span, event) {
|
|||||||
function handleResizePointerDown(span, mode, event) {
|
function handleResizePointerDown(span, mode, event) {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const baseId = span.id
|
const baseId = span.id
|
||||||
|
const fullStart = span.occurrenceStartDate || span.startDate
|
||||||
|
const fullEnd = span.occurrenceEndDate || span.endDate
|
||||||
startLocalDrag(
|
startLocalDrag(
|
||||||
{
|
{
|
||||||
id: baseId,
|
id: baseId,
|
||||||
@ -222,8 +267,9 @@ function handleResizePointerDown(span, mode, event) {
|
|||||||
pointerStartX: event.clientX,
|
pointerStartX: event.clientX,
|
||||||
pointerStartY: event.clientY,
|
pointerStartY: event.clientY,
|
||||||
anchorDate: null,
|
anchorDate: null,
|
||||||
startDate: span.startDate,
|
startDate: fullStart,
|
||||||
endDate: span.endDate,
|
endDate: fullEnd,
|
||||||
|
n: span.n,
|
||||||
},
|
},
|
||||||
event,
|
event,
|
||||||
)
|
)
|
||||||
@ -268,7 +314,46 @@ function startLocalDrag(init, evt) {
|
|||||||
realizedId: null,
|
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) {
|
if (evt.currentTarget && evt.pointerId !== undefined) {
|
||||||
try {
|
try {
|
||||||
@ -278,9 +363,7 @@ function startLocalDrag(init, evt) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(evt.pointerType === 'touch')) {
|
if (evt.cancelable) 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 })
|
||||||
@ -325,7 +408,7 @@ function onDragPointerMove(e) {
|
|||||||
if (st.mode === 'move') {
|
if (st.mode === 'move') {
|
||||||
if (st.n && st.n > 0) {
|
if (st.n && st.n > 0) {
|
||||||
if (!st.realizedId) {
|
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) {
|
if (newId) {
|
||||||
st.realizedId = newId
|
st.realizedId = newId
|
||||||
st.id = newId
|
st.id = newId
|
||||||
@ -357,7 +440,7 @@ function onDragPointerMove(e) {
|
|||||||
if (!st.realizedId) {
|
if (!st.realizedId) {
|
||||||
const initialStart = ns
|
const initialStart = ns
|
||||||
const initialEnd = ne
|
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) {
|
if (newId) {
|
||||||
st.realizedId = newId
|
st.realizedId = newId
|
||||||
st.id = newId
|
st.id = newId
|
||||||
@ -409,38 +492,34 @@ function onDragPointerUp(e) {
|
|||||||
justDragged.value = false
|
justDragged.value = false
|
||||||
}, 120)
|
}, 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 anchorOffset = st.anchorOffset || 0
|
||||||
const spanDays = st.originSpanDays || daysInclusive(st.startDate, st.endDate)
|
const spanDays = st.originSpanDays || daysInclusive(st.startDate, st.endDate)
|
||||||
let startStr = st.startDate
|
|
||||||
let endStr = st.endDate
|
|
||||||
if (st.mode === 'move') {
|
if (st.mode === 'move') {
|
||||||
startStr = addDaysStr(dropDateStr, -anchorOffset)
|
const ns = addDaysStr(current, -anchorOffset)
|
||||||
endStr = addDaysStr(startStr, spanDays - 1)
|
const ne = addDaysStr(ns, spanDays - 1)
|
||||||
} else if (st.mode === 'resize-left') {
|
return [ns, ne]
|
||||||
startStr = dropDateStr
|
|
||||||
endStr = st.endDate
|
|
||||||
} else if (st.mode === 'resize-right') {
|
|
||||||
startStr = st.startDate
|
|
||||||
endStr = dropDateStr
|
|
||||||
}
|
}
|
||||||
return normalizeDateOrder(startStr, endStr)
|
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) {
|
return [st.startDate, st.endDate]
|
||||||
if (!aStr) return [bStr, bStr]
|
|
||||||
if (!bStr) return [aStr, aStr]
|
|
||||||
return aStr <= bStr ? [aStr, bStr] : [bStr, aStr]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRangeDuringDrag(st, startDate, endDate) {
|
function applyRangeDuringDrag(st, startDate, endDate) {
|
||||||
if (st.n && st.n > 0) {
|
if (st.n && st.n > 0) {
|
||||||
if (st.mode !== 'move') return // no resize for virtual occurrence
|
if (st.mode !== 'move') return // no resize for virtual occurrence
|
||||||
// Split-move: occurrence being dragged treated as first of new series
|
// 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
|
return
|
||||||
}
|
}
|
||||||
store.setEventRange(st.id, startDate, endDate, { mode: st.mode })
|
store.setEventRange(st.id, startDate, endDate, { mode: st.mode })
|
||||||
@ -486,6 +565,18 @@ function applyRangeDuringDrag(st, startDate, endDate) {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
text-align: center;
|
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 */
|
/* Inner title wrapper ensures proper ellipsis within flex/grid constraints */
|
||||||
@ -510,6 +601,7 @@ function applyRangeDuringDrag(st, startDate, endDate) {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
|
touch-action: none; /* Allow touch resizing without scroll */
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-span .resize-handle.left {
|
.event-span .resize-handle.left {
|
||||||
|
@ -1,54 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<Transition name="header-controls" appear>
|
<div class="header-controls-wrapper">
|
||||||
<div v-if="isVisible" class="header-controls">
|
<Transition name="header-controls" appear>
|
||||||
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
<div v-if="isVisible" class="header-controls">
|
||||||
<button
|
<EventSearch
|
||||||
type="button"
|
ref="eventSearchRef"
|
||||||
class="hist-btn"
|
:reference-date="referenceDate"
|
||||||
:disabled="!calendarStore.historyCanUndo"
|
@activate="handleSearchActivate"
|
||||||
@click="calendarStore.$history?.undo()"
|
@preview="(r) => emit('search-preview', r)"
|
||||||
title="Undo (Ctrl+Z)"
|
/>
|
||||||
aria-label="Undo"
|
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
||||||
>
|
<button
|
||||||
↶
|
type="button"
|
||||||
</button>
|
class="hist-btn"
|
||||||
<button
|
:disabled="!calendarStore.historyCanUndo"
|
||||||
type="button"
|
@click="calendarStore.$history?.undo()"
|
||||||
class="hist-btn"
|
title="Undo (Ctrl+Z)"
|
||||||
:disabled="!calendarStore.historyCanRedo"
|
aria-label="Undo"
|
||||||
@click="calendarStore.$history?.redo()"
|
>
|
||||||
title="Redo (Ctrl+Shift+Z)"
|
↶
|
||||||
aria-label="Redo"
|
</button>
|
||||||
>
|
<button
|
||||||
↷
|
type="button"
|
||||||
</button>
|
class="hist-btn"
|
||||||
<button
|
:disabled="!calendarStore.historyCanRedo"
|
||||||
type="button"
|
@click="calendarStore.$history?.redo()"
|
||||||
class="settings-btn"
|
title="Redo (Ctrl+Shift+Z)"
|
||||||
@click="openSettings"
|
aria-label="Redo"
|
||||||
aria-label="Open settings"
|
>
|
||||||
title="Settings"
|
↷
|
||||||
>
|
</button>
|
||||||
⚙
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<SettingsDialog ref="settingsDialog" />
|
class="settings-btn"
|
||||||
</div>
|
@click="openSettings"
|
||||||
</Transition>
|
aria-label="Open settings"
|
||||||
<button
|
title="Settings"
|
||||||
type="button"
|
>
|
||||||
class="toggle-btn"
|
⚙
|
||||||
@click="toggleVisibility"
|
</button>
|
||||||
:aria-label="isVisible ? 'Hide controls' : 'Show controls'"
|
<SettingsDialog ref="settingsDialog" />
|
||||||
:title="isVisible ? 'Hide controls' : 'Show controls'"
|
</div>
|
||||||
>
|
</Transition>
|
||||||
⋯
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
class="toggle-btn"
|
||||||
|
@click="toggleVisibility"
|
||||||
|
:aria-label="isVisible ? 'Hide controls' : 'Show controls'"
|
||||||
|
:title="isVisible ? 'Hide controls' : 'Show controls'"
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
import { computed, ref, onMounted, onBeforeUnmount, defineExpose, nextTick } from 'vue'
|
||||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
import { formatTodayString } from '@/utils/date'
|
import { formatTodayString } from '@/utils/date'
|
||||||
|
import EventSearch from '@/components/Search.vue'
|
||||||
import SettingsDialog from '@/components/SettingsDialog.vue'
|
import SettingsDialog from '@/components/SettingsDialog.vue'
|
||||||
|
|
||||||
const calendarStore = useCalendarStore()
|
const calendarStore = useCalendarStore()
|
||||||
@ -58,7 +67,8 @@ const todayString = computed(() => {
|
|||||||
return formatTodayString(d)
|
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() {
|
function goToToday() {
|
||||||
// Emit the event so the parent can handle the viewport scrolling logic
|
// Emit the event so the parent can handle the viewport scrolling logic
|
||||||
@ -68,6 +78,8 @@ function goToToday() {
|
|||||||
|
|
||||||
// Screen size detection and visibility toggle
|
// Screen size detection and visibility toggle
|
||||||
const isVisible = ref(false)
|
const isVisible = ref(false)
|
||||||
|
// Track if we auto-opened due to a find (Ctrl/Cmd+F)
|
||||||
|
const autoOpenedForSearch = ref(false)
|
||||||
|
|
||||||
function checkScreenSize() {
|
function checkScreenSize() {
|
||||||
const isSmallScreen = window.innerHeight < 600
|
const isSmallScreen = window.innerHeight < 600
|
||||||
@ -77,30 +89,88 @@ function checkScreenSize() {
|
|||||||
|
|
||||||
function toggleVisibility() {
|
function toggleVisibility() {
|
||||||
isVisible.value = !isVisible.value
|
isVisible.value = !isVisible.value
|
||||||
|
if (!isVisible.value) autoOpenedForSearch.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings dialog integration
|
// Settings dialog integration
|
||||||
const settingsDialog = ref(null)
|
const settingsDialog = ref(null)
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
|
// Capture baseline before opening settings
|
||||||
|
try {
|
||||||
|
calendarStore.$history?._baselineIfNeeded?.(true)
|
||||||
|
} catch {}
|
||||||
settingsDialog.value?.open()
|
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(() => {
|
onMounted(() => {
|
||||||
checkScreenSize()
|
checkScreenSize()
|
||||||
window.addEventListener('resize', checkScreenSize)
|
window.addEventListener('resize', checkScreenSize)
|
||||||
|
document.addEventListener('keydown', handleGlobalFind, { passive: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', checkScreenSize)
|
window.removeEventListener('resize', checkScreenSize)
|
||||||
|
document.removeEventListener('keydown', handleGlobalFind)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.header-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: end;
|
|
||||||
align-items: center;
|
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 {
|
.toggle-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -201,6 +271,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.today-date {
|
.today-date {
|
||||||
|
font-size: 1.5em;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-inline-end: 2rem;
|
margin-inline-end: 2rem;
|
||||||
|
381
src/components/Search.vue
Normal file
381
src/components/Search.vue
Normal 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>
|
@ -3,7 +3,7 @@ import './assets/calendar.css'
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||||
import { calendarHistory } from '@/plugins/calendarHistory'
|
import { history } from '@/plugins/history'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ const app = createApp(App)
|
|||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
// Order: persistence first so snapshots recorded by undo reflect already-hydrated state
|
// Order: persistence first so snapshots recorded by undo reflect already-hydrated state
|
||||||
pinia.use(piniaPluginPersistedstate)
|
pinia.use(piniaPluginPersistedstate)
|
||||||
pinia.use(calendarHistory)
|
pinia.use(history)
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
@ -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 }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
110
src/plugins/history.js
Normal 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() {},
|
||||||
|
}
|
||||||
|
}
|
@ -124,7 +124,6 @@ function createMomentumDrag({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
applyDragPosition(e.touches[0].clientY, reasonDragTouch)
|
applyDragPosition(e.touches[0].clientY, reasonDragTouch)
|
||||||
e.preventDefault()
|
|
||||||
}
|
}
|
||||||
function handlePointerDown(e) {
|
function handlePointerDown(e) {
|
||||||
if (e.button !== undefined && e.button !== 0) return
|
if (e.button !== undefined && e.button !== 0) return
|
||||||
@ -158,7 +157,7 @@ function createMomentumDrag({
|
|||||||
window.addEventListener('touchmove', onTouchMove, { passive: false })
|
window.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
window.addEventListener('touchend', endDrag, { passive: false })
|
window.addEventListener('touchend', endDrag, { passive: false })
|
||||||
window.addEventListener('touchcancel', endDrag, { passive: false })
|
window.addEventListener('touchcancel', endDrag, { passive: false })
|
||||||
e.preventDefault()
|
if (e.cancelable) e.preventDefault()
|
||||||
}
|
}
|
||||||
function onPointerLockChange() {
|
function onPointerLockChange() {
|
||||||
const lockedEl = document.pointerLockElement
|
const lockedEl = document.pointerLockElement
|
||||||
|
@ -285,10 +285,24 @@ export function createVirtualWeekManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goToToday() {
|
function goToToday() {
|
||||||
const top = addDays(new Date(calendarStore.now), -21)
|
const todayDate = new Date(calendarStore.now)
|
||||||
const targetWeekIndex = getWeekIndex(top)
|
const targetWeekIndex = getWeekIndex(todayDate)
|
||||||
const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
scrollToWeekCentered(targetWeekIndex, 'go-to-today', true)
|
||||||
if (setScrollTopFn) setScrollTopFn(newScrollTop, 'go-to-today')
|
}
|
||||||
|
|
||||||
|
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 }) {
|
function handleHeaderYearChange({ scrollTop }) {
|
||||||
@ -308,6 +322,7 @@ export function createVirtualWeekManager({
|
|||||||
getWeekIndex,
|
getWeekIndex,
|
||||||
getFirstDayForVirtualWeek,
|
getFirstDayForVirtualWeek,
|
||||||
goToToday,
|
goToToday,
|
||||||
|
scrollToWeekCentered,
|
||||||
handleHeaderYearChange,
|
handleHeaderYearChange,
|
||||||
attachScroll,
|
attachScroll,
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
DEFAULT_TZ,
|
DEFAULT_TZ,
|
||||||
} from '@/utils/date'
|
} from '@/utils/date'
|
||||||
import { differenceInCalendarDays, addDays } from 'date-fns'
|
import { differenceInCalendarDays, addDays } from 'date-fns'
|
||||||
|
import { getDate } from '@/utils/events'
|
||||||
import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays'
|
import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays'
|
||||||
|
|
||||||
export const useCalendarStore = defineStore('calendar', {
|
export const useCalendarStore = defineStore('calendar', {
|
||||||
@ -14,9 +15,6 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
today: toLocalString(new Date(), DEFAULT_TZ),
|
today: toLocalString(new Date(), DEFAULT_TZ),
|
||||||
now: new Date().toISOString(),
|
now: new Date().toISOString(),
|
||||||
events: new Map(),
|
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
|
// Incremented internally by history plugin to force reactive updates for canUndo/canRedo
|
||||||
historyTick: 0,
|
historyTick: 0,
|
||||||
historyCanUndo: false,
|
historyCanUndo: false,
|
||||||
@ -117,10 +115,7 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36)
|
return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36)
|
||||||
},
|
},
|
||||||
|
|
||||||
notifyEventsChanged() {
|
notifyEventsChanged() {},
|
||||||
// Bump simple counter (wrapping to avoid overflow in extreme long sessions)
|
|
||||||
this.eventsMutation = (this.eventsMutation + 1) % 1_000_000_000
|
|
||||||
},
|
|
||||||
touchEvents() {
|
touchEvents() {
|
||||||
this.notifyEventsChanged()
|
this.notifyEventsChanged()
|
||||||
},
|
},
|
||||||
@ -208,30 +203,30 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteSingleOccurrence(ctx) {
|
deleteSingleOccurrence(ctx) {
|
||||||
const { baseId, occurrenceIndex } = ctx || {}
|
const { baseId, n } = ctx || {}
|
||||||
if (occurrenceIndex == null) return
|
if (n == null) return
|
||||||
const base = this.getEventById(baseId)
|
const base = this.getEventById(baseId)
|
||||||
if (!base) return
|
if (!base) return
|
||||||
if (!base.recur) {
|
if (!base.recur) {
|
||||||
if (occurrenceIndex === 0) this.deleteEvent(baseId)
|
if (n === 0) this.deleteEvent(baseId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (occurrenceIndex === 0) {
|
if (n === 0) {
|
||||||
this.deleteFirstOccurrence(baseId)
|
this.deleteFirstOccurrence(baseId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const snapshot = { ...base }
|
const snapshot = { ...base }
|
||||||
snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null
|
snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null
|
||||||
if (base.recur.count === occurrenceIndex + 1) {
|
if (base.recur.count === n + 1) {
|
||||||
base.recur.count = occurrenceIndex
|
base.recur.count = n
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
base.recur.count = occurrenceIndex
|
base.recur.count = n
|
||||||
const originalNumeric =
|
const originalNumeric =
|
||||||
snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10)
|
snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10)
|
||||||
let remainingCount = 'unlimited'
|
let remainingCount = 'unlimited'
|
||||||
if (originalNumeric !== Infinity) {
|
if (originalNumeric !== Infinity) {
|
||||||
const rem = originalNumeric - (occurrenceIndex + 1)
|
const rem = originalNumeric - (n + 1)
|
||||||
if (rem <= 0) return
|
if (rem <= 0) return
|
||||||
remainingCount = String(rem)
|
remainingCount = String(rem)
|
||||||
}
|
}
|
||||||
@ -253,14 +248,14 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteFromOccurrence(ctx) {
|
deleteFromOccurrence(ctx) {
|
||||||
const { baseId, occurrenceIndex } = ctx
|
const { baseId, n } = ctx
|
||||||
const base = this.getEventById(baseId)
|
const base = this.getEventById(baseId)
|
||||||
if (!base || !base.recur) return
|
if (!base || !base.recur) return
|
||||||
if (occurrenceIndex === 0) {
|
if (n === 0) {
|
||||||
this.deleteEvent(baseId)
|
this.deleteEvent(baseId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
this._terminateRepeatSeriesAtIndex(baseId, n)
|
||||||
this.notifyEventsChanged()
|
this.notifyEventsChanged()
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -295,10 +290,16 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
this.notifyEventsChanged()
|
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)
|
const base = this.events.get(baseId)
|
||||||
if (!base || !base.recur) return
|
if (!base || !base.recur) return
|
||||||
const originalCountRaw = base.recur.count
|
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 occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ)
|
||||||
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
||||||
// If series effectively has <=1 occurrence, treat as simple move (no split) and flatten
|
// 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 })
|
this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move', rotatePattern: true })
|
||||||
return baseId
|
return baseId
|
||||||
}
|
}
|
||||||
if (occurrenceDate <= baseStart) {
|
// First occurrence: just move the event
|
||||||
|
if (n === 0) {
|
||||||
this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' })
|
this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' })
|
||||||
return baseId
|
return baseId
|
||||||
}
|
}
|
||||||
@ -408,19 +410,21 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.notifyEventsChanged()
|
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
|
return newId
|
||||||
},
|
},
|
||||||
|
|
||||||
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, _newEndStr) {
|
splitRepeatSeries(baseId, n, newStartStr, _newEndStr) {
|
||||||
const base = this.events.get(baseId)
|
const base = this.events.get(baseId)
|
||||||
if (!base || !base.recur) return null
|
if (!base || !base.recur) return null
|
||||||
const originalCountRaw = base.recur.count
|
const originalCountRaw = base.recur.count
|
||||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
this._terminateRepeatSeriesAtIndex(baseId, n)
|
||||||
let newSeriesCount = 'unlimited'
|
let newSeriesCount = 'unlimited'
|
||||||
if (originalCountRaw !== 'unlimited') {
|
if (originalCountRaw !== 'unlimited') {
|
||||||
const originalNum = parseInt(originalCountRaw, 10)
|
const originalNum = parseInt(originalCountRaw, 10)
|
||||||
if (!isNaN(originalNum)) {
|
if (!isNaN(originalNum)) {
|
||||||
const remaining = originalNum - occurrenceIndex
|
const remaining = originalNum - n
|
||||||
newSeriesCount = String(Math.max(1, remaining))
|
newSeriesCount = String(Math.max(1, remaining))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,8 +23,9 @@ const monthAbbr = [
|
|||||||
'nov',
|
'nov',
|
||||||
'dec',
|
'dec',
|
||||||
]
|
]
|
||||||
const MIN_YEAR = 1000
|
// We get scrolling issues if the virtual view is bigger than that
|
||||||
const MAX_YEAR = 9999
|
const MIN_YEAR = 1582
|
||||||
|
const MAX_YEAR = 3000
|
||||||
|
|
||||||
// Core helpers ------------------------------------------------------------
|
// Core helpers ------------------------------------------------------------
|
||||||
/**
|
/**
|
||||||
|
@ -139,6 +139,68 @@ export function getDate(event, n, timeZone = DEFAULT_TZ) {
|
|||||||
return null
|
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) {
|
export function buildDayEvents(events, dateStr, timeZone = DEFAULT_TZ) {
|
||||||
const date = fromLocalString(dateStr, timeZone)
|
const date = fromLocalString(dateStr, timeZone)
|
||||||
const out = []
|
const out = []
|
||||||
|
Loading…
x
Reference in New Issue
Block a user