Major new version #2

Merged
LeoVasanko merged 86 commits from vol002 into main 2025-08-26 05:58:24 +01:00
2 changed files with 295 additions and 161 deletions
Showing only changes of commit b07c0808ab - Show all commits

View File

@ -57,7 +57,7 @@ watch(
() => [calendarStore.selectedDate, calendarStore.rangeStartDate], () => [calendarStore.selectedDate, calendarStore.rangeStartDate],
() => { () => {
if (calendarStore.selectedDate || calendarStore.rangeStartDate) { if (calendarStore.selectedDate || calendarStore.rangeStartDate) {
scheduleRebuild('selection-change') scheduleDataRebuild('selection-change')
} }
}, },
{ flush: 'sync' }, { flush: 'sync' },
@ -115,28 +115,30 @@ const contentHeight = computed(() => {
const visibleWeeks = ref([]) const visibleWeeks = ref([])
let lastScrollRange = { startVW: null, endVW: null } let lastScrollRange = { startVW: null, endVW: null }
let windowTimer = null
let rebuildTimer = null let dataTimer = null
let lastReason = null const WINDOW_DEBOUNCE_MS = 30
const REBUILD_DEBOUNCE_MS = 40 const DATA_DEBOUNCE_MS = 40
function scheduleWindowUpdate(reason) {
function scheduleRebuild(reason) { if (windowTimer) return
lastReason = lastReason ? lastReason + ',' + reason : reason windowTimer = setTimeout(() => {
if (rebuildTimer) return windowTimer = null
rebuildTimer = setTimeout(() => { const fn = () => updateVisibleWeeks(reason)
const r = lastReason if ('requestIdleCallback' in window) requestIdleCallback(fn, { timeout: 80 })
rebuildTimer = null else requestAnimationFrame(fn)
lastReason = null }, WINDOW_DEBOUNCE_MS)
const fn = () => rebuildVisibleWeeks(r) }
if ('requestIdleCallback' in window) { function scheduleDataRebuild(reason) {
requestIdleCallback(fn, { timeout: 120 }) if (dataTimer) return
} else { dataTimer = setTimeout(() => {
requestAnimationFrame(fn) dataTimer = null
} const fn = () => rebuildVisibleWeeks(reason)
}, REBUILD_DEBOUNCE_MS) if ('requestIdleCallback' in window) requestIdleCallback(fn, { timeout: 120 })
else requestAnimationFrame(fn)
}, DATA_DEBOUNCE_MS)
} }
const scrollManager = createScrollManager({ viewport, scheduleRebuild }) const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate })
const { scrollTop, setScrollTop, onScroll } = scrollManager const { scrollTop, setScrollTop, onScroll } = scrollManager
@ -174,6 +176,44 @@ const selectedDateRange = computed(() => {
) )
}) })
function updateVisibleWeeks(reason) {
const buffer = 10
const currentScrollTop = viewport.value?.scrollTop ?? scrollTop.value
const startIdx = Math.floor((currentScrollTop - buffer * rowHeight.value) / rowHeight.value)
const endIdx = Math.ceil(
(currentScrollTop + viewportHeight.value + buffer * rowHeight.value) / rowHeight.value,
)
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
const endVW = Math.min(maxVirtualWeek.value, endIdx + minVirtualWeek.value)
if (
lastScrollRange.startVW === startVW &&
lastScrollRange.endVW === endVW &&
visibleWeeks.value.length
)
return
if (visibleWeeks.value.length) {
while (visibleWeeks.value.length && visibleWeeks.value[0].virtualWeek < startVW)
visibleWeeks.value.shift()
while (
visibleWeeks.value.length &&
visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek > endVW
)
visibleWeeks.value.pop()
let needFirst = visibleWeeks.value[0]?.virtualWeek
if (visibleWeeks.value.length === 0) needFirst = endVW + 1
for (let vw = (needFirst ?? startVW) - 1; vw >= startVW; vw--)
visibleWeeks.value.unshift(createWeek(vw))
let needLast = visibleWeeks.value[visibleWeeks.value.length - 1]?.virtualWeek
for (let vw = (needLast ?? startVW - 1) + 1; vw <= endVW; vw++)
visibleWeeks.value.push(createWeek(vw))
lastScrollRange = { startVW, endVW }
return
}
const weeks = []
for (let vw = startVW; vw <= endVW; vw++) weeks.push(createWeek(vw))
visibleWeeks.value = weeks
lastScrollRange = { startVW, endVW }
}
function rebuildVisibleWeeks(reason) { function rebuildVisibleWeeks(reason) {
const buffer = 10 const buffer = 10
const currentScrollTop = viewport.value?.scrollTop ?? scrollTop.value const currentScrollTop = viewport.value?.scrollTop ?? scrollTop.value
@ -183,28 +223,16 @@ function rebuildVisibleWeeks(reason) {
) )
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value) const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
const endVW = Math.min(maxVirtualWeek.value, endIdx + minVirtualWeek.value) const endVW = Math.min(maxVirtualWeek.value, endIdx + minVirtualWeek.value)
console.debug('[CalendarView] rebuildVisibleWeeks', {
reason,
currentScrollTop,
startIdx,
endIdx,
startVW,
endVW,
rowHeight: rowHeight.value,
})
if (
reason === 'scroll' &&
lastScrollRange.startVW === startVW &&
lastScrollRange.endVW === endVW &&
visibleWeeks.value.length
) {
return
}
const weeks = [] const weeks = []
for (let vw = startVW; vw <= endVW; vw++) weeks.push(createWeek(vw)) for (let vw = startVW; vw <= endVW; vw++) weeks.push(createWeek(vw))
visibleWeeks.value = weeks visibleWeeks.value = weeks
lastScrollRange = { startVW, endVW } lastScrollRange = { startVW, endVW }
console.debug('[CalendarView] rebuildVisibleWeeks', {
reason,
startVW,
endVW,
count: weeks.length,
})
} }
function computeRowHeight() { function computeRowHeight() {
@ -235,6 +263,7 @@ function measureFromProbe() {
rowHeight.value = newH rowHeight.value = newH
const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH
setScrollTop(newScrollTop, 'row-height-change') setScrollTop(newScrollTop, 'row-height-change')
scheduleDataRebuild('row-height-change')
} }
} }
@ -562,7 +591,7 @@ onMounted(() => {
}, 60000) }, 60000)
// Initial build after mount & measurement // Initial build after mount & measurement
scheduleRebuild('init') scheduleDataRebuild('init')
if (window.ResizeObserver && rowProbe.value) { if (window.ResizeObserver && rowProbe.value) {
rowProbeObserver = new ResizeObserver(() => { rowProbeObserver = new ResizeObserver(() => {
@ -646,7 +675,7 @@ watch(
const newTopWeekIndex = getWeekIndex(currentTopDate) const newTopWeekIndex = getWeekIndex(currentTopDate)
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
setScrollTop(newScroll, 'first-day-change') setScrollTop(newScroll, 'first-day-change')
scheduleRebuild('first-day-change') scheduleDataRebuild('first-day-change')
}) })
}, },
) )
@ -655,7 +684,7 @@ watch(
watch( watch(
() => calendarStore.events, () => calendarStore.events,
() => { () => {
scheduleRebuild('events') scheduleDataRebuild('events')
}, },
{ deep: true }, { deep: true },
) )
@ -664,7 +693,7 @@ watch(
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
if (viewport.value) viewportHeight.value = viewport.value.clientHeight if (viewport.value) viewportHeight.value = viewport.value.clientHeight
measureFromProbe() measureFromProbe()
scheduleRebuild('resize') scheduleWindowUpdate('resize')
}) })
</script> </script>

View File

@ -1,5 +1,185 @@
import { ref } from 'vue' import { ref } from 'vue'
function createMomentumDrag({
viewport,
viewportHeight,
contentHeight,
setScrollTop,
speed,
reasonDragPointer,
reasonDragTouch,
reasonMomentum,
allowTouch,
hitTest,
}) {
let dragging = false
let startY = 0
let startScroll = 0
let velocity = 0
let samples = [] // { timestamp, position }
let momentumActive = false
let momentumFrame = null
let dragAccumY = 0 // used when pointer lock active
let usingPointerLock = false
const frictionPerMs = 0.0018
const MIN_V = 0.03
const VELOCITY_MS = 50
function cancelMomentum() {
if (!momentumActive) return
momentumActive = false
if (momentumFrame) cancelAnimationFrame(momentumFrame)
momentumFrame = null
}
function startMomentum() {
if (Math.abs(velocity) < MIN_V) return
cancelMomentum()
momentumActive = true
let lastTs = performance.now()
const step = () => {
if (!momentumActive) return
const now = performance.now()
const dt = now - lastTs
lastTs = now
if (dt <= 0) {
momentumFrame = requestAnimationFrame(step)
return
}
const decay = Math.exp(-frictionPerMs * dt)
velocity *= decay
const delta = velocity * dt
if (viewport.value) {
let cur = viewport.value.scrollTop
let target = cur + delta
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
if (target < 0) {
target = 0
velocity = 0
} else if (target > maxScroll) {
target = maxScroll
velocity = 0
}
setScrollTop(target, reasonMomentum)
}
if (Math.abs(velocity) < MIN_V * 0.6) {
momentumActive = false
return
}
momentumFrame = requestAnimationFrame(step)
}
momentumFrame = requestAnimationFrame(step)
}
function applyDragByDelta(deltaY, reason) {
const newScrollTop = startScroll - deltaY * speed
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
const clamped = Math.max(0, Math.min(newScrollTop, maxScroll))
setScrollTop(clamped, reason)
}
function applyDragPosition(clientY, reason) {
const deltaY = clientY - startY
applyDragByDelta(deltaY, reason)
}
function endDrag() {
dragging = false
window.removeEventListener('pointermove', onPointerMove, true)
window.removeEventListener('pointerup', onPointerUp, true)
window.removeEventListener('pointercancel', onPointerUp, true)
if (allowTouch) {
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', onTouchEnd)
window.removeEventListener('touchcancel', onTouchEnd)
}
document.removeEventListener('pointerlockchange', onPointerLockChange, true)
if (usingPointerLock && document.pointerLockElement === viewport.value) {
try {
document.exitPointerLock()
} catch {}
}
usingPointerLock = false
if (samples.length) {
const first = samples[0]
const now = performance.now()
const last = samples[samples.length - 1]
const dy = last.position - first.position
velocity = (-dy * speed) / (now - first.timestamp)
} else velocity = 0
console.log(velocity, samples)
samples = []
startMomentum()
}
function onPointerMove(e) {
if (!dragging) return
if (document.pointerLockElement === viewport.value) {
// Use movementY deltas under pointer lock
const now = performance.now()
dragAccumY += e.movementY
while (samples[0]?.timestamp < now - VELOCITY_MS) samples.shift()
samples.push({ timestamp: now, position: dragAccumY })
applyDragByDelta(dragAccumY, reasonDragPointer)
}
e.preventDefault()
}
function onPointerUp() {
if (dragging) endDrag()
}
function onTouchMove(e) {
if (!dragging) return
const t = e.touches[0]
const now = performance.now()
while (samples[0]?.timestamp < now - VELOCITY_MS) samples.shift()
samples.push({ timestamp: now, position: t.clientY })
applyDragPosition(t.clientY, reasonDragTouch)
e.preventDefault()
}
function onTouchEnd() {
if (dragging) endDrag()
}
function handlePointerDown(e) {
if (e.button !== undefined && e.button !== 0) return
if (hitTest && !hitTest(e)) return
e.preventDefault()
cancelMomentum()
dragging = true
startY = e.clientY
startScroll = viewport.value?.scrollTop || 0
velocity = 0
dragAccumY = 0
samples = [{ timestamp: performance.now(), position: e.clientY }]
window.addEventListener('pointermove', onPointerMove, true)
window.addEventListener('pointerup', onPointerUp, true)
window.addEventListener('pointercancel', onPointerUp, true)
document.addEventListener('pointerlockchange', onPointerLockChange, true)
viewport.value.requestPointerLock()
}
function handleTouchStart(e) {
if (!allowTouch) return
if (e.touches.length !== 1) return
if (hitTest && !hitTest(e.touches[0])) return
cancelMomentum()
dragging = true
const t = e.touches[0]
startY = t.clientY
startScroll = viewport.value?.scrollTop || 0
velocity = 0
dragAccumY = 0
samples = [{ timestamp: performance.now(), position: t.clientY }]
window.addEventListener('touchmove', onTouchMove, { passive: false })
window.addEventListener('touchend', onTouchEnd, { passive: false })
window.addEventListener('touchcancel', onTouchEnd, { passive: false })
e.preventDefault()
}
function onPointerLockChange() {
const lockedEl = document.pointerLockElement
if (dragging && lockedEl === viewport.value) {
usingPointerLock = true
return
}
if (dragging && usingPointerLock && lockedEl !== viewport.value) endDrag()
if (!dragging) usingPointerLock = false
}
return { handlePointerDown, handleTouchStart, cancelMomentum }
}
export function createScrollManager({ viewport, scheduleRebuild }) { export function createScrollManager({ viewport, scheduleRebuild }) {
const scrollTop = ref(0) const scrollTop = ref(0)
let lastProgrammatic = null let lastProgrammatic = null
@ -79,75 +259,47 @@ export function createWeekColumnScrollManager({
setScrollTop, setScrollTop,
}) { }) {
const isWeekColDragging = ref(false) const isWeekColDragging = ref(false)
let weekColDragStartScroll = 0
let weekColAccum = 0
let weekColPointerLocked = false
let weekColLastY = 0
function getWeekLabelRect() { function getWeekLabelRect() {
const headerYear = document.querySelector('.calendar-header .year-label') const headerYear = document.querySelector('.calendar-header .year-label')
if (headerYear) return headerYear.getBoundingClientRect() if (headerYear) return headerYear.getBoundingClientRect()
const weekLabel = viewport.value?.querySelector('.week-row .week-label') const weekLabel = viewport.value?.querySelector('.week-row .week-label')
return weekLabel ? weekLabel.getBoundingClientRect() : null return weekLabel ? weekLabel.getBoundingClientRect() : null
} }
const drag = createMomentumDrag({
viewport,
viewportHeight,
contentHeight,
setScrollTop,
speed: 1,
reasonDragPointer: 'week-col-drag',
reasonDragTouch: 'week-col-drag',
reasonMomentum: 'week-col-momentum',
allowTouch: false,
hitTest: (e) => {
const rect = getWeekLabelRect()
if (!rect) return false
const x = e.clientX ?? e.pageX
return x >= rect.left && x <= rect.right
},
})
function handleWeekColMouseDown(e) { function handleWeekColMouseDown(e) {
if (e.button !== 0) return
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return
if (!viewport.value) return
const rect = getWeekLabelRect()
if (!rect) return
if (e.clientX < rect.left || e.clientX > rect.right) return
isWeekColDragging.value = true isWeekColDragging.value = true
weekColDragStartScroll = viewport.value.scrollTop drag.handlePointerDown(e)
weekColAccum = 0 const end = () => {
weekColLastY = e.clientY isWeekColDragging.value = false
if (viewport.value.requestPointerLock) viewport.value.requestPointerLock() window.removeEventListener('pointerup', end, true)
window.addEventListener('mousemove', handleWeekColMouseMove, { passive: false }) window.removeEventListener('pointercancel', end, true)
window.addEventListener('mouseup', handleWeekColMouseUp, { passive: false })
e.preventDefault()
e.stopPropagation()
}
function handleWeekColMouseMove(e) {
if (!isWeekColDragging.value || !viewport.value) return
let dy
if (weekColPointerLocked) {
dy = e.movementY
} else {
dy = e.clientY - weekColLastY
weekColLastY = e.clientY
} }
weekColAccum += dy window.addEventListener('pointerup', end, true)
let desired = weekColDragStartScroll - weekColAccum window.addEventListener('pointercancel', end, true)
if (desired < 0) desired = 0
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
if (desired > maxScroll) desired = maxScroll
setScrollTop(desired, 'week-col-drag')
e.preventDefault()
} }
function handleWeekColMouseUp(e) {
if (!isWeekColDragging.value) return
isWeekColDragging.value = false
window.removeEventListener('mousemove', handleWeekColMouseMove)
window.removeEventListener('mouseup', handleWeekColMouseUp)
if (weekColPointerLocked && document.exitPointerLock) document.exitPointerLock()
e.preventDefault()
}
function handlePointerLockChange() { function handlePointerLockChange() {
weekColPointerLocked = document.pointerLockElement === viewport.value if (document.pointerLockElement !== viewport.value) {
if (!weekColPointerLocked && isWeekColDragging.value) { isWeekColDragging.value = false
handleWeekColMouseUp(new MouseEvent('mouseup'))
} }
} }
return { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange }
return {
isWeekColDragging,
handleWeekColMouseDown,
handlePointerLockChange,
}
} }
export function createMonthScrollManager({ export function createMonthScrollManager({
@ -156,79 +308,32 @@ export function createMonthScrollManager({
contentHeight, contentHeight,
setScrollTop, setScrollTop,
}) { }) {
let dragging = false const drag = createMomentumDrag({
let startY = 0 viewport,
let startScroll = 0 viewportHeight,
const SPEED = 10 contentHeight,
setScrollTop,
function applyDrag(clientY, reason) { speed: 10,
const deltaY = clientY - startY reasonDragPointer: 'month-scroll-drag',
const newScrollTop = startScroll - deltaY * SPEED reasonDragTouch: 'month-scroll-touch',
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) reasonMomentum: 'month-scroll-momentum',
const clamped = Math.max(0, Math.min(newScrollTop, maxScroll)) allowTouch: true,
setScrollTop(clamped, reason) hitTest: null,
} })
function endDrag() {
dragging = false
window.removeEventListener('pointermove', onPointerMove, true)
window.removeEventListener('pointerup', onPointerUp, true)
window.removeEventListener('pointercancel', onPointerUp, true)
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', onTouchEnd)
window.removeEventListener('touchcancel', onTouchEnd)
}
function onPointerMove(e) {
if (!dragging) return
applyDrag(e.clientY, 'month-scroll-drag')
e.preventDefault()
}
function onPointerUp() {
if (dragging) endDrag()
}
function onTouchMove(e) {
if (!dragging) return
const t = e.touches[0]
applyDrag(t.clientY, 'month-scroll-touch')
e.preventDefault()
}
function onTouchEnd() {
if (dragging) endDrag()
}
function handleMonthScrollPointerDown(e) { function handleMonthScrollPointerDown(e) {
if (e.button !== undefined && e.button !== 0) return drag.handlePointerDown(e)
e.preventDefault()
dragging = true
startY = e.clientY
startScroll = viewport.value?.scrollTop || 0
window.addEventListener('pointermove', onPointerMove, true)
window.addEventListener('pointerup', onPointerUp, true)
window.addEventListener('pointercancel', onPointerUp, true)
} }
function handleMonthScrollTouchStart(e) { function handleMonthScrollTouchStart(e) {
if (e.touches.length !== 1) return drag.handleTouchStart(e)
dragging = true
const t = e.touches[0]
startY = t.clientY
startScroll = viewport.value?.scrollTop || 0
window.addEventListener('touchmove', onTouchMove, { passive: false })
window.addEventListener('touchend', onTouchEnd, { passive: false })
window.addEventListener('touchcancel', onTouchEnd, { passive: false })
e.preventDefault()
} }
function handleMonthScrollWheel(e) { function handleMonthScrollWheel(e) {
drag.cancelMomentum()
const currentScroll = viewport.value?.scrollTop || 0 const currentScroll = viewport.value?.scrollTop || 0
const newScrollTop = currentScroll + e.deltaY * SPEED const newScrollTop = currentScroll + e.deltaY * 10
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
const clamped = Math.max(0, Math.min(newScrollTop, maxScroll)) const clamped = Math.max(0, Math.min(newScrollTop, maxScroll))
setScrollTop(clamped, 'month-scroll-wheel') setScrollTop(clamped, 'month-scroll-wheel')
e.preventDefault() e.preventDefault()
} }
return { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel } return { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel }
} }