203 lines
5.5 KiB
Vue
203 lines
5.5 KiB
Vue
<template>
|
|
<div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll">
|
|
<div
|
|
class="jogwheel-content"
|
|
ref="jogwheelContent"
|
|
:style="{ height: jogwheelHeight + 'px' }"
|
|
></div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
|
|
const props = defineProps({
|
|
totalVirtualWeeks: { type: Number, required: true },
|
|
rowHeight: { type: Number, required: true },
|
|
viewportHeight: { type: Number, required: true },
|
|
scrollTop: { type: Number, required: true },
|
|
})
|
|
|
|
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
|
|
|
|
// Jogwheel content height is 1/10th of main calendar
|
|
const jogwheelHeight = computed(() => {
|
|
return (props.totalVirtualWeeks * props.rowHeight) / 10
|
|
})
|
|
|
|
const handleJogwheelScroll = () => {
|
|
if (syncLock.value === 'jogwheel') return
|
|
syncFromJogwheel()
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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 maxScroll = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
|
if (desired > maxScroll) desired = maxScroll
|
|
emit('scroll-to', desired)
|
|
e.preventDefault()
|
|
}
|
|
|
|
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 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(() => {
|
|
if (jogwheelViewport.value) {
|
|
jogwheelViewport.value.removeEventListener('mousedown', onDragMouseDown)
|
|
}
|
|
window.removeEventListener('mousemove', onDragMouseMove)
|
|
window.removeEventListener('mouseup', onDragMouseUp)
|
|
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
})
|
|
|
|
const syncFromJogwheel = () => {
|
|
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
|
|
|
syncLock.value = 'main'
|
|
|
|
const jogScrollable = Math.max(
|
|
0,
|
|
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
|
)
|
|
const mainScrollable = Math.max(
|
|
0,
|
|
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
|
)
|
|
|
|
if (jogScrollable > 0) {
|
|
const ratio = jogwheelViewport.value.scrollTop / jogScrollable
|
|
|
|
// Emit scroll event to parent to update main viewport
|
|
emit('scroll-to', ratio * mainScrollable)
|
|
}
|
|
|
|
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,
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.jogwheel-viewport {
|
|
position: absolute;
|
|
top: 0;
|
|
inset-inline-end: 0;
|
|
bottom: 0;
|
|
width: var(--month-w);
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
scrollbar-width: none;
|
|
z-index: 20;
|
|
cursor: ns-resize;
|
|
}
|
|
|
|
.jogwheel-viewport::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.jogwheel-content {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
</style>
|