Event search (Ctrl+F), locale/RTL handling, weekday selector workday/weekend, refactored event handling, Firefox compatibility (#3)
Major refactoring for cleanup, with various bugfixes. Weekday selector in Settings now shows workdays/weekend bars based on current locale (also used to choose default values). Weekday selector in event dialog uses the days set in settings, as expected.
This commit is contained in:
		| @@ -79,10 +79,10 @@ const modalStyle = computed(() => { | ||||
|   if (modalRef.value && props.modelValue) { | ||||
|     const style = { | ||||
|       transform: 'none', | ||||
|       left: modalPosition.value.x + 'px', | ||||
|       insetInlineStart: modalPosition.value.x + 'px', | ||||
|       top: modalPosition.value.y + 'px', | ||||
|       bottom: 'auto', | ||||
|       right: 'auto', | ||||
|       insetInlineEnd: 'auto', | ||||
|     } | ||||
|     if (hasMoved.value) { | ||||
|       style.width = dialogWidth.value ? dialogWidth.value + 'px' : undefined | ||||
|   | ||||
| @@ -21,13 +21,10 @@ const props = defineProps({ | ||||
|     ]" | ||||
|     :data-date="props.day.date" | ||||
|   > | ||||
|     <h1>{{ props.day.displayText }}</h1> | ||||
|     <h1 class="day-number">{{ props.day.displayText }}</h1> | ||||
|     <span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span> | ||||
|  | ||||
|     <div v-if="props.day.holiday" class="holiday-info"> | ||||
|       <span class="holiday-name" :title="props.day.holiday.name"> | ||||
|         {{ props.day.holiday.name }} | ||||
|       </span> | ||||
|     <div v-if="props.day.holiday" class="holiday-info" dir="auto" :title="props.day.holiday.name"> | ||||
|       {{ props.day.holiday.name }} | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -35,22 +32,30 @@ const props = defineProps({ | ||||
| <style scoped> | ||||
| .cell { | ||||
|   position: relative; | ||||
|   border-right: 1px solid var(--border-color); | ||||
|   border-inline-end: 1px solid var(--border-color); | ||||
|   border-bottom: 1px solid var(--border-color); | ||||
|   user-select: none; | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   align-items: flex-start; | ||||
|   justify-content: flex-start; | ||||
|   display: grid; | ||||
|   /* 3 columns: day number, flexible space, lunar phase */ | ||||
|   grid-template-columns: min-content 1fr min-content; | ||||
|   /* 3 rows: header, flexible filler, holiday label */ | ||||
|   grid-template-rows: auto 1fr auto; | ||||
|   /* Named grid areas (only ones actually used) */ | ||||
|   grid-template-areas: | ||||
|     'day-number . lunar-phase' | ||||
|     'day-number . lunar-phase' | ||||
|     'holiday-info holiday-info holiday-info'; | ||||
|   /* Explicit areas mainly for clarity */ | ||||
|   grid-auto-flow: row; | ||||
|   padding: 0.25em; | ||||
|   overflow: hidden; | ||||
|   width: 100%; | ||||
|   height: var(--row-h); | ||||
|   font-weight: 700; | ||||
|   transition: background-color 0.15s ease; | ||||
|   align-items: start; | ||||
| } | ||||
|  | ||||
| .cell h1 { | ||||
| .cell h1.day-number { | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   min-width: 1.5em; | ||||
| @@ -58,15 +63,16 @@ const props = defineProps({ | ||||
|   font-weight: 700; | ||||
|   color: var(--ink); | ||||
|   transition: background-color 0.15s ease; | ||||
|   grid-area: day-number; | ||||
| } | ||||
| .cell.weekend h1 { | ||||
| .cell.weekend h1.day-number { | ||||
|   color: var(--weekend); | ||||
| } | ||||
| .cell.firstday h1 { | ||||
| .cell.firstday h1.day-number { | ||||
|   color: var(--firstday); | ||||
|   text-shadow: 0 0 0.1em var(--strong); | ||||
| } | ||||
| .cell.today h1 { | ||||
| .cell.today h1.day-number { | ||||
|   border-radius: 2em; | ||||
|   background: var(--today); | ||||
|   border: 0.2em solid var(--today); | ||||
| @@ -77,16 +83,9 @@ const props = defineProps({ | ||||
| .cell.selected { | ||||
|   filter: hue-rotate(180deg); | ||||
| } | ||||
| .cell.selected h1 { | ||||
| .cell.selected h1.day-number { | ||||
|   color: var(--strong); | ||||
| } | ||||
| .lunar-phase { | ||||
|   position: absolute; | ||||
|   top: 0.5em; | ||||
|   right: 0.2em; | ||||
|   font-size: 0.8em; | ||||
|   opacity: 0.7; | ||||
| } | ||||
| .cell.holiday { | ||||
|   background-image: linear-gradient( | ||||
|     135deg, | ||||
| @@ -103,27 +102,32 @@ const props = defineProps({ | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| .cell.holiday h1 { | ||||
| .cell.holiday h1.day-number { | ||||
|   /* Slight emphasis without forcing a specific hue */ | ||||
|   color: var(--holiday); | ||||
|   text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4); | ||||
| } | ||||
| .holiday-info { | ||||
|   position: absolute; | ||||
|   bottom: 0.1em; | ||||
|   left: 0.1em; | ||||
|   right: 0.1em; | ||||
|   line-height: 1; | ||||
|   overflow: hidden; | ||||
|   font-size: clamp(1.2vw, 0.6em, 1em); | ||||
| .lunar-phase { | ||||
|   grid-area: lunar-phase; | ||||
|   align-self: start; | ||||
|   justify-self: end; | ||||
|   margin-top: 0.5em; | ||||
|   margin-inline-end: 0.2em; | ||||
|   font-size: 0.8em; | ||||
|   opacity: 0.7; | ||||
| } | ||||
|  | ||||
| .holiday-name { | ||||
|   display: block; | ||||
|   color: var(--holiday-label); | ||||
|   padding: 0.15em 0.35em 0.15em 0.25em; | ||||
| .holiday-info { | ||||
|   grid-area: holiday-info; | ||||
|   align-self: end; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   overflow: hidden; | ||||
|   color: var(--holiday-label); | ||||
|   font-size: clamp(1.2vw, 0.6em, 1em); | ||||
|   line-height: 1; | ||||
|   padding-inline: 0.15em; | ||||
|   padding-block-end: 0.05em; | ||||
|   pointer-events: auto; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -78,8 +78,7 @@ function changeYear(y) { | ||||
|  | ||||
| const weekdayNames = computed(() => { | ||||
|   // Reorder names & weekend flags | ||||
|   const mondayFirstNames = getLocalizedWeekdayNames() | ||||
|   const sundayFirstNames = [mondayFirstNames[6], ...mondayFirstNames.slice(0, 6)] | ||||
|   const sundayFirstNames = getLocalizedWeekdayNames() | ||||
|   const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day) | ||||
|   const reorderedWeekend = reorderByFirstDay(calendarStore.weekend, calendarStore.config.first_day) | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <script setup> | ||||
| import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' | ||||
| import { ref, onMounted, onBeforeUnmount, computed, watch, nextTick } from 'vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
| import CalendarHeader from '@/components/CalendarHeader.vue' | ||||
| import CalendarWeek from '@/components/CalendarWeek.vue' | ||||
| @@ -13,9 +13,21 @@ import { daysInclusive, addDaysStr, MIN_YEAR, MAX_YEAR } from '@/utils/date' | ||||
| import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date' | ||||
| import { addDays, differenceInWeeks } from 'date-fns' | ||||
| import { createVirtualWeekManager } from '@/plugins/virtualWeeks' | ||||
| import { rtl } from '@/utils/locale' | ||||
| import EventDialog from '@/components/EventDialog.vue' | ||||
|  | ||||
| const calendarStore = useCalendarStore() | ||||
| const emit = defineEmits(['create-event', 'edit-event']) | ||||
| defineEmits([]) // previously emitted create/edit events externally | ||||
| import { shallowRef } from 'vue' | ||||
| const eventDialogRef = shallowRef(null) | ||||
| function openCreateEventDialog(eventData) { | ||||
|   if (!eventDialogRef.value) return | ||||
|   const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount } | ||||
|   setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30) | ||||
| } | ||||
| function openEditEventDialog(eventClickPayload) { | ||||
|   eventDialogRef.value?.openEditDialog(eventClickPayload) | ||||
| } | ||||
| const viewport = ref(null) | ||||
| const viewportHeight = ref(600) | ||||
| const rowHeight = ref(64) | ||||
| @@ -87,7 +99,7 @@ const vwm = createVirtualWeekManager({ | ||||
|   contentHeight, | ||||
| }) | ||||
| const visibleWeeks = vwm.visibleWeeks | ||||
| const { scheduleWindowUpdate, resetWeeks, refreshEvents } = vwm | ||||
| const { scheduleWindowUpdate, resetWeeks, refreshEvents, refreshHolidays } = vwm | ||||
|  | ||||
| // Scroll managers (after scheduleWindowUpdate available) | ||||
| const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate }) | ||||
| @@ -98,8 +110,7 @@ const weekColumnScrollManager = createWeekColumnScrollManager({ | ||||
|   contentHeight, | ||||
|   setScrollTop, | ||||
| }) | ||||
| const { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } = | ||||
|   weekColumnScrollManager | ||||
| const { handleWeekColMouseDown, handlePointerLockChange } = weekColumnScrollManager | ||||
| const monthScrollManager = createMonthScrollManager({ | ||||
|   viewport, | ||||
|   viewportHeight, | ||||
| @@ -160,6 +171,25 @@ function clearSelection() { | ||||
|   selection.value = { startDate: null, dayCount: 0 } | ||||
| } | ||||
|  | ||||
| // React to holiday config changes: rebuild or refresh holidays | ||||
| watch( | ||||
|   () => [ | ||||
|     calendarStore.config.holidays.enabled, | ||||
|     calendarStore.config.holidays.country, | ||||
|     calendarStore.config.holidays.state, | ||||
|     calendarStore.config.holidays.region, | ||||
|   ], | ||||
|   (_newVals, _oldVals) => { | ||||
|     // If weeks already built, just refresh holiday info | ||||
|     if (visibleWeeks.value.length) { | ||||
|       refreshHolidays('config-change') | ||||
|     } else { | ||||
|       resetWeeks('holiday-config-change') | ||||
|     } | ||||
|   }, | ||||
|   { deep: false }, | ||||
| ) | ||||
|  | ||||
| function startDrag(dateStr) { | ||||
|   dateStr = normalizeDate(dateStr) | ||||
|   if (calendarStore.config.select_days === 0) return | ||||
| @@ -188,7 +218,7 @@ function finalizeDragAndCreate() { | ||||
|   const eventData = createEventFromSelection() | ||||
|   if (eventData) { | ||||
|     clearSelection() | ||||
|     emit('create-event', eventData) | ||||
|     openCreateEventDialog(eventData) | ||||
|   } | ||||
|   removeGlobalTouchListeners() | ||||
| } | ||||
| @@ -294,15 +324,6 @@ function calculateSelection(anchorStr, otherStr) { | ||||
|   } | ||||
| } | ||||
|  | ||||
| // ---------------- Week label column drag scrolling ---------------- | ||||
| function getWeekLabelRect() { | ||||
|   // Prefer header year label width as stable reference | ||||
|   const headerYear = document.querySelector('.calendar-header .year-label') | ||||
|   if (headerYear) return headerYear.getBoundingClientRect() | ||||
|   const weekLabel = viewport.value?.querySelector('.week-row .week-label') | ||||
|   return weekLabel ? weekLabel.getBoundingClientRect() : null | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
|   computeRowHeight() | ||||
|   calendarStore.updateCurrentDate() | ||||
| @@ -363,7 +384,7 @@ const handleDayMouseUp = (d) => { | ||||
|   const ev = createEventFromSelection() | ||||
|   if (ev) { | ||||
|     clearSelection() | ||||
|     emit('create-event', ev) | ||||
|     openCreateEventDialog(ev) | ||||
|   } | ||||
| } | ||||
| const handleDayTouchStart = (d) => { | ||||
| @@ -373,10 +394,156 @@ const handleDayTouchStart = (d) => { | ||||
| } | ||||
|  | ||||
| const handleEventClick = (payload) => { | ||||
|   emit('edit-event', payload) | ||||
|   openEditEventDialog(payload) | ||||
| } | ||||
|  | ||||
| // header year change delegated to manager | ||||
| // ------------------------------ | ||||
| // 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 { | ||||
|     const dateObj = fromLocalString(cur.startDate, DEFAULT_TZ) | ||||
|     const weekIndex = getWeekIndex(dateObj) | ||||
|     const offsetWeeks = 2 | ||||
|     const targetScrollWeek = Math.max(minVirtualWeek.value, weekIndex - offsetWeeks) | ||||
|     const newScrollTop = (targetScrollWeek - minVirtualWeek.value) * rowHeight.value | ||||
|     setScrollTop(newScrollTop, 'search-jump') | ||||
|     scheduleWindowUpdate('search-jump') | ||||
|   } catch {} | ||||
| } | ||||
| function activateCurrentResult() { | ||||
|   scrollToCurrentResult() | ||||
| } | ||||
|  | ||||
| function handleGlobalFind(e) { | ||||
|   if (!(e.ctrlKey || e.metaKey)) return | ||||
|   const k = e.key | ||||
|   if (k === 'f' || k === 'F') { | ||||
|     if (isEditableElement(e.target)) return | ||||
|     e.preventDefault() | ||||
|     if (!searchOpen.value) openSearch('') | ||||
|     else { | ||||
|       // If already open, select input text for quick overwrite | ||||
|       nextTick(() => { | ||||
|         if (searchInputRef.value) { | ||||
|           searchInputRef.value.focus() | ||||
|           searchInputRef.value.select() | ||||
|         } | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
|   // While open: Enter confirms current selection & closes dialog | ||||
|   if (searchOpen.value && (k === 'Enter' || k === 'Return')) { | ||||
|     e.preventDefault() | ||||
|     activateCurrentResult() | ||||
|     closeSearch() | ||||
|   } | ||||
| } | ||||
|  | ||||
| function handleSearchKeydown(e) { | ||||
|   if (!searchOpen.value) return | ||||
|   if (e.key === 'Escape') { | ||||
|     e.preventDefault() | ||||
|     closeSearch() | ||||
|   } else if (e.key === 'ArrowDown') { | ||||
|     e.preventDefault() | ||||
|     navigateSearch(1) | ||||
|   } else if (e.key === 'ArrowUp') { | ||||
|     e.preventDefault() | ||||
|     navigateSearch(-1) | ||||
|   } else if (e.key === 'Enter') { | ||||
|     // Enter inside input: activate current and close | ||||
|     e.preventDefault() | ||||
|     activateCurrentResult() | ||||
|     closeSearch() | ||||
|   } | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
|   document.addEventListener('keydown', handleGlobalFind, { passive: false }) | ||||
| }) | ||||
| onBeforeUnmount(() => { | ||||
|   document.removeEventListener('keydown', handleGlobalFind) | ||||
| }) | ||||
|  | ||||
| // Ensure focus when (re)opening via reactive watch (catches programmatic toggles too) | ||||
| watch( | ||||
|   () => searchOpen.value, | ||||
|   (v) => { | ||||
|     if (v) { | ||||
|       nextTick(() => { | ||||
|         if (searchInputRef.value) { | ||||
|           searchInputRef.value.focus() | ||||
|           searchInputRef.value.select() | ||||
|         } | ||||
|       }) | ||||
|     } | ||||
|   }, | ||||
| ) | ||||
|  | ||||
| // Heuristic: rotate month label (180deg) only for predominantly Latin text. | ||||
| // We explicitly avoid locale detection; rely solely on characters present. | ||||
| @@ -402,7 +569,7 @@ watch( | ||||
|   }, | ||||
| ) | ||||
|  | ||||
| // Watch lightweight mutation counter only (not deep events map) and rebuild lazily | ||||
| // Event changes | ||||
| watch( | ||||
|   () => calendarStore.events, | ||||
|   () => { | ||||
| @@ -426,7 +593,7 @@ window.addEventListener('resize', () => { | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div class="calendar-view-root"> | ||||
|   <div class="calendar-view-root" :dir="rtl && 'rtl'"> | ||||
|     <div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div> | ||||
|     <div class="wrap"> | ||||
|       <HeaderControls @go-to-today="goToToday" /> | ||||
| @@ -438,27 +605,22 @@ window.addEventListener('resize', () => { | ||||
|       /> | ||||
|       <div class="calendar-container"> | ||||
|         <div class="calendar-viewport" ref="viewport"> | ||||
|           <!-- Main calendar content (weeks and days) --> | ||||
|           <div class="main-calendar-area"> | ||||
|             <div class="calendar-content" :style="{ height: contentHeight + 'px' }"> | ||||
|               <CalendarWeek | ||||
|                 v-for="week in visibleWeeks" | ||||
|                 :key="week.virtualWeek" | ||||
|                 :week="week" | ||||
|                 :dragging="isDragging" | ||||
|                 :style="{ top: week.top + 'px' }" | ||||
|                 @day-mousedown="handleDayMouseDown" | ||||
|                 @day-mouseenter="handleDayMouseEnter" | ||||
|                 @day-mouseup="handleDayMouseUp" | ||||
|                 @day-touchstart="handleDayTouchStart" | ||||
|                 @event-click="handleEventClick" | ||||
|               /> | ||||
|             </div> | ||||
|           <div class="calendar-content" :style="{ height: contentHeight + 'px' }"> | ||||
|             <CalendarWeek | ||||
|               v-for="week in visibleWeeks" | ||||
|               :key="week.virtualWeek" | ||||
|               :week="week" | ||||
|               :dragging="isDragging" | ||||
|               :style="{ top: week.top + 'px' }" | ||||
|               @day-mousedown="handleDayMouseDown" | ||||
|               @day-mouseenter="handleDayMouseEnter" | ||||
|               @day-mouseup="handleDayMouseUp" | ||||
|               @day-touchstart="handleDayTouchStart" | ||||
|               @event-click="handleEventClick" | ||||
|             /> | ||||
|           </div> | ||||
|           <!-- Month column area --> | ||||
|           <div class="month-column-area"> | ||||
|             <!-- Month labels --> | ||||
|             <div class="month-labels-container" :style="{ height: contentHeight + 'px' }"> | ||||
|           <div class="month-column-area" :style="{ height: contentHeight + 'px' }"> | ||||
|             <div class="month-labels-container" :style="{ height: '100%' }"> | ||||
|               <template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'"> | ||||
|                 <div | ||||
|                   v-if="monthWeek && monthWeek.monthLabel" | ||||
| @@ -481,6 +643,34 @@ window.addEventListener('resize', () => { | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|       <EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" /> | ||||
|       <!-- Event Search Overlay --> | ||||
|       <div v-if="searchOpen" class="event-search" @keydown.capture="handleSearchKeydown"> | ||||
|         <div class="search-row"> | ||||
|           <input | ||||
|             ref="searchInputRef" | ||||
|             v-model="searchQuery" | ||||
|             type="text" | ||||
|             placeholder="Search events..." | ||||
|             aria-label="Search events" | ||||
|             autofocus | ||||
|           /> | ||||
|           <button type="button" @click="closeSearch" title="Close (Esc)">✕</button> | ||||
|         </div> | ||||
|         <ul class="results" v-if="searchResults.length"> | ||||
|           <li | ||||
|             v-for="(r, i) in searchResults" | ||||
|             :key="r.id" | ||||
|             :class="{ active: i === searchIndex }" | ||||
|             @click="((searchIndex = i), activateCurrentResult(), closeSearch())" | ||||
|           > | ||||
|             <span class="title">{{ r.title }}</span> | ||||
|             <span class="date">{{ r.startDate }}</span> | ||||
|           </li> | ||||
|         </ul> | ||||
|         <div v-else class="no-results">{{ searchQuery ? 'No matches' : 'Type to search' }}</div> | ||||
|         <div class="hint">Enter to go, Esc to close, ↑/↓ to browse</div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -529,11 +719,6 @@ header h1 { | ||||
|   grid-template-columns: 1fr var(--month-w); | ||||
| } | ||||
|  | ||||
| .main-calendar-area { | ||||
|   position: relative; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .calendar-content { | ||||
|   position: relative; | ||||
|   width: 100%; | ||||
| @@ -552,7 +737,7 @@ header h1 { | ||||
|  | ||||
| .month-label { | ||||
|   position: absolute; | ||||
|   left: 0; | ||||
|   inset-inline-start: 0; | ||||
|   width: 100%; | ||||
|   background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2)); | ||||
|   font-size: 2em; | ||||
| @@ -587,4 +772,93 @@ header h1 { | ||||
|   height: var(--row-h); | ||||
|   pointer-events: none; | ||||
| } | ||||
|  | ||||
| /* Search overlay */ | ||||
| .event-search { | ||||
|   position: fixed; | ||||
|   top: 0.75rem; | ||||
|   inset-inline-end: 0.75rem; | ||||
|   z-index: 1200; | ||||
|   background: color-mix(in srgb, var(--panel) 90%, transparent); | ||||
|   backdrop-filter: blur(0.75em); | ||||
|   -webkit-backdrop-filter: blur(0.75em); | ||||
|   color: var(--ink); | ||||
|   padding: 0.75rem 0.75rem 0.6rem 0.75rem; | ||||
|   border-radius: 0.6rem; | ||||
|   width: min(28rem, 80vw); | ||||
|   box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.35); | ||||
|   border: 1px solid color-mix(in srgb, var(--muted) 40%, transparent); | ||||
|   font-size: 0.9rem; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 0.4rem; | ||||
| } | ||||
| .event-search .search-row { | ||||
|   display: flex; | ||||
|   gap: 0.4rem; | ||||
| } | ||||
| .event-search input[type='text'] { | ||||
|   flex: 1; | ||||
|   padding: 0.45rem 0.6rem; | ||||
|   border-radius: 0.4rem; | ||||
|   border: 1px solid color-mix(in srgb, var(--muted) 40%, transparent); | ||||
|   background: color-mix(in srgb, var(--panel) 85%, transparent); | ||||
|   color: inherit; | ||||
| } | ||||
| .event-search button { | ||||
|   background: color-mix(in srgb, var(--accent, #4b7) 70%, transparent); | ||||
|   color: var(--ink, #111); | ||||
|   border: 0; | ||||
|   border-radius: 0.4rem; | ||||
|   padding: 0.45rem 0.6rem; | ||||
|   cursor: pointer; | ||||
| } | ||||
| .event-search button:disabled { | ||||
|   opacity: 0.4; | ||||
|   cursor: default; | ||||
| } | ||||
| .event-search .results { | ||||
|   list-style: none; | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   max-height: 14rem; | ||||
|   overflow: auto; | ||||
|   border: 1px solid color-mix(in srgb, var(--muted) 30%, transparent); | ||||
|   border-radius: 0.4rem; | ||||
| } | ||||
| .event-search .results li { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   gap: 0.75rem; | ||||
|   padding: 0.4rem 0.55rem; | ||||
|   cursor: pointer; | ||||
|   font-size: 0.85rem; | ||||
|   line-height: 1.2; | ||||
| } | ||||
| .event-search .results li.active { | ||||
|   background: color-mix(in srgb, var(--accent, #4b7) 45%, transparent); | ||||
|   color: var(--ink, #111); | ||||
|   font-weight: 600; | ||||
| } | ||||
| .event-search .results li:hover:not(.active) { | ||||
|   background: color-mix(in srgb, var(--panel) 70%, transparent); | ||||
| } | ||||
| .event-search .results .title { | ||||
|   flex: 1; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
| } | ||||
| .event-search .results .date { | ||||
|   opacity: 0.6; | ||||
|   font-family: monospace; | ||||
| } | ||||
| .event-search .no-results { | ||||
|   padding: 0.25rem 0.1rem; | ||||
|   opacity: 0.7; | ||||
| } | ||||
| .event-search .hint { | ||||
|   opacity: 0.55; | ||||
|   font-size: 0.7rem; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import { | ||||
|   formatDateLong, | ||||
|   DEFAULT_TZ, | ||||
| } from '@/utils/date' | ||||
| import { getDate as getOccurrenceDate } from '@/utils/events' | ||||
| import { addDays, addMonths } from 'date-fns' | ||||
|  | ||||
| const props = defineProps({ | ||||
| @@ -301,48 +302,18 @@ function openEditDialog(payload) { | ||||
|   if (!payload) return | ||||
|  | ||||
|   const baseId = payload.id | ||||
|   let occurrenceIndex = payload.occurrenceIndex || 0 | ||||
|   let n = payload.n || 0 | ||||
|   let weekday = null | ||||
|   let occurrenceDate = null | ||||
|  | ||||
|   const event = calendarStore.getEventById(baseId) | ||||
|   if (!event) return | ||||
|  | ||||
|   if (event.recur) { | ||||
|     if (event.recur.freq === 'weeks' && occurrenceIndex >= 0) { | ||||
|       const pattern = event.recur.weekdays || [] | ||||
|       const baseStart = fromLocalString(event.startDate, DEFAULT_TZ) | ||||
|       const baseEnd = new Date(fromLocalString(event.startDate, DEFAULT_TZ)) | ||||
|       baseEnd.setDate(baseEnd.getDate() + (event.days || 1) - 1) | ||||
|       if (occurrenceIndex === 0) { | ||||
|         occurrenceDate = baseStart | ||||
|         weekday = baseStart.getDay() | ||||
|       } else { | ||||
|         const interval = event.recur.interval || 1 | ||||
|         const WEEK_MS = 7 * 86400000 | ||||
|         const baseBlockStart = getMondayOfISOWeek(baseStart) | ||||
|         function isAligned(d) { | ||||
|           const blk = getMondayOfISOWeek(d) | ||||
|           const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) | ||||
|           return diff % interval === 0 | ||||
|         } | ||||
|         let cur = addDays(baseEnd, 1) | ||||
|         let found = 0 | ||||
|         let safety = 0 | ||||
|         while (found < occurrenceIndex && safety < 20000) { | ||||
|           if (pattern[cur.getDay()] && isAligned(cur)) { | ||||
|             found++ | ||||
|             if (found === occurrenceIndex) break | ||||
|           } | ||||
|           cur = addDays(cur, 1) | ||||
|           safety++ | ||||
|         } | ||||
|         occurrenceDate = cur | ||||
|         weekday = cur.getDay() | ||||
|       } | ||||
|     } else if (event.recur.freq === 'months' && occurrenceIndex >= 0) { | ||||
|       const baseDate = fromLocalString(event.startDate, DEFAULT_TZ) | ||||
|       occurrenceDate = addMonths(baseDate, occurrenceIndex) | ||||
|   if (event.recur && n >= 0) { | ||||
|     const occStr = getOccurrenceDate(event, n, DEFAULT_TZ) | ||||
|     if (occStr) { | ||||
|       occurrenceDate = fromLocalString(occStr, DEFAULT_TZ) | ||||
|       weekday = occurrenceDate.getDay() | ||||
|     } | ||||
|   } | ||||
|   dialogMode.value = 'edit' | ||||
| @@ -372,10 +343,10 @@ function openEditDialog(payload) { | ||||
|   eventSaved.value = false | ||||
|  | ||||
|   if (event.recur) { | ||||
|     if (event.recur.freq === 'weeks' && occurrenceIndex >= 0) { | ||||
|       occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate } | ||||
|     } else if (event.recur.freq === 'months' && occurrenceIndex > 0) { | ||||
|       occurrenceContext.value = { baseId, occurrenceIndex, weekday: null, occurrenceDate } | ||||
|     if (event.recur.freq === 'weeks' && n >= 0) { | ||||
|       occurrenceContext.value = { baseId, occurrenceIndex: n, weekday, occurrenceDate } | ||||
|     } else if (event.recur.freq === 'months' && n > 0) { | ||||
|       occurrenceContext.value = { baseId, occurrenceIndex: n, weekday: null, occurrenceDate } | ||||
|     } | ||||
|   } | ||||
|   // anchor to base event start date | ||||
| @@ -594,8 +565,10 @@ const recurrenceSummary = computed(() => { | ||||
| <template> | ||||
|   <BaseDialog v-model="showDialog" :anchor-el="anchorElement" @submit="saveEvent"> | ||||
|     <template #title> | ||||
|       {{ dialogMode === 'create' ? 'Create Event' : 'Edit Event' | ||||
|       }}<template v-if="headerDateShort"> · {{ headerDateShort }}</template> | ||||
|       <div class="dialog-title-row"> | ||||
|         {{ dialogMode === 'create' ? 'Create Event' : 'Edit Event' }} | ||||
|         <span> · {{ headerDateShort }}</span> | ||||
|       </div> | ||||
|     </template> | ||||
|     <label class="ec-field"> | ||||
|       <input type="text" v-model="title" autocomplete="off" ref="titleInput" /> | ||||
| @@ -620,9 +593,7 @@ const recurrenceSummary = computed(() => { | ||||
|         </label> | ||||
|         <span class="recurrence-summary" v-if="recurrenceEnabled"> | ||||
|           {{ recurrenceSummary }} | ||||
|           <template v-if="recurrenceOccurrences > 0"> | ||||
|             until {{ formattedFinalOccurrence }}</template | ||||
|           > | ||||
|           <span v-if="recurrenceOccurrences > 0"> until {{ formattedFinalOccurrence }} </span> | ||||
|         </span> | ||||
|         <span class="recurrence-summary muted" v-else>Does not recur</span> | ||||
|       </div> | ||||
| @@ -655,6 +626,7 @@ const recurrenceSummary = computed(() => { | ||||
|             v-model="recurrenceWeekdays" | ||||
|             :fallback="fallbackWeekdays" | ||||
|             :first-day="calendarStore.config.first_day" | ||||
|             :weekend="calendarStore.weekend" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
| @@ -668,7 +640,7 @@ const recurrenceSummary = computed(() => { | ||||
|         <template v-if="showDeleteVariants"> | ||||
|           <div class="ec-delete-group"> | ||||
|             <button type="button" class="ec-btn delete-btn" @click="deleteEventOne"> | ||||
|               Delete {{ formattedOccurrenceShort }} | ||||
|               Delete <span>{{ formattedOccurrenceShort }}</span> | ||||
|             </button> | ||||
|             <button | ||||
|               v-if="!isLastOccurrence" | ||||
| @@ -676,7 +648,7 @@ const recurrenceSummary = computed(() => { | ||||
|               class="ec-btn delete-btn" | ||||
|               @click="deleteEventFrom" | ||||
|             > | ||||
|               Rest | ||||
|               + Rest | ||||
|             </button> | ||||
|             <button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button> | ||||
|           </div> | ||||
| @@ -943,7 +915,7 @@ const recurrenceSummary = computed(() => { | ||||
|   border-radius: 0.4rem; | ||||
|   cursor: pointer; | ||||
|   font-size: 0.9rem; | ||||
|   text-align: left; | ||||
|   text-align: start; | ||||
|   transition: background-color 0.15s ease; | ||||
| } | ||||
| .ec-recurrence-toggle:hover { | ||||
| @@ -1003,4 +975,7 @@ const recurrenceSummary = computed(() => { | ||||
| .ec-occurrences-field .ec-field input[type='number'] { | ||||
|   max-width: 6rem; | ||||
| } | ||||
| span { | ||||
|   unicode-bidi: isolate; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,33 +1,41 @@ | ||||
| <template> | ||||
|   <div class="week-overlay"> | ||||
|   <div class="week-overlay" :style="{ gridColumn: '1 / -1' }" ref="weekOverlayRef"> | ||||
|     <div | ||||
|       v-for="span in eventSpans" | ||||
|       :key="span.id" | ||||
|       class="event-span" | ||||
|       :class="[`event-color-${span.colorId}`]" | ||||
|       :data-id="span.id" | ||||
|       :data-n="span._recurrenceIndex != null ? span._recurrenceIndex : 0" | ||||
|       :style="{ | ||||
|         gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`, | ||||
|         gridRow: `${span.row}`, | ||||
|       }" | ||||
|       @click="handleEventClick(span)" | ||||
|       @pointerdown="handleEventPointerDown(span, $event)" | ||||
|       v-for="seg in eventSegments" | ||||
|       :key="'seg-' + seg.startIdx + '-' + seg.endIdx" | ||||
|       :class="['segment-grid', { compress: isSegmentCompressed(seg) }]" | ||||
|       :style="segmentStyle(seg)" | ||||
|     > | ||||
|       <span class="event-title">{{ span.title }}</span> | ||||
|       <div | ||||
|         class="resize-handle left" | ||||
|         @pointerdown="handleResizePointerDown(span, 'resize-left', $event)" | ||||
|       ></div> | ||||
|       <div | ||||
|         class="resize-handle right" | ||||
|         @pointerdown="handleResizePointerDown(span, 'resize-right', $event)" | ||||
|       ></div> | ||||
|         v-for="span in seg.events" | ||||
|         :key="span.id + '-' + (span.n != null ? span.n : 0)" | ||||
|         class="event-span" | ||||
|         dir="auto" | ||||
|         :class="[`event-color-${span.colorId}`]" | ||||
|         :data-id="span.id" | ||||
|         :data-n="span.n != null ? span.n : 0" | ||||
|         :style="{ | ||||
|           gridColumn: `${span.startIdxRel + 1} / ${span.endIdxRel + 2}`, | ||||
|           gridRow: `${span.row}`, | ||||
|         }" | ||||
|         @click="handleEventClick(span)" | ||||
|         @pointerdown="handleEventPointerDown(span, $event)" | ||||
|       > | ||||
|         <span class="event-title">{{ span.title }}</span> | ||||
|         <div | ||||
|           class="resize-handle left" | ||||
|           @pointerdown="handleResizePointerDown(span, 'resize-left', $event)" | ||||
|         ></div> | ||||
|         <div | ||||
|           class="resize-handle right" | ||||
|           @pointerdown="handleResizePointerDown(span, 'resize-right', $event)" | ||||
|         ></div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| <script setup> | ||||
| import { computed, ref } from 'vue' | ||||
| import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
| import { daysInclusive, addDaysStr } from '@/utils/date' | ||||
|  | ||||
| @@ -40,68 +48,139 @@ const store = useCalendarStore() | ||||
| // Drag state | ||||
| const dragState = ref(null) | ||||
| const justDragged = ref(false) | ||||
| const weekOverlayRef = ref(null) | ||||
| const segmentCompression = ref({}) // key -> boolean | ||||
|  | ||||
| // Consolidate already-provided day.events into contiguous spans (no recurrence generation) | ||||
| const eventSpans = computed(() => { | ||||
|   const weekEvents = new Map() | ||||
|   props.week.days.forEach((day, dayIndex) => { | ||||
| // Build event segments: each segment is a contiguous day range with at least one bridging event between any adjacent days within it. | ||||
| const eventSegments = computed(() => { | ||||
|   // Construct spans across the week | ||||
|   const spanMap = new Map() | ||||
|   props.week.days.forEach((day, di) => { | ||||
|     day.events.forEach((ev) => { | ||||
|       const key = ev.id | ||||
|       if (!weekEvents.has(key)) { | ||||
|         weekEvents.set(key, { ...ev, startIdx: dayIndex, endIdx: dayIndex }) | ||||
|       } else { | ||||
|         const ref = weekEvents.get(key) | ||||
|         ref.endIdx = Math.max(ref.endIdx, dayIndex) | ||||
|       } | ||||
|       const key = ev.id + '|' + (ev.n ?? 0) | ||||
|       if (!spanMap.has(key)) spanMap.set(key, { ...ev, startIdx: di, endIdx: di }) | ||||
|       else spanMap.get(key).endIdx = Math.max(spanMap.get(key).endIdx, di) | ||||
|     }) | ||||
|   }) | ||||
|   const arr = Array.from(weekEvents.values()) | ||||
|   arr.sort((a, b) => { | ||||
|     const spanA = a.endIdx - a.startIdx | ||||
|     const spanB = b.endIdx - b.startIdx | ||||
|     if (spanA !== spanB) return spanB - spanA | ||||
|   const spans = Array.from(spanMap.values()) | ||||
|   // Derive span start/end date strings from week day indices (removes need for per-day stored endDate) | ||||
|   spans.forEach((sp) => { | ||||
|     sp.startDate = props.week.days[sp.startIdx].date | ||||
|     sp.endDate = props.week.days[sp.endIdx].date | ||||
|   }) | ||||
|   // Sort so longer multi-day first, then earlier, then id for stability | ||||
|   spans.sort((a, b) => { | ||||
|     const la = a.endIdx - a.startIdx | ||||
|     const lb = b.endIdx - b.startIdx | ||||
|     if (la !== lb) return lb - la | ||||
|     if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx | ||||
|     // For one-day events that are otherwise equal, sort by color (0 first) | ||||
|     if (spanA === 0 && spanB === 0 && a.startIdx === b.startIdx) { | ||||
|       const colorA = a.colorId || 0 | ||||
|       const colorB = b.colorId || 0 | ||||
|       if (colorA !== colorB) return colorA - colorB | ||||
|     } | ||||
|     const ca = a.colorId != null ? a.colorId : 0 | ||||
|     const cb = b.colorId != null ? b.colorId : 0 | ||||
|     if (ca !== cb) return ca - cb | ||||
|     return String(a.id).localeCompare(String(b.id)) | ||||
|   }) | ||||
|   // Assign non-overlapping rows | ||||
|   const rowsLastEnd = [] | ||||
|   arr.forEach((ev) => { | ||||
|     let row = 0 | ||||
|     while (row < rowsLastEnd.length && !(ev.startIdx > rowsLastEnd[row])) row++ | ||||
|     if (row === rowsLastEnd.length) rowsLastEnd.push(-1) | ||||
|     rowsLastEnd[row] = ev.endIdx | ||||
|     ev.row = row + 1 | ||||
|   // Identify breaks | ||||
|   const breaks = [] | ||||
|   for (let d = 0; d < 6; d++) { | ||||
|     const bridged = spans.some((sp) => sp.startIdx <= d && sp.endIdx >= d + 1) | ||||
|     if (!bridged) breaks.push(d) | ||||
|   } | ||||
|   const rawSegments = [] | ||||
|   let segStart = 0 | ||||
|   for (const b of breaks) { | ||||
|     rawSegments.push([segStart, b]) | ||||
|     segStart = b + 1 | ||||
|   } | ||||
|   rawSegments.push([segStart, 6]) | ||||
|  | ||||
|   const segments = rawSegments.map(([s, e]) => { | ||||
|     const evs = spans.filter((sp) => sp.startIdx >= s && sp.endIdx <= e) | ||||
|     // Row packing in this segment (gap fill) | ||||
|     const rows = [] // each row: intervals | ||||
|     function fits(row, a, b) { | ||||
|       return row.every((iv) => b < iv.start || a > iv.end) | ||||
|     } | ||||
|     function addInterval(row, a, b) { | ||||
|       let inserted = false | ||||
|       for (let i = 0; i < row.length; i++) { | ||||
|         if (b < row[i].start) { | ||||
|           row.splice(i, 0, { start: a, end: b }) | ||||
|           inserted = true | ||||
|           break | ||||
|         } | ||||
|       } | ||||
|       if (!inserted) row.push({ start: a, end: b }) | ||||
|     } | ||||
|     evs.forEach((ev) => { | ||||
|       let placed = false | ||||
|       for (let r = 0; r < rows.length; r++) { | ||||
|         if (fits(rows[r], ev.startIdx, ev.endIdx)) { | ||||
|           addInterval(rows[r], ev.startIdx, ev.endIdx) | ||||
|           ev.row = r + 1 | ||||
|           placed = true | ||||
|           break | ||||
|         } | ||||
|       } | ||||
|       if (!placed) { | ||||
|         rows.push([{ start: ev.startIdx, end: ev.endIdx }]) | ||||
|         ev.row = rows.length | ||||
|       } | ||||
|       ev.startIdxRel = ev.startIdx - s | ||||
|       ev.endIdxRel = ev.endIdx - s | ||||
|     }) | ||||
|     return { startIdx: s, endIdx: e, events: evs, rowsCount: rows.length } | ||||
|   }) | ||||
|   return arr | ||||
|   return segments | ||||
| }) | ||||
|  | ||||
| function segmentStyle(seg) { | ||||
|   return { gridColumn: `${seg.startIdx + 1} / ${seg.endIdx + 2}` } | ||||
| } | ||||
|  | ||||
| function segmentKey(seg) { | ||||
|   return seg.startIdx + '-' + seg.endIdx | ||||
| } | ||||
|  | ||||
| function isSegmentCompressed(seg) { | ||||
|   return !!segmentCompression.value[segmentKey(seg)] | ||||
| } | ||||
|  | ||||
| function recomputeCompression() { | ||||
|   const el = weekOverlayRef.value | ||||
|   if (!el) return | ||||
|   const available = el.clientHeight || 0 | ||||
|   if (!available) return | ||||
|   const cs = getComputedStyle(el) | ||||
|   const fontSize = parseFloat(cs.fontSize) || 16 | ||||
|   const baseRowPx = fontSize * 1.5 // desired row height (matches CSS 1.5em) | ||||
|   const marginTop = 0 // already applied outside height | ||||
|   const usable = Math.max(0, available - marginTop) | ||||
|   const nextMap = {} | ||||
|   for (const seg of eventSegments.value) { | ||||
|     const desired = (seg.rowsCount || 1) * baseRowPx | ||||
|     nextMap[segmentKey(seg)] = desired > usable | ||||
|   } | ||||
|   segmentCompression.value = nextMap | ||||
| } | ||||
|  | ||||
| watch(eventSegments, () => nextTick(() => recomputeCompression())) | ||||
| onMounted(() => { | ||||
|   nextTick(() => recomputeCompression()) | ||||
|   window.addEventListener('resize', recomputeCompression) | ||||
| }) | ||||
| onBeforeUnmount(() => { | ||||
|   window.removeEventListener('resize', recomputeCompression) | ||||
| }) | ||||
|  | ||||
| function handleEventClick(span) { | ||||
|   if (justDragged.value) return | ||||
|   // Emit composite payload: base id (without virtual marker), instance id, occurrence index (data-n) | ||||
|   const idStr = span.id | ||||
|   const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_') | ||||
|   const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr | ||||
|   emit('event-click', { | ||||
|     id: baseId, | ||||
|     instanceId: span.id, | ||||
|     occurrenceIndex: span._recurrenceIndex != null ? span._recurrenceIndex : 0, | ||||
|   }) | ||||
|   emit('event-click', { id: span.id, n: span.n != null ? span.n : 0 }) | ||||
| } | ||||
|  | ||||
| function handleEventPointerDown(span, event) { | ||||
|   if (event.target.classList.contains('resize-handle')) return | ||||
|   event.stopPropagation() | ||||
|   const idStr = span.id | ||||
|   const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_') | ||||
|   const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr | ||||
|   const isVirtual = hasVirtualMarker | ||||
|   // Determine which day within the span was grabbed so we maintain relative position | ||||
|   const baseId = span.id | ||||
|   let anchorDate = span.startDate | ||||
|   try { | ||||
|     const spanDays = daysInclusive(span.startDate, span.endDate) | ||||
| @@ -116,14 +195,11 @@ function handleEventPointerDown(span, event) { | ||||
|       if (dayIndex >= spanDays) dayIndex = spanDays - 1 | ||||
|       anchorDate = addDaysStr(span.startDate, dayIndex) | ||||
|     } | ||||
|   } catch (e) { | ||||
|     // Fallback to startDate if any calculation fails | ||||
|   } | ||||
|   } catch (e) {} | ||||
|   startLocalDrag( | ||||
|     { | ||||
|       id: baseId, | ||||
|       originalId: span.id, | ||||
|       isVirtual, | ||||
|       mode: 'move', | ||||
|       pointerStartX: event.clientX, | ||||
|       pointerStartY: event.clientY, | ||||
| @@ -137,15 +213,11 @@ function handleEventPointerDown(span, event) { | ||||
|  | ||||
| function handleResizePointerDown(span, mode, event) { | ||||
|   event.stopPropagation() | ||||
|   const idStr = span.id | ||||
|   const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_') | ||||
|   const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr | ||||
|   const isVirtual = hasVirtualMarker | ||||
|   const baseId = span.id | ||||
|   startLocalDrag( | ||||
|     { | ||||
|       id: baseId, | ||||
|       originalId: span.id, | ||||
|       isVirtual, | ||||
|       mode, | ||||
|       pointerStartX: event.clientX, | ||||
|       pointerStartY: event.clientY, | ||||
| @@ -167,7 +239,6 @@ function startLocalDrag(init, evt) { | ||||
|     else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1 | ||||
|   } | ||||
|  | ||||
|   // Capture original repeating pattern & weekday (for weekly repeats) so we can rotate relative to original | ||||
|   let originalWeekday = null | ||||
|   let originalPattern = null | ||||
|   if (init.mode === 'move') { | ||||
| @@ -194,13 +265,11 @@ function startLocalDrag(init, evt) { | ||||
|     tentativeEnd: init.endDate, | ||||
|     originalWeekday, | ||||
|     originalPattern, | ||||
|     realizedId: null, // for virtual occurrence converted to real during drag | ||||
|     realizedId: null, | ||||
|   } | ||||
|  | ||||
|   // Begin compound history session (single snapshot after drag completes) | ||||
|   store.$history?.beginCompound() | ||||
|  | ||||
|   // Capture pointer events globally | ||||
|   if (evt.currentTarget && evt.pointerId !== undefined) { | ||||
|     try { | ||||
|       evt.currentTarget.setPointerCapture(evt.pointerId) | ||||
| @@ -209,7 +278,6 @@ function startLocalDrag(init, evt) { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Prevent default for mouse/pen to avoid text selection. For touch we skip so the user can still scroll. | ||||
|   if (!(evt.pointerType === 'touch')) { | ||||
|     evt.preventDefault() | ||||
|   } | ||||
| @@ -221,19 +289,15 @@ function startLocalDrag(init, evt) { | ||||
|  | ||||
| // Determine date under pointer: traverse DOM to find day cell carrying data-date attribute | ||||
| function getDateUnderPointer(x, y, el) { | ||||
|   let cur = el | ||||
|   while (cur) { | ||||
|     if (cur.dataset && cur.dataset.date) { | ||||
|       return { date: cur.dataset.date } | ||||
|   for (let cur = el; cur; cur = cur.parentElement) | ||||
|     if (cur.dataset?.date) return { date: cur.dataset.date } | ||||
|   const overlayEl = weekOverlayRef.value | ||||
|   const container = overlayEl?.parentElement // .days-grid | ||||
|   if (container) { | ||||
|     for (const d of container.querySelectorAll('[data-date]')) { | ||||
|       const { left, right, top, bottom } = d.getBoundingClientRect() | ||||
|       if (y >= top && y <= bottom && x >= left && x <= right) return { date: d.dataset.date } | ||||
|     } | ||||
|     cur = cur.parentElement | ||||
|   } | ||||
|   // Fallback: elementFromPoint scan | ||||
|   const probe = document.elementFromPoint(x, y) | ||||
|   let p = probe | ||||
|   while (p) { | ||||
|     if (p.dataset && p.dataset.date) return { date: p.dataset.date } | ||||
|     p = p.parentElement | ||||
|   } | ||||
|   return null | ||||
| } | ||||
| @@ -250,7 +314,6 @@ function onDragPointerMove(e) { | ||||
|   const hitEl = document.elementFromPoint(e.clientX, e.clientY) | ||||
|   const hit = getDateUnderPointer(e.clientX, e.clientY, hitEl) | ||||
|  | ||||
|   // If we can't find a date, don't update the range but keep the drag active | ||||
|   if (!hit || !hit.date) return | ||||
|  | ||||
|   const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date) | ||||
| @@ -260,26 +323,22 @@ function onDragPointerMove(e) { | ||||
|   st.tentativeStart = ns | ||||
|   st.tentativeEnd = ne | ||||
|   if (st.mode === 'move') { | ||||
|     if (st.isVirtual) { | ||||
|       // On first movement convert virtual occurrence into a real new event (split series) | ||||
|     if (st.n && st.n > 0) { | ||||
|       if (!st.realizedId) { | ||||
|         const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne) | ||||
|         if (newId) { | ||||
|           st.realizedId = newId | ||||
|           st.id = newId | ||||
|           st.isVirtual = false | ||||
|           // converted to standalone event | ||||
|         } else { | ||||
|           return | ||||
|         } | ||||
|       } else { | ||||
|         // Subsequent moves: update range without rotating pattern automatically | ||||
|         store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false }) | ||||
|       } | ||||
|     } else { | ||||
|       // Normal non-virtual move; rotate handled in setEventRange | ||||
|       store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false }) | ||||
|     } | ||||
|     // Manual rotation relative to original pattern (keeps pattern anchored to initially grabbed weekday) | ||||
|     if (st.originalPattern && st.originalWeekday != null) { | ||||
|       try { | ||||
|         const currentWeekday = new Date(ns + 'T00:00:00').getDay() | ||||
| @@ -292,15 +351,9 @@ function onDragPointerMove(e) { | ||||
|         } | ||||
|       } catch {} | ||||
|     } | ||||
|   } else if (!st.isVirtual) { | ||||
|     // Resizes on real events update immediately | ||||
|     applyRangeDuringDrag( | ||||
|       { id: st.id, isVirtual: st.isVirtual, mode: st.mode, startDate: ns, endDate: ne }, | ||||
|       ns, | ||||
|       ne, | ||||
|     ) | ||||
|   } else if (st.isVirtual && (st.mode === 'resize-left' || st.mode === 'resize-right')) { | ||||
|     // For virtual occurrence resize: convert to real once, then adjust range | ||||
|   } else if (!(st.n && st.n > 0)) { | ||||
|     applyRangeDuringDrag({ id: st.id, mode: st.mode, startDate: ns, endDate: ne }, ns, ne) | ||||
|   } else if (st.n && st.n > 0 && (st.mode === 'resize-left' || st.mode === 'resize-right')) { | ||||
|     if (!st.realizedId) { | ||||
|       const initialStart = ns | ||||
|       const initialEnd = ne | ||||
| @@ -308,10 +361,9 @@ function onDragPointerMove(e) { | ||||
|       if (newId) { | ||||
|         st.realizedId = newId | ||||
|         st.id = newId | ||||
|         st.isVirtual = false | ||||
|         // converted | ||||
|       } else return | ||||
|     } | ||||
|     // Apply range change; rotate if left edge moved and weekday changed | ||||
|     const rotate = st.mode === 'resize-left' | ||||
|     store.setEventRange(st.id, ns, ne, { mode: st.mode, rotatePattern: rotate }) | ||||
|   } | ||||
| @@ -321,7 +373,6 @@ function onDragPointerUp(e) { | ||||
|   const st = dragState.value | ||||
|   if (!st) return | ||||
|  | ||||
|   // Release pointer capture if it was set | ||||
|   if (e.target && e.pointerId !== undefined) { | ||||
|     try { | ||||
|       e.target.releasePointerCapture(e.pointerId) | ||||
| @@ -341,11 +392,10 @@ function onDragPointerUp(e) { | ||||
|  | ||||
|   if (moved) { | ||||
|     // Apply final mutation if virtual (we deferred) or if non-virtual no further change (rare) | ||||
|     if (st.isVirtual) { | ||||
|     if (st.n && st.n > 0) { | ||||
|       applyRangeDuringDrag( | ||||
|         { | ||||
|           id: st.id, | ||||
|           isVirtual: st.isVirtual, | ||||
|           mode: st.mode, | ||||
|           startDate: finalStart, | ||||
|           endDate: finalEnd, | ||||
| @@ -359,7 +409,6 @@ function onDragPointerUp(e) { | ||||
|       justDragged.value = false | ||||
|     }, 120) | ||||
|   } | ||||
|   // End compound session (snapshot if changed) | ||||
|   store.$history?.endCompound() | ||||
| } | ||||
|  | ||||
| @@ -388,7 +437,7 @@ function normalizeDateOrder(aStr, bStr) { | ||||
| } | ||||
|  | ||||
| function applyRangeDuringDrag(st, startDate, endDate) { | ||||
|   if (st.isVirtual) { | ||||
|   if (st.n && st.n > 0) { | ||||
|     if (st.mode !== 'move') return // no resize for virtual occurrence | ||||
|     // Split-move: occurrence being dragged treated as first of new series | ||||
|     store.splitMoveVirtualOccurrence(st.id, st.startDate, startDate, endDate) | ||||
| @@ -402,16 +451,22 @@ function applyRangeDuringDrag(st, startDate, endDate) { | ||||
| .week-overlay { | ||||
|   position: absolute; | ||||
|   inset: 0; | ||||
|   pointer-events: none; | ||||
|   z-index: 15; | ||||
|   display: grid; | ||||
|   /* Prevent content from expanding tracks beyond container width */ | ||||
|   grid-template-columns: repeat(7, minmax(0, 1fr)); | ||||
|   grid-auto-rows: minmax(0, 1.5em); | ||||
|  | ||||
|   row-gap: 0.05em; | ||||
|   grid-template-columns: repeat(7, 1fr); | ||||
|   margin-top: 1.8em; | ||||
|   pointer-events: none; | ||||
| } | ||||
| .segment-grid { | ||||
|   display: grid; | ||||
|   gap: 2px; | ||||
|   align-content: start; | ||||
|   pointer-events: none; | ||||
|   overflow: hidden; | ||||
|   grid-auto-columns: 1fr; | ||||
|   grid-auto-rows: 1.5em; | ||||
| } | ||||
| .segment-grid.compress { | ||||
|   grid-auto-rows: 1fr; | ||||
| } | ||||
|  | ||||
| .event-span { | ||||
| @@ -429,13 +484,8 @@ function applyRangeDuringDrag(st, startDate, endDate) { | ||||
|   align-items: center; | ||||
|   position: relative; | ||||
|   user-select: none; | ||||
|   height: 100%; | ||||
|   width: 100%; | ||||
|   max-width: 100%; | ||||
|   box-sizing: border-box; | ||||
|   z-index: 1; | ||||
|   text-align: center; | ||||
|   min-width: 0; | ||||
| } | ||||
|  | ||||
| /* Inner title wrapper ensures proper ellipsis within flex/grid constraints */ | ||||
| @@ -463,10 +513,10 @@ function applyRangeDuringDrag(st, startDate, endDate) { | ||||
| } | ||||
|  | ||||
| .event-span .resize-handle.left { | ||||
|   left: 0; | ||||
|   inset-inline-start: 0; | ||||
| } | ||||
|  | ||||
| .event-span .resize-handle.right { | ||||
|   right: 0; | ||||
|   inset-inline-end: 0; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -31,7 +31,6 @@ | ||||
|       > | ||||
|         ⚙ | ||||
|       </button> | ||||
|       <!-- Settings dialog now lives here --> | ||||
|       <SettingsDialog ref="settingsDialog" /> | ||||
|     </div> | ||||
|   </Transition> | ||||
| @@ -101,12 +100,12 @@ onBeforeUnmount(() => { | ||||
|   display: flex; | ||||
|   justify-content: end; | ||||
|   align-items: center; | ||||
|   margin-right: 1.5rem; | ||||
|   margin-inline-end: 2rem; | ||||
| } | ||||
| .toggle-btn { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   right: 0; | ||||
|   inset-inline-end: 0; | ||||
|   background: transparent; | ||||
|   border: none; | ||||
|   color: var(--muted); | ||||
| @@ -157,7 +156,6 @@ onBeforeUnmount(() => { | ||||
|   color: var(--muted); | ||||
|   padding: 0; | ||||
|   margin: 0; | ||||
|   margin-right: 0.6rem; | ||||
|   cursor: pointer; | ||||
|   font-size: 1.5rem; | ||||
|   line-height: 1; | ||||
| @@ -205,6 +203,6 @@ onBeforeUnmount(() => { | ||||
| .today-date { | ||||
|   white-space: pre-line; | ||||
|   text-align: center; | ||||
|   margin-right: 2rem; | ||||
|   margin-inline-end: 2rem; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -181,7 +181,7 @@ defineExpose({ | ||||
| .jogwheel-viewport { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   right: 0; | ||||
|   inset-inline-end: 0; | ||||
|   bottom: 0; | ||||
|   width: var(--month-w); | ||||
|   overflow-y: auto; | ||||
|   | ||||
| @@ -3,10 +3,14 @@ import { ref, computed } from 'vue' | ||||
| import BaseDialog from './BaseDialog.vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
| import WeekdaySelector from './WeekdaySelector.vue' | ||||
| import { getLocalizedWeekdayNamesLong } from '@/utils/date' | ||||
|  | ||||
| const show = ref(false) | ||||
| const calendarStore = useCalendarStore() | ||||
|  | ||||
| // Localized weekday names (now Sunday-first from util) for select 0=Sunday ..6=Saturday | ||||
| const weekdayNames = getLocalizedWeekdayNamesLong() | ||||
|  | ||||
| // Reactive bindings to store | ||||
| const firstDay = computed({ | ||||
|   get: () => calendarStore.config.first_day, | ||||
| @@ -159,19 +163,21 @@ defineExpose({ open }) | ||||
|     v-model="show" | ||||
|     title="Settings" | ||||
|     class="settings-modal" | ||||
|     :style="{ top: '4.5rem', right: '2rem', bottom: 'auto', left: 'auto', transform: 'none' }" | ||||
|     :style="{ | ||||
|       top: '4.5rem', | ||||
|       insetInlineEnd: '2rem', | ||||
|       bottom: 'auto', | ||||
|       insetInlineStart: 'auto', | ||||
|       transform: 'none', | ||||
|     }" | ||||
|   > | ||||
|     <div class="setting-group"> | ||||
|       <label class="ec-field"> | ||||
|         <span>First day of week</span> | ||||
|         <select v-model.number="firstDay"> | ||||
|           <option :value="0">Sunday</option> | ||||
|           <option :value="1">Monday</option> | ||||
|           <option :value="2">Tuesday</option> | ||||
|           <option :value="3">Wednesday</option> | ||||
|           <option :value="4">Thursday</option> | ||||
|           <option :value="5">Friday</option> | ||||
|           <option :value="6">Saturday</option> | ||||
|           <option v-for="(name, idx) in weekdayNames" :key="idx" :value="idx"> | ||||
|             {{ name.charAt(0).toUpperCase() + name.slice(1) }} | ||||
|           </option> | ||||
|         </select> | ||||
|       </label> | ||||
|       <div class="weekend-select ec-field"> | ||||
| @@ -242,9 +248,9 @@ defineExpose({ open }) | ||||
| .holiday-settings { | ||||
|   display: grid; | ||||
|   gap: 0.75rem; | ||||
|   margin-left: 1rem; | ||||
|   padding-left: 1rem; | ||||
|   border-left: 2px solid var(--border-color); | ||||
|   margin-inline-start: 1rem; | ||||
|   padding-inline-start: 1rem; | ||||
|   border-inline-start: 2px solid var(--border-color); | ||||
| } | ||||
| select { | ||||
|   border: 1px solid var(--muted); | ||||
| @@ -269,7 +275,7 @@ select { | ||||
|   flex: 0 0 auto; | ||||
|   min-width: 120px; | ||||
| } | ||||
| /* WeekdaySelector display tweaks */ | ||||
|  | ||||
| .footer-row { | ||||
|   display: flex; | ||||
|   justify-content: flex-end; | ||||
|   | ||||
| @@ -16,7 +16,7 @@ | ||||
|       @pointerenter="onDragOver(di)" | ||||
|       @pointerup="onPointerUp" | ||||
|     > | ||||
|       {{ d.slice(0, 3) }} | ||||
|       {{ d }} | ||||
|     </button> | ||||
|     <button | ||||
|       v-for="g in barGroups" | ||||
| @@ -60,8 +60,8 @@ const props = defineProps({ | ||||
|  | ||||
| // Initialize internal from external if it has any true; else keep empty (fallback handled on emit) | ||||
| if (model.value?.some?.(Boolean)) internal.value = [...model.value] | ||||
| const labelsMondayFirst = getLocalizedWeekdayNames() | ||||
| const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)] | ||||
| // getLocalizedWeekdayNames now returns Sunday-first already | ||||
| const labels = getLocalizedWeekdayNames() | ||||
| const anySelected = computed(() => internal.value.some(Boolean)) | ||||
| const localeFirst = getLocaleFirstDay() | ||||
| const localeWeekend = getLocaleWeekendDays() | ||||
|   | ||||
		Reference in New Issue
	
	Block a user