diff --git a/src/components/CalendarView.vue b/src/components/CalendarView.vue index 7cbbc42..6bbe1c3 100644 --- a/src/components/CalendarView.vue +++ b/src/components/CalendarView.vue @@ -4,7 +4,11 @@ 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 { getLocalizedMonthName, monthAbbr, @@ -38,10 +42,8 @@ function createEventFromSelection() { } } -const scrollTop = ref(0) const viewportHeight = ref(600) const rowHeight = ref(64) -// Probe element & observer for dynamic var(--row-h) changes const rowProbe = ref(null) let rowProbeObserver = null @@ -51,17 +53,14 @@ const selection = ref({ startDate: null, dayCount: 0 }) const isDragging = ref(false) const dragAnchor = ref(null) -// Rebuild visible weeks whenever selection changes so day.isSelected stays in sync. watch( - () => [selection.value.startDate, selection.value.dayCount], + () => [calendarStore.selectedDate, calendarStore.rangeStartDate], () => { - // Skip if no selection (both null/0) to avoid unnecessary work. - if (!selection.value.startDate || selection.value.dayCount === 0) { - scheduleRebuild('selection-clear') - } else { - scheduleRebuild('selection') + if (calendarStore.selectedDate || calendarStore.rangeStartDate) { + scheduleRebuild('selection-change') } }, + { flush: 'sync' }, ) const DOUBLE_TAP_DELAY = 300 @@ -110,6 +109,52 @@ const totalVirtualWeeks = computed(() => { return maxVirtualWeek.value - minVirtualWeek.value + 1 }) +const contentHeight = computed(() => { + return totalVirtualWeeks.value * rowHeight.value +}) + +const visibleWeeks = ref([]) +let lastScrollRange = { startVW: null, endVW: null } +let pendingRebuild = false + +function scheduleRebuild(reason) { + if (pendingRebuild) return + pendingRebuild = true + const cb = () => { + pendingRebuild = false + rebuildVisibleWeeks(reason) + } + if ('requestIdleCallback' in window) { + requestIdleCallback(cb, { timeout: 120 }) + } else { + requestAnimationFrame(cb) + } +} + +const scrollManager = createScrollManager({ viewport, scheduleRebuild }) + +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 { handleMonthScrollMouseDown, handleMonthScrollTouchStart, handleMonthScrollWheel } = + monthScrollManager + const initialScrollTop = computed(() => { const nowDate = new Date(calendarStore.now) const targetWeekIndex = getWeekIndex(nowDate) - 3 @@ -124,42 +169,25 @@ const selectedDateRange = computed(() => { ) }) -// PERFORMANCE: Maintain a manual cache of computed weeks instead of relying on -// deep reactive tracking of every event & day object. We rebuild lazily when -// (a) scrolling changes the needed range or (b) eventsMutation counter bumps. -const visibleWeeks = ref([]) -let lastScrollRange = { startVW: null, endVW: null } -let pendingRebuild = false -// Week label column drag scrolling state (no momentum) -const isWeekColDragging = ref(false) -let weekColDragStartScroll = 0 -let weekColAccum = 0 -let weekColPointerLocked = false -let weekColLastY = 0 - -function scheduleRebuild(reason) { - if (pendingRebuild) return - pendingRebuild = true - // Use requestIdleCallback when available, else fallback to rAF - const cb = () => { - pendingRebuild = false - rebuildVisibleWeeks(reason) - } - if ('requestIdleCallback' in window) { - requestIdleCallback(cb, { timeout: 120 }) - } else { - requestAnimationFrame(cb) - } -} - function rebuildVisibleWeeks(reason) { const buffer = 10 - const startIdx = Math.floor((scrollTop.value - buffer * rowHeight.value) / rowHeight.value) + const currentScrollTop = viewport.value?.scrollTop ?? scrollTop.value + const startIdx = Math.floor((currentScrollTop - buffer * rowHeight.value) / rowHeight.value) const endIdx = Math.ceil( - (scrollTop.value + viewportHeight.value + buffer * rowHeight.value) / rowHeight.value, + (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) + console.log('[CalendarView] rebuildVisibleWeeks', { + reason, + currentScrollTop, + startIdx, + endIdx, + startVW, + endVW, + rowHeight: rowHeight.value, + }) + if ( reason === 'scroll' && lastScrollRange.startVW === startVW && @@ -174,10 +202,6 @@ function rebuildVisibleWeeks(reason) { lastScrollRange = { startVW, endVW } } -const contentHeight = computed(() => { - return totalVirtualWeeks.value * rowHeight.value -}) - function computeRowHeight() { if (rowProbe.value) { const h = rowProbe.value.getBoundingClientRect().height || 64 @@ -201,12 +225,18 @@ function measureFromProbe() { const newH = Math.round(h) if (newH !== rowHeight.value) { const oldH = rowHeight.value - const currentTopVW = Math.floor(scrollTop.value / oldH) + minVirtualWeek.value + // Anchor: keep the same top virtual week visible. + const topVirtualWeek = Math.floor(scrollTop.value / oldH) + minVirtualWeek.value rowHeight.value = newH - const newScrollTop = (currentTopVW - minVirtualWeek.value) * newH - scrollTop.value = newScrollTop - if (viewport.value) viewport.value.scrollTop = newScrollTop - scheduleRebuild('row-height-change') + const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH + console.log('[CalendarView] measureFromProbe rowHeight change', { + oldH, + newH, + topVirtualWeek, + oldScrollTop: scrollTop.value, + newScrollTop, + }) + setScrollTop(newScrollTop, 'row-height-change') } } @@ -376,10 +406,8 @@ function createWeek(virtualWeek) { function goToToday() { const top = addDays(new Date(calendarStore.now), -21) const targetWeekIndex = getWeekIndex(top) - scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value - if (viewport.value) { - viewport.value.scrollTop = scrollTop.value - } + const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value + setScrollTop(newScrollTop, 'go-to-today') } function clearSelection() { @@ -478,10 +506,9 @@ function getDateFromCoordinates(clientX, clientY) { const sampleWeek = viewport.value.querySelector('.week-row') if (!sampleWeek) return null const labelEl = sampleWeek.querySelector('.week-label') - const jogwheelWidth = 48 const wrRect = sampleWeek.getBoundingClientRect() const labelRight = labelEl ? labelEl.getBoundingClientRect().right : wrRect.left - const daysAreaRight = wrRect.right - jogwheelWidth + const 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))) @@ -519,76 +546,13 @@ function getWeekLabelRect() { return weekLabel ? weekLabel.getBoundingClientRect() : null } -function handleWeekColMouseDown(e) { - if (e.button !== 0) return - if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return - if (!viewport.value) return - const rect = getWeekLabelRect() - if (!rect) return - if (e.clientX < rect.left || e.clientX > rect.right) return - isWeekColDragging.value = true - weekColDragStartScroll = viewport.value.scrollTop - weekColAccum = 0 - weekColLastY = e.clientY - if (viewport.value.requestPointerLock) viewport.value.requestPointerLock() - window.addEventListener('mousemove', handleWeekColMouseMove, { passive: false }) - window.addEventListener('mouseup', handleWeekColMouseUp, { passive: false }) - e.preventDefault() - e.stopPropagation() -} - -function handleWeekColMouseMove(e) { - if (!isWeekColDragging.value || !viewport.value) return - let dy - if (weekColPointerLocked) { - dy = e.movementY - } else { - dy = e.clientY - weekColLastY - weekColLastY = e.clientY - } - weekColAccum += dy - let desired = weekColDragStartScroll - weekColAccum - if (desired < 0) desired = 0 - const maxScroll = Math.max(0, contentHeight.value - viewportHeight.vale) - if (desired > maxScroll) desired = maxScroll - viewport.value.scrollTop = desired - e.preventDefault() -} - -function handleWeekColMouseUp(e) { - if (!isWeekColDragging.value) return - isWeekColDragging.value = false - window.removeEventListener('mousemove', handleWeekColMouseMove) - window.removeEventListener('mouseup', handleWeekColMouseUp) - if (weekColPointerLocked && document.exitPointerLock) document.exitPointerLock() - e.preventDefault() -} - -function handlePointerLockChange() { - weekColPointerLocked = document.pointerLockElement === viewport.value - if (!weekColPointerLocked && isWeekColDragging.value) { - handleWeekColMouseUp(new MouseEvent('mouseup')) - } -} - -const onScroll = () => { - if (viewport.value) scrollTop.value = viewport.value.scrollTop - scheduleRebuild('scroll') -} - -const handleJogwheelScrollTo = (newScrollTop) => { - if (viewport.value) { - viewport.value.scrollTop = newScrollTop - } -} - onMounted(() => { computeRowHeight() calendarStore.updateCurrentDate() if (viewport.value) { viewportHeight.value = viewport.value.clientHeight - viewport.value.scrollTop = initialScrollTop.value + 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) @@ -658,13 +622,22 @@ const handleEventClick = (payload) => { const handleHeaderYearChange = ({ scrollTop: st }) => { const maxScroll = contentHeight.value - viewportHeight.value const clamped = Math.max(0, Math.min(st, isFinite(maxScroll) ? maxScroll : st)) - scrollTop.value = clamped - viewport.value && (viewport.value.scrollTop = clamped) + setScrollTop(clamped, 'header-year-change') } // 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 + try { + 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 // Keep roughly same visible date when first_day setting changes. watch( () => calendarStore.config.first_day, @@ -674,8 +647,7 @@ watch( requestAnimationFrame(() => { const newTopWeekIndex = getWeekIndex(currentTopDate) const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value - scrollTop.value = newScroll - if (viewport.value) viewport.value.scrollTop = newScroll + setScrollTop(newScroll, 'first-day-change') scheduleRebuild('first-day-change') }) }, @@ -710,28 +682,48 @@ window.addEventListener('resize', () => { />