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).
332 lines
10 KiB
JavaScript
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 }
|
|
}
|