diff --git a/src/components/CalendarView.vue b/src/components/CalendarView.vue index ca8031c..6f21877 100644 --- a/src/components/CalendarView.vue +++ b/src/components/CalendarView.vue @@ -57,7 +57,7 @@ watch( () => [calendarStore.selectedDate, calendarStore.rangeStartDate], () => { if (calendarStore.selectedDate || calendarStore.rangeStartDate) { - scheduleRebuild('selection-change') + scheduleDataRebuild('selection-change') } }, { flush: 'sync' }, @@ -115,28 +115,30 @@ const contentHeight = computed(() => { const visibleWeeks = ref([]) let lastScrollRange = { startVW: null, endVW: null } - -let rebuildTimer = null -let lastReason = null -const REBUILD_DEBOUNCE_MS = 40 - -function scheduleRebuild(reason) { - lastReason = lastReason ? lastReason + ',' + reason : reason - if (rebuildTimer) return - rebuildTimer = setTimeout(() => { - const r = lastReason - rebuildTimer = null - lastReason = null - const fn = () => rebuildVisibleWeeks(r) - if ('requestIdleCallback' in window) { - requestIdleCallback(fn, { timeout: 120 }) - } else { - requestAnimationFrame(fn) - } - }, REBUILD_DEBOUNCE_MS) +let windowTimer = null +let dataTimer = null +const WINDOW_DEBOUNCE_MS = 30 +const DATA_DEBOUNCE_MS = 40 +function scheduleWindowUpdate(reason) { + if (windowTimer) return + windowTimer = setTimeout(() => { + windowTimer = null + const fn = () => updateVisibleWeeks(reason) + if ('requestIdleCallback' in window) requestIdleCallback(fn, { timeout: 80 }) + else requestAnimationFrame(fn) + }, WINDOW_DEBOUNCE_MS) +} +function scheduleDataRebuild(reason) { + if (dataTimer) return + dataTimer = setTimeout(() => { + dataTimer = null + const fn = () => rebuildVisibleWeeks(reason) + if ('requestIdleCallback' in window) requestIdleCallback(fn, { timeout: 120 }) + else requestAnimationFrame(fn) + }, DATA_DEBOUNCE_MS) } -const scrollManager = createScrollManager({ viewport, scheduleRebuild }) +const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate }) const { scrollTop, setScrollTop, onScroll } = scrollManager @@ -174,6 +176,44 @@ const selectedDateRange = computed(() => { ) }) +function updateVisibleWeeks(reason) { + const buffer = 10 + 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) + if ( + lastScrollRange.startVW === startVW && + lastScrollRange.endVW === endVW && + visibleWeeks.value.length + ) + return + 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() + let needFirst = visibleWeeks.value[0]?.virtualWeek + if (visibleWeeks.value.length === 0) needFirst = endVW + 1 + for (let vw = (needFirst ?? startVW) - 1; vw >= startVW; vw--) + visibleWeeks.value.unshift(createWeek(vw)) + let needLast = visibleWeeks.value[visibleWeeks.value.length - 1]?.virtualWeek + for (let vw = (needLast ?? startVW - 1) + 1; vw <= endVW; vw++) + visibleWeeks.value.push(createWeek(vw)) + lastScrollRange = { startVW, endVW } + return + } + const weeks = [] + for (let vw = startVW; vw <= endVW; vw++) weeks.push(createWeek(vw)) + visibleWeeks.value = weeks + lastScrollRange = { startVW, endVW } +} function rebuildVisibleWeeks(reason) { const buffer = 10 const currentScrollTop = viewport.value?.scrollTop ?? scrollTop.value @@ -183,28 +223,16 @@ function rebuildVisibleWeeks(reason) { ) const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value) const endVW = Math.min(maxVirtualWeek.value, endIdx + minVirtualWeek.value) - console.debug('[CalendarView] rebuildVisibleWeeks', { - reason, - currentScrollTop, - startIdx, - endIdx, - startVW, - endVW, - rowHeight: rowHeight.value, - }) - - if ( - reason === 'scroll' && - lastScrollRange.startVW === startVW && - lastScrollRange.endVW === endVW && - visibleWeeks.value.length - ) { - return - } 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() { @@ -235,6 +263,7 @@ function measureFromProbe() { rowHeight.value = newH const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH setScrollTop(newScrollTop, 'row-height-change') + scheduleDataRebuild('row-height-change') } } @@ -562,7 +591,7 @@ onMounted(() => { }, 60000) // Initial build after mount & measurement - scheduleRebuild('init') + scheduleDataRebuild('init') if (window.ResizeObserver && rowProbe.value) { rowProbeObserver = new ResizeObserver(() => { @@ -646,7 +675,7 @@ watch( const newTopWeekIndex = getWeekIndex(currentTopDate) const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value setScrollTop(newScroll, 'first-day-change') - scheduleRebuild('first-day-change') + scheduleDataRebuild('first-day-change') }) }, ) @@ -655,7 +684,7 @@ watch( watch( () => calendarStore.events, () => { - scheduleRebuild('events') + scheduleDataRebuild('events') }, { deep: true }, ) @@ -664,7 +693,7 @@ watch( window.addEventListener('resize', () => { if (viewport.value) viewportHeight.value = viewport.value.clientHeight measureFromProbe() - scheduleRebuild('resize') + scheduleWindowUpdate('resize') }) diff --git a/src/plugins/scrollManager.js b/src/plugins/scrollManager.js index 9b9db23..73feb77 100644 --- a/src/plugins/scrollManager.js +++ b/src/plugins/scrollManager.js @@ -1,5 +1,185 @@ import { ref } from 'vue' +function createMomentumDrag({ + viewport, + viewportHeight, + contentHeight, + setScrollTop, + speed, + reasonDragPointer, + reasonDragTouch, + reasonMomentum, + allowTouch, + hitTest, +}) { + let dragging = false + let startY = 0 + let startScroll = 0 + let velocity = 0 + let samples = [] // { timestamp, position } + let momentumActive = false + let momentumFrame = null + let dragAccumY = 0 // used when pointer lock active + let usingPointerLock = false + const frictionPerMs = 0.0018 + const MIN_V = 0.03 + const VELOCITY_MS = 50 + + function cancelMomentum() { + if (!momentumActive) return + momentumActive = false + if (momentumFrame) cancelAnimationFrame(momentumFrame) + momentumFrame = null + } + function startMomentum() { + if (Math.abs(velocity) < MIN_V) return + cancelMomentum() + momentumActive = true + let lastTs = performance.now() + const step = () => { + if (!momentumActive) return + const now = performance.now() + const dt = now - lastTs + lastTs = now + if (dt <= 0) { + momentumFrame = requestAnimationFrame(step) + return + } + const decay = Math.exp(-frictionPerMs * dt) + velocity *= decay + const delta = velocity * dt + if (viewport.value) { + let cur = viewport.value.scrollTop + let target = cur + delta + const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) + if (target < 0) { + target = 0 + velocity = 0 + } else if (target > maxScroll) { + target = maxScroll + velocity = 0 + } + setScrollTop(target, reasonMomentum) + } + if (Math.abs(velocity) < MIN_V * 0.6) { + momentumActive = false + return + } + momentumFrame = requestAnimationFrame(step) + } + momentumFrame = requestAnimationFrame(step) + } + function applyDragByDelta(deltaY, reason) { + const newScrollTop = startScroll - deltaY * speed + const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) + const clamped = Math.max(0, Math.min(newScrollTop, maxScroll)) + setScrollTop(clamped, reason) + } + function applyDragPosition(clientY, reason) { + const deltaY = clientY - startY + applyDragByDelta(deltaY, reason) + } + function endDrag() { + dragging = false + window.removeEventListener('pointermove', onPointerMove, true) + window.removeEventListener('pointerup', onPointerUp, true) + window.removeEventListener('pointercancel', onPointerUp, true) + if (allowTouch) { + window.removeEventListener('touchmove', onTouchMove) + window.removeEventListener('touchend', onTouchEnd) + window.removeEventListener('touchcancel', onTouchEnd) + } + document.removeEventListener('pointerlockchange', onPointerLockChange, true) + if (usingPointerLock && document.pointerLockElement === viewport.value) { + try { + document.exitPointerLock() + } catch {} + } + usingPointerLock = false + if (samples.length) { + const first = samples[0] + const now = performance.now() + const last = samples[samples.length - 1] + const dy = last.position - first.position + velocity = (-dy * speed) / (now - first.timestamp) + } else velocity = 0 + console.log(velocity, samples) + samples = [] + startMomentum() + } + function onPointerMove(e) { + if (!dragging) return + if (document.pointerLockElement === viewport.value) { + // Use movementY deltas under pointer lock + const now = performance.now() + dragAccumY += e.movementY + while (samples[0]?.timestamp < now - VELOCITY_MS) samples.shift() + samples.push({ timestamp: now, position: dragAccumY }) + applyDragByDelta(dragAccumY, reasonDragPointer) + } + e.preventDefault() + } + function onPointerUp() { + if (dragging) endDrag() + } + function onTouchMove(e) { + if (!dragging) return + const t = e.touches[0] + const now = performance.now() + while (samples[0]?.timestamp < now - VELOCITY_MS) samples.shift() + samples.push({ timestamp: now, position: t.clientY }) + applyDragPosition(t.clientY, reasonDragTouch) + e.preventDefault() + } + function onTouchEnd() { + if (dragging) endDrag() + } + function handlePointerDown(e) { + if (e.button !== undefined && e.button !== 0) return + if (hitTest && !hitTest(e)) return + e.preventDefault() + cancelMomentum() + dragging = true + startY = e.clientY + startScroll = viewport.value?.scrollTop || 0 + velocity = 0 + dragAccumY = 0 + samples = [{ timestamp: performance.now(), position: e.clientY }] + window.addEventListener('pointermove', onPointerMove, true) + window.addEventListener('pointerup', onPointerUp, true) + window.addEventListener('pointercancel', onPointerUp, true) + document.addEventListener('pointerlockchange', onPointerLockChange, true) + viewport.value.requestPointerLock() + } + function handleTouchStart(e) { + if (!allowTouch) return + if (e.touches.length !== 1) return + if (hitTest && !hitTest(e.touches[0])) return + cancelMomentum() + dragging = true + const t = e.touches[0] + startY = t.clientY + startScroll = viewport.value?.scrollTop || 0 + velocity = 0 + dragAccumY = 0 + samples = [{ timestamp: performance.now(), position: t.clientY }] + window.addEventListener('touchmove', onTouchMove, { passive: false }) + window.addEventListener('touchend', onTouchEnd, { passive: false }) + window.addEventListener('touchcancel', onTouchEnd, { passive: false }) + e.preventDefault() + } + function onPointerLockChange() { + const lockedEl = document.pointerLockElement + if (dragging && lockedEl === viewport.value) { + usingPointerLock = true + return + } + if (dragging && usingPointerLock && lockedEl !== viewport.value) endDrag() + if (!dragging) usingPointerLock = false + } + return { handlePointerDown, handleTouchStart, cancelMomentum } +} + export function createScrollManager({ viewport, scheduleRebuild }) { const scrollTop = ref(0) let lastProgrammatic = null @@ -79,75 +259,47 @@ export function createWeekColumnScrollManager({ 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 } - + const drag = createMomentumDrag({ + viewport, + viewportHeight, + contentHeight, + setScrollTop, + speed: 1, + reasonDragPointer: 'week-col-drag', + reasonDragTouch: 'week-col-drag', + reasonMomentum: 'week-col-momentum', + allowTouch: false, + hitTest: (e) => { + const rect = getWeekLabelRect() + if (!rect) return false + const x = e.clientX ?? e.pageX + return x >= rect.left && x <= rect.right + }, + }) 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 + drag.handlePointerDown(e) + const end = () => { + isWeekColDragging.value = false + window.removeEventListener('pointerup', end, true) + window.removeEventListener('pointercancel', end, true) } - 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() + window.addEventListener('pointerup', end, true) + window.addEventListener('pointercancel', end, true) } - - 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')) + if (document.pointerLockElement !== viewport.value) { + isWeekColDragging.value = false } } - - return { - isWeekColDragging, - handleWeekColMouseDown, - handlePointerLockChange, - } + return { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } } export function createMonthScrollManager({ @@ -156,79 +308,32 @@ export function createMonthScrollManager({ contentHeight, setScrollTop, }) { - let dragging = false - let startY = 0 - let startScroll = 0 - const SPEED = 10 - - function applyDrag(clientY, reason) { - const deltaY = clientY - startY - const newScrollTop = startScroll - deltaY * SPEED - const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) - const clamped = Math.max(0, Math.min(newScrollTop, maxScroll)) - setScrollTop(clamped, reason) - } - - function endDrag() { - dragging = false - window.removeEventListener('pointermove', onPointerMove, true) - window.removeEventListener('pointerup', onPointerUp, true) - window.removeEventListener('pointercancel', onPointerUp, true) - window.removeEventListener('touchmove', onTouchMove) - window.removeEventListener('touchend', onTouchEnd) - window.removeEventListener('touchcancel', onTouchEnd) - } - - function onPointerMove(e) { - if (!dragging) return - applyDrag(e.clientY, 'month-scroll-drag') - e.preventDefault() - } - function onPointerUp() { - if (dragging) endDrag() - } - - function onTouchMove(e) { - if (!dragging) return - const t = e.touches[0] - applyDrag(t.clientY, 'month-scroll-touch') - e.preventDefault() - } - function onTouchEnd() { - if (dragging) endDrag() - } - + const drag = createMomentumDrag({ + viewport, + viewportHeight, + contentHeight, + setScrollTop, + speed: 10, + reasonDragPointer: 'month-scroll-drag', + reasonDragTouch: 'month-scroll-touch', + reasonMomentum: 'month-scroll-momentum', + allowTouch: true, + hitTest: null, + }) function handleMonthScrollPointerDown(e) { - if (e.button !== undefined && e.button !== 0) return - e.preventDefault() - dragging = true - startY = e.clientY - startScroll = viewport.value?.scrollTop || 0 - window.addEventListener('pointermove', onPointerMove, true) - window.addEventListener('pointerup', onPointerUp, true) - window.addEventListener('pointercancel', onPointerUp, true) + drag.handlePointerDown(e) } - function handleMonthScrollTouchStart(e) { - if (e.touches.length !== 1) return - dragging = true - const t = e.touches[0] - startY = t.clientY - startScroll = viewport.value?.scrollTop || 0 - window.addEventListener('touchmove', onTouchMove, { passive: false }) - window.addEventListener('touchend', onTouchEnd, { passive: false }) - window.addEventListener('touchcancel', onTouchEnd, { passive: false }) - e.preventDefault() + drag.handleTouchStart(e) } - function handleMonthScrollWheel(e) { + drag.cancelMomentum() const currentScroll = viewport.value?.scrollTop || 0 - const newScrollTop = currentScroll + e.deltaY * SPEED + const newScrollTop = currentScroll + e.deltaY * 10 const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) const clamped = Math.max(0, Math.min(newScrollTop, maxScroll)) setScrollTop(clamped, 'month-scroll-wheel') e.preventDefault() } - return { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel } }