Major new version #2
@ -9,64 +9,25 @@ import {
|
|||||||
createWeekColumnScrollManager,
|
createWeekColumnScrollManager,
|
||||||
createMonthScrollManager,
|
createMonthScrollManager,
|
||||||
} from '@/plugins/scrollManager'
|
} from '@/plugins/scrollManager'
|
||||||
import {
|
import { daysInclusive, addDaysStr, MIN_YEAR, MAX_YEAR } from '@/utils/date'
|
||||||
getLocalizedMonthName,
|
|
||||||
monthAbbr,
|
|
||||||
lunarPhaseSymbol,
|
|
||||||
pad,
|
|
||||||
daysInclusive,
|
|
||||||
addDaysStr,
|
|
||||||
formatDateRange,
|
|
||||||
getOccurrenceIndex,
|
|
||||||
getVirtualOccurrenceEndDate,
|
|
||||||
getISOWeek,
|
|
||||||
MIN_YEAR,
|
|
||||||
MAX_YEAR,
|
|
||||||
} from '@/utils/date'
|
|
||||||
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
|
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
|
||||||
import { addDays, differenceInCalendarDays, differenceInWeeks } from 'date-fns'
|
import { addDays, differenceInWeeks } from 'date-fns'
|
||||||
import { getHolidayForDate } from '@/utils/holidays'
|
import { createVirtualWeekManager } from '@/plugins/virtualWeeks'
|
||||||
|
|
||||||
const calendarStore = useCalendarStore()
|
const calendarStore = useCalendarStore()
|
||||||
const viewport = ref(null)
|
|
||||||
|
|
||||||
const emit = defineEmits(['create-event', 'edit-event'])
|
const emit = defineEmits(['create-event', 'edit-event'])
|
||||||
|
const viewport = ref(null)
|
||||||
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 viewportHeight = ref(600)
|
const viewportHeight = ref(600)
|
||||||
const rowHeight = ref(64)
|
const rowHeight = ref(64)
|
||||||
const rowProbe = ref(null)
|
const rowProbe = ref(null)
|
||||||
let rowProbeObserver = null
|
let rowProbeObserver = null
|
||||||
|
|
||||||
const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))
|
const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))
|
||||||
|
|
||||||
const selection = ref({ startDate: null, dayCount: 0 })
|
const selection = ref({ startDate: null, dayCount: 0 })
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const dragAnchor = ref(null)
|
const dragAnchor = ref(null)
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [calendarStore.selectedDate, calendarStore.rangeStartDate],
|
|
||||||
() => {
|
|
||||||
if (calendarStore.selectedDate || calendarStore.rangeStartDate) {
|
|
||||||
scheduleDataRebuild('selection-change')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ flush: 'sync' },
|
|
||||||
)
|
|
||||||
|
|
||||||
const DOUBLE_TAP_DELAY = 300
|
const DOUBLE_TAP_DELAY = 300
|
||||||
const pendingTap = ref({ date: null, time: 0, type: null })
|
const pendingTap = ref({ date: null, time: 0, type: null })
|
||||||
const suppressMouseUntil = ref(0)
|
const suppressMouseUntil = ref(0)
|
||||||
|
|
||||||
function normalizeDate(val) {
|
function normalizeDate(val) {
|
||||||
if (typeof val === 'string') return val
|
if (typeof val === 'string') return val
|
||||||
if (val && typeof val === 'object') {
|
if (val && typeof val === 'object') {
|
||||||
@ -113,196 +74,50 @@ const contentHeight = computed(() => {
|
|||||||
return totalVirtualWeeks.value * rowHeight.value
|
return totalVirtualWeeks.value * rowHeight.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const visibleWeeks = ref([])
|
// Virtual weeks manager (after dependent refs exist)
|
||||||
let lastScrollRange = { startVW: null, endVW: null }
|
const vwm = createVirtualWeekManager({
|
||||||
let updating = 0 // 0 idle, 1 window incremental, 2 full rebuild
|
calendarStore,
|
||||||
function scheduleWindowUpdate(reason) {
|
viewport,
|
||||||
if (updating !== 0) return
|
viewportHeight,
|
||||||
updating = 1
|
rowHeight,
|
||||||
const run = () => {
|
selection,
|
||||||
let complete = true
|
baseDate,
|
||||||
try {
|
minVirtualWeek,
|
||||||
complete = updateVisibleWeeks(reason)
|
maxVirtualWeek,
|
||||||
} finally {
|
contentHeight,
|
||||||
updating = 0
|
})
|
||||||
}
|
const visibleWeeks = vwm.visibleWeeks
|
||||||
if (!complete) scheduleWindowUpdate('incremental-build')
|
const { scheduleWindowUpdate, resetWeeks } = vwm
|
||||||
}
|
|
||||||
if ('requestIdleCallback' in window) requestIdleCallback(run, { timeout: 16 })
|
|
||||||
else requestAnimationFrame(run)
|
|
||||||
}
|
|
||||||
function scheduleDataRebuild(reason) {
|
|
||||||
if (updating === 2) return // already rebuilding
|
|
||||||
const doRebuild = () => {
|
|
||||||
updating = 2
|
|
||||||
const run = () => {
|
|
||||||
try {
|
|
||||||
rebuildVisibleWeeks(reason)
|
|
||||||
} finally {
|
|
||||||
updating = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ('requestIdleCallback' in window) requestIdleCallback(run, { timeout: 60 })
|
|
||||||
else requestAnimationFrame(run)
|
|
||||||
}
|
|
||||||
// If we're mid incremental window update, defer slightly to next frame
|
|
||||||
if (updating === 1) {
|
|
||||||
requestAnimationFrame(doRebuild)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
doRebuild()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Scroll managers (after scheduleWindowUpdate available)
|
||||||
const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate })
|
const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate })
|
||||||
|
|
||||||
const { scrollTop, setScrollTop, onScroll } = scrollManager
|
const { scrollTop, setScrollTop, onScroll } = scrollManager
|
||||||
|
|
||||||
const weekColumnScrollManager = createWeekColumnScrollManager({
|
const weekColumnScrollManager = createWeekColumnScrollManager({
|
||||||
viewport,
|
viewport,
|
||||||
viewportHeight,
|
viewportHeight,
|
||||||
contentHeight,
|
contentHeight,
|
||||||
setScrollTop,
|
setScrollTop,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } =
|
const { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } =
|
||||||
weekColumnScrollManager
|
weekColumnScrollManager
|
||||||
|
|
||||||
const monthScrollManager = createMonthScrollManager({
|
const monthScrollManager = createMonthScrollManager({
|
||||||
viewport,
|
viewport,
|
||||||
viewportHeight,
|
viewportHeight,
|
||||||
contentHeight,
|
contentHeight,
|
||||||
setScrollTop,
|
setScrollTop,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel } =
|
const { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel } =
|
||||||
monthScrollManager
|
monthScrollManager
|
||||||
|
|
||||||
|
// Provide scroll refs to virtual week manager
|
||||||
|
vwm.attachScroll(scrollTop, setScrollTop)
|
||||||
|
|
||||||
const initialScrollTop = computed(() => {
|
const initialScrollTop = computed(() => {
|
||||||
const nowDate = new Date(calendarStore.now)
|
const nowDate = new Date(calendarStore.now)
|
||||||
const targetWeekIndex = getWeekIndex(nowDate) - 3
|
const targetWeekIndex = getWeekIndex(nowDate) - 3
|
||||||
return (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
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),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function updateVisibleWeeks(reason) {
|
|
||||||
// Compute desired virtual week window with buffer
|
|
||||||
const buffer = 4
|
|
||||||
const currentScrollTop = viewport.value?.scrollTop ?? scrollTop.value
|
|
||||||
const startIdx = Math.floor((currentScrollTop - buffer * rowHeight.value) / rowHeight.value)
|
|
||||||
const endIdx = Math.ceil(
|
|
||||||
(currentScrollTop + 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)
|
|
||||||
|
|
||||||
// Step 1: prune anything outside the desired window
|
|
||||||
if (visibleWeeks.value.length) {
|
|
||||||
while (visibleWeeks.value.length && visibleWeeks.value[0].virtualWeek < startVW) {
|
|
||||||
visibleWeeks.value.shift()
|
|
||||||
}
|
|
||||||
while (
|
|
||||||
visibleWeeks.value.length &&
|
|
||||||
visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek > endVW
|
|
||||||
) {
|
|
||||||
visibleWeeks.value.pop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: ensure no gaps; add at most one adjacent missing week each pass
|
|
||||||
let added = false
|
|
||||||
const len = visibleWeeks.value.length
|
|
||||||
if (len === 0) {
|
|
||||||
visibleWeeks.value.push(createWeek(startVW))
|
|
||||||
added = true
|
|
||||||
} else {
|
|
||||||
// Sort defensively (should already be sorted)
|
|
||||||
visibleWeeks.value.sort((a, b) => a.virtualWeek - b.virtualWeek)
|
|
||||||
const firstVW = visibleWeeks.value[0].virtualWeek
|
|
||||||
const lastVW = visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek
|
|
||||||
if (firstVW > startVW) {
|
|
||||||
// Add one week just before current first to close front gap gradually
|
|
||||||
visibleWeeks.value.unshift(createWeek(firstVW - 1))
|
|
||||||
added = true
|
|
||||||
} else {
|
|
||||||
// Look for first internal gap
|
|
||||||
let gapInserted = false
|
|
||||||
for (let i = 0; i < visibleWeeks.value.length - 1; i++) {
|
|
||||||
const curVW = visibleWeeks.value[i].virtualWeek
|
|
||||||
const nextVW = visibleWeeks.value[i + 1].virtualWeek
|
|
||||||
if (nextVW - curVW > 1 && curVW < endVW) {
|
|
||||||
// Insert the immediate missing week after curVW
|
|
||||||
visibleWeeks.value.splice(i + 1, 0, createWeek(curVW + 1))
|
|
||||||
added = true
|
|
||||||
gapInserted = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!gapInserted && lastVW < endVW) {
|
|
||||||
// Extend at end
|
|
||||||
visibleWeeks.value.push(createWeek(lastVW + 1))
|
|
||||||
added = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: assess coverage
|
|
||||||
const firstAfter = visibleWeeks.value[0].virtualWeek
|
|
||||||
const lastAfter = visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek
|
|
||||||
const contiguous = (() => {
|
|
||||||
for (let i = 0; i < visibleWeeks.value.length - 1; i++) {
|
|
||||||
if (visibleWeeks.value[i + 1].virtualWeek !== visibleWeeks.value[i].virtualWeek + 1)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})()
|
|
||||||
const coverageComplete =
|
|
||||||
firstAfter <= startVW &&
|
|
||||||
lastAfter >= endVW &&
|
|
||||||
contiguous &&
|
|
||||||
visibleWeeks.value.length === endVW - startVW + 1
|
|
||||||
if (!coverageComplete) {
|
|
||||||
// Incomplete; do not update lastScrollRange so subsequent runs keep adding
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
lastScrollRange.startVW === startVW &&
|
|
||||||
lastScrollRange.endVW === endVW &&
|
|
||||||
!added &&
|
|
||||||
visibleWeeks.value.length
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
lastScrollRange = { startVW, endVW }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
function rebuildVisibleWeeks(reason) {
|
|
||||||
const buffer = 4
|
|
||||||
const currentScrollTop = viewport.value?.scrollTop ?? scrollTop.value
|
|
||||||
const startIdx = Math.floor((currentScrollTop - buffer * rowHeight.value) / rowHeight.value)
|
|
||||||
const endIdx = Math.ceil(
|
|
||||||
(currentScrollTop + 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))
|
|
||||||
visibleWeeks.value = weeks
|
|
||||||
lastScrollRange = { startVW, endVW }
|
|
||||||
console.debug('[CalendarView] rebuildVisibleWeeks', {
|
|
||||||
reason,
|
|
||||||
startVW,
|
|
||||||
endVW,
|
|
||||||
count: weeks.length,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeRowHeight() {
|
function computeRowHeight() {
|
||||||
if (rowProbe.value) {
|
if (rowProbe.value) {
|
||||||
const h = rowProbe.value.getBoundingClientRect().height || 64
|
const h = rowProbe.value.getBoundingClientRect().height || 64
|
||||||
@ -331,179 +146,15 @@ function measureFromProbe() {
|
|||||||
rowHeight.value = newH
|
rowHeight.value = newH
|
||||||
const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH
|
const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH
|
||||||
setScrollTop(newScrollTop, 'row-height-change')
|
setScrollTop(newScrollTop, 'row-height-change')
|
||||||
scheduleDataRebuild('row-height-change')
|
resetWeeks('row-height-change')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWeekIndex(date) {
|
const { getWeekIndex, getFirstDayForVirtualWeek, goToToday, handleHeaderYearChange } = vwm
|
||||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
|
||||||
const firstDayOfWeek = addDays(date, -dayOffset)
|
|
||||||
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFirstDayForVirtualWeek(virtualWeek) {
|
// createWeek logic moved to virtualWeeks plugin
|
||||||
return addDays(baseDate.value, virtualWeek * 7)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWeek(virtualWeek) {
|
// goToToday now provided by manager
|
||||||
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,
|
|
||||||
monthClass: monthAbbr[monthToLabel],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
|
||||||
setScrollTop(newScrollTop, 'go-to-today')
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
selection.value = { startDate: null, dayCount: 0 }
|
selection.value = { startDate: null, dayCount: 0 }
|
||||||
@ -658,8 +309,8 @@ onMounted(() => {
|
|||||||
calendarStore.updateCurrentDate()
|
calendarStore.updateCurrentDate()
|
||||||
}, 60000)
|
}, 60000)
|
||||||
|
|
||||||
// Initial build after mount & measurement
|
// Initial incremental build (no existing weeks yet)
|
||||||
scheduleDataRebuild('init')
|
scheduleWindowUpdate('init')
|
||||||
|
|
||||||
if (window.ResizeObserver && rowProbe.value) {
|
if (window.ResizeObserver && rowProbe.value) {
|
||||||
rowProbeObserver = new ResizeObserver(() => {
|
rowProbeObserver = new ResizeObserver(() => {
|
||||||
@ -714,24 +365,14 @@ const handleEventClick = (payload) => {
|
|||||||
emit('edit-event', payload)
|
emit('edit-event', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHeaderYearChange = ({ scrollTop: st }) => {
|
// header year change delegated to manager
|
||||||
const maxScroll = contentHeight.value - viewportHeight.value
|
|
||||||
const clamped = Math.max(0, Math.min(st, isFinite(maxScroll) ? maxScroll : st))
|
|
||||||
setScrollTop(clamped, 'header-year-change')
|
|
||||||
// Force a full rebuild so the new year range appears instantly
|
|
||||||
scheduleDataRebuild('header-year-change')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
||||||
// We explicitly avoid locale detection; rely solely on characters present.
|
// We explicitly avoid locale detection; rely solely on characters present.
|
||||||
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
|
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
|
||||||
function shouldRotateMonth(label) {
|
function shouldRotateMonth(label) {
|
||||||
if (!label) return false
|
if (!label) return false
|
||||||
try {
|
return /\p{Script=Latin}/u.test(label)
|
||||||
return /\p{Script=Latin}/u.test(label)
|
|
||||||
} catch (e) {
|
|
||||||
return /[A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/u.test(label)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch first day changes (e.g., first_day config update) to adjust scroll
|
// Watch first day changes (e.g., first_day config update) to adjust scroll
|
||||||
@ -745,7 +386,7 @@ watch(
|
|||||||
const newTopWeekIndex = getWeekIndex(currentTopDate)
|
const newTopWeekIndex = getWeekIndex(currentTopDate)
|
||||||
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
|
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||||
setScrollTop(newScroll, 'first-day-change')
|
setScrollTop(newScroll, 'first-day-change')
|
||||||
scheduleDataRebuild('first-day-change')
|
resetWeeks('first-day-change')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -754,7 +395,7 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => calendarStore.events,
|
() => calendarStore.events,
|
||||||
() => {
|
() => {
|
||||||
scheduleDataRebuild('events')
|
resetWeeks('events')
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
@ -768,56 +409,58 @@ window.addEventListener('resize', () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
<div class="calendar-view-root">
|
||||||
<div class="wrap">
|
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
||||||
<HeaderControls @go-to-today="goToToday" />
|
<div class="wrap">
|
||||||
<CalendarHeader
|
<HeaderControls @go-to-today="goToToday" />
|
||||||
:scroll-top="scrollTop"
|
<CalendarHeader
|
||||||
:row-height="rowHeight"
|
:scroll-top="scrollTop"
|
||||||
:min-virtual-week="minVirtualWeek"
|
:row-height="rowHeight"
|
||||||
@year-change="handleHeaderYearChange"
|
:min-virtual-week="minVirtualWeek"
|
||||||
/>
|
@year-change="handleHeaderYearChange"
|
||||||
<div class="calendar-container">
|
/>
|
||||||
<div class="calendar-viewport" ref="viewport">
|
<div class="calendar-container">
|
||||||
<!-- Main calendar content (weeks and days) -->
|
<div class="calendar-viewport" ref="viewport">
|
||||||
<div class="main-calendar-area">
|
<!-- Main calendar content (weeks and days) -->
|
||||||
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
<div class="main-calendar-area">
|
||||||
<CalendarWeek
|
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
||||||
v-for="week in visibleWeeks"
|
<CalendarWeek
|
||||||
:key="week.virtualWeek"
|
v-for="week in visibleWeeks"
|
||||||
:week="week"
|
:key="week.virtualWeek"
|
||||||
:dragging="isDragging"
|
:week="week"
|
||||||
:style="{ top: week.top + 'px' }"
|
:dragging="isDragging"
|
||||||
@day-mousedown="handleDayMouseDown"
|
:style="{ top: week.top + 'px' }"
|
||||||
@day-mouseenter="handleDayMouseEnter"
|
@day-mousedown="handleDayMouseDown"
|
||||||
@day-mouseup="handleDayMouseUp"
|
@day-mouseenter="handleDayMouseEnter"
|
||||||
@day-touchstart="handleDayTouchStart"
|
@day-mouseup="handleDayMouseUp"
|
||||||
@event-click="handleEventClick"
|
@day-touchstart="handleDayTouchStart"
|
||||||
/>
|
@event-click="handleEventClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Month column area -->
|
||||||
<!-- Month column area -->
|
<div class="month-column-area">
|
||||||
<div class="month-column-area">
|
<!-- Month labels -->
|
||||||
<!-- Month labels -->
|
<div class="month-labels-container" :style="{ height: contentHeight + 'px' }">
|
||||||
<div class="month-labels-container" :style="{ height: contentHeight + 'px' }">
|
<template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'">
|
||||||
<template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'">
|
<div
|
||||||
<div
|
v-if="monthWeek && monthWeek.monthLabel"
|
||||||
v-if="monthWeek && monthWeek.monthLabel"
|
class="month-label"
|
||||||
class="month-label"
|
:class="monthWeek.monthLabel?.monthClass"
|
||||||
:class="monthWeek.monthLabel?.monthClass"
|
:style="{
|
||||||
:style="{
|
height: `calc(var(--row-h) * ${monthWeek.monthLabel?.weeksSpan || 1})`,
|
||||||
height: `calc(var(--row-h) * ${monthWeek.monthLabel?.weeksSpan || 1})`,
|
top: (monthWeek.top || 0) + 'px',
|
||||||
top: (monthWeek.top || 0) + 'px',
|
}"
|
||||||
}"
|
@pointerdown="handleMonthScrollPointerDown"
|
||||||
@pointerdown="handleMonthScrollPointerDown"
|
@touchstart.prevent="handleMonthScrollTouchStart"
|
||||||
@touchstart.prevent="handleMonthScrollTouchStart"
|
@wheel="handleMonthScrollWheel"
|
||||||
@wheel="handleMonthScrollWheel"
|
>
|
||||||
>
|
<span :class="{ bottomup: shouldRotateMonth(monthWeek.monthLabel?.text) }">{{
|
||||||
<span :class="{ bottomup: shouldRotateMonth(monthWeek.monthLabel?.text) }">{{
|
monthWeek.monthLabel?.text || ''
|
||||||
monthWeek.monthLabel?.text || ''
|
}}</span>
|
||||||
}}</span>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -826,6 +469,9 @@ window.addEventListener('resize', () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.calendar-view-root {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
.wrap {
|
.wrap {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
309
src/plugins/virtualWeeks.js
Normal file
309
src/plugins/virtualWeeks.js
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { addDays, differenceInWeeks, differenceInCalendarDays } from 'date-fns'
|
||||||
|
import {
|
||||||
|
toLocalString,
|
||||||
|
fromLocalString,
|
||||||
|
DEFAULT_TZ,
|
||||||
|
getISOWeek,
|
||||||
|
addDaysStr,
|
||||||
|
pad,
|
||||||
|
getLocalizedMonthName,
|
||||||
|
monthAbbr,
|
||||||
|
lunarPhaseSymbol,
|
||||||
|
MAX_YEAR,
|
||||||
|
getOccurrenceIndex,
|
||||||
|
getVirtualOccurrenceEndDate,
|
||||||
|
} from '@/utils/date'
|
||||||
|
import { getHolidayForDate } from '@/utils/holidays'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory handling virtual week window & incremental building.
|
||||||
|
* Exposes reactive visibleWeeks plus scheduling functions.
|
||||||
|
*/
|
||||||
|
export function createVirtualWeekManager({
|
||||||
|
calendarStore,
|
||||||
|
viewport,
|
||||||
|
viewportHeight,
|
||||||
|
rowHeight,
|
||||||
|
selection,
|
||||||
|
baseDate,
|
||||||
|
minVirtualWeek,
|
||||||
|
maxVirtualWeek,
|
||||||
|
contentHeight, // not currently used inside manager but kept for future
|
||||||
|
}) {
|
||||||
|
const visibleWeeks = ref([])
|
||||||
|
let lastScrollRange = { startVW: null, endVW: null }
|
||||||
|
let updating = false
|
||||||
|
// Scroll refs injected later to break cyclic dependency with scroll manager
|
||||||
|
let scrollTopRef = null
|
||||||
|
let setScrollTopFn = null
|
||||||
|
|
||||||
|
function attachScroll(scrollTop, setScrollTop) {
|
||||||
|
scrollTopRef = scrollTop
|
||||||
|
setScrollTopFn = setScrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
for (const base of repeatingBases) {
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
monthClass: monthAbbr[monthToLabel],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
virtualWeek,
|
||||||
|
weekNumber: pad(weekNumber),
|
||||||
|
days,
|
||||||
|
monthLabel,
|
||||||
|
top: (virtualWeek - minVirtualWeek.value) * rowHeight.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function internalWindowCalc() {
|
||||||
|
const buffer = 6
|
||||||
|
const currentScrollTop = viewport.value?.scrollTop ?? scrollTopRef?.value ?? 0
|
||||||
|
const startIdx = Math.floor((currentScrollTop - buffer * rowHeight.value) / rowHeight.value)
|
||||||
|
const endIdx = Math.ceil(
|
||||||
|
(currentScrollTop + 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)
|
||||||
|
return { startVW, endVW }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVisibleWeeks(_reason) {
|
||||||
|
const { startVW, endVW } = internalWindowCalc()
|
||||||
|
// Prune outside
|
||||||
|
if (visibleWeeks.value.length) {
|
||||||
|
while (visibleWeeks.value.length && visibleWeeks.value[0].virtualWeek < startVW) {
|
||||||
|
visibleWeeks.value.shift()
|
||||||
|
}
|
||||||
|
while (
|
||||||
|
visibleWeeks.value.length &&
|
||||||
|
visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek > endVW
|
||||||
|
) {
|
||||||
|
visibleWeeks.value.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add at most one week (ensuring contiguity)
|
||||||
|
let added = false
|
||||||
|
if (!visibleWeeks.value.length) {
|
||||||
|
visibleWeeks.value.push(createWeek(startVW))
|
||||||
|
added = true
|
||||||
|
} else {
|
||||||
|
visibleWeeks.value.sort((a, b) => a.virtualWeek - b.virtualWeek)
|
||||||
|
const firstVW = visibleWeeks.value[0].virtualWeek
|
||||||
|
const lastVW = visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek
|
||||||
|
if (firstVW > startVW) {
|
||||||
|
visibleWeeks.value.unshift(createWeek(firstVW - 1))
|
||||||
|
added = true
|
||||||
|
} else {
|
||||||
|
let gapInserted = false
|
||||||
|
for (let i = 0; i < visibleWeeks.value.length - 1; i++) {
|
||||||
|
const curVW = visibleWeeks.value[i].virtualWeek
|
||||||
|
const nextVW = visibleWeeks.value[i + 1].virtualWeek
|
||||||
|
if (nextVW - curVW > 1 && curVW < endVW) {
|
||||||
|
visibleWeeks.value.splice(i + 1, 0, createWeek(curVW + 1))
|
||||||
|
added = true
|
||||||
|
gapInserted = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!gapInserted && lastVW < endVW) {
|
||||||
|
visibleWeeks.value.push(createWeek(lastVW + 1))
|
||||||
|
added = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Coverage check
|
||||||
|
const firstAfter = visibleWeeks.value[0].virtualWeek
|
||||||
|
const lastAfter = visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek
|
||||||
|
let contiguous = true
|
||||||
|
for (let i = 0; i < visibleWeeks.value.length - 1; i++) {
|
||||||
|
if (visibleWeeks.value[i + 1].virtualWeek !== visibleWeeks.value[i].virtualWeek + 1) {
|
||||||
|
contiguous = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const coverageComplete =
|
||||||
|
firstAfter <= startVW &&
|
||||||
|
lastAfter >= endVW &&
|
||||||
|
contiguous &&
|
||||||
|
visibleWeeks.value.length === endVW - startVW + 1
|
||||||
|
if (!coverageComplete) return false
|
||||||
|
if (
|
||||||
|
lastScrollRange.startVW === startVW &&
|
||||||
|
lastScrollRange.endVW === endVW &&
|
||||||
|
!added &&
|
||||||
|
visibleWeeks.value.length
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
lastScrollRange = { startVW, endVW }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWindowUpdate(reason) {
|
||||||
|
if (updating) return
|
||||||
|
updating = true
|
||||||
|
const run = () => {
|
||||||
|
updating = false
|
||||||
|
updateVisibleWeeks(reason) || scheduleWindowUpdate('incremental-build')
|
||||||
|
}
|
||||||
|
if ('requestIdleCallback' in window) requestIdleCallback(run, { timeout: 16 })
|
||||||
|
else requestAnimationFrame(run)
|
||||||
|
}
|
||||||
|
function resetWeeks(reason = 'reset') {
|
||||||
|
visibleWeeks.value = []
|
||||||
|
lastScrollRange = { startVW: null, endVW: null }
|
||||||
|
scheduleWindowUpdate(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToToday() {
|
||||||
|
const top = addDays(new Date(calendarStore.now), -21)
|
||||||
|
const targetWeekIndex = getWeekIndex(top)
|
||||||
|
const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||||
|
if (setScrollTopFn) setScrollTopFn(newScrollTop, 'go-to-today')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHeaderYearChange({ scrollTop }) {
|
||||||
|
const maxScroll = contentHeight.value - viewportHeight.value
|
||||||
|
const clamped = Math.max(0, Math.min(scrollTop, isFinite(maxScroll) ? maxScroll : scrollTop))
|
||||||
|
if (setScrollTopFn) setScrollTopFn(clamped, 'header-year-change')
|
||||||
|
resetWeeks('header-year-change')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleWeeks,
|
||||||
|
scheduleWindowUpdate,
|
||||||
|
resetWeeks,
|
||||||
|
updateVisibleWeeks,
|
||||||
|
getWeekIndex,
|
||||||
|
getFirstDayForVirtualWeek,
|
||||||
|
goToToday,
|
||||||
|
handleHeaderYearChange,
|
||||||
|
attachScroll,
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user