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).
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user