diff --git a/src/components/CalendarView.vue b/src/components/CalendarView.vue index b6e77b0..8035000 100644 --- a/src/components/CalendarView.vue +++ b/src/components/CalendarView.vue @@ -126,8 +126,9 @@ let lastScrollRange = { startVW: null, endVW: null } let pendingRebuild = false // Week label column drag scrolling state (no momentum) const isWeekColDragging = ref(false) -let weekColDragStartY = 0 let weekColDragStartScroll = 0 +let weekColAccum = 0 +let weekColPointerLocked = false function scheduleRebuild(reason) { if (pendingRebuild) return @@ -519,8 +520,9 @@ function handleWeekColMouseDown(e) { if (!rect) return if (e.clientX < rect.left || e.clientX > rect.right) return isWeekColDragging.value = true - weekColDragStartY = e.clientY weekColDragStartScroll = viewport.value.scrollTop + weekColAccum = 0 + if (viewport.value.requestPointerLock) viewport.value.requestPointerLock() window.addEventListener('mousemove', handleWeekColMouseMove, { passive: false }) window.addEventListener('mouseup', handleWeekColMouseUp, { passive: false }) e.preventDefault() @@ -529,9 +531,13 @@ function handleWeekColMouseDown(e) { function handleWeekColMouseMove(e) { if (!isWeekColDragging.value || !viewport.value) return - const dy = e.clientY - weekColDragStartY - // Natural: drag down moves view to earlier content (scroll up) - viewport.value.scrollTop = Math.max(0, weekColDragStartScroll - dy) + const dy = weekColPointerLocked ? e.movementY : e.clientY // movementY if locked + 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 + viewport.value.scrollTop = desired e.preventDefault() } @@ -542,9 +548,17 @@ function handleWeekColMouseUp(e) { 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') @@ -567,6 +581,7 @@ onMounted(() => { // Capture mousedown in viewport to allow dragging via week label column viewport.value.addEventListener('mousedown', handleWeekColMouseDown, true) } + document.addEventListener('pointerlockchange', handlePointerLockChange) const timer = setInterval(() => { calendarStore.updateCurrentDate() @@ -598,6 +613,7 @@ onBeforeUnmount(() => { rowProbeObserver.disconnect() } catch (e) {} } + document.removeEventListener('pointerlockchange', handlePointerLockChange) }) const handleDayMouseDown = (d) => { diff --git a/src/components/Jogwheel.vue b/src/components/Jogwheel.vue index bf81219..3b0ec19 100644 --- a/src/components/Jogwheel.vue +++ b/src/components/Jogwheel.vue @@ -21,9 +21,10 @@ const jogwheelContent = ref(null) const syncLock = ref(null) // Drag state (no momentum, 1:1 mapping) const isDragging = ref(false) -let dragStartY = 0 let mainStartScroll = 0 let dragScale = 1 // mainScrollPixels per mouse pixel +let accumDelta = 0 +let pointerLocked = false // Jogwheel content height is 1/10th of main calendar const jogwheelHeight = computed(() => { @@ -38,8 +39,8 @@ const handleJogwheelScroll = () => { function onDragMouseDown(e) { if (e.button !== 0) return isDragging.value = true - dragStartY = e.clientY 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 @@ -48,6 +49,10 @@ function onDragMouseDown(e) { } 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() @@ -55,9 +60,9 @@ function onDragMouseDown(e) { function onDragMouseMove(e) { if (!isDragging.value) return - const dy = e.clientY - dragStartY - // Natural content drag (drag down => scrollTop decreases) - let desired = mainStartScroll - dy * dragScale + 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 maxScroll = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight) if (desired > maxScroll) desired = maxScroll @@ -70,13 +75,23 @@ function onDragMouseUp(e) { isDragging.value = false window.removeEventListener('mousemove', onDragMouseMove) window.removeEventListener('mouseup', onDragMouseUp) + if (pointerLocked && document.exitPointerLock) document.exitPointerLock() e.preventDefault() } +function handlePointerLockChange() { + pointerLocked = document.pointerLockElement === jogwheelViewport.value + if (!pointerLocked && isDragging.value) { + // Pointer lock lost (Esc) -> end drag gracefully + onDragMouseUp(new MouseEvent('mouseup')) + } +} + onMounted(() => { if (jogwheelViewport.value) { jogwheelViewport.value.addEventListener('mousedown', onDragMouseDown) } + document.addEventListener('pointerlockchange', handlePointerLockChange) }) onBeforeUnmount(() => { @@ -85,6 +100,7 @@ onBeforeUnmount(() => { } window.removeEventListener('mousemove', onDragMouseMove) window.removeEventListener('mouseup', onDragMouseUp) + document.removeEventListener('pointerlockchange', handlePointerLockChange) }) const syncFromJogwheel = () => {