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)
|
||||
|
||||
|
||||
+320
-46
@@ -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>
|
||||
|
||||
+180
-130
@@ -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