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 now = performance.now() while (samples[0]?.timestamp < now - VELOCITY_MS) samples.shift() samples.push({ timestamp: now, position: deltaY }) 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() { if (!dragging) return dragging = false window.removeEventListener('pointermove', onPointerMove, true) window.removeEventListener('pointerup', endDrag, true) window.removeEventListener('pointercancel', endDrag, true) if (allowTouch) { window.removeEventListener('touchmove', onTouchMove) window.removeEventListener('touchend', endDrag) window.removeEventListener('touchcancel', endDrag) } document.removeEventListener('pointerlockchange', onPointerLockChange, true) if (usingPointerLock && document.pointerLockElement === viewport.value) { try { document.exitPointerLock() } catch {} } usingPointerLock = false velocity = 0 if (samples.length) { const first = samples[0] const now = performance.now() const last = samples[samples.length - 1] const dy = last.position - first.position if (Math.abs(dy) > 5) velocity = (-dy * speed) / (now - first.timestamp) } samples = [] startMomentum() } function onPointerMove(e) { if (!dragging || document.pointerLockElement !== viewport.value) return dragAccumY += e.movementY applyDragByDelta(dragAccumY, reasonDragPointer) e.preventDefault() } function onTouchMove(e) { if (!dragging) return if (e.touches.length !== 1) { endDrag() return } applyDragPosition(e.touches[0].clientY, reasonDragTouch) e.preventDefault() } 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', endDrag, true) window.addEventListener('pointercancel', endDrag, true) document.addEventListener('pointerlockchange', onPointerLockChange, true) viewport.value.requestPointerLock({ unadjustedMovement: true }) } 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', endDrag, { passive: false }) window.addEventListener('touchcancel', endDrag, { 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 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) 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.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return isWeekColDragging.value = true drag.handlePointerDown(e) const end = () => { isWeekColDragging.value = false window.removeEventListener('pointerup', end, true) window.removeEventListener('pointercancel', end, true) } window.addEventListener('pointerup', end, true) window.addEventListener('pointercancel', end, true) } function handlePointerLockChange() { if (document.pointerLockElement !== viewport.value) { isWeekColDragging.value = false } } return { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } } export function createMonthScrollManager({ viewport, viewportHeight, contentHeight, setScrollTop, }) { 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) { drag.handlePointerDown(e) } function handleMonthScrollTouchStart(e) { drag.handleTouchStart(e) } function handleMonthScrollWheel(e) { drag.cancelMomentum() const currentScroll = viewport.value?.scrollTop || 0 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 } }