Major new version #2
@ -4,7 +4,11 @@ import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import CalendarHeader from '@/components/CalendarHeader.vue'
|
||||
import CalendarWeek from '@/components/CalendarWeek.vue'
|
||||
import HeaderControls from '@/components/HeaderControls.vue'
|
||||
import Jogwheel from '@/components/Jogwheel.vue'
|
||||
import {
|
||||
createScrollManager,
|
||||
createWeekColumnScrollManager,
|
||||
createMonthScrollManager,
|
||||
} from '@/plugins/scrollManager'
|
||||
import {
|
||||
getLocalizedMonthName,
|
||||
monthAbbr,
|
||||
@ -38,10 +42,8 @@ function createEventFromSelection() {
|
||||
}
|
||||
}
|
||||
|
||||
const scrollTop = ref(0)
|
||||
const viewportHeight = ref(600)
|
||||
const rowHeight = ref(64)
|
||||
// Probe element & observer for dynamic var(--row-h) changes
|
||||
const rowProbe = ref(null)
|
||||
let rowProbeObserver = null
|
||||
|
||||
@ -51,17 +53,14 @@ const selection = ref({ startDate: null, dayCount: 0 })
|
||||
const isDragging = ref(false)
|
||||
const dragAnchor = ref(null)
|
||||
|
||||
// Rebuild visible weeks whenever selection changes so day.isSelected stays in sync.
|
||||
watch(
|
||||
() => [selection.value.startDate, selection.value.dayCount],
|
||||
() => [calendarStore.selectedDate, calendarStore.rangeStartDate],
|
||||
() => {
|
||||
// Skip if no selection (both null/0) to avoid unnecessary work.
|
||||
if (!selection.value.startDate || selection.value.dayCount === 0) {
|
||||
scheduleRebuild('selection-clear')
|
||||
} else {
|
||||
scheduleRebuild('selection')
|
||||
if (calendarStore.selectedDate || calendarStore.rangeStartDate) {
|
||||
scheduleRebuild('selection-change')
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
const DOUBLE_TAP_DELAY = 300
|
||||
@ -110,6 +109,52 @@ const totalVirtualWeeks = computed(() => {
|
||||
return maxVirtualWeek.value - minVirtualWeek.value + 1
|
||||
})
|
||||
|
||||
const contentHeight = computed(() => {
|
||||
return totalVirtualWeeks.value * rowHeight.value
|
||||
})
|
||||
|
||||
const visibleWeeks = ref([])
|
||||
let lastScrollRange = { startVW: null, endVW: null }
|
||||
let pendingRebuild = false
|
||||
|
||||
function scheduleRebuild(reason) {
|
||||
if (pendingRebuild) return
|
||||
pendingRebuild = true
|
||||
const cb = () => {
|
||||
pendingRebuild = false
|
||||
rebuildVisibleWeeks(reason)
|
||||
}
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(cb, { timeout: 120 })
|
||||
} else {
|
||||
requestAnimationFrame(cb)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollManager = createScrollManager({ viewport, scheduleRebuild })
|
||||
|
||||
const { scrollTop, setScrollTop, onScroll } = scrollManager
|
||||
|
||||
const weekColumnScrollManager = createWeekColumnScrollManager({
|
||||
viewport,
|
||||
viewportHeight,
|
||||
contentHeight,
|
||||
setScrollTop,
|
||||
})
|
||||
|
||||
const { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } =
|
||||
weekColumnScrollManager
|
||||
|
||||
const monthScrollManager = createMonthScrollManager({
|
||||
viewport,
|
||||
viewportHeight,
|
||||
contentHeight,
|
||||
setScrollTop,
|
||||
})
|
||||
|
||||
const { handleMonthScrollMouseDown, handleMonthScrollTouchStart, handleMonthScrollWheel } =
|
||||
monthScrollManager
|
||||
|
||||
const initialScrollTop = computed(() => {
|
||||
const nowDate = new Date(calendarStore.now)
|
||||
const targetWeekIndex = getWeekIndex(nowDate) - 3
|
||||
@ -124,42 +169,25 @@ const selectedDateRange = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// PERFORMANCE: Maintain a manual cache of computed weeks instead of relying on
|
||||
// deep reactive tracking of every event & day object. We rebuild lazily when
|
||||
// (a) scrolling changes the needed range or (b) eventsMutation counter bumps.
|
||||
const visibleWeeks = ref([])
|
||||
let lastScrollRange = { startVW: null, endVW: null }
|
||||
let pendingRebuild = false
|
||||
// Week label column drag scrolling state (no momentum)
|
||||
const isWeekColDragging = ref(false)
|
||||
let weekColDragStartScroll = 0
|
||||
let weekColAccum = 0
|
||||
let weekColPointerLocked = false
|
||||
let weekColLastY = 0
|
||||
|
||||
function scheduleRebuild(reason) {
|
||||
if (pendingRebuild) return
|
||||
pendingRebuild = true
|
||||
// Use requestIdleCallback when available, else fallback to rAF
|
||||
const cb = () => {
|
||||
pendingRebuild = false
|
||||
rebuildVisibleWeeks(reason)
|
||||
}
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(cb, { timeout: 120 })
|
||||
} else {
|
||||
requestAnimationFrame(cb)
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildVisibleWeeks(reason) {
|
||||
const buffer = 10
|
||||
const startIdx = Math.floor((scrollTop.value - buffer * rowHeight.value) / rowHeight.value)
|
||||
const currentScrollTop = viewport.value?.scrollTop ?? scrollTop.value
|
||||
const startIdx = Math.floor((currentScrollTop - buffer * rowHeight.value) / rowHeight.value)
|
||||
const endIdx = Math.ceil(
|
||||
(scrollTop.value + viewportHeight.value + buffer * rowHeight.value) / rowHeight.value,
|
||||
(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)
|
||||
console.log('[CalendarView] rebuildVisibleWeeks', {
|
||||
reason,
|
||||
currentScrollTop,
|
||||
startIdx,
|
||||
endIdx,
|
||||
startVW,
|
||||
endVW,
|
||||
rowHeight: rowHeight.value,
|
||||
})
|
||||
|
||||
if (
|
||||
reason === 'scroll' &&
|
||||
lastScrollRange.startVW === startVW &&
|
||||
@ -174,10 +202,6 @@ function rebuildVisibleWeeks(reason) {
|
||||
lastScrollRange = { startVW, endVW }
|
||||
}
|
||||
|
||||
const contentHeight = computed(() => {
|
||||
return totalVirtualWeeks.value * rowHeight.value
|
||||
})
|
||||
|
||||
function computeRowHeight() {
|
||||
if (rowProbe.value) {
|
||||
const h = rowProbe.value.getBoundingClientRect().height || 64
|
||||
@ -201,12 +225,18 @@ function measureFromProbe() {
|
||||
const newH = Math.round(h)
|
||||
if (newH !== rowHeight.value) {
|
||||
const oldH = rowHeight.value
|
||||
const currentTopVW = Math.floor(scrollTop.value / oldH) + minVirtualWeek.value
|
||||
// Anchor: keep the same top virtual week visible.
|
||||
const topVirtualWeek = Math.floor(scrollTop.value / oldH) + minVirtualWeek.value
|
||||
rowHeight.value = newH
|
||||
const newScrollTop = (currentTopVW - minVirtualWeek.value) * newH
|
||||
scrollTop.value = newScrollTop
|
||||
if (viewport.value) viewport.value.scrollTop = newScrollTop
|
||||
scheduleRebuild('row-height-change')
|
||||
const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH
|
||||
console.log('[CalendarView] measureFromProbe rowHeight change', {
|
||||
oldH,
|
||||
newH,
|
||||
topVirtualWeek,
|
||||
oldScrollTop: scrollTop.value,
|
||||
newScrollTop,
|
||||
})
|
||||
setScrollTop(newScrollTop, 'row-height-change')
|
||||
}
|
||||
}
|
||||
|
||||
@ -376,10 +406,8 @@ function createWeek(virtualWeek) {
|
||||
function goToToday() {
|
||||
const top = addDays(new Date(calendarStore.now), -21)
|
||||
const targetWeekIndex = getWeekIndex(top)
|
||||
scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
if (viewport.value) {
|
||||
viewport.value.scrollTop = scrollTop.value
|
||||
}
|
||||
const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
setScrollTop(newScrollTop, 'go-to-today')
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
@ -478,10 +506,9 @@ function getDateFromCoordinates(clientX, clientY) {
|
||||
const sampleWeek = viewport.value.querySelector('.week-row')
|
||||
if (!sampleWeek) return null
|
||||
const labelEl = sampleWeek.querySelector('.week-label')
|
||||
const jogwheelWidth = 48
|
||||
const wrRect = sampleWeek.getBoundingClientRect()
|
||||
const labelRight = labelEl ? labelEl.getBoundingClientRect().right : wrRect.left
|
||||
const daysAreaRight = wrRect.right - jogwheelWidth
|
||||
const daysAreaRight = wrRect.right
|
||||
const daysWidth = daysAreaRight - labelRight
|
||||
if (clientX < labelRight || clientX > daysAreaRight) return null
|
||||
const col = Math.min(6, Math.max(0, Math.floor(((clientX - labelRight) / daysWidth) * 7)))
|
||||
@ -519,76 +546,13 @@ function getWeekLabelRect() {
|
||||
return weekLabel ? weekLabel.getBoundingClientRect() : null
|
||||
}
|
||||
|
||||
function handleWeekColMouseDown(e) {
|
||||
if (e.button !== 0) 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
|
||||
weekColDragStartScroll = viewport.value.scrollTop
|
||||
weekColAccum = 0
|
||||
weekColLastY = e.clientY
|
||||
if (viewport.value.requestPointerLock) viewport.value.requestPointerLock()
|
||||
window.addEventListener('mousemove', handleWeekColMouseMove, { passive: false })
|
||||
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
|
||||
let desired = weekColDragStartScroll - weekColAccum
|
||||
if (desired < 0) desired = 0
|
||||
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.vale)
|
||||
if (desired > maxScroll) desired = maxScroll
|
||||
viewport.value.scrollTop = desired
|
||||
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() {
|
||||
weekColPointerLocked = document.pointerLockElement === viewport.value
|
||||
if (!weekColPointerLocked && isWeekColDragging.value) {
|
||||
handleWeekColMouseUp(new MouseEvent('mouseup'))
|
||||
}
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
if (viewport.value) scrollTop.value = viewport.value.scrollTop
|
||||
scheduleRebuild('scroll')
|
||||
}
|
||||
|
||||
const handleJogwheelScrollTo = (newScrollTop) => {
|
||||
if (viewport.value) {
|
||||
viewport.value.scrollTop = newScrollTop
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
computeRowHeight()
|
||||
calendarStore.updateCurrentDate()
|
||||
|
||||
if (viewport.value) {
|
||||
viewportHeight.value = viewport.value.clientHeight
|
||||
viewport.value.scrollTop = initialScrollTop.value
|
||||
setScrollTop(initialScrollTop.value, 'initial-mount')
|
||||
viewport.value.addEventListener('scroll', onScroll)
|
||||
// Capture mousedown in viewport to allow dragging via week label column
|
||||
viewport.value.addEventListener('mousedown', handleWeekColMouseDown, true)
|
||||
@ -658,13 +622,22 @@ const handleEventClick = (payload) => {
|
||||
const handleHeaderYearChange = ({ scrollTop: st }) => {
|
||||
const maxScroll = contentHeight.value - viewportHeight.value
|
||||
const clamped = Math.max(0, Math.min(st, isFinite(maxScroll) ? maxScroll : st))
|
||||
scrollTop.value = clamped
|
||||
viewport.value && (viewport.value.scrollTop = clamped)
|
||||
setScrollTop(clamped, 'header-year-change')
|
||||
}
|
||||
|
||||
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
||||
// We explicitly avoid locale detection; rely solely on characters present.
|
||||
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
|
||||
function shouldRotateMonth(label) {
|
||||
if (!label) return false
|
||||
try {
|
||||
return /\p{Script=Latin}/u.test(label)
|
||||
} catch (e) {
|
||||
return /[A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/u.test(label)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch first day changes (e.g., first_day config update) to adjust scroll
|
||||
// Keep roughly same visible date when first_day setting changes.
|
||||
watch(
|
||||
() => calendarStore.config.first_day,
|
||||
@ -674,8 +647,7 @@ watch(
|
||||
requestAnimationFrame(() => {
|
||||
const newTopWeekIndex = getWeekIndex(currentTopDate)
|
||||
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
scrollTop.value = newScroll
|
||||
if (viewport.value) viewport.value.scrollTop = newScroll
|
||||
setScrollTop(newScroll, 'first-day-change')
|
||||
scheduleRebuild('first-day-change')
|
||||
})
|
||||
},
|
||||
@ -710,28 +682,48 @@ window.addEventListener('resize', () => {
|
||||
/>
|
||||
<div class="calendar-container">
|
||||
<div class="calendar-viewport" ref="viewport">
|
||||
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
||||
<CalendarWeek
|
||||
v-for="week in visibleWeeks"
|
||||
:key="week.virtualWeek"
|
||||
:week="week"
|
||||
:dragging="isDragging"
|
||||
:style="{ top: week.top + 'px' }"
|
||||
@day-mousedown="handleDayMouseDown"
|
||||
@day-mouseenter="handleDayMouseEnter"
|
||||
@day-mouseup="handleDayMouseUp"
|
||||
@day-touchstart="handleDayTouchStart"
|
||||
@event-click="handleEventClick"
|
||||
/>
|
||||
<!-- Main calendar content (weeks and days) -->
|
||||
<div class="main-calendar-area">
|
||||
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
||||
<CalendarWeek
|
||||
v-for="week in visibleWeeks"
|
||||
:key="week.virtualWeek"
|
||||
:week="week"
|
||||
:dragging="isDragging"
|
||||
:style="{ top: week.top + 'px' }"
|
||||
@day-mousedown="handleDayMouseDown"
|
||||
@day-mouseenter="handleDayMouseEnter"
|
||||
@day-mouseup="handleDayMouseUp"
|
||||
@day-touchstart="handleDayTouchStart"
|
||||
@event-click="handleEventClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Month column area -->
|
||||
<div class="month-column-area">
|
||||
<!-- Month labels -->
|
||||
<div class="month-labels-container" :style="{ height: contentHeight + 'px' }">
|
||||
<template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'">
|
||||
<div
|
||||
v-if="monthWeek && monthWeek.monthLabel"
|
||||
class="month-label"
|
||||
:class="monthWeek.monthLabel?.monthClass"
|
||||
:style="{
|
||||
height: `calc(var(--row-h) * ${monthWeek.monthLabel?.weeksSpan || 1})`,
|
||||
top: (monthWeek.top || 0) + 'px',
|
||||
}"
|
||||
@mousedown="handleMonthScrollMouseDown"
|
||||
@touchstart="handleMonthScrollTouchStart"
|
||||
@wheel="handleMonthScrollWheel"
|
||||
>
|
||||
<span :class="{ bottomup: shouldRotateMonth(monthWeek.monthLabel?.text) }">{{
|
||||
monthWeek.monthLabel?.text || ''
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Jogwheel
|
||||
:total-virtual-weeks="totalVirtualWeeks"
|
||||
:row-height="rowHeight"
|
||||
:viewport-height="viewportHeight"
|
||||
:scroll-top="scrollTop"
|
||||
@scroll-to="handleJogwheelScrollTo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -773,7 +765,13 @@ header h1 {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr var(--month-w);
|
||||
}
|
||||
|
||||
.main-calendar-area {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-content {
|
||||
@ -781,6 +779,47 @@ header h1 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.month-column-area {
|
||||
position: relative;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.month-labels-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2));
|
||||
font-size: 2em;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 15;
|
||||
overflow: hidden;
|
||||
cursor: ns-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.month-label > span {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
transform-origin: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bottomup {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.row-height-probe {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
|
@ -61,23 +61,13 @@ function shouldRotateMonth(label) {
|
||||
/>
|
||||
<EventOverlay :week="props.week" @event-click="handleEventClick" />
|
||||
</div>
|
||||
<div
|
||||
v-if="props.week.monthLabel"
|
||||
class="month-label"
|
||||
:class="props.week.monthLabel.monthClass"
|
||||
:style="{ height: `calc(var(--row-h) * ${props.week.monthLabel.weeksSpan})` }"
|
||||
>
|
||||
<span :class="{ bottomup: shouldRotateMonth(props.week.monthLabel.text) }">{{
|
||||
props.week.monthLabel.text
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.week-row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--week-w) repeat(7, 1fr) var(--month-w);
|
||||
grid-template-columns: var(--week-w) repeat(7, 1fr);
|
||||
position: absolute;
|
||||
height: var(--row-h);
|
||||
width: 100%;
|
||||
@ -91,7 +81,9 @@ function shouldRotateMonth(label) {
|
||||
font-size: 1.2em;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
height: var(--row-h);
|
||||
}
|
||||
|
||||
.days-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
@ -99,32 +91,4 @@ function shouldRotateMonth(label) {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.week-label {
|
||||
height: var(--row-h);
|
||||
}
|
||||
.month-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: var(--month-w);
|
||||
background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2));
|
||||
font-size: 2em;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 15;
|
||||
overflow: hidden;
|
||||
}
|
||||
.month-label > span {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
transform-origin: center;
|
||||
}
|
||||
.bottomup {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
|
234
src/plugins/scrollManager.js
Normal file
234
src/plugins/scrollManager.js
Normal file
@ -0,0 +1,234 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
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)
|
||||
let weekColDragStartScroll = 0
|
||||
let weekColAccum = 0
|
||||
let weekColPointerLocked = false
|
||||
let weekColLastY = 0
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function handleWeekColMouseDown(e) {
|
||||
if (e.button !== 0) 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
|
||||
weekColDragStartScroll = viewport.value.scrollTop
|
||||
weekColAccum = 0
|
||||
weekColLastY = e.clientY
|
||||
if (viewport.value.requestPointerLock) viewport.value.requestPointerLock()
|
||||
window.addEventListener('mousemove', handleWeekColMouseMove, { passive: false })
|
||||
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
|
||||
let desired = weekColDragStartScroll - weekColAccum
|
||||
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() {
|
||||
weekColPointerLocked = document.pointerLockElement === viewport.value
|
||||
if (!weekColPointerLocked && isWeekColDragging.value) {
|
||||
handleWeekColMouseUp(new MouseEvent('mouseup'))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isWeekColDragging,
|
||||
handleWeekColMouseDown,
|
||||
handlePointerLockChange,
|
||||
}
|
||||
}
|
||||
|
||||
export function createMonthScrollManager({
|
||||
viewport,
|
||||
viewportHeight,
|
||||
contentHeight,
|
||||
setScrollTop,
|
||||
}) {
|
||||
let isMonthScrolling = false
|
||||
let monthScrollStartY = 0
|
||||
let monthScrollStartScroll = 0
|
||||
|
||||
const handleMonthScrollMouseDown = (e) => {
|
||||
if (e.button !== 0) return
|
||||
isMonthScrolling = true
|
||||
monthScrollStartY = e.clientY
|
||||
monthScrollStartScroll = viewport.value?.scrollTop || 0
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isMonthScrolling) return
|
||||
const deltaY = e.clientY - monthScrollStartY
|
||||
const newScrollTop = monthScrollStartScroll - deltaY * 10
|
||||
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
|
||||
const clampedScroll = Math.max(0, Math.min(newScrollTop, maxScroll))
|
||||
|
||||
setScrollTop(clampedScroll, 'month-scroll-drag')
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isMonthScrolling = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleMonthScrollTouchStart = (e) => {
|
||||
if (e.touches.length !== 1) return
|
||||
isMonthScrolling = true
|
||||
monthScrollStartY = e.touches[0].clientY
|
||||
monthScrollStartScroll = viewport.value?.scrollTop || 0
|
||||
|
||||
const handleTouchMove = (e) => {
|
||||
if (!isMonthScrolling || e.touches.length !== 1) return
|
||||
const deltaY = e.touches[0].clientY - monthScrollStartY
|
||||
const newScrollTop = monthScrollStartScroll - deltaY * 10
|
||||
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
|
||||
const clampedScroll = Math.max(0, Math.min(newScrollTop, maxScroll))
|
||||
|
||||
setScrollTop(clampedScroll, 'month-scroll-touch')
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
isMonthScrolling = false
|
||||
document.removeEventListener('touchmove', handleTouchMove)
|
||||
document.removeEventListener('touchend', handleTouchEnd)
|
||||
}
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', handleTouchEnd)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleMonthScrollWheel = (e) => {
|
||||
const currentScroll = viewport.value?.scrollTop || 0
|
||||
const newScrollTop = currentScroll + e.deltaY * 10
|
||||
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
|
||||
const clampedScroll = Math.max(0, Math.min(newScrollTop, maxScroll))
|
||||
|
||||
setScrollTop(clampedScroll, 'month-scroll-wheel')
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
return {
|
||||
handleMonthScrollMouseDown,
|
||||
handleMonthScrollTouchStart,
|
||||
handleMonthScrollWheel,
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user