calendar/src/plugins/scrollManager.js
Leo Vasanko 9e3f7ddd57 Major new version (#2)
Release Notes

Architecture
- Component refactor: removed monoliths (`Calendar.vue`, `CalendarGrid.vue`); expanded granular view + header/control components.
- Dialog system introduced (`BaseDialog`, `SettingsDialog`).

State & Data
- Store redesigned: Map-based events + recurrence map; mutation counters.
- Local persistence + undo/redo history (custom plugins).

Date & Holidays
- Migrated all date logic to `date-fns` (+ tz).
- Added national holiday support (toggle + loading utilities).

Recurrence & Events
- Consolidated recurrence handling; fixes for monthly edge days (29–31), annual, multi‑day, and complex weekly repeats.
- Reliable splitting/moving/resizing/deletion of repeating and multi‑day events.

Interaction & UX
- Double‑tap to create events; improved drag (multi‑day + position retention).
- Scroll & inertial/momentum navigation; year change via numeric scroller.
- Movable event dialog; live settings application.

Performance
- Progressive / virtual week rendering, reduced off‑screen buffer.
- Targeted repaint strategy; minimized full re-renders.

Plugins Added
- History, undo normalization, persistence, scroll manager, virtual weeks.

Styling & Layout
- Responsive + compact layout refinements; header restructured.
- Simplified visual elements (removed dots/overflow text); holiday styling adjustments.

Reliability / Fixes
- Numerous recurrence, deletion, orientation/rotation, and event indexing corrections.
- Cross-browser fallback (Firefox week info).

Dependencies Added
- date-fns, date-fns-tz, date-holidays, pinia-plugin-persistedstate.

Net Change
- 28 files modified; ~4.4K insertions / ~2.2K deletions (major refactor + feature set).
2025-08-26 05:58:24 +01:00

332 lines
10 KiB
JavaScript

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 now = performance.now()
while (samples[0]?.timestamp < now - VELOCITY_MS) samples.shift()
samples.push({ timestamp: now, position: deltaY })
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() {
if (!dragging) return
dragging = false
window.removeEventListener('pointermove', onPointerMove, true)
window.removeEventListener('pointerup', endDrag, true)
window.removeEventListener('pointercancel', endDrag, true)
if (allowTouch) {
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', endDrag)
window.removeEventListener('touchcancel', endDrag)
}
document.removeEventListener('pointerlockchange', onPointerLockChange, true)
if (usingPointerLock && document.pointerLockElement === viewport.value) {
try {
document.exitPointerLock()
} catch {}
}
usingPointerLock = false
velocity = 0
if (samples.length) {
const first = samples[0]
const now = performance.now()
const last = samples[samples.length - 1]
const dy = last.position - first.position
if (Math.abs(dy) > 5) velocity = (-dy * speed) / (now - first.timestamp)
}
samples = []
startMomentum()
}
function onPointerMove(e) {
if (!dragging || document.pointerLockElement !== viewport.value) return
dragAccumY += e.movementY
applyDragByDelta(dragAccumY, reasonDragPointer)
e.preventDefault()
}
function onTouchMove(e) {
if (!dragging) return
if (e.touches.length !== 1) {
endDrag()
return
}
applyDragPosition(e.touches[0].clientY, reasonDragTouch)
e.preventDefault()
}
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', endDrag, true)
window.addEventListener('pointercancel', endDrag, true)
document.addEventListener('pointerlockchange', onPointerLockChange, true)
viewport.value.requestPointerLock({ unadjustedMovement: true })
}
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', endDrag, { passive: false })
window.addEventListener('touchcancel', endDrag, { 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 }) {
const scrollTop = ref(0)
let lastProgrammatic = null
let pendingTarget = null
let pendingAttempts = 0
let pendingLoopActive = false
function setScrollTop(val, reason = 'programmatic') {
let applied = val
if (viewport.value) {
const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight
if (applied > maxScroll) {
applied = maxScroll < 0 ? 0 : maxScroll
pendingTarget = val
pendingAttempts = 0
startPendingLoop()
}
if (applied < 0) applied = 0
viewport.value.scrollTop = applied
}
scrollTop.value = applied
lastProgrammatic = applied
scheduleRebuild(reason)
}
function onScroll() {
if (!viewport.value) return
const cur = viewport.value.scrollTop
const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight
let effective = cur
if (cur < 0) effective = 0
else if (cur > maxScroll) effective = maxScroll
scrollTop.value = effective
if (lastProgrammatic !== null && effective === lastProgrammatic) {
lastProgrammatic = null
return
}
if (pendingTarget !== null && Math.abs(effective - pendingTarget) > 4) {
pendingTarget = null
}
scheduleRebuild('scroll')
}
function startPendingLoop() {
if (pendingLoopActive || !viewport.value) return
pendingLoopActive = true
const loop = () => {
if (pendingTarget == null || !viewport.value) {
pendingLoopActive = false
return
}
const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight
if (pendingTarget <= maxScroll) {
setScrollTop(pendingTarget, 'pending-fulfill')
pendingTarget = null
pendingLoopActive = false
return
}
pendingAttempts++
if (pendingAttempts > 120) {
pendingTarget = null
pendingLoopActive = false
return
}
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
}
return { scrollTop, setScrollTop, onScroll }
}
export function createWeekColumnScrollManager({
viewport,
viewportHeight,
contentHeight,
setScrollTop,
}) {
const isWeekColDragging = ref(false)
function getWeekLabelRect() {
const headerYear = document.querySelector('.calendar-header .year-label')
if (headerYear) return headerYear.getBoundingClientRect()
const weekLabel = viewport.value?.querySelector('.week-row .week-label')
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) {
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return
isWeekColDragging.value = true
drag.handlePointerDown(e)
const end = () => {
isWeekColDragging.value = false
window.removeEventListener('pointerup', end, true)
window.removeEventListener('pointercancel', end, true)
}
window.addEventListener('pointerup', end, true)
window.addEventListener('pointercancel', end, true)
}
function handlePointerLockChange() {
if (document.pointerLockElement !== viewport.value) {
isWeekColDragging.value = false
}
}
return { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange }
}
export function createMonthScrollManager({
viewport,
viewportHeight,
contentHeight,
setScrollTop,
}) {
const drag = createMomentumDrag({
viewport,
viewportHeight,
contentHeight,
setScrollTop,
speed: 10,
reasonDragPointer: 'month-scroll-drag',
reasonDragTouch: 'month-scroll-touch',
reasonMomentum: 'month-scroll-momentum',
allowTouch: true,
hitTest: null,
})
function handleMonthScrollPointerDown(e) {
drag.handlePointerDown(e)
}
function handleMonthScrollTouchStart(e) {
drag.handleTouchStart(e)
}
function handleMonthScrollWheel(e) {
drag.cancelMomentum()
const currentScroll = viewport.value?.scrollTop || 0
const newScrollTop = currentScroll + e.deltaY * 10
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
const clamped = Math.max(0, Math.min(newScrollTop, maxScroll))
setScrollTop(clamped, 'month-scroll-wheel')
e.preventDefault()
}
return { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel }
}