720 lines
22 KiB
Vue
720 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 HeaderControls from '@/components/HeaderControls.vue'
|
|
import Jogwheel from '@/components/Jogwheel.vue'
|
|
import {
|
|
createScrollManager,
|
|
createWeekColumnScrollManager,
|
|
createMonthScrollManager,
|
|
} from '@/plugins/scrollManager'
|
|
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()
|
|
defineEmits([]) // previously emitted create/edit events externally
|
|
import { shallowRef } from 'vue'
|
|
const eventDialogRef = shallowRef(null)
|
|
function openCreateEventDialog(eventData) {
|
|
if (!eventDialogRef.value) return
|
|
// Capture baseline before dialog opens (new event creation flow)
|
|
try {
|
|
calendarStore.$history?._baselineIfNeeded?.(true)
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount }
|
|
setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30)
|
|
}
|
|
function openEditEventDialog(eventClickPayload) {
|
|
// Capture baseline before editing existing event
|
|
try {
|
|
calendarStore.$history?._baselineIfNeeded?.(true)
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
eventDialogRef.value?.openEditDialog(eventClickPayload)
|
|
}
|
|
const viewport = ref(null)
|
|
const viewportHeight = ref(600)
|
|
const rowHeight = ref(64)
|
|
const rowProbe = ref(null)
|
|
let rowProbeObserver = null
|
|
|
|
// Scrolling blur effect
|
|
const blurAmount = ref(0) // pixels
|
|
let _lastBlurPos = 0
|
|
let _blurFrame = null
|
|
|
|
function _updateMotionBlur() {
|
|
const pos = scrollTop.value || 0
|
|
if (_lastBlurPos) blurAmount.value = Math.min(20, 0.5 * Math.abs(pos - _lastBlurPos))
|
|
if (!_lastBlurPos || blurAmount.value < 5) blurAmount.value = 0
|
|
_lastBlurPos = pos
|
|
_blurFrame = requestAnimationFrame(_updateMotionBlur)
|
|
}
|
|
|
|
const viewportBlurStyle = computed(() => {
|
|
return blurAmount.value > 0
|
|
? { filter: 'url(#cal-vert-blur)', willChange: 'filter' }
|
|
: { filter: 'none' }
|
|
})
|
|
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 contentHeight = computed(() => {
|
|
return totalVirtualWeeks.value * rowHeight.value
|
|
})
|
|
|
|
// Virtual weeks manager (after dependent refs exist)
|
|
const vwm = createVirtualWeekManager({
|
|
calendarStore,
|
|
viewport,
|
|
viewportHeight,
|
|
rowHeight,
|
|
selection,
|
|
baseDate,
|
|
minVirtualWeek,
|
|
maxVirtualWeek,
|
|
contentHeight,
|
|
})
|
|
const visibleWeeks = vwm.visibleWeeks
|
|
const { scheduleWindowUpdate, resetWeeks, refreshEvents, refreshHolidays } = vwm
|
|
|
|
// Scroll managers (after scheduleWindowUpdate available)
|
|
const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate })
|
|
const { scrollTop, setScrollTop, onScroll } = scrollManager
|
|
const weekColumnScrollManager = createWeekColumnScrollManager({
|
|
viewport,
|
|
viewportHeight,
|
|
contentHeight,
|
|
setScrollTop,
|
|
})
|
|
const { handleWeekColMouseDown, handlePointerLockChange } = weekColumnScrollManager
|
|
const monthScrollManager = createMonthScrollManager({
|
|
viewport,
|
|
viewportHeight,
|
|
contentHeight,
|
|
setScrollTop,
|
|
})
|
|
const { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel } =
|
|
monthScrollManager
|
|
|
|
// Provide scroll refs to virtual week manager
|
|
vwm.attachScroll(scrollTop, setScrollTop)
|
|
|
|
const initialScrollTop = computed(() => {
|
|
const nowDate = new Date(calendarStore.now)
|
|
const targetWeekIndex = getWeekIndex(nowDate) - 3
|
|
return (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
|
})
|
|
|
|
function computeRowHeight() {
|
|
if (rowProbe.value) {
|
|
const h = rowProbe.value.getBoundingClientRect().height || 64
|
|
rowHeight.value = Math.round(h)
|
|
return rowHeight.value
|
|
}
|
|
const el = document.createElement('div')
|
|
el.style.position = 'absolute'
|
|
el.style.visibility = 'hidden'
|
|
el.style.height = 'var(--row-h)'
|
|
document.body.appendChild(el)
|
|
const h = el.getBoundingClientRect().height || 64
|
|
el.remove()
|
|
rowHeight.value = Math.round(h)
|
|
return rowHeight.value
|
|
}
|
|
function measureFromProbe() {
|
|
if (!rowProbe.value) return
|
|
const h = rowProbe.value.getBoundingClientRect().height
|
|
if (!h) return
|
|
const newH = Math.round(h)
|
|
if (newH !== rowHeight.value) {
|
|
const oldH = rowHeight.value
|
|
// Anchor: keep the same top virtual week visible.
|
|
const topVirtualWeek = Math.floor(scrollTop.value / oldH) + minVirtualWeek.value
|
|
rowHeight.value = newH
|
|
const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH
|
|
setScrollTop(newScrollTop, 'row-height-change')
|
|
resetWeeks('row-height-change')
|
|
}
|
|
}
|
|
|
|
const {
|
|
getWeekIndex,
|
|
getFirstDayForVirtualWeek,
|
|
handleHeaderYearChange,
|
|
scrollToWeek,
|
|
} = vwm
|
|
|
|
function showDay(input) {
|
|
const dateStr = input instanceof Date ? toLocalString(input, DEFAULT_TZ) : String(input)
|
|
const weekIndex = getWeekIndex(fromLocalString(dateStr, DEFAULT_TZ))
|
|
scrollToWeek(weekIndex, 'nav', true)
|
|
const diff = Math.abs(weekIndex - centerVisibleWeek.value)
|
|
const delay = Math.min(800, diff * 40)
|
|
setTimeout(() => {
|
|
const el = document.querySelector(`[data-date="${dateStr}"]`)
|
|
if (!el) return
|
|
el.classList.add('search-highlight-flash')
|
|
setTimeout(() => el.classList.remove('search-highlight-flash'), 1500)
|
|
}, delay)
|
|
}
|
|
|
|
// Reference date for search: center of the current viewport (virtual week at vertical midpoint)
|
|
const centerVisibleWeek = computed(() => {
|
|
const midRow = (scrollTop.value + viewportHeight.value / 2) / rowHeight.value
|
|
return Math.floor(midRow) + minVirtualWeek.value
|
|
})
|
|
const centerVisibleDateStr = computed(() => {
|
|
try {
|
|
const d = getFirstDayForVirtualWeek(centerVisibleWeek.value)
|
|
return toLocalString(d, DEFAULT_TZ)
|
|
} catch {
|
|
return calendarStore.today
|
|
}
|
|
})
|
|
|
|
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,
|
|
],
|
|
() => {
|
|
// 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)
|
|
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()
|
|
openCreateEventDialog(eventData)
|
|
}
|
|
removeGlobalTouchListeners()
|
|
}
|
|
|
|
// Build a minimal event creation payload from current selection
|
|
// Returns null if selection is invalid or empty.
|
|
function createEventFromSelection() {
|
|
const sel = selection.value || {}
|
|
if (!sel.startDate || !sel.dayCount || sel.dayCount <= 0) return null
|
|
return {
|
|
startDate: sel.startDate,
|
|
dayCount: sel.dayCount,
|
|
}
|
|
}
|
|
|
|
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
|
|
if (e.cancelable) 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 wrRect = sampleWeek.getBoundingClientRect()
|
|
const labelRight = labelEl ? labelEl.getBoundingClientRect().right : wrRect.left
|
|
const daysAreaRight = wrRect.right
|
|
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 anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
|
|
const otherDate = fromLocalString(otherStr, DEFAULT_TZ)
|
|
const forward = otherDate >= anchorDate
|
|
const span = daysInclusive(anchorStr, otherStr)
|
|
|
|
const startDate = forward ? anchorStr : otherStr
|
|
return { startDate, dayCount: span }
|
|
}
|
|
|
|
onMounted(() => {
|
|
computeRowHeight()
|
|
calendarStore.updateCurrentDate()
|
|
|
|
if (viewport.value) {
|
|
viewportHeight.value = viewport.value.clientHeight
|
|
setScrollTop(initialScrollTop.value, 'initial-mount')
|
|
viewport.value.addEventListener('scroll', onScroll)
|
|
// Capture mousedown in viewport to allow dragging via week label column
|
|
viewport.value.addEventListener('mousedown', handleWeekColMouseDown, true)
|
|
}
|
|
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
|
|
|
const timer = setInterval(() => {
|
|
calendarStore.updateCurrentDate()
|
|
}, 60000)
|
|
|
|
// Initial incremental build (no existing weeks yet)
|
|
scheduleWindowUpdate('init')
|
|
|
|
if (window.ResizeObserver && rowProbe.value) {
|
|
rowProbeObserver = new ResizeObserver(() => {
|
|
measureFromProbe()
|
|
})
|
|
rowProbeObserver.observe(rowProbe.value)
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
clearInterval(timer)
|
|
})
|
|
|
|
// Start motion blur loop
|
|
_lastBlurPos = scrollTop.value || 0
|
|
_blurFrame = requestAnimationFrame(_updateMotionBlur)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (viewport.value) {
|
|
viewport.value.removeEventListener('scroll', onScroll)
|
|
viewport.value.removeEventListener('mousedown', handleWeekColMouseDown, true)
|
|
}
|
|
if (rowProbeObserver && rowProbe.value) {
|
|
try {
|
|
rowProbeObserver.unobserve(rowProbe.value)
|
|
rowProbeObserver.disconnect()
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
if (_blurFrame) cancelAnimationFrame(_blurFrame)
|
|
})
|
|
|
|
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()
|
|
openCreateEventDialog(ev)
|
|
}
|
|
}
|
|
const handleDayTouchStart = (d) => {
|
|
d = normalizeDate(d)
|
|
suppressMouseUntil.value = Date.now() + 800
|
|
if (registerTap(d, 'touch')) startDrag(d)
|
|
}
|
|
|
|
const handleEventClick = (payload) => {
|
|
openEditEventDialog(payload)
|
|
}
|
|
|
|
function handleHeaderSearchPreview(r) { if (r) showDay(r.startDate) }
|
|
function handleHeaderSearchActivate(r) {
|
|
if (!r) return
|
|
showDay(r.startDate)
|
|
if (!r._goto && !r._holiday) {
|
|
const ev = calendarStore.getEventById(r.id)
|
|
if (ev) openEditEventDialog({ id: ev.id, event: ev })
|
|
}
|
|
}
|
|
|
|
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
|
// We explicitly avoid locale detection; rely solely on characters present.
|
|
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
|
|
function shouldRotateMonth(label) {
|
|
if (!label) return false
|
|
return /\p{Script=Latin}/u.test(label)
|
|
}
|
|
|
|
// Watch first day changes (e.g., first_day config update) to adjust scroll
|
|
// 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
|
|
setScrollTop(newScroll, 'first-day-change')
|
|
resetWeeks('first-day-change')
|
|
})
|
|
},
|
|
)
|
|
|
|
// Event changes (optimized): react to mutation counter & targeted range payload
|
|
watch(
|
|
() => calendarStore.events,
|
|
() => refreshEvents('events'),
|
|
{ deep: true },
|
|
)
|
|
|
|
// Reflect selection & events by rebuilding day objects in-place
|
|
watch(
|
|
() => [selection.value.startDate, selection.value.dayCount],
|
|
([start, count]) => {
|
|
const hasSel = !!start && !!count && count > 0
|
|
const end = hasSel ? addDaysStr(start, count, DEFAULT_TZ) : null
|
|
for (const w of visibleWeeks.value)
|
|
for (const d of w.days) d.isSelected = hasSel && d.date >= start && d.date < end
|
|
},
|
|
)
|
|
|
|
// Rebuild if viewport height changes (e.g., resize)
|
|
window.addEventListener('resize', () => {
|
|
if (viewport.value) viewportHeight.value = viewport.value.clientHeight
|
|
measureFromProbe()
|
|
scheduleWindowUpdate('resize')
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="calendar-view-root" :dir="rtl && 'rtl'">
|
|
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
|
<!-- Inline SVG filter for vertical motion blur -->
|
|
<svg width="0" height="0" aria-hidden="true" focusable="false" class="motion-blur-defs">
|
|
<defs>
|
|
<!-- stdDeviation: x y; keep a tiny epsilon on X so some browsers don't drop the filter entirely -->
|
|
<filter id="cal-vert-blur" color-interpolation-filters="sRGB" x="-10%" width="120%" y="-10%" height="120%">
|
|
<feGaussianBlur :stdDeviation="`${0.001} ${blurAmount.toFixed(2)}`" edgeMode="duplicate" />
|
|
</filter>
|
|
</defs>
|
|
</svg>
|
|
<div class="wrap">
|
|
<HeaderControls
|
|
:reference-date="centerVisibleDateStr"
|
|
@go-to-today="() => showDay(calendarStore.today)"
|
|
@search-preview="handleHeaderSearchPreview"
|
|
@search-activate="handleHeaderSearchActivate"
|
|
/>
|
|
<CalendarHeader
|
|
:scroll-top="scrollTop"
|
|
:row-height="rowHeight"
|
|
:min-virtual-week="minVirtualWeek"
|
|
@year-change="handleHeaderYearChange"
|
|
/>
|
|
<div class="calendar-container">
|
|
<div class="calendar-viewport" ref="viewport" :style="viewportBlurStyle">
|
|
<div class="month-column-area" :style="{ height: contentHeight + 'px' }">
|
|
<div class="month-labels-container" :style="{ height: '100%' }">
|
|
<div
|
|
class="month-labels-wrapper"
|
|
:style="{
|
|
transform: `translateY(${visibleWeeks.length ? visibleWeeks[0].top : 0}px)`,
|
|
gridTemplateRows: `repeat(${visibleWeeks.length}, var(--row-h))`,
|
|
}"
|
|
>
|
|
<template v-for="(monthWeek, i) in visibleWeeks" :key="monthWeek.virtualWeek + '-month'">
|
|
<div
|
|
v-if="monthWeek && monthWeek.monthLabel"
|
|
class="month-label"
|
|
:class="monthWeek.monthLabel?.monthClass"
|
|
:style="{ gridRow: `${i + 1} / span ${monthWeek.monthLabel?.weeksSpan || 1}` }"
|
|
@pointerdown="handleMonthScrollPointerDown"
|
|
@touchstart.prevent="handleMonthScrollTouchStart"
|
|
@wheel="handleMonthScrollWheel"
|
|
>
|
|
<span :class="{ bottomup: shouldRotateMonth(monthWeek.monthLabel?.text) }">{{
|
|
monthWeek.monthLabel?.text || ''
|
|
}}</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
|
<div
|
|
class="weeks-wrapper"
|
|
:style="{
|
|
transform: `translateY(${visibleWeeks.length ? visibleWeeks[0].top : 0}px)`,
|
|
}"
|
|
>
|
|
<CalendarWeek
|
|
v-for="week in visibleWeeks"
|
|
:key="week.virtualWeek"
|
|
:week="week"
|
|
:dragging="isDragging"
|
|
@day-mousedown="handleDayMouseDown"
|
|
@day-mouseenter="handleDayMouseEnter"
|
|
@day-mouseup="handleDayMouseUp"
|
|
@day-touchstart="handleDayTouchStart"
|
|
@event-click="handleEventClick"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Jogwheel overlay captures drag + wheel over month name column -->
|
|
<Jogwheel
|
|
:total-virtual-weeks="totalVirtualWeeks"
|
|
:row-height="rowHeight"
|
|
:viewport-height="viewportHeight"
|
|
:scroll-top="scrollTop"
|
|
@scroll-to="(v) => setScrollTop(v, 'jogwheel')"
|
|
/>
|
|
</div>
|
|
<EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.calendar-view-root {
|
|
display: contents;
|
|
}
|
|
.wrap {
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1.25rem;
|
|
padding: 0.75rem 0.5rem 0.25rem 0.5rem;
|
|
}
|
|
header h1 {
|
|
margin: 0;
|
|
padding: 0;
|
|
font-size: 1.6rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.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;
|
|
display: grid;
|
|
grid-template-columns: 1fr var(--month-w);
|
|
}
|
|
|
|
.calendar-content {
|
|
position: relative;
|
|
width: 100%;
|
|
grid-column: 1;
|
|
grid-row: 1;
|
|
}
|
|
|
|
.weeks-wrapper {
|
|
position: absolute;
|
|
inset: 0 auto auto 0;
|
|
width: 100%;
|
|
will-change: transform;
|
|
}
|
|
|
|
.month-column-area {
|
|
position: relative;
|
|
cursor: ns-resize;
|
|
grid-column: 2;
|
|
grid-row: 1;
|
|
}
|
|
|
|
.month-labels-container {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.month-labels-wrapper {
|
|
position: absolute;
|
|
inset: 0 auto auto 0;
|
|
width: 100%;
|
|
will-change: transform;
|
|
display: grid;
|
|
grid-auto-flow: row;
|
|
}
|
|
|
|
.month-label {
|
|
width: 100%;
|
|
opacity: 0.8;
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
cursor: ns-resize;
|
|
user-select: none;
|
|
touch-action: none;
|
|
}
|
|
|
|
.month-label > span {
|
|
display: inline-block;
|
|
white-space: nowrap;
|
|
writing-mode: vertical-rl;
|
|
text-orientation: mixed;
|
|
transform-origin: center;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.bottomup {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.row-height-probe {
|
|
position: absolute;
|
|
visibility: hidden;
|
|
height: var(--row-h);
|
|
pointer-events: none;
|
|
}
|
|
</style>
|