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', () => { />
-
- + +
+
+ +
+
+ +
+ +
+ +
-
@@ -773,7 +765,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 { @@ -781,6 +779,47 @@ header h1 { width: 100%; } +.month-column-area { + position: relative; + cursor: ns-resize; +} + +.month-labels-container { + position: relative; + width: 100%; + height: 100%; +} + +.month-label { + position: absolute; + 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; + z-index: 15; + overflow: hidden; + cursor: ns-resize; + user-select: 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; diff --git a/src/components/CalendarWeek.vue b/src/components/CalendarWeek.vue index da567c2..b17f955 100644 --- a/src/components/CalendarWeek.vue +++ b/src/components/CalendarWeek.vue @@ -61,23 +61,13 @@ function shouldRotateMonth(label) { /> -
- {{ - props.week.monthLabel.text - }} -
diff --git a/src/plugins/scrollManager.js b/src/plugins/scrollManager.js new file mode 100644 index 0000000..2b0f3c4 --- /dev/null +++ b/src/plugins/scrollManager.js @@ -0,0 +1,234 @@ +import { ref } from 'vue' + +export function createScrollManager({ viewport, scheduleRebuild }) { + const scrollTop = ref(0) + let lastProgrammatic = null + let pendingTarget = null + let pendingAttempts = 0 + let pendingLoopActive = false + + function setScrollTop(val, reason = 'programmatic') { + let applied = val + if (viewport.value) { + const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight + if (applied > maxScroll) { + applied = maxScroll < 0 ? 0 : maxScroll + pendingTarget = val + pendingAttempts = 0 + startPendingLoop() + } + if (applied < 0) applied = 0 + viewport.value.scrollTop = applied + } + scrollTop.value = applied + lastProgrammatic = applied + scheduleRebuild(reason) + } + + function onScroll() { + if (!viewport.value) return + const cur = viewport.value.scrollTop + const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight + let effective = cur + if (cur < 0) effective = 0 + else if (cur > maxScroll) effective = maxScroll + scrollTop.value = effective + if (lastProgrammatic !== null && effective === lastProgrammatic) { + lastProgrammatic = null + return + } + if (pendingTarget !== null && Math.abs(effective - pendingTarget) > 4) { + pendingTarget = null + } + scheduleRebuild('scroll') + } + + function startPendingLoop() { + if (pendingLoopActive || !viewport.value) return + pendingLoopActive = true + const loop = () => { + if (pendingTarget == null || !viewport.value) { + pendingLoopActive = false + return + } + const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight + if (pendingTarget <= maxScroll) { + setScrollTop(pendingTarget, 'pending-fulfill') + pendingTarget = null + pendingLoopActive = false + return + } + pendingAttempts++ + if (pendingAttempts > 120) { + pendingTarget = null + pendingLoopActive = false + return + } + requestAnimationFrame(loop) + } + requestAnimationFrame(loop) + } + + return { scrollTop, setScrollTop, onScroll } +} + +export function createWeekColumnScrollManager({ + viewport, + viewportHeight, + contentHeight, + setScrollTop, +}) { + const isWeekColDragging = ref(false) + let weekColDragStartScroll = 0 + let weekColAccum = 0 + let weekColPointerLocked = false + let weekColLastY = 0 + + function getWeekLabelRect() { + 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 + } + + 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.value) + if (desired > maxScroll) desired = maxScroll + setScrollTop(desired, 'week-col-drag') + 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')) + } + } + + return { + isWeekColDragging, + handleWeekColMouseDown, + handlePointerLockChange, + } +} + +export function createMonthScrollManager({ + viewport, + viewportHeight, + contentHeight, + setScrollTop, +}) { + let isMonthScrolling = false + let monthScrollStartY = 0 + let monthScrollStartScroll = 0 + + const handleMonthScrollMouseDown = (e) => { + if (e.button !== 0) return + isMonthScrolling = true + monthScrollStartY = e.clientY + monthScrollStartScroll = viewport.value?.scrollTop || 0 + + const handleMouseMove = (e) => { + if (!isMonthScrolling) return + const deltaY = e.clientY - monthScrollStartY + const newScrollTop = monthScrollStartScroll - deltaY * 10 + const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) + const clampedScroll = Math.max(0, Math.min(newScrollTop, maxScroll)) + + setScrollTop(clampedScroll, 'month-scroll-drag') + e.preventDefault() + } + + const handleMouseUp = () => { + isMonthScrolling = false + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + e.preventDefault() + } + + const handleMonthScrollTouchStart = (e) => { + if (e.touches.length !== 1) return + isMonthScrolling = true + monthScrollStartY = e.touches[0].clientY + monthScrollStartScroll = viewport.value?.scrollTop || 0 + + const handleTouchMove = (e) => { + if (!isMonthScrolling || e.touches.length !== 1) return + const deltaY = e.touches[0].clientY - monthScrollStartY + const newScrollTop = monthScrollStartScroll - deltaY * 10 + const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) + const clampedScroll = Math.max(0, Math.min(newScrollTop, maxScroll)) + + setScrollTop(clampedScroll, 'month-scroll-touch') + e.preventDefault() + } + + const handleTouchEnd = () => { + isMonthScrolling = false + document.removeEventListener('touchmove', handleTouchMove) + document.removeEventListener('touchend', handleTouchEnd) + } + + document.addEventListener('touchmove', handleTouchMove, { passive: false }) + document.addEventListener('touchend', handleTouchEnd) + e.preventDefault() + } + + const handleMonthScrollWheel = (e) => { + const currentScroll = viewport.value?.scrollTop || 0 + const newScrollTop = currentScroll + e.deltaY * 10 + const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) + const clampedScroll = Math.max(0, Math.min(newScrollTop, maxScroll)) + + setScrollTop(clampedScroll, 'month-scroll-wheel') + e.preventDefault() + } + + return { + handleMonthScrollMouseDown, + handleMonthScrollTouchStart, + handleMonthScrollWheel, + } +}