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:
200
src/plugins/calendarHistory.js
Normal file
200
src/plugins/calendarHistory.js
Normal file
@@ -0,0 +1,200 @@
|
||||
// Custom lightweight undo/redo specifically for calendar store with Map support
|
||||
// Adds store.$history = { undo(), redo(), canUndo, canRedo, clear(), pushManual() }
|
||||
// Wraps action calls to create history entries only for meaningful mutations.
|
||||
|
||||
function deepCloneCalendarState(raw) {
|
||||
// We only need to snapshot keys we care about; omit volatile fields
|
||||
const { today, events, config, weekend } = raw
|
||||
return {
|
||||
today,
|
||||
weekend: Array.isArray(weekend) ? [...weekend] : weekend,
|
||||
config: JSON.parse(JSON.stringify(config)),
|
||||
events: new Map([...events].map(([k, v]) => [k, { ...v }])),
|
||||
}
|
||||
}
|
||||
|
||||
function restoreCalendarState(store, snap) {
|
||||
store.today = snap.today
|
||||
store.weekend = Array.isArray(snap.weekend) ? [...snap.weekend] : snap.weekend
|
||||
store.config = JSON.parse(JSON.stringify(snap.config))
|
||||
store.events = new Map([...snap.events].map(([k, v]) => [k, { ...v }]))
|
||||
store.eventsMutation = (store.eventsMutation + 1) % 1_000_000_000
|
||||
}
|
||||
|
||||
export function calendarHistory({ store }) {
|
||||
if (store.$id !== 'calendar') return
|
||||
|
||||
const max = 100 // history depth limit
|
||||
const history = [] // past states
|
||||
let pointer = -1 // index of current state in history
|
||||
let isRestoring = false
|
||||
let lastSerialized = null
|
||||
// Compound editing session (e.g. event dialog) flags
|
||||
let compoundActive = false
|
||||
let compoundBaseSig = null
|
||||
let compoundChanged = false
|
||||
|
||||
function serializeForComparison() {
|
||||
const evCount = store.events instanceof Map ? store.events.size : 0
|
||||
const em = store.eventsMutation || 0
|
||||
return `${em}|${evCount}|${store.today}|${JSON.stringify(store.config)}`
|
||||
}
|
||||
|
||||
function pushSnapshot() {
|
||||
if (isRestoring) return
|
||||
const sig = serializeForComparison()
|
||||
if (sig === lastSerialized) return
|
||||
// Drop any redo branch
|
||||
if (pointer < history.length - 1) history.splice(pointer + 1)
|
||||
history.push(deepCloneCalendarState(store))
|
||||
if (history.length > max) history.shift()
|
||||
pointer = history.length - 1
|
||||
lastSerialized = sig
|
||||
bumpIndicators()
|
||||
// console.log('[history] pushed', pointer, sig)
|
||||
}
|
||||
|
||||
function bumpIndicators() {
|
||||
if (typeof store.historyTick === 'number') {
|
||||
store.historyTick = (store.historyTick + 1) % 1_000_000_000
|
||||
}
|
||||
if (typeof store.historyCanUndo === 'boolean') {
|
||||
store.historyCanUndo = pointer > 0
|
||||
}
|
||||
if (typeof store.historyCanRedo === 'boolean') {
|
||||
store.historyCanRedo = pointer >= 0 && pointer < history.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
function markPotentialChange() {
|
||||
if (isRestoring) return
|
||||
if (compoundActive) {
|
||||
const sig = serializeForComparison()
|
||||
if (sig !== compoundBaseSig) compoundChanged = true
|
||||
return
|
||||
}
|
||||
pushSnapshot()
|
||||
}
|
||||
|
||||
function beginCompound() {
|
||||
if (compoundActive) return
|
||||
compoundActive = true
|
||||
compoundBaseSig = serializeForComparison()
|
||||
compoundChanged = false
|
||||
}
|
||||
function endCompound() {
|
||||
if (!compoundActive) return
|
||||
const finalSig = serializeForComparison()
|
||||
const changed = compoundChanged || finalSig !== compoundBaseSig
|
||||
compoundActive = false
|
||||
compoundBaseSig = null
|
||||
if (changed) pushSnapshot()
|
||||
else bumpIndicators() // session ended without change – still refresh flags
|
||||
}
|
||||
|
||||
function undo() {
|
||||
// Ensure any active compound changes are finalized before moving back
|
||||
if (compoundActive) endCompound()
|
||||
else {
|
||||
// If current state differs from last snapshot, push it so redo can restore it
|
||||
const curSig = serializeForComparison()
|
||||
if (curSig !== lastSerialized) pushSnapshot()
|
||||
}
|
||||
if (pointer <= 0) return
|
||||
pointer--
|
||||
isRestoring = true
|
||||
try {
|
||||
restoreCalendarState(store, history[pointer])
|
||||
lastSerialized = serializeForComparison()
|
||||
} finally {
|
||||
isRestoring = false
|
||||
}
|
||||
bumpIndicators()
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (compoundActive) endCompound()
|
||||
else {
|
||||
const curSig = serializeForComparison()
|
||||
if (curSig !== lastSerialized) pushSnapshot()
|
||||
}
|
||||
if (pointer >= history.length - 1) return
|
||||
pointer++
|
||||
isRestoring = true
|
||||
try {
|
||||
restoreCalendarState(store, history[pointer])
|
||||
lastSerialized = serializeForComparison()
|
||||
} finally {
|
||||
isRestoring = false
|
||||
}
|
||||
bumpIndicators()
|
||||
}
|
||||
|
||||
function clear() {
|
||||
history.length = 0
|
||||
pointer = -1
|
||||
lastSerialized = null
|
||||
bumpIndicators()
|
||||
}
|
||||
|
||||
// Wrap selected mutating actions to push snapshot AFTER they run if state changed.
|
||||
const actionNames = [
|
||||
'createEvent',
|
||||
'deleteEvent',
|
||||
'deleteFirstOccurrence',
|
||||
'deleteSingleOccurrence',
|
||||
'deleteFromOccurrence',
|
||||
'setEventRange',
|
||||
'splitMoveVirtualOccurrence',
|
||||
'splitRepeatSeries',
|
||||
'_terminateRepeatSeriesAtIndex',
|
||||
'toggleHolidays',
|
||||
'initializeHolidays',
|
||||
]
|
||||
|
||||
for (const name of actionNames) {
|
||||
if (typeof store[name] === 'function') {
|
||||
const original = store[name].bind(store)
|
||||
store[name] = (...args) => {
|
||||
const beforeSig = serializeForComparison()
|
||||
const result = original(...args)
|
||||
const afterSig = serializeForComparison()
|
||||
if (afterSig !== beforeSig) markPotentialChange()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture direct property edits (e.g., deep field edits signaled via touchEvents())
|
||||
store.$subscribe((mutation, _state) => {
|
||||
if (mutation.storeId !== 'calendar') return
|
||||
markPotentialChange()
|
||||
})
|
||||
|
||||
// Initial snapshot after hydration (next microtask to let persistence load)
|
||||
Promise.resolve().then(() => pushSnapshot())
|
||||
|
||||
store.$history = {
|
||||
undo,
|
||||
redo,
|
||||
clear,
|
||||
pushManual: pushSnapshot,
|
||||
beginCompound,
|
||||
endCompound,
|
||||
flush() {
|
||||
pushSnapshot()
|
||||
},
|
||||
get canUndo() {
|
||||
return pointer > 0
|
||||
},
|
||||
get canRedo() {
|
||||
return pointer >= 0 && pointer < history.length - 1
|
||||
},
|
||||
get compoundActive() {
|
||||
return compoundActive
|
||||
},
|
||||
_debug() {
|
||||
return { pointer, length: history.length }
|
||||
},
|
||||
}
|
||||
}
|
||||
57
src/plugins/calendarUndoNormalize.js
Normal file
57
src/plugins/calendarUndoNormalize.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// Pinia plugin to ensure calendar store keeps Map for events after undo/redo snapshots
|
||||
export function calendarUndoNormalize({ store }) {
|
||||
if (store.$id !== 'calendar') return
|
||||
|
||||
function fixEvents() {
|
||||
const evs = store.events
|
||||
if (evs instanceof Map) return
|
||||
// If serialized form { __map: true, data: [...] }
|
||||
if (evs && evs.__map && Array.isArray(evs.data)) {
|
||||
store.events = new Map(evs.data)
|
||||
return
|
||||
}
|
||||
// If an array of [k,v]
|
||||
if (Array.isArray(evs) && evs.every((x) => Array.isArray(x) && x.length === 2)) {
|
||||
store.events = new Map(evs)
|
||||
return
|
||||
}
|
||||
// If plain object, convert own enumerable props
|
||||
if (evs && typeof evs === 'object') {
|
||||
store.events = new Map(Object.entries(evs))
|
||||
}
|
||||
}
|
||||
|
||||
// Patch undo/redo if present (after pinia-undo is installed)
|
||||
const patchFns = ['undo', 'redo']
|
||||
for (const fn of patchFns) {
|
||||
if (typeof store[fn] === 'function') {
|
||||
const original = store[fn].bind(store)
|
||||
store[fn] = (...args) => {
|
||||
console.log(`[calendar history] ${fn} invoked`)
|
||||
const beforeType = store.events && store.events.constructor && store.events.constructor.name
|
||||
const out = original(...args)
|
||||
const afterRawType =
|
||||
store.events && store.events.constructor && store.events.constructor.name
|
||||
fixEvents()
|
||||
const finalType = store.events && store.events.constructor && store.events.constructor.name
|
||||
let size = null
|
||||
try {
|
||||
if (store.events instanceof Map) size = store.events.size
|
||||
else if (Array.isArray(store.events)) size = store.events.length
|
||||
} catch {}
|
||||
console.log(
|
||||
`[calendar history] ${fn} types: before=${beforeType} afterRaw=${afterRawType} final=${finalType} size=${size}`,
|
||||
)
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also watch all mutations (includes direct assigns and action commits)
|
||||
store.$subscribe(() => {
|
||||
fixEvents()
|
||||
})
|
||||
|
||||
// Initial sanity
|
||||
fixEvents()
|
||||
}
|
||||
24
src/plugins/persist.js
Normal file
24
src/plugins/persist.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// Simple Pinia persistence plugin supporting `persist: true` and Map serialization.
|
||||
export function persistPlugin({ store }) {
|
||||
if (!store.$options || !store.$options.persist) return
|
||||
const key = `pinia-${store.$id}`
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw) {
|
||||
const state = JSON.parse(raw, (k, v) => {
|
||||
if (v && v.__map === true && Array.isArray(v.data)) return new Map(v.data)
|
||||
return v
|
||||
})
|
||||
store.$patch(state)
|
||||
}
|
||||
} catch {}
|
||||
store.$subscribe((_mutation, state) => {
|
||||
try {
|
||||
const json = JSON.stringify(state, (_k, v) => {
|
||||
if (v instanceof Map) return { __map: true, data: Array.from(v.entries()) }
|
||||
return v
|
||||
})
|
||||
localStorage.setItem(key, json)
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
331
src/plugins/scrollManager.js
Normal file
331
src/plugins/scrollManager.js
Normal file
@@ -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 }
|
||||
}
|
||||
400
src/plugins/virtualWeeks.js
Normal file
400
src/plugins/virtualWeeks.js
Normal file
@@ -0,0 +1,400 @@
|
||||
import { ref } from 'vue'
|
||||
import { addDays, differenceInWeeks } from 'date-fns'
|
||||
import {
|
||||
toLocalString,
|
||||
fromLocalString,
|
||||
DEFAULT_TZ,
|
||||
getISOWeek,
|
||||
addDaysStr,
|
||||
pad,
|
||||
getLocalizedMonthName,
|
||||
monthAbbr,
|
||||
lunarPhaseSymbol,
|
||||
MAX_YEAR,
|
||||
getOccurrenceIndex,
|
||||
getVirtualOccurrenceEndDate,
|
||||
} from '@/utils/date'
|
||||
import { getHolidayForDate } from '@/utils/holidays'
|
||||
|
||||
/**
|
||||
* Factory handling virtual week window & incremental building.
|
||||
* Exposes reactive visibleWeeks plus scheduling functions.
|
||||
*/
|
||||
export function createVirtualWeekManager({
|
||||
calendarStore,
|
||||
viewport,
|
||||
viewportHeight,
|
||||
rowHeight,
|
||||
selection,
|
||||
baseDate,
|
||||
minVirtualWeek,
|
||||
maxVirtualWeek,
|
||||
contentHeight, // not currently used inside manager but kept for future
|
||||
}) {
|
||||
const visibleWeeks = ref([])
|
||||
let lastScrollRange = { startVW: null, endVW: null }
|
||||
let updating = false
|
||||
// Scroll refs injected later to break cyclic dependency with scroll manager
|
||||
let scrollTopRef = null
|
||||
let setScrollTopFn = null
|
||||
|
||||
function attachScroll(scrollTop, setScrollTop) {
|
||||
scrollTopRef = scrollTop
|
||||
setScrollTopFn = setScrollTop
|
||||
}
|
||||
|
||||
function getWeekIndex(date) {
|
||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
const firstDayOfWeek = addDays(date, -dayOffset)
|
||||
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
||||
}
|
||||
function getFirstDayForVirtualWeek(virtualWeek) {
|
||||
return addDays(baseDate.value, virtualWeek * 7)
|
||||
}
|
||||
|
||||
function createWeek(virtualWeek) {
|
||||
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
|
||||
const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
|
||||
const weekNumber = getISOWeek(isoAnchor)
|
||||
const days = []
|
||||
let cur = new Date(firstDay)
|
||||
let hasFirst = false
|
||||
let monthToLabel = null
|
||||
let labelYear = null
|
||||
|
||||
const repeatingBases = []
|
||||
if (calendarStore.events) {
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
if (ev.recur) repeatingBases.push(ev)
|
||||
}
|
||||
}
|
||||
|
||||
const collectEventsForDate = (dateStr, curDateObj) => {
|
||||
const storedEvents = []
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
if (!ev.recur) {
|
||||
const evEnd = toLocalString(
|
||||
addDays(fromLocalString(ev.startDate, DEFAULT_TZ), (ev.days || 1) - 1),
|
||||
DEFAULT_TZ,
|
||||
)
|
||||
if (dateStr >= ev.startDate && dateStr <= evEnd) {
|
||||
storedEvents.push({ ...ev, endDate: evEnd })
|
||||
}
|
||||
}
|
||||
}
|
||||
const dayEvents = [...storedEvents]
|
||||
for (const base of repeatingBases) {
|
||||
const baseEnd = toLocalString(
|
||||
addDays(fromLocalString(base.startDate, DEFAULT_TZ), (base.days || 1) - 1),
|
||||
DEFAULT_TZ,
|
||||
)
|
||||
if (dateStr >= base.startDate && dateStr <= baseEnd) {
|
||||
dayEvents.push({ ...base, endDate: baseEnd, _recurrenceIndex: 0, _baseId: base.id })
|
||||
continue
|
||||
}
|
||||
const spanDays = (base.days || 1) - 1
|
||||
const currentDate = curDateObj
|
||||
let occurrenceFound = false
|
||||
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
|
||||
const candidateStart = addDays(currentDate, -offset)
|
||||
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
|
||||
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
|
||||
if (occurrenceIndex !== null) {
|
||||
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
|
||||
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
|
||||
const virtualId = base.id + '_v_' + candidateStartStr
|
||||
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
|
||||
if (!alreadyExists) {
|
||||
dayEvents.push({
|
||||
...base,
|
||||
id: virtualId,
|
||||
startDate: candidateStartStr,
|
||||
endDate: virtualEndDate,
|
||||
_recurrenceIndex: occurrenceIndex,
|
||||
_baseId: base.id,
|
||||
})
|
||||
}
|
||||
occurrenceFound = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return dayEvents
|
||||
}
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dateStr = toLocalString(cur, DEFAULT_TZ)
|
||||
const dayEvents = collectEventsForDate(dateStr, fromLocalString(dateStr, DEFAULT_TZ))
|
||||
const dow = cur.getDay()
|
||||
const isFirst = cur.getDate() === 1
|
||||
if (isFirst) {
|
||||
hasFirst = true
|
||||
monthToLabel = cur.getMonth()
|
||||
labelYear = cur.getFullYear()
|
||||
}
|
||||
let displayText = String(cur.getDate())
|
||||
if (isFirst) {
|
||||
if (cur.getMonth() === 0) displayText = cur.getFullYear()
|
||||
else displayText = monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase()
|
||||
}
|
||||
let holiday = null
|
||||
if (calendarStore.config.holidays.enabled) {
|
||||
calendarStore._ensureHolidaysInitialized?.()
|
||||
holiday = getHolidayForDate(dateStr)
|
||||
}
|
||||
days.push({
|
||||
date: dateStr,
|
||||
dayOfMonth: cur.getDate(),
|
||||
displayText,
|
||||
monthClass: monthAbbr[cur.getMonth()],
|
||||
isToday: dateStr === calendarStore.today,
|
||||
isWeekend: calendarStore.weekend[dow],
|
||||
isFirstDay: isFirst,
|
||||
lunarPhase: lunarPhaseSymbol(cur),
|
||||
holiday,
|
||||
isHoliday: holiday !== null,
|
||||
isSelected:
|
||||
selection.value.startDate &&
|
||||
selection.value.dayCount > 0 &&
|
||||
dateStr >= selection.value.startDate &&
|
||||
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
||||
events: dayEvents,
|
||||
})
|
||||
cur = addDays(cur, 1)
|
||||
}
|
||||
let monthLabel = null
|
||||
if (hasFirst && monthToLabel !== null) {
|
||||
if (labelYear && labelYear <= MAX_YEAR) {
|
||||
let weeksSpan = 0
|
||||
const d = addDays(cur, -1)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const probe = addDays(cur, -1 + i * 7)
|
||||
d.setTime(probe.getTime())
|
||||
if (d.getMonth() === monthToLabel) weeksSpan++
|
||||
}
|
||||
const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1)
|
||||
weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))
|
||||
const year = String(labelYear).slice(-2)
|
||||
monthLabel = {
|
||||
text: `${getLocalizedMonthName(monthToLabel)} '${year}`,
|
||||
month: monthToLabel,
|
||||
weeksSpan,
|
||||
monthClass: monthAbbr[monthToLabel],
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
virtualWeek,
|
||||
weekNumber: pad(weekNumber),
|
||||
days,
|
||||
monthLabel,
|
||||
top: (virtualWeek - minVirtualWeek.value) * rowHeight.value,
|
||||
}
|
||||
}
|
||||
|
||||
function internalWindowCalc() {
|
||||
const buffer = 6
|
||||
const currentScrollTop = viewport.value?.scrollTop ?? scrollTopRef?.value ?? 0
|
||||
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)
|
||||
return { startVW, endVW }
|
||||
}
|
||||
|
||||
function updateVisibleWeeks(_reason) {
|
||||
const { startVW, endVW } = internalWindowCalc()
|
||||
// Prune outside
|
||||
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()
|
||||
}
|
||||
}
|
||||
// Add at most one week (ensuring contiguity)
|
||||
let added = false
|
||||
if (!visibleWeeks.value.length) {
|
||||
visibleWeeks.value.push(createWeek(startVW))
|
||||
added = true
|
||||
} else {
|
||||
visibleWeeks.value.sort((a, b) => a.virtualWeek - b.virtualWeek)
|
||||
const firstVW = visibleWeeks.value[0].virtualWeek
|
||||
const lastVW = visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek
|
||||
if (firstVW > startVW) {
|
||||
visibleWeeks.value.unshift(createWeek(firstVW - 1))
|
||||
added = true
|
||||
} else {
|
||||
let gapInserted = false
|
||||
for (let i = 0; i < visibleWeeks.value.length - 1; i++) {
|
||||
const curVW = visibleWeeks.value[i].virtualWeek
|
||||
const nextVW = visibleWeeks.value[i + 1].virtualWeek
|
||||
if (nextVW - curVW > 1 && curVW < endVW) {
|
||||
visibleWeeks.value.splice(i + 1, 0, createWeek(curVW + 1))
|
||||
added = true
|
||||
gapInserted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!gapInserted && lastVW < endVW) {
|
||||
visibleWeeks.value.push(createWeek(lastVW + 1))
|
||||
added = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Coverage check
|
||||
const firstAfter = visibleWeeks.value[0].virtualWeek
|
||||
const lastAfter = visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek
|
||||
let contiguous = true
|
||||
for (let i = 0; i < visibleWeeks.value.length - 1; i++) {
|
||||
if (visibleWeeks.value[i + 1].virtualWeek !== visibleWeeks.value[i].virtualWeek + 1) {
|
||||
contiguous = false
|
||||
break
|
||||
}
|
||||
}
|
||||
const coverageComplete =
|
||||
firstAfter <= startVW &&
|
||||
lastAfter >= endVW &&
|
||||
contiguous &&
|
||||
visibleWeeks.value.length === endVW - startVW + 1
|
||||
if (!coverageComplete) return false
|
||||
if (
|
||||
lastScrollRange.startVW === startVW &&
|
||||
lastScrollRange.endVW === endVW &&
|
||||
!added &&
|
||||
visibleWeeks.value.length
|
||||
) {
|
||||
return true
|
||||
}
|
||||
lastScrollRange = { startVW, endVW }
|
||||
return true
|
||||
}
|
||||
|
||||
function scheduleWindowUpdate(reason) {
|
||||
if (updating) return
|
||||
updating = true
|
||||
const run = () => {
|
||||
updating = false
|
||||
updateVisibleWeeks(reason) || scheduleWindowUpdate('incremental-build')
|
||||
}
|
||||
if ('requestIdleCallback' in window) requestIdleCallback(run, { timeout: 16 })
|
||||
else requestAnimationFrame(run)
|
||||
}
|
||||
function resetWeeks(reason = 'reset') {
|
||||
visibleWeeks.value = []
|
||||
lastScrollRange = { startVW: null, endVW: null }
|
||||
scheduleWindowUpdate(reason)
|
||||
}
|
||||
|
||||
// Reflective update of only events inside currently visible weeks (keeps week objects stable)
|
||||
function refreshEvents(reason = 'events-refresh') {
|
||||
if (!visibleWeeks.value.length) return
|
||||
const repeatingBases = []
|
||||
if (calendarStore.events) {
|
||||
for (const ev of calendarStore.events.values()) if (ev.recur) repeatingBases.push(ev)
|
||||
}
|
||||
const selStart = selection.value.startDate
|
||||
const selCount = selection.value.dayCount
|
||||
const selEnd = selStart && selCount > 0 ? addDaysStr(selStart, selCount - 1) : null
|
||||
for (const week of visibleWeeks.value) {
|
||||
for (const day of week.days) {
|
||||
const dateStr = day.date
|
||||
// Update selection flag
|
||||
if (selStart && selEnd) day.isSelected = dateStr >= selStart && dateStr <= selEnd
|
||||
else day.isSelected = false
|
||||
// Rebuild events list for this day
|
||||
const storedEvents = []
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
if (!ev.recur) {
|
||||
const evEnd = toLocalString(
|
||||
addDays(fromLocalString(ev.startDate, DEFAULT_TZ), (ev.days || 1) - 1),
|
||||
DEFAULT_TZ,
|
||||
)
|
||||
if (dateStr >= ev.startDate && dateStr <= evEnd) {
|
||||
storedEvents.push({ ...ev, endDate: evEnd })
|
||||
}
|
||||
}
|
||||
}
|
||||
const dayEvents = [...storedEvents]
|
||||
for (const base of repeatingBases) {
|
||||
const baseEndStr = toLocalString(
|
||||
addDays(fromLocalString(base.startDate, DEFAULT_TZ), (base.days || 1) - 1),
|
||||
DEFAULT_TZ,
|
||||
)
|
||||
if (dateStr >= base.startDate && dateStr <= baseEndStr) {
|
||||
dayEvents.push({ ...base, endDate: baseEndStr, _recurrenceIndex: 0, _baseId: base.id })
|
||||
continue
|
||||
}
|
||||
const spanDays = (base.days || 1) - 1
|
||||
const currentDate = fromLocalString(dateStr, DEFAULT_TZ)
|
||||
let occurrenceFound = false
|
||||
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
|
||||
const candidateStart = addDays(currentDate, -offset)
|
||||
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
|
||||
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
|
||||
if (occurrenceIndex !== null) {
|
||||
const virtualEndDate = getVirtualOccurrenceEndDate(
|
||||
base,
|
||||
candidateStartStr,
|
||||
DEFAULT_TZ,
|
||||
)
|
||||
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
|
||||
const virtualId = base.id + '_v_' + candidateStartStr
|
||||
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
|
||||
if (!alreadyExists) {
|
||||
dayEvents.push({
|
||||
...base,
|
||||
id: virtualId,
|
||||
startDate: candidateStartStr,
|
||||
endDate: virtualEndDate,
|
||||
_recurrenceIndex: occurrenceIndex,
|
||||
_baseId: base.id,
|
||||
})
|
||||
}
|
||||
occurrenceFound = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
day.events = dayEvents
|
||||
}
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('[VirtualWeeks] refreshEvents', reason, { weeks: visibleWeeks.value.length })
|
||||
}
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
const top = addDays(new Date(calendarStore.now), -21)
|
||||
const targetWeekIndex = getWeekIndex(top)
|
||||
const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
if (setScrollTopFn) setScrollTopFn(newScrollTop, 'go-to-today')
|
||||
}
|
||||
|
||||
function handleHeaderYearChange({ scrollTop }) {
|
||||
const maxScroll = contentHeight.value - viewportHeight.value
|
||||
const clamped = Math.max(0, Math.min(scrollTop, isFinite(maxScroll) ? maxScroll : scrollTop))
|
||||
if (setScrollTopFn) setScrollTopFn(clamped, 'header-year-change')
|
||||
resetWeeks('header-year-change')
|
||||
}
|
||||
|
||||
return {
|
||||
visibleWeeks,
|
||||
scheduleWindowUpdate,
|
||||
resetWeeks,
|
||||
updateVisibleWeeks,
|
||||
refreshEvents,
|
||||
getWeekIndex,
|
||||
getFirstDayForVirtualWeek,
|
||||
goToToday,
|
||||
handleHeaderYearChange,
|
||||
attachScroll,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user