Improved scrolling on month bar (fast drag scroll, per-month wheel scroll, glitches eliminated).

This commit is contained in:
Leo Vasanko
2025-09-24 13:34:30 -06:00
parent dca3e21843
commit c134d8875c
2 changed files with 69 additions and 161 deletions

View File

@@ -1,9 +1,10 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, computed, watch, nextTick } from 'vue'
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
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,
@@ -25,7 +26,9 @@ function openCreateEventDialog(eventData) {
// Capture baseline before dialog opens (new event creation flow)
try {
calendarStore.$history?._baselineIfNeeded?.(true)
} catch {}
} catch {
/* noop */
}
const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount }
setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30)
}
@@ -33,7 +36,9 @@ function openEditEventDialog(eventClickPayload) {
// Capture baseline before editing existing event
try {
calendarStore.$history?._baselineIfNeeded?.(true)
} catch {}
} catch {
/* noop */
}
eventDialogRef.value?.openEditDialog(eventClickPayload)
}
const viewport = ref(null)
@@ -207,7 +212,7 @@ watch(
calendarStore.config.holidays.state,
calendarStore.config.holidays.region,
],
(_newVals, _oldVals) => {
() => {
// If weeks already built, just refresh holiday info
if (visibleWeeks.value.length) {
refreshHolidays('config-change')
@@ -393,7 +398,9 @@ onBeforeUnmount(() => {
try {
rowProbeObserver.unobserve(rowProbe.value)
rowProbeObserver.disconnect()
} catch (e) {}
} catch {
/* noop */
}
}
document.removeEventListener('pointerlockchange', handlePointerLockChange)
})
@@ -430,7 +437,9 @@ function scrollToEventStart(startDate, smooth = true) {
const dateObj = fromLocalString(startDate, DEFAULT_TZ)
const weekIndex = getWeekIndex(dateObj)
scrollToWeekCentered(weekIndex, 'search-jump', smooth)
} catch {}
} catch {
/* noop */
}
}
function handleHeaderSearchPreview(result) {
if (!result) return
@@ -560,6 +569,14 @@ window.addEventListener('resize', () => {
</div>
</div>
</div>
<!-- Jogwheel overlay captures drag + wheel over month name column -->
<Jogwheel
:total-virtual-weeks="totalVirtualWeeks"
:row-height="rowHeight"
:viewport-height="viewportHeight"
:scroll-top="scrollTop"
@scroll-to="(v) => setScrollTop(v, 'jogwheel')"
/>
</div>
<EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" />
</div>

View File

@@ -1,15 +1,7 @@
<template>
<div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll">
<div
class="jogwheel-content"
ref="jogwheelContent"
:style="{ height: jogwheelHeight + 'px' }"
></div>
</div>
</template>
<template><div class="jogwheel-viewport" ref="jogwheelViewport" /></template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { ref, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({
totalVirtualWeeks: { type: Number, required: true },
@@ -21,160 +13,66 @@ const props = defineProps({
const emit = defineEmits(['scroll-to'])
const jogwheelViewport = ref(null)
const jogwheelContent = ref(null)
const syncLock = ref(null)
// Drag state (no momentum, 1:1 mapping)
const isDragging = ref(false)
let mainStartScroll = 0
let dragScale = 1 // mainScrollPixels per mouse pixel
let accumDelta = 0
let pointerLocked = false
let lastClientY = null
// Jogwheel content height is 1/4h of main calendar
const jogwheelHeight = computed(() => {
return (props.totalVirtualWeeks * props.rowHeight) / 4
})
const SPEED_DRAG = 4
const handleJogwheelScroll = () => {
if (syncLock.value === 'jogwheel') return
syncFromJogwheel()
}
const WEEKS_PER_MONTH = 30.4375 / 7
const MONTH_SCROLL = () => props.rowHeight * WEEKS_PER_MONTH
const ANIM_DURATION = 420 // ms
let animActive = false
let animFrom = 0
let animTo = 0
let animStart = 0
let animFrame = null
function onDragMouseDown(e) {
if (e.button !== 0) return
isDragging.value = true
mainStartScroll = props.scrollTop
accumDelta = 0
// Precompute scale between jogwheel scrollable range and main scrollable range
const mainScrollable = Math.max(
0,
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
)
let jogScrollable = 0
if (jogwheelViewport.value && jogwheelContent.value) {
jogScrollable = Math.max(
0,
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
)
}
dragScale = jogScrollable > 0 ? mainScrollable / jogScrollable : 1
if (!isFinite(dragScale) || dragScale <= 0) dragScale = 1
// Attempt pointer lock for relative movement
if (jogwheelViewport.value && jogwheelViewport.value.requestPointerLock) {
jogwheelViewport.value.requestPointerLock()
}
window.addEventListener('mousemove', onDragMouseMove, { passive: false })
window.addEventListener('mouseup', onDragMouseUp, { passive: false })
e.preventDefault()
}
// Drag momentum (independent from month-step animation)
let dragMomentumActive = false
let dragMomentumFrame = null
let dragMomentumVelocity = 0
let dragMomentumPos = 0
const DRAG_FRICTION_PER_MS = 0.0018
const DRAG_MIN_V = 0.03
let dragSamples = [] // { t, s } sampled scroll positions during drag
function onDragMouseMove(e) {
if (!isDragging.value) return
const dy = pointerLocked ? e.movementY : e.clientY // movementY only valid in pointer lock
accumDelta += dy
let desired = mainStartScroll - accumDelta * dragScale
if (desired < 0) desired = 0
const MIN_WHEEL_ABS = 2
function easeOutCubic(t){return 1-Math.pow(1-t,3)}
function clampScroll(x) {
const maxScroll = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
if (desired > maxScroll) desired = maxScroll
emit('scroll-to', desired)
e.preventDefault()
if (x < 0) return 0
if (x > maxScroll) return maxScroll
return x
}
function onDragMouseUp(e) {
if (!isDragging.value) return
isDragging.value = false
window.removeEventListener('mousemove', onDragMouseMove)
window.removeEventListener('mouseup', onDragMouseUp)
if (pointerLocked && document.exitPointerLock) document.exitPointerLock()
e.preventDefault()
}
function animateTo(target){target=clampScroll(target);const now=performance.now();if(animActive){const p=Math.min(1,(now-animStart)/ANIM_DURATION);animFrom=animFrom+(animTo-animFrom)*easeOutCubic(p);animTo=target;animStart=now;}else{animFrom=props.scrollTop;animTo=target;animStart=now;animActive=true;animFrame=requestAnimationFrame(stepAnim);return}if(!animFrame)animFrame=requestAnimationFrame(stepAnim)}
function stepAnim(){if(!animActive)return;const t=Math.min(1,(performance.now()-animStart)/ANIM_DURATION);const val=animFrom+(animTo-animFrom)*easeOutCubic(t);emit('scroll-to',clampScroll(val));if(t>=1){animActive=false;animFrame=null;return}animFrame=requestAnimationFrame(stepAnim)}
function handlePointerLockChange() {
pointerLocked = document.pointerLockElement === jogwheelViewport.value
if (!pointerLocked && isDragging.value) {
// Pointer lock lost (Esc) -> end drag gracefully
onDragMouseUp(new MouseEvent('mouseup'))
}
}
function onDragMouseDown(e){if(e.button!==0)return;if(animActive){const now=performance.now();const p=Math.min(1,(now-animStart)/ANIM_DURATION);const cur=animFrom+(animTo-animFrom)*easeOutCubic(p);animActive=false;animFrame&&cancelAnimationFrame(animFrame);animFrame=null;emit('scroll-to',clampScroll(cur));}cancelDragMomentum();isDragging.value=true;mainStartScroll=props.scrollTop;accumDelta=0;lastClientY=e.clientY;dragSamples=[{t:performance.now(),s:mainStartScroll}];if(jogwheelViewport.value&&jogwheelViewport.value.requestPointerLock)jogwheelViewport.value.requestPointerLock();window.addEventListener('mousemove',onDragMouseMove,{passive:false});window.addEventListener('mouseup',onDragMouseUp,{passive:false});e.preventDefault()}
onMounted(() => {
if (jogwheelViewport.value) {
jogwheelViewport.value.addEventListener('mousedown', onDragMouseDown)
}
document.addEventListener('pointerlockchange', handlePointerLockChange)
})
function onDragMouseMove(e){if(!isDragging.value) return;let dy=typeof e.movementY==='number'?e.movementY:0;if(!pointerLocked){if(lastClientY!=null)dy=e.clientY-lastClientY;lastClientY=e.clientY;}accumDelta+=dy;let desired=mainStartScroll-accumDelta*SPEED_DRAG;if(desired<0)desired=0;const maxScroll=Math.max(0,props.totalVirtualWeeks*props.rowHeight-props.viewportHeight);if(desired>maxScroll)desired=maxScroll;emit('scroll-to',desired);dragSamples.push({t:performance.now(),s:desired});e.preventDefault()}
onBeforeUnmount(() => {
if (jogwheelViewport.value) {
jogwheelViewport.value.removeEventListener('mousedown', onDragMouseDown)
}
window.removeEventListener('mousemove', onDragMouseMove)
window.removeEventListener('mouseup', onDragMouseUp)
document.removeEventListener('pointerlockchange', handlePointerLockChange)
})
function onDragMouseUp(e){if(!isDragging.value)return;isDragging.value=false;lastClientY=null;window.removeEventListener('mousemove',onDragMouseMove);window.removeEventListener('mouseup',onDragMouseUp);if(pointerLocked&&document.exitPointerLock)document.exitPointerLock();const v=computeDragVelocity();dragSamples=[];if(Math.abs(v)>=DRAG_MIN_V)startDragMomentum(v);e.preventDefault()}
const syncFromJogwheel = () => {
if (!jogwheelViewport.value || !jogwheelContent.value) return
function handlePointerLockChange(){pointerLocked=document.pointerLockElement===jogwheelViewport.value;if(!pointerLocked&&isDragging.value)onDragMouseUp(new MouseEvent('mouseup'))}
syncLock.value = 'main'
onMounted(()=>{if(jogwheelViewport.value){jogwheelViewport.value.addEventListener('mousedown',onDragMouseDown);jogwheelViewport.value.addEventListener('wheel',onWheel,{passive:false,capture:true});}document.addEventListener('pointerlockchange',handlePointerLockChange)})
const jogScrollable = Math.max(
0,
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
)
const mainScrollable = Math.max(
0,
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
)
onBeforeUnmount(()=>{if(jogwheelViewport.value){jogwheelViewport.value.removeEventListener('mousedown',onDragMouseDown);jogwheelViewport.value.removeEventListener('wheel',onWheel);}window.removeEventListener('mousemove',onDragMouseMove);window.removeEventListener('mouseup',onDragMouseUp);document.removeEventListener('pointerlockchange',handlePointerLockChange)})
if (jogScrollable > 0) {
const ratio = jogwheelViewport.value.scrollTop / jogScrollable
function onWheel(e){if(e.ctrlKey)return;e.preventDefault();e.stopPropagation();cancelDragMomentum();const dy=e.deltaY;if(Math.abs(dy)<MIN_WHEEL_ABS)return;const dir=dy>0?1:-1;const base=animActive?animTo:props.scrollTop;animateTo(base+dir*MONTH_SCROLL())}
// Emit scroll event to parent to update main viewport
emit('scroll-to', ratio * mainScrollable)
}
// Keep API stable for parent components (previously exposed)
function syncFromMain(){};defineExpose({syncFromMain})
setTimeout(() => {
if (syncLock.value === 'main') syncLock.value = null
}, 50)
}
const syncFromMain = (mainScrollTop) => {
if (!jogwheelViewport.value || !jogwheelContent.value) return
if (syncLock.value === 'main') return
syncLock.value = 'jogwheel'
const mainScrollable = Math.max(
0,
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
)
const jogScrollable = Math.max(
0,
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
)
if (mainScrollable > 0) {
const ratio = mainScrollTop / mainScrollable
jogwheelViewport.value.scrollTop = ratio * jogScrollable
}
setTimeout(() => {
if (syncLock.value === 'jogwheel') syncLock.value = null
}, 50)
}
// Watch for main calendar scroll changes
watch(
() => props.scrollTop,
(newScrollTop) => {
syncFromMain(newScrollTop)
},
)
defineExpose({
syncFromMain,
})
// ---- Drag Momentum Helpers ----
function cancelDragMomentum(){if(!dragMomentumActive)return;dragMomentumActive=false;dragMomentumFrame&&cancelAnimationFrame(dragMomentumFrame);dragMomentumFrame=null}
function computeDragVelocity(){if(dragSamples.length<2)return 0;const now=performance.now();const cutoff=now-80;while(dragSamples.length&&dragSamples[0].t<cutoff)dragSamples.shift();if(dragSamples.length<2)return 0;const first=dragSamples[0],last=dragSamples[dragSamples.length-1],dt=last.t-first.t;if(dt<=8)return 0;return (last.s-first.s)/dt}
function startDragMomentum(v){cancelDragMomentum();dragMomentumVelocity=v;dragMomentumPos=props.scrollTop;if(!isFinite(v)||Math.abs(v)<DRAG_MIN_V)return;dragMomentumActive=true;let lastTs=performance.now();const stepM=()=>{if(!dragMomentumActive)return;const now=performance.now(),dt=now-lastTs;lastTs=now;if(dt<=0){dragMomentumFrame=requestAnimationFrame(stepM);return}dragMomentumVelocity*=Math.exp(-DRAG_FRICTION_PER_MS*dt);dragMomentumPos=clampScroll(dragMomentumPos+dragMomentumVelocity*dt);const maxScroll=Math.max(0,props.totalVirtualWeeks*props.rowHeight-props.viewportHeight);if((dragMomentumPos<=0&&dragMomentumVelocity<0)||(dragMomentumPos>=maxScroll&&dragMomentumVelocity>0))dragMomentumVelocity=0;emit('scroll-to',dragMomentumPos);if(Math.abs(dragMomentumVelocity)<DRAG_MIN_V*0.6){cancelDragMomentum();return}dragMomentumFrame=requestAnimationFrame(stepM)};dragMomentumFrame=requestAnimationFrame(stepM)}
</script>
<style scoped>
@@ -184,19 +82,12 @@ defineExpose({
inset-inline-end: 0;
bottom: 0;
width: var(--month-w);
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
/* Transparent interactive overlay */
overflow: hidden;
z-index: 20;
cursor: ns-resize;
overscroll-behavior: contain;
}
.jogwheel-viewport::-webkit-scrollbar {
display: none;
}
.jogwheel-content {
position: relative;
width: 100%;
}
.jogwheel-viewport::-webkit-scrollbar { display: none; }
</style>