Improved scrolling on month bar (fast drag scroll, per-month wheel scroll, glitches eliminated).
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user