calendar/src/components/Jogwheel.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>