Major new version (#2)
Release Notes Architecture - Component refactor: removed monoliths (`Calendar.vue`, `CalendarGrid.vue`); expanded granular view + header/control components. - Dialog system introduced (`BaseDialog`, `SettingsDialog`). State & Data - Store redesigned: Map-based events + recurrence map; mutation counters. - Local persistence + undo/redo history (custom plugins). Date & Holidays - Migrated all date logic to `date-fns` (+ tz). - Added national holiday support (toggle + loading utilities). Recurrence & Events - Consolidated recurrence handling; fixes for monthly edge days (29–31), annual, multi‑day, and complex weekly repeats. - Reliable splitting/moving/resizing/deletion of repeating and multi‑day events. Interaction & UX - Double‑tap to create events; improved drag (multi‑day + position retention). - Scroll & inertial/momentum navigation; year change via numeric scroller. - Movable event dialog; live settings application. Performance - Progressive / virtual week rendering, reduced off‑screen buffer. - Targeted repaint strategy; minimized full re-renders. Plugins Added - History, undo normalization, persistence, scroll manager, virtual weeks. Styling & Layout - Responsive + compact layout refinements; header restructured. - Simplified visual elements (removed dots/overflow text); holiday styling adjustments. Reliability / Fixes - Numerous recurrence, deletion, orientation/rotation, and event indexing corrections. - Cross-browser fallback (Firefox week info). Dependencies Added - date-fns, date-fns-tz, date-holidays, pinia-plugin-persistedstate. Net Change - 28 files modified; ~4.4K insertions / ~2.2K deletions (major refactor + feature set).
This commit is contained in:
+400
-304
@@ -1,235 +1,172 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
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 HeaderControls from '@/components/HeaderControls.vue'
|
||||
import {
|
||||
isoWeekInfo,
|
||||
getLocalizedMonthName,
|
||||
monthAbbr,
|
||||
lunarPhaseSymbol,
|
||||
pad,
|
||||
daysInclusive,
|
||||
addDaysStr,
|
||||
formatDateRange,
|
||||
} from '@/utils/date'
|
||||
import { toLocalString, fromLocalString } from '@/utils/date'
|
||||
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'
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
const viewport = 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 viewport = ref(null)
|
||||
const viewportHeight = ref(600)
|
||||
const rowHeight = ref(64)
|
||||
const baseDate = new Date(1970, 0, 4 + calendarStore.config.first_day)
|
||||
|
||||
const rowProbe = ref(null)
|
||||
let rowProbeObserver = null
|
||||
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)
|
||||
}
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
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(calendarStore.minYear, 0, 1)
|
||||
const firstDayOfWeek = new Date(date)
|
||||
const date = new Date(MIN_YEAR, 0, 1)
|
||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
firstDayOfWeek.setDate(date.getDate() - dayOffset)
|
||||
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS)
|
||||
const firstDayOfWeek = addDays(date, -dayOffset)
|
||||
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
||||
})
|
||||
|
||||
const maxVirtualWeek = computed(() => {
|
||||
const date = new Date(calendarStore.maxYear, 11, 31)
|
||||
const firstDayOfWeek = new Date(date)
|
||||
const date = new Date(MAX_YEAR, 11, 31)
|
||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
firstDayOfWeek.setDate(date.getDate() - dayOffset)
|
||||
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS)
|
||||
const firstDayOfWeek = addDays(date, -dayOffset)
|
||||
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
// 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 } = 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 { isWeekColDragging, 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(--cell-h)'
|
||||
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 getWeekIndex(date) {
|
||||
const firstDayOfWeek = new Date(date)
|
||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
firstDayOfWeek.setDate(date.getDate() - dayOffset)
|
||||
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS)
|
||||
}
|
||||
|
||||
function getFirstDayForVirtualWeek(virtualWeek) {
|
||||
const firstDay = new Date(baseDate)
|
||||
firstDay.setDate(firstDay.getDate() + virtualWeek * 7)
|
||||
return firstDay
|
||||
}
|
||||
|
||||
function createWeek(virtualWeek) {
|
||||
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
|
||||
const weekNumber = isoWeekInfo(firstDay).week
|
||||
const days = []
|
||||
const cur = new Date(firstDay)
|
||||
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.startDate &&
|
||||
selection.value.dayCount > 0 &&
|
||||
dateStr >= selection.value.startDate &&
|
||||
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
||||
events: eventsForDay,
|
||||
})
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
|
||||
let monthLabel = null
|
||||
if (hasFirst && monthToLabel !== null) {
|
||||
if (labelYear && labelYear <= calendarStore.config.max_year) {
|
||||
let weeksSpan = 0
|
||||
const d = new Date(cur)
|
||||
d.setDate(cur.getDate() - 1)
|
||||
|
||||
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 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')
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
const { getWeekIndex, getFirstDayForVirtualWeek, goToToday, handleHeaderYearChange } = vwm
|
||||
|
||||
// createWeek logic moved to virtualWeeks plugin
|
||||
|
||||
// goToToday now provided by manager
|
||||
|
||||
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) {
|
||||
@@ -245,10 +182,102 @@ function endDrag(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()
|
||||
}
|
||||
|
||||
// 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
|
||||
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 limit = calendarStore.config.select_days
|
||||
const anchorDate = fromLocalString(anchorStr)
|
||||
const otherDate = fromLocalString(otherStr)
|
||||
const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
|
||||
const otherDate = fromLocalString(otherStr, DEFAULT_TZ)
|
||||
const forward = otherDate >= anchorDate
|
||||
const span = daysInclusive(anchorStr, otherStr)
|
||||
|
||||
@@ -260,21 +289,18 @@ function calculateSelection(anchorStr, otherStr) {
|
||||
if (forward) {
|
||||
return { startDate: anchorStr, dayCount: limit }
|
||||
} else {
|
||||
const startDate = addDaysStr(anchorStr, -(limit - 1))
|
||||
const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ)
|
||||
return { startDate, dayCount: limit }
|
||||
}
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
if (viewport.value) {
|
||||
scrollTop.value = viewport.value.scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
const handleJogwheelScrollTo = (newScrollTop) => {
|
||||
if (viewport.value) {
|
||||
viewport.value.scrollTop = newScrollTop
|
||||
}
|
||||
// ---------------- 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(() => {
|
||||
@@ -283,14 +309,27 @@ onMounted(() => {
|
||||
|
||||
if (viewport.value) {
|
||||
viewportHeight.value = viewport.value.clientHeight
|
||||
viewport.value.scrollTop = initialScrollTop.value
|
||||
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)
|
||||
})
|
||||
@@ -299,113 +338,157 @@ onMounted(() => {
|
||||
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 (e) {}
|
||||
}
|
||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
||||
})
|
||||
|
||||
const handleDayMouseDown = (dateStr) => {
|
||||
startDrag(dateStr)
|
||||
const handleDayMouseDown = (d) => {
|
||||
d = normalizeDate(d)
|
||||
if (Date.now() < suppressMouseUntil.value) return
|
||||
if (registerTap(d, 'mouse')) startDrag(d)
|
||||
}
|
||||
|
||||
const handleDayMouseEnter = (dateStr) => {
|
||||
if (isDragging.value) {
|
||||
updateDrag(dateStr)
|
||||
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 handleDayMouseUp = (dateStr) => {
|
||||
if (isDragging.value) {
|
||||
endDrag(dateStr)
|
||||
const eventData = createEventFromSelection()
|
||||
if (eventData) {
|
||||
clearSelection()
|
||||
emit('create-event', eventData)
|
||||
}
|
||||
}
|
||||
const handleDayTouchStart = (d) => {
|
||||
d = normalizeDate(d)
|
||||
suppressMouseUntil.value = Date.now() + 800
|
||||
if (registerTap(d, 'touch')) startDrag(d)
|
||||
}
|
||||
|
||||
const handleDayTouchStart = (dateStr) => {
|
||||
startDrag(dateStr)
|
||||
const handleEventClick = (payload) => {
|
||||
emit('edit-event', payload)
|
||||
}
|
||||
|
||||
const handleDayTouchMove = (dateStr) => {
|
||||
if (isDragging.value) {
|
||||
updateDrag(dateStr)
|
||||
}
|
||||
// header year change delegated to manager
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
const handleDayTouchEnd = (dateStr) => {
|
||||
if (isDragging.value) {
|
||||
endDrag(dateStr)
|
||||
const eventData = createEventFromSelection()
|
||||
if (eventData) {
|
||||
clearSelection()
|
||||
emit('create-event', eventData)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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')
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const handleEventClick = (eventInstanceId) => {
|
||||
emit('edit-event', eventInstanceId)
|
||||
}
|
||||
// Watch lightweight mutation counter only (not deep events map) and rebuild lazily
|
||||
watch(
|
||||
() => calendarStore.events,
|
||||
() => {
|
||||
refreshEvents('events')
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// Reflect selection & events by rebuilding day objects in-place
|
||||
watch(
|
||||
() => [selection.value.startDate, selection.value.dayCount],
|
||||
() => refreshEvents('selection'),
|
||||
)
|
||||
|
||||
// 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="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 class="calendar-view-root">
|
||||
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
||||
<div class="wrap">
|
||||
<HeaderControls @go-to-today="goToToday" />
|
||||
<CalendarHeader
|
||||
:scroll-top="scrollTop"
|
||||
:row-height="rowHeight"
|
||||
:min-virtual-week="minVirtualWeek"
|
||||
@year-change="handleHeaderYearChange"
|
||||
/>
|
||||
<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>
|
||||
<!-- Month column area -->
|
||||
<div class="month-column-area">
|
||||
<!-- Month labels -->
|
||||
<div class="month-labels-container" :style="{ height: contentHeight + 'px' }">
|
||||
<template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'">
|
||||
<div
|
||||
v-if="monthWeek && monthWeek.monthLabel"
|
||||
class="month-label"
|
||||
:class="monthWeek.monthLabel?.monthClass"
|
||||
:style="{
|
||||
height: `calc(var(--row-h) * ${monthWeek.monthLabel?.weeksSpan || 1})`,
|
||||
top: (monthWeek.top || 0) + 'px',
|
||||
}"
|
||||
@pointerdown="handleMonthScrollPointerDown"
|
||||
@touchstart.prevent="handleMonthScrollTouchStart"
|
||||
@wheel="handleMonthScrollWheel"
|
||||
>
|
||||
<span :class="{ bottomup: shouldRotateMonth(monthWeek.monthLabel?.text) }">{{
|
||||
monthWeek.monthLabel?.text || ''
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-view-root {
|
||||
display: contents;
|
||||
}
|
||||
.wrap {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
@@ -414,33 +497,15 @@ const handleEventClick = (eventInstanceId) => {
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 0.75rem 0.5rem 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
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);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-container {
|
||||
@@ -460,7 +525,13 @@ header h1 {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr var(--month-w);
|
||||
}
|
||||
|
||||
.main-calendar-area {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-content {
|
||||
@@ -468,27 +539,52 @@ header h1 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.month-name-label {
|
||||
.month-column-area {
|
||||
position: relative;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.month-labels-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 3rem; /* Match jogwheel width */
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2));
|
||||
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;
|
||||
overflow: hidden;
|
||||
cursor: ns-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.month-name-label > span {
|
||||
.month-label > span {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
transform: rotate(180deg);
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user