466 lines
12 KiB
Vue
466 lines
12 KiB
Vue
<script setup>
|
|
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
|
import CalendarHeader from '@/components/CalendarHeader.vue'
|
|
import CalendarWeek from '@/components/CalendarWeek.vue'
|
|
import Jogwheel from '@/components/Jogwheel.vue'
|
|
import EventDialog from '@/components/EventDialog.vue'
|
|
import { isoWeekInfo, getLocalizedMonthName, monthAbbr, lunarPhaseSymbol, pad, mondayIndex, daysInclusive, addDaysStr, formatDateRange } from '@/utils/date'
|
|
import { toLocalString, fromLocalString } from '@/utils/date'
|
|
|
|
const calendarStore = useCalendarStore()
|
|
const viewport = ref(null)
|
|
const eventDialog = ref(null)
|
|
|
|
// UI state moved from store
|
|
const scrollTop = ref(0)
|
|
const viewportHeight = ref(600)
|
|
const rowHeight = ref(64)
|
|
const baseDate = new Date(2024, 0, 1) // Monday
|
|
|
|
// Selection state moved from store
|
|
const selection = ref({ start: null, end: null })
|
|
const isDragging = ref(false)
|
|
const dragAnchor = ref(null)
|
|
|
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
|
|
|
const minVirtualWeek = computed(() => {
|
|
const date = new Date(calendarStore.minYear, 0, 1)
|
|
const monday = new Date(date)
|
|
monday.setDate(date.getDate() - ((date.getDay() + 6) % 7))
|
|
return Math.floor((monday - baseDate) / WEEK_MS)
|
|
})
|
|
|
|
const maxVirtualWeek = computed(() => {
|
|
const date = new Date(calendarStore.maxYear, 11, 31)
|
|
const monday = new Date(date)
|
|
monday.setDate(date.getDate() - ((date.getDay() + 6) % 7))
|
|
return Math.floor((monday - baseDate) / WEEK_MS)
|
|
})
|
|
|
|
const totalVirtualWeeks = computed(() => {
|
|
return maxVirtualWeek.value - minVirtualWeek.value + 1
|
|
})
|
|
|
|
const initialScrollTop = computed(() => {
|
|
const targetWeekIndex = getWeekIndex(calendarStore.now) - 3
|
|
return (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
|
})
|
|
|
|
const selectedDateRange = computed(() => {
|
|
if (!selection.value.start || !selection.value.end) return ''
|
|
return formatDateRange(fromLocalString(selection.value.start), fromLocalString(selection.value.end))
|
|
})
|
|
|
|
const todayString = computed(() => {
|
|
const t = calendarStore.now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric'}).replace(/,? /, "\n")
|
|
return t.charAt(0).toUpperCase() + t.slice(1)
|
|
})
|
|
|
|
const visibleWeeks = computed(() => {
|
|
const buffer = 10
|
|
const startIdx = Math.floor((scrollTop.value - buffer * rowHeight.value) / rowHeight.value)
|
|
const endIdx = Math.ceil((scrollTop.value + viewportHeight.value + buffer * rowHeight.value) / rowHeight.value)
|
|
|
|
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
|
|
const endVW = Math.min(maxVirtualWeek.value, endIdx + minVirtualWeek.value)
|
|
|
|
const weeks = []
|
|
for (let vw = startVW; vw <= endVW; vw++) {
|
|
weeks.push(createWeek(vw))
|
|
}
|
|
return weeks
|
|
})
|
|
|
|
const contentHeight = computed(() => {
|
|
return totalVirtualWeeks.value * rowHeight.value
|
|
})
|
|
|
|
// Functions moved from store
|
|
function computeRowHeight() {
|
|
const el = document.createElement('div')
|
|
el.style.position = 'absolute'
|
|
el.style.visibility = 'hidden'
|
|
el.style.height = 'var(--cell-h)'
|
|
document.body.appendChild(el)
|
|
const h = el.getBoundingClientRect().height || 64
|
|
el.remove()
|
|
rowHeight.value = Math.round(h)
|
|
return rowHeight.value
|
|
}
|
|
|
|
function getWeekIndex(date) {
|
|
const monday = new Date(date)
|
|
monday.setDate(date.getDate() - mondayIndex(date))
|
|
return Math.floor((monday - baseDate) / WEEK_MS)
|
|
}
|
|
|
|
function getMondayForVirtualWeek(virtualWeek) {
|
|
const monday = new Date(baseDate)
|
|
monday.setDate(monday.getDate() + virtualWeek * 7)
|
|
return monday
|
|
}
|
|
|
|
function createWeek(virtualWeek) {
|
|
const monday = getMondayForVirtualWeek(virtualWeek)
|
|
const weekNumber = isoWeekInfo(monday).week
|
|
const days = []
|
|
const cur = new Date(monday)
|
|
let hasFirst = false
|
|
let monthToLabel = null
|
|
let labelYear = null
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
const dateStr = toLocalString(cur)
|
|
const eventsForDay = calendarStore.events.get(dateStr) || []
|
|
const dow = cur.getDay()
|
|
const isFirst = cur.getDate() === 1
|
|
|
|
if (isFirst) {
|
|
hasFirst = true
|
|
monthToLabel = cur.getMonth()
|
|
labelYear = cur.getFullYear()
|
|
}
|
|
|
|
let displayText = String(cur.getDate())
|
|
if (isFirst) {
|
|
if (cur.getMonth() === 0) {
|
|
displayText = cur.getFullYear()
|
|
} else {
|
|
displayText = monthAbbr[cur.getMonth()].slice(0,3).toUpperCase()
|
|
}
|
|
}
|
|
|
|
days.push({
|
|
date: dateStr,
|
|
dayOfMonth: cur.getDate(),
|
|
displayText,
|
|
monthClass: monthAbbr[cur.getMonth()],
|
|
isToday: dateStr === calendarStore.today,
|
|
isWeekend: calendarStore.weekend[dow],
|
|
isFirstDay: isFirst,
|
|
lunarPhase: lunarPhaseSymbol(cur),
|
|
isSelected: selection.value.start && selection.value.end && dateStr >= selection.value.start && dateStr <= selection.value.end,
|
|
events: eventsForDay
|
|
})
|
|
cur.setDate(cur.getDate() + 1)
|
|
}
|
|
|
|
let monthLabel = null
|
|
if (hasFirst && monthToLabel !== null) {
|
|
if (labelYear && labelYear <= calendarStore.config.max_year) {
|
|
// Calculate how many weeks this month spans
|
|
let weeksSpan = 0
|
|
const d = new Date(cur)
|
|
d.setDate(cur.getDate() - 1) // Go back to last day of the week we just processed
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
d.setDate(cur.getDate() - 1 + i * 7)
|
|
if (d.getMonth() === monthToLabel) weeksSpan++
|
|
}
|
|
|
|
const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1)
|
|
weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))
|
|
|
|
const year = String(labelYear).slice(-2)
|
|
monthLabel = {
|
|
text: `${getLocalizedMonthName(monthToLabel)} '${year}`,
|
|
month: monthToLabel,
|
|
weeksSpan: weeksSpan,
|
|
height: weeksSpan * rowHeight.value
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
virtualWeek,
|
|
weekNumber: pad(weekNumber),
|
|
days,
|
|
monthLabel,
|
|
top: (virtualWeek - minVirtualWeek.value) * rowHeight.value
|
|
}
|
|
}
|
|
|
|
function goToToday() {
|
|
const top = new Date(calendarStore.now)
|
|
top.setDate(top.getDate() - 21)
|
|
const targetWeekIndex = getWeekIndex(top)
|
|
scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
|
if (viewport.value) {
|
|
viewport.value.scrollTop = scrollTop.value
|
|
}
|
|
}
|
|
|
|
function clearSelection() {
|
|
selection.value = { start: null, end: null }
|
|
}
|
|
|
|
function startDrag(dateStr) {
|
|
if (calendarStore.config.select_days === 0) return
|
|
isDragging.value = true
|
|
dragAnchor.value = dateStr
|
|
selection.value = { start: dateStr, end: dateStr }
|
|
}
|
|
|
|
function updateDrag(dateStr) {
|
|
if (!isDragging.value) return
|
|
const [start, end] = clampRange(dragAnchor.value, dateStr)
|
|
selection.value = { start, end }
|
|
}
|
|
|
|
function endDrag(dateStr) {
|
|
if (!isDragging.value) return
|
|
isDragging.value = false
|
|
const [start, end] = clampRange(dragAnchor.value, dateStr)
|
|
selection.value = { start, end }
|
|
}
|
|
|
|
function clampRange(anchorStr, otherStr) {
|
|
const limit = calendarStore.config.select_days
|
|
const forward = fromLocalString(otherStr) >= fromLocalString(anchorStr)
|
|
const span = daysInclusive(anchorStr, otherStr)
|
|
if (span <= limit) {
|
|
const a = [anchorStr, otherStr].sort()
|
|
return [a[0], a[1]]
|
|
}
|
|
if (forward) return [anchorStr, addDaysStr(anchorStr, limit - 1)]
|
|
return [addDaysStr(anchorStr, -(limit - 1)), anchorStr]
|
|
}
|
|
|
|
const onScroll = () => {
|
|
if (viewport.value) {
|
|
scrollTop.value = viewport.value.scrollTop
|
|
}
|
|
}
|
|
|
|
const handleJogwheelScrollTo = (newScrollTop) => {
|
|
if (viewport.value) {
|
|
viewport.value.scrollTop = newScrollTop
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
// Compute row height and initialize
|
|
computeRowHeight()
|
|
calendarStore.updateCurrentDate()
|
|
|
|
if (viewport.value) {
|
|
viewportHeight.value = viewport.value.clientHeight
|
|
viewport.value.scrollTop = initialScrollTop.value
|
|
viewport.value.addEventListener('scroll', onScroll)
|
|
}
|
|
|
|
// Update time periodically
|
|
const timer = setInterval(() => {
|
|
calendarStore.updateCurrentDate()
|
|
}, 60000) // Update every minute
|
|
|
|
onBeforeUnmount(() => {
|
|
clearInterval(timer)
|
|
})
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (viewport.value) {
|
|
viewport.value.removeEventListener('scroll', onScroll)
|
|
}
|
|
})
|
|
|
|
const handleDayMouseDown = (dateStr) => {
|
|
startDrag(dateStr)
|
|
}
|
|
|
|
const handleDayMouseEnter = (dateStr) => {
|
|
if (isDragging.value) {
|
|
updateDrag(dateStr)
|
|
}
|
|
}
|
|
|
|
const handleDayMouseUp = (dateStr) => {
|
|
if (isDragging.value) {
|
|
endDrag(dateStr)
|
|
// Show event dialog if we have a selection
|
|
if (selection.value.start && selection.value.end && eventDialog.value) {
|
|
setTimeout(() => eventDialog.value.openCreateDialog(), 50)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Touch event handlers
|
|
const handleDayTouchStart = (dateStr) => {
|
|
startDrag(dateStr)
|
|
}
|
|
|
|
const handleDayTouchMove = (dateStr) => {
|
|
if (isDragging.value) {
|
|
updateDrag(dateStr)
|
|
}
|
|
}
|
|
|
|
const handleDayTouchEnd = (dateStr) => {
|
|
if (isDragging.value) {
|
|
endDrag(dateStr)
|
|
// Show event dialog if we have a selection
|
|
if (selection.value.start && selection.value.end && eventDialog.value) {
|
|
setTimeout(() => eventDialog.value.openCreateDialog(), 50)
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleEventClick = (eventInstanceId) => {
|
|
if (eventDialog.value) {
|
|
eventDialog.value.openEditDialog(eventInstanceId)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="wrap">
|
|
<header>
|
|
<h1>Calendar</h1>
|
|
<div class="header-controls">
|
|
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
|
</div>
|
|
</header>
|
|
<CalendarHeader
|
|
:scroll-top="scrollTop"
|
|
:row-height="rowHeight"
|
|
:min-virtual-week="minVirtualWeek"
|
|
/>
|
|
<div class="calendar-container">
|
|
<div class="calendar-viewport" ref="viewport">
|
|
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
|
<CalendarWeek
|
|
v-for="week in visibleWeeks"
|
|
:key="week.virtualWeek"
|
|
:week="week"
|
|
:style="{ top: week.top + 'px' }"
|
|
@day-mousedown="handleDayMouseDown"
|
|
@day-mouseenter="handleDayMouseEnter"
|
|
@day-mouseup="handleDayMouseUp"
|
|
@day-touchstart="handleDayTouchStart"
|
|
@day-touchmove="handleDayTouchMove"
|
|
@day-touchend="handleDayTouchEnd"
|
|
@event-click="handleEventClick"
|
|
/>
|
|
<!-- Month labels positioned absolutely -->
|
|
<div
|
|
v-for="week in visibleWeeks"
|
|
:key="`month-${week.virtualWeek}`"
|
|
v-show="week.monthLabel"
|
|
class="month-name-label"
|
|
:style="{
|
|
top: week.top + 'px',
|
|
height: week.monthLabel?.height + 'px'
|
|
}"
|
|
>
|
|
<span>{{ week.monthLabel?.text }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Jogwheel as sibling to calendar-viewport -->
|
|
<Jogwheel
|
|
:total-virtual-weeks="totalVirtualWeeks"
|
|
:row-height="rowHeight"
|
|
:viewport-height="viewportHeight"
|
|
:scroll-top="scrollTop"
|
|
@scroll-to="handleJogwheelScrollTo"
|
|
/>
|
|
</div>
|
|
<EventDialog
|
|
ref="eventDialog"
|
|
:selection="selection"
|
|
@clear-selection="clearSelection"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.wrap {
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
header h1 {
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
.header-controls {
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.today-date {
|
|
cursor: pointer;
|
|
padding: 0.5rem;
|
|
background: var(--today-btn-bg);
|
|
color: var(--today-btn-text);
|
|
border-radius: 4px;
|
|
white-space: pre-line;
|
|
text-align: center;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.today-date:hover {
|
|
background: var(--today-btn-hover-bg);
|
|
}
|
|
|
|
.calendar-container {
|
|
flex: 1;
|
|
display: flex;
|
|
position: relative;
|
|
/* Prevent text selection in calendar */
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
user-select: none;
|
|
-webkit-touch-callout: none;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
|
|
.calendar-viewport {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.calendar-content {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.month-name-label {
|
|
position: absolute;
|
|
right: 0;
|
|
width: 3rem; /* Match jogwheel width */
|
|
font-size: 2em;
|
|
font-weight: 700;
|
|
color: var(--muted);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
pointer-events: none;
|
|
z-index: 15;
|
|
overflow: visible;
|
|
}
|
|
|
|
.month-name-label > span {
|
|
display: inline-block;
|
|
white-space: nowrap;
|
|
writing-mode: vertical-rl;
|
|
text-orientation: mixed;
|
|
transform: rotate(180deg);
|
|
transform-origin: center;
|
|
}
|
|
</style>
|