Major new version #2

Merged
LeoVasanko merged 86 commits from vol002 into main 2025-08-26 05:58:24 +01:00
3 changed files with 419 additions and 182 deletions
Showing only changes of commit 7c94fcbb45 - Show all commits

View File

@ -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,6 +682,8 @@ window.addEventListener('resize', () => {
/>
<div class="calendar-container">
<div class="calendar-viewport" ref="viewport">
<!-- 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"
@ -725,13 +699,31 @@ window.addEventListener('resize', () => {
/>
</div>
</div>
<Jogwheel
:total-virtual-weeks="totalVirtualWeeks"
:row-height="rowHeight"
:viewport-height="viewportHeight"
:scroll-top="scrollTop"
@scroll-to="handleJogwheelScrollTo"
/>
<!-- 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>
</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;

View File

@ -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>

View 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,
}
}