765 lines
22 KiB
Vue
765 lines
22 KiB
Vue
<script setup>
|
|
import { ref, onMounted, onBeforeUnmount, computed, watch } 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 SettingsDialog from '@/components/SettingsDialog.vue'
|
|
import {
|
|
getLocalizedMonthName,
|
|
monthAbbr,
|
|
lunarPhaseSymbol,
|
|
pad,
|
|
daysInclusive,
|
|
addDaysStr,
|
|
formatDateRange,
|
|
formatTodayString,
|
|
getOccurrenceIndex,
|
|
getVirtualOccurrenceEndDate,
|
|
getISOWeek,
|
|
MIN_YEAR,
|
|
MAX_YEAR,
|
|
} from '@/utils/date'
|
|
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
|
|
import { addDays, differenceInCalendarDays, differenceInWeeks } from 'date-fns'
|
|
import { getHolidayForDate } from '@/utils/holidays'
|
|
|
|
const calendarStore = useCalendarStore()
|
|
const viewport = ref(null)
|
|
const settingsDialog = ref(null)
|
|
|
|
const emit = defineEmits(['create-event', 'edit-event'])
|
|
|
|
function createEventFromSelection() {
|
|
if (!selection.value.startDate || selection.value.dayCount === 0) return null
|
|
|
|
return {
|
|
startDate: selection.value.startDate,
|
|
dayCount: selection.value.dayCount,
|
|
endDate: addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
|
}
|
|
}
|
|
|
|
const scrollTop = ref(0)
|
|
const viewportHeight = ref(600)
|
|
const rowHeight = ref(64)
|
|
const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))
|
|
|
|
const selection = ref({ startDate: null, dayCount: 0 })
|
|
const isDragging = ref(false)
|
|
const dragAnchor = ref(null)
|
|
|
|
const DOUBLE_TAP_DELAY = 300
|
|
const pendingTap = ref({ date: null, time: 0, type: null })
|
|
const suppressMouseUntil = ref(0)
|
|
|
|
function normalizeDate(val) {
|
|
if (typeof val === 'string') return val
|
|
if (val && typeof val === 'object') {
|
|
if (val.date) return String(val.date)
|
|
if (val.startDate) return String(val.startDate)
|
|
}
|
|
return String(val)
|
|
}
|
|
|
|
function registerTap(rawDate, type) {
|
|
const dateStr = normalizeDate(rawDate)
|
|
const now = Date.now()
|
|
const prev = pendingTap.value
|
|
const delta = now - prev.time
|
|
const isDouble =
|
|
prev.date === dateStr && prev.type === type && delta <= DOUBLE_TAP_DELAY && delta >= 35
|
|
if (isDouble) {
|
|
pendingTap.value = { date: null, time: 0, type: null }
|
|
return true
|
|
}
|
|
pendingTap.value = { date: dateStr, time: now, type }
|
|
return false
|
|
}
|
|
|
|
const minVirtualWeek = computed(() => {
|
|
const date = new Date(MIN_YEAR, 0, 1)
|
|
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
|
const firstDayOfWeek = addDays(date, -dayOffset)
|
|
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
|
})
|
|
|
|
const maxVirtualWeek = computed(() => {
|
|
const date = new Date(MAX_YEAR, 11, 31)
|
|
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
|
const firstDayOfWeek = addDays(date, -dayOffset)
|
|
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
|
})
|
|
|
|
const totalVirtualWeeks = computed(() => {
|
|
return maxVirtualWeek.value - minVirtualWeek.value + 1
|
|
})
|
|
|
|
const initialScrollTop = computed(() => {
|
|
const nowDate = new Date(calendarStore.now)
|
|
const targetWeekIndex = getWeekIndex(nowDate) - 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 d = new Date(calendarStore.now)
|
|
return formatTodayString(d)
|
|
})
|
|
|
|
// PERFORMANCE: Maintain a manual cache of computed weeks instead of relying on
|
|
// deep reactive tracking of every event & day object. We rebuild lazily when
|
|
// (a) scrolling changes the needed range or (b) eventsMutation counter bumps.
|
|
const visibleWeeks = ref([])
|
|
let lastScrollRange = { startVW: null, endVW: null }
|
|
let pendingRebuild = false
|
|
|
|
function scheduleRebuild(reason) {
|
|
if (pendingRebuild) return
|
|
pendingRebuild = true
|
|
// Use requestIdleCallback when available, else fallback to rAF
|
|
const cb = () => {
|
|
pendingRebuild = false
|
|
rebuildVisibleWeeks(reason)
|
|
}
|
|
if ('requestIdleCallback' in window) {
|
|
requestIdleCallback(cb, { timeout: 120 })
|
|
} else {
|
|
requestAnimationFrame(cb)
|
|
}
|
|
}
|
|
|
|
function rebuildVisibleWeeks(reason) {
|
|
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)
|
|
if (
|
|
reason === 'scroll' &&
|
|
lastScrollRange.startVW === startVW &&
|
|
lastScrollRange.endVW === endVW &&
|
|
visibleWeeks.value.length
|
|
) {
|
|
return
|
|
}
|
|
const weeks = []
|
|
for (let vw = startVW; vw <= endVW; vw++) weeks.push(createWeek(vw))
|
|
visibleWeeks.value = weeks
|
|
lastScrollRange = { startVW, endVW }
|
|
}
|
|
|
|
const contentHeight = computed(() => {
|
|
return totalVirtualWeeks.value * rowHeight.value
|
|
})
|
|
|
|
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 dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
|
const firstDayOfWeek = addDays(date, -dayOffset)
|
|
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
|
}
|
|
|
|
function getFirstDayForVirtualWeek(virtualWeek) {
|
|
return addDays(baseDate.value, virtualWeek * 7)
|
|
}
|
|
|
|
function createWeek(virtualWeek) {
|
|
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
|
|
const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
|
|
const weekNumber = getISOWeek(isoAnchor)
|
|
const days = []
|
|
let cur = new Date(firstDay)
|
|
let hasFirst = false
|
|
let monthToLabel = null
|
|
let labelYear = null
|
|
|
|
const repeatingBases = []
|
|
if (calendarStore.events) {
|
|
for (const ev of calendarStore.events.values()) {
|
|
if (ev.isRepeating) repeatingBases.push(ev)
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
const dateStr = toLocalString(cur, DEFAULT_TZ)
|
|
const storedEvents = []
|
|
|
|
for (const ev of calendarStore.events.values()) {
|
|
if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) {
|
|
storedEvents.push(ev)
|
|
}
|
|
}
|
|
const dayEvents = [...storedEvents]
|
|
// Expand repeating events into per-day occurrences (including virtual spans) when they cover this date.
|
|
for (const base of repeatingBases) {
|
|
// Base event's original span: include it directly as occurrence index 0.
|
|
if (dateStr >= base.startDate && dateStr <= base.endDate) {
|
|
dayEvents.push({
|
|
...base,
|
|
_recurrenceIndex: 0,
|
|
_baseId: base.id,
|
|
})
|
|
continue
|
|
}
|
|
|
|
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
|
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
|
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
|
|
const currentDate = fromLocalString(dateStr, DEFAULT_TZ)
|
|
|
|
let occurrenceFound = false
|
|
|
|
// Walk backwards within the base span to locate a matching virtual occurrence start.
|
|
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
|
|
const candidateStart = addDays(currentDate, -offset)
|
|
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
|
|
|
|
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
|
|
if (occurrenceIndex !== null) {
|
|
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
|
|
|
|
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
|
|
const virtualId = base.id + '_v_' + candidateStartStr
|
|
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
|
|
|
|
if (!alreadyExists) {
|
|
dayEvents.push({
|
|
...base,
|
|
id: virtualId,
|
|
startDate: candidateStartStr,
|
|
endDate: virtualEndDate,
|
|
_recurrenceIndex: occurrenceIndex,
|
|
_baseId: base.id,
|
|
})
|
|
}
|
|
occurrenceFound = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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()
|
|
}
|
|
}
|
|
|
|
let holiday = null
|
|
if (calendarStore.config.holidays.enabled) {
|
|
calendarStore._ensureHolidaysInitialized?.()
|
|
holiday = getHolidayForDate(dateStr)
|
|
}
|
|
|
|
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),
|
|
holiday: holiday,
|
|
isHoliday: holiday !== null,
|
|
isSelected:
|
|
selection.value.startDate &&
|
|
selection.value.dayCount > 0 &&
|
|
dateStr >= selection.value.startDate &&
|
|
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
|
events: dayEvents,
|
|
})
|
|
cur = addDays(cur, 1)
|
|
}
|
|
|
|
let monthLabel = null
|
|
if (hasFirst && monthToLabel !== null) {
|
|
if (labelYear && labelYear <= MAX_YEAR) {
|
|
let weeksSpan = 0
|
|
const d = addDays(cur, -1)
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
const probe = addDays(cur, -1 + i * 7)
|
|
d.setTime(probe.getTime())
|
|
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 = addDays(new Date(calendarStore.now), -21)
|
|
const targetWeekIndex = getWeekIndex(top)
|
|
scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
|
if (viewport.value) {
|
|
viewport.value.scrollTop = scrollTop.value
|
|
}
|
|
}
|
|
|
|
function clearSelection() {
|
|
selection.value = { startDate: null, dayCount: 0 }
|
|
}
|
|
|
|
function startDrag(dateStr) {
|
|
dateStr = normalizeDate(dateStr)
|
|
if (calendarStore.config.select_days === 0) return
|
|
isDragging.value = true
|
|
dragAnchor.value = dateStr
|
|
selection.value = { startDate: dateStr, dayCount: 1 }
|
|
addGlobalTouchListeners()
|
|
}
|
|
|
|
function updateDrag(dateStr) {
|
|
if (!isDragging.value) return
|
|
const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
|
|
selection.value = { startDate, dayCount }
|
|
}
|
|
|
|
function endDrag(dateStr) {
|
|
if (!isDragging.value) return
|
|
isDragging.value = false
|
|
const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
|
|
selection.value = { startDate, dayCount }
|
|
}
|
|
|
|
function finalizeDragAndCreate() {
|
|
if (!isDragging.value) return
|
|
isDragging.value = false
|
|
const eventData = createEventFromSelection()
|
|
if (eventData) {
|
|
clearSelection()
|
|
emit('create-event', eventData)
|
|
}
|
|
removeGlobalTouchListeners()
|
|
}
|
|
|
|
function getDateUnderPoint(x, y) {
|
|
const el = document.elementFromPoint(x, y)
|
|
let cur = el
|
|
while (cur) {
|
|
if (cur.dataset && cur.dataset.date) return cur.dataset.date
|
|
cur = cur.parentElement
|
|
}
|
|
return getDateFromCoordinates(x, y)
|
|
}
|
|
|
|
function onGlobalTouchMove(e) {
|
|
if (!isDragging.value) return
|
|
const t = e.touches && e.touches[0]
|
|
if (!t) return
|
|
e.preventDefault()
|
|
const dateStr = getDateUnderPoint(t.clientX, t.clientY)
|
|
if (dateStr) updateDrag(dateStr)
|
|
}
|
|
|
|
function onGlobalTouchEnd(e) {
|
|
if (!isDragging.value) {
|
|
removeGlobalTouchListeners()
|
|
return
|
|
}
|
|
const t = (e.changedTouches && e.changedTouches[0]) || (e.touches && e.touches[0])
|
|
if (t) {
|
|
const dateStr = getDateUnderPoint(t.clientX, t.clientY)
|
|
if (dateStr) {
|
|
const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
|
|
selection.value = { startDate, dayCount }
|
|
}
|
|
}
|
|
finalizeDragAndCreate()
|
|
}
|
|
|
|
function addGlobalTouchListeners() {
|
|
window.addEventListener('touchmove', onGlobalTouchMove, { passive: false })
|
|
window.addEventListener('touchend', onGlobalTouchEnd, { passive: false })
|
|
window.addEventListener('touchcancel', onGlobalTouchEnd, { passive: false })
|
|
}
|
|
|
|
function removeGlobalTouchListeners() {
|
|
window.removeEventListener('touchmove', onGlobalTouchMove)
|
|
window.removeEventListener('touchend', onGlobalTouchEnd)
|
|
window.removeEventListener('touchcancel', onGlobalTouchEnd)
|
|
}
|
|
|
|
// Fallback hit-test if elementFromPoint doesn't find a day cell (e.g., moving between rows).
|
|
function getDateFromCoordinates(clientX, clientY) {
|
|
if (!viewport.value) return null
|
|
const vpRect = viewport.value.getBoundingClientRect()
|
|
const yOffset = clientY - vpRect.top + viewport.value.scrollTop
|
|
if (yOffset < 0) return null
|
|
const rowIndex = Math.floor(yOffset / rowHeight.value)
|
|
const virtualWeek = minVirtualWeek.value + rowIndex
|
|
if (virtualWeek < minVirtualWeek.value || virtualWeek > maxVirtualWeek.value) return null
|
|
const sampleWeek = viewport.value.querySelector('.week-row')
|
|
if (!sampleWeek) return null
|
|
const labelEl = sampleWeek.querySelector('.week-label')
|
|
const jogwheelWidth = 48
|
|
const wrRect = sampleWeek.getBoundingClientRect()
|
|
const labelRight = labelEl ? labelEl.getBoundingClientRect().right : wrRect.left
|
|
const daysAreaRight = wrRect.right - jogwheelWidth
|
|
const daysWidth = daysAreaRight - labelRight
|
|
if (clientX < labelRight || clientX > daysAreaRight) return null
|
|
const col = Math.min(6, Math.max(0, Math.floor(((clientX - labelRight) / daysWidth) * 7)))
|
|
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
|
|
const targetDate = addDays(firstDay, col)
|
|
return toLocalString(targetDate, DEFAULT_TZ)
|
|
}
|
|
|
|
function calculateSelection(anchorStr, otherStr) {
|
|
const limit = calendarStore.config.select_days
|
|
const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
|
|
const otherDate = fromLocalString(otherStr, DEFAULT_TZ)
|
|
const forward = otherDate >= anchorDate
|
|
const span = daysInclusive(anchorStr, otherStr)
|
|
|
|
if (span <= limit) {
|
|
const startDate = forward ? anchorStr : otherStr
|
|
return { startDate, dayCount: span }
|
|
}
|
|
|
|
if (forward) {
|
|
return { startDate: anchorStr, dayCount: limit }
|
|
} else {
|
|
const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ)
|
|
return { startDate, dayCount: limit }
|
|
}
|
|
}
|
|
|
|
const onScroll = () => {
|
|
if (viewport.value) scrollTop.value = viewport.value.scrollTop
|
|
scheduleRebuild('scroll')
|
|
}
|
|
|
|
const handleJogwheelScrollTo = (newScrollTop) => {
|
|
if (viewport.value) {
|
|
viewport.value.scrollTop = newScrollTop
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
computeRowHeight()
|
|
calendarStore.updateCurrentDate()
|
|
|
|
if (viewport.value) {
|
|
viewportHeight.value = viewport.value.clientHeight
|
|
viewport.value.scrollTop = initialScrollTop.value
|
|
viewport.value.addEventListener('scroll', onScroll)
|
|
}
|
|
|
|
const timer = setInterval(() => {
|
|
calendarStore.updateCurrentDate()
|
|
}, 60000)
|
|
|
|
// Initial build after mount & measurement
|
|
scheduleRebuild('init')
|
|
|
|
onBeforeUnmount(() => {
|
|
clearInterval(timer)
|
|
})
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (viewport.value) {
|
|
viewport.value.removeEventListener('scroll', onScroll)
|
|
}
|
|
})
|
|
|
|
const handleDayMouseDown = (d) => {
|
|
d = normalizeDate(d)
|
|
if (Date.now() < suppressMouseUntil.value) return
|
|
if (registerTap(d, 'mouse')) startDrag(d)
|
|
}
|
|
const handleDayMouseEnter = (d) => updateDrag(normalizeDate(d))
|
|
const handleDayMouseUp = (d) => {
|
|
d = normalizeDate(d)
|
|
if (Date.now() < suppressMouseUntil.value && !isDragging.value) return
|
|
if (!isDragging.value) return
|
|
endDrag(d)
|
|
const ev = createEventFromSelection()
|
|
if (ev) {
|
|
clearSelection()
|
|
emit('create-event', ev)
|
|
}
|
|
}
|
|
const handleDayTouchStart = (d) => {
|
|
d = normalizeDate(d)
|
|
suppressMouseUntil.value = Date.now() + 800
|
|
if (registerTap(d, 'touch')) startDrag(d)
|
|
}
|
|
|
|
const handleEventClick = (payload) => {
|
|
emit('edit-event', payload)
|
|
}
|
|
|
|
const handleHeaderYearChange = ({ scrollTop: st }) => {
|
|
const maxScroll = contentHeight.value - viewportHeight.value
|
|
const clamped = Math.max(0, Math.min(st, isFinite(maxScroll) ? maxScroll : st))
|
|
scrollTop.value = clamped
|
|
viewport.value && (viewport.value.scrollTop = clamped)
|
|
}
|
|
|
|
function openSettings() {
|
|
settingsDialog.value?.open()
|
|
}
|
|
// Keep roughly same visible date when first_day setting changes.
|
|
watch(
|
|
() => calendarStore.config.first_day,
|
|
() => {
|
|
const currentTopVW = Math.floor(scrollTop.value / rowHeight.value) + minVirtualWeek.value
|
|
const currentTopDate = getFirstDayForVirtualWeek(currentTopVW)
|
|
requestAnimationFrame(() => {
|
|
const newTopWeekIndex = getWeekIndex(currentTopDate)
|
|
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
|
|
scrollTop.value = newScroll
|
|
if (viewport.value) viewport.value.scrollTop = newScroll
|
|
scheduleRebuild('first-day-change')
|
|
})
|
|
},
|
|
)
|
|
|
|
// Watch lightweight mutation counter only (not deep events map) and rebuild lazily
|
|
watch(
|
|
() => calendarStore.events,
|
|
() => {
|
|
scheduleRebuild('events')
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
// Rebuild if viewport height changes (e.g., resize)
|
|
window.addEventListener('resize', () => {
|
|
if (viewport.value) viewportHeight.value = viewport.value.clientHeight
|
|
scheduleRebuild('resize')
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="wrap">
|
|
<header>
|
|
<h1>Calendar</h1>
|
|
<div class="header-controls">
|
|
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
|
<button
|
|
type="button"
|
|
class="settings-btn"
|
|
@click="openSettings"
|
|
aria-label="Open settings"
|
|
title="Settings"
|
|
>
|
|
⚙
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<CalendarHeader
|
|
:scroll-top="scrollTop"
|
|
:row-height="rowHeight"
|
|
:min-virtual-week="minVirtualWeek"
|
|
@year-change="handleHeaderYearChange"
|
|
/>
|
|
<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"
|
|
:dragging="isDragging"
|
|
:style="{ top: week.top + 'px' }"
|
|
@day-mousedown="handleDayMouseDown"
|
|
@day-mouseenter="handleDayMouseEnter"
|
|
@day-mouseup="handleDayMouseUp"
|
|
@day-touchstart="handleDayTouchStart"
|
|
@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>
|
|
<SettingsDialog ref="settingsDialog" />
|
|
</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;
|
|
}
|
|
|
|
.settings-btn {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--muted);
|
|
padding: 0;
|
|
margin: 0;
|
|
margin-right: 0.6rem;
|
|
cursor: pointer;
|
|
font-size: 1.5rem;
|
|
line-height: 1;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
outline: none;
|
|
}
|
|
.settings-btn:hover {
|
|
color: var(--strong);
|
|
}
|
|
.settings-btn:focus-visible {
|
|
/* Keep visual accessibility without background */
|
|
outline: 2px solid var(--selected);
|
|
outline-offset: 2px;
|
|
}
|
|
.settings-btn:active {
|
|
transform: scale(0.88);
|
|
}
|
|
|
|
.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>
|