Compare commits
No commits in common. "01ea168ebe2bbb2de6c6a9f4872949b082f00362" and "ceeb7f1d592c86417b2e9ab884e98e3d0e3a5559" have entirely different histories.
01ea168ebe
...
ceeb7f1d59
@ -1,9 +1,14 @@
|
|||||||
|
/* Layout variables */
|
||||||
:root {
|
:root {
|
||||||
--week-w: 3rem;
|
/* Layout */
|
||||||
--day-w: 1fr;
|
--row-h: 2.2em;
|
||||||
--month-w: 2rem;
|
--label-w: minmax(4em, 8%);
|
||||||
--row-h: 15vh;
|
--cell-w: 1fr;
|
||||||
|
--cell-h: 12vh;
|
||||||
|
--overlay-w: minmax(3rem, 5%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Layout & typography */
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@ -25,8 +30,17 @@ body {
|
|||||||
Arial;
|
Arial;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
/* Prevent body scrolling / unwanted scrollbars due to mobile browser UI chrome affecting vh */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure root app container doesn't introduce its own scrollbars */
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@ -35,6 +49,12 @@ header {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.today-date {
|
.today-date {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@ -44,10 +64,12 @@ header {
|
|||||||
.today-button:hover {
|
.today-button:hover {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Header row */
|
||||||
.calendar-header,
|
.calendar-header,
|
||||||
#calendar-header {
|
#calendar-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--week-w) repeat(7, var(--day-w)) var(--month-w);
|
grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w);
|
||||||
border-bottom: 0.2em solid var(--muted);
|
border-bottom: 0.2em solid var(--muted);
|
||||||
align-items: last baseline;
|
align-items: last baseline;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@ -78,17 +100,42 @@ header {
|
|||||||
#calendar-viewport::-webkit-scrollbar {
|
#calendar-viewport::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jogwheel-viewport,
|
||||||
|
#jogwheel-viewport {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--overlay-w);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
z-index: 20;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
.jogwheel-viewport::-webkit-scrollbar,
|
||||||
|
#jogwheel-viewport::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jogwheel-content,
|
||||||
|
#jogwheel-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.calendar-content,
|
.calendar-content,
|
||||||
#calendar-content {
|
#calendar-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Week row: label + 7-day grid + jogwheel column */
|
/* Week row: label + 7-day grid + jogwheel column */
|
||||||
.week-row {
|
.week-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--week-w) repeat(7, var(--day-w)) var(--month-w);
|
grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
height: var(--row-h);
|
height: var(--cell-h);
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@ -105,7 +152,7 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.week-label {
|
.week-label {
|
||||||
height: var(--row-h);
|
height: var(--cell-h);
|
||||||
}
|
}
|
||||||
/* 7-day grid inside each week row */
|
/* 7-day grid inside each week row */
|
||||||
.week-row > .days-grid {
|
.week-row > .days-grid {
|
||||||
|
@ -1,37 +1,22 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, useAttrs } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
|
||||||
// Disable automatic attr inheritance so we can forward class/style specifically to the modal element
|
|
||||||
defineOptions({ inheritAttrs: false })
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: Boolean, default: false },
|
modelValue: { type: Boolean, default: false },
|
||||||
title: { type: String, default: '' },
|
title: { type: String, default: '' },
|
||||||
draggable: { type: Boolean, default: true },
|
draggable: { type: Boolean, default: true },
|
||||||
autoFocus: { type: Boolean, default: true },
|
autoFocus: { type: Boolean, default: true },
|
||||||
// Optional external anchor element (e.g., a day cell) to position the dialog below.
|
|
||||||
// If not provided, falls back to internal anchorRef span (original behavior).
|
|
||||||
anchorEl: { type: Object, default: null },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'opened', 'closed', 'submit'])
|
const emit = defineEmits(['update:modelValue', 'opened', 'closed', 'submit'])
|
||||||
|
|
||||||
const modalRef = ref(null)
|
const modalRef = ref(null)
|
||||||
const anchorRef = ref(null)
|
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const dragOffset = ref({ x: 0, y: 0 })
|
const dragOffset = ref({ x: 0, y: 0 })
|
||||||
const modalPosition = ref({ x: 0, y: 0 })
|
const modalPosition = ref({ x: 0, y: 0 })
|
||||||
const dialogWidth = ref(null)
|
const dialogWidth = ref(null)
|
||||||
const dialogHeight = ref(null)
|
const dialogHeight = ref(null)
|
||||||
const hasMoved = ref(false)
|
const hasMoved = ref(false)
|
||||||
const margin = 8 // viewport margin in px to keep dialog from touching edges
|
|
||||||
|
|
||||||
// Collect incoming non-prop attributes (e.g., class / style from usage site)
|
|
||||||
const attrs = useAttrs()
|
|
||||||
|
|
||||||
function clamp(val, min, max) {
|
|
||||||
return Math.min(Math.max(val, min), max)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startDrag(event) {
|
function startDrag(event) {
|
||||||
if (!props.draggable || !modalRef.value) return
|
if (!props.draggable || !modalRef.value) return
|
||||||
@ -56,15 +41,10 @@ function startDrag(event) {
|
|||||||
}
|
}
|
||||||
function handleDrag(event) {
|
function handleDrag(event) {
|
||||||
if (!isDragging.value) return
|
if (!isDragging.value) return
|
||||||
let x = event.clientX - dragOffset.value.x
|
modalPosition.value = {
|
||||||
let y = event.clientY - dragOffset.value.y
|
x: event.clientX - dragOffset.value.x,
|
||||||
const w = dialogWidth.value || modalRef.value?.offsetWidth || 0
|
y: event.clientY - dragOffset.value.y,
|
||||||
const h = dialogHeight.value || modalRef.value?.offsetHeight || 0
|
}
|
||||||
const vw = window.innerWidth
|
|
||||||
const vh = window.innerHeight
|
|
||||||
x = clamp(x, margin, Math.max(margin, vw - w - margin))
|
|
||||||
y = clamp(y, margin, Math.max(margin, vh - h - margin))
|
|
||||||
modalPosition.value = { x, y }
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
function stopDrag() {
|
function stopDrag() {
|
||||||
@ -75,35 +55,20 @@ function stopDrag() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modalStyle = computed(() => {
|
const modalStyle = computed(() => {
|
||||||
// Always position relative to calculated modalPosition once opened
|
if (hasMoved.value) {
|
||||||
if (modalRef.value && props.modelValue) {
|
return {
|
||||||
const style = {
|
|
||||||
transform: 'none',
|
transform: 'none',
|
||||||
left: modalPosition.value.x + 'px',
|
left: `${modalPosition.value.x}px`,
|
||||||
top: modalPosition.value.y + 'px',
|
top: `${modalPosition.value.y}px`,
|
||||||
bottom: 'auto',
|
bottom: 'auto',
|
||||||
right: 'auto',
|
right: 'auto',
|
||||||
|
width: dialogWidth.value ? dialogWidth.value + 'px' : undefined,
|
||||||
|
height: dialogHeight.value ? dialogHeight.value + 'px' : undefined,
|
||||||
}
|
}
|
||||||
if (hasMoved.value) {
|
|
||||||
style.width = dialogWidth.value ? dialogWidth.value + 'px' : undefined
|
|
||||||
style.height = dialogHeight.value ? dialogHeight.value + 'px' : undefined
|
|
||||||
}
|
|
||||||
return style
|
|
||||||
}
|
}
|
||||||
return {}
|
return {}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Merge external class / style with internal ones so parent usage like
|
|
||||||
// <BaseDialog class="settings-modal" :style="{ top: '...' }" /> works even with fragment root.
|
|
||||||
const modalAttrs = computed(() => {
|
|
||||||
const { class: extClass, style: extStyle, ...rest } = attrs
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
class: ['ec-modal', extClass].filter(Boolean),
|
|
||||||
style: [modalStyle.value, extStyle].filter(Boolean), // external style overrides internal
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
emit('closed')
|
emit('closed')
|
||||||
@ -116,34 +81,12 @@ function handleKeydown(e) {
|
|||||||
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||||
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||||
|
|
||||||
function positionNearAnchor() {
|
|
||||||
const anchor = props.anchorEl || anchorRef.value
|
|
||||||
if (!anchor) return
|
|
||||||
const rect = anchor.getBoundingClientRect()
|
|
||||||
const offsetY = 8 // vertical gap below the anchor
|
|
||||||
const w = modalRef.value?.offsetWidth || dialogWidth.value || 320
|
|
||||||
const h = modalRef.value?.offsetHeight || dialogHeight.value || 200
|
|
||||||
const vw = window.innerWidth
|
|
||||||
const vh = window.innerHeight
|
|
||||||
let x = rect.left
|
|
||||||
let y = rect.bottom + offsetY
|
|
||||||
// If anchor is wider than dialog and would overflow right edge, clamp; otherwise keep left align
|
|
||||||
x = clamp(x, margin, Math.max(margin, vw - w - margin))
|
|
||||||
y = clamp(y, margin, Math.max(margin, vh - h - margin))
|
|
||||||
modalPosition.value = { x, y }
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
async (v) => {
|
async (v) => {
|
||||||
if (v) {
|
if (v) {
|
||||||
emit('opened')
|
emit('opened')
|
||||||
await nextTick()
|
await nextTick()
|
||||||
// Reset movement state each time opened
|
|
||||||
hasMoved.value = false
|
|
||||||
dialogWidth.value = null
|
|
||||||
dialogHeight.value = null
|
|
||||||
positionNearAnchor()
|
|
||||||
if (props.autoFocus) {
|
if (props.autoFocus) {
|
||||||
const el = modalRef.value?.querySelector('[autofocus]')
|
const el = modalRef.value?.querySelector('[autofocus]')
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
@ -151,44 +94,10 @@ watch(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reposition if anchorEl changes while open and user hasn't dragged dialog yet
|
|
||||||
watch(
|
|
||||||
() => props.anchorEl,
|
|
||||||
() => {
|
|
||||||
if (props.modelValue && !hasMoved.value) {
|
|
||||||
nextTick(() => positionNearAnchor())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function handleResize() {
|
|
||||||
if (!props.modelValue) return
|
|
||||||
// Re-clamp current position, and if not moved recalc near anchor
|
|
||||||
if (!hasMoved.value) positionNearAnchor()
|
|
||||||
else if (modalRef.value) {
|
|
||||||
const w = modalRef.value.offsetWidth
|
|
||||||
const h = modalRef.value.offsetHeight
|
|
||||||
const vw = window.innerWidth
|
|
||||||
const vh = window.innerHeight
|
|
||||||
modalPosition.value = {
|
|
||||||
x: clamp(modalPosition.value.x, margin, Math.max(margin, vw - w - margin)),
|
|
||||||
y: clamp(modalPosition.value.y, margin, Math.max(margin, vh - h - margin)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
})
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('resize', handleResize)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span ref="anchorRef" class="ec-modal-anchor" aria-hidden="true"></span>
|
<div v-if="modelValue" class="ec-modal" ref="modalRef" :style="modalStyle">
|
||||||
<div v-if="modelValue" ref="modalRef" v-bind="modalAttrs">
|
|
||||||
<form class="ec-form" @submit.prevent="emit('submit')">
|
<form class="ec-form" @submit.prevent="emit('submit')">
|
||||||
<header class="ec-header" @pointerdown="startDrag">
|
<header class="ec-header" @pointerdown="startDrag">
|
||||||
<h2 class="ec-title">
|
<h2 class="ec-title">
|
||||||
@ -208,7 +117,9 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ec-modal {
|
.ec-modal {
|
||||||
position: fixed; /* still fixed for overlay & dragging, but now top/left are set dynamically */
|
position: fixed;
|
||||||
|
bottom: 3em;
|
||||||
|
right: 2em;
|
||||||
background: color-mix(in srgb, var(--panel) 85%, transparent);
|
background: color-mix(in srgb, var(--panel) 85%, transparent);
|
||||||
backdrop-filter: blur(0.625em);
|
backdrop-filter: blur(0.625em);
|
||||||
-webkit-backdrop-filter: blur(0.625em);
|
-webkit-backdrop-filter: blur(0.625em);
|
||||||
@ -222,11 +133,6 @@ onUnmounted(() => {
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.ec-modal-anchor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
.ec-form {
|
.ec-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
|
73
src/components/Calendar.vue
Normal file
73
src/components/Calendar.vue
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div class="wrap">
|
||||||
|
<!-- AppHeader component reference removed (file missing); add inline header with Settings button -->
|
||||||
|
<div class="inline-header">
|
||||||
|
<h1 class="app-title">Calendar</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button type="button" class="settings-btn" @click="openSettings">Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="calendar-container" ref="containerEl">
|
||||||
|
<CalendarGrid />
|
||||||
|
<Jogwheel />
|
||||||
|
</div>
|
||||||
|
<EventDialog />
|
||||||
|
<SettingsDialog ref="settingsDialog" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import CalendarGrid from './CalendarGrid.vue'
|
||||||
|
import Jogwheel from './Jogwheel.vue'
|
||||||
|
import EventDialog from './EventDialog.vue'
|
||||||
|
import SettingsDialog from './SettingsDialog.vue'
|
||||||
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
|
|
||||||
|
const calendarStore = useCalendarStore()
|
||||||
|
const containerEl = ref(null)
|
||||||
|
const settingsDialog = ref(null)
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
settingsDialog.value?.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
let intervalId
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
calendarStore.setToday()
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
calendarStore.setToday()
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.inline-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.app-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.settings-btn {
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
background: var(--panel-alt, transparent);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.settings-btn:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
@ -24,6 +24,7 @@ const props = defineProps({
|
|||||||
<h1>{{ props.day.displayText }}</h1>
|
<h1>{{ props.day.displayText }}</h1>
|
||||||
<span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span>
|
<span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span>
|
||||||
|
|
||||||
|
<!-- Holiday indicator -->
|
||||||
<div v-if="props.day.holiday" class="holiday-info">
|
<div v-if="props.day.holiday" class="holiday-info">
|
||||||
<span class="holiday-name" :title="props.day.holiday.name">
|
<span class="holiday-name" :title="props.day.holiday.name">
|
||||||
{{ props.day.holiday.name }}
|
{{ props.day.holiday.name }}
|
||||||
@ -45,7 +46,7 @@ const props = defineProps({
|
|||||||
padding: 0.25em;
|
padding: 0.25em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: var(--row-h);
|
height: var(--cell-h);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
}
|
}
|
||||||
@ -80,34 +81,37 @@ const props = defineProps({
|
|||||||
.cell.selected h1 {
|
.cell.selected h1 {
|
||||||
color: var(--strong);
|
color: var(--strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lunar-phase {
|
.lunar-phase {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.5em;
|
top: 0.1em;
|
||||||
right: 0.2em;
|
right: 0.1em;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell.holiday {
|
.cell.holiday {
|
||||||
background-image: linear-gradient(
|
/* Remove solid background & border color overrides; use gradient overlay instead */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cell.holiday::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
var(--holiday-grad-start, rgba(255, 255, 255, 0.5)) 0%,
|
var(--holiday-grad-start, rgba(255, 255, 255, 0.1)) 0%,
|
||||||
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
|
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
|
||||||
);
|
);
|
||||||
}
|
pointer-events: none;
|
||||||
@media (prefers-color-scheme: dark) {
|
mix-blend-mode: normal; /* can switch to 'overlay' or 'screen' if thematic */
|
||||||
.cell.holiday {
|
|
||||||
background-image: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
var(--holiday-grad-start, rgba(255, 255, 255, 0.1)) 0%,
|
|
||||||
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.cell.holiday h1 {
|
.cell.holiday h1 {
|
||||||
/* Slight emphasis without forcing a specific hue */
|
/* Slight emphasis without forcing a specific hue */
|
||||||
color: var(--holiday);
|
color: var(--holiday);
|
||||||
text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4);
|
text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.holiday-info {
|
.holiday-info {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0.1em;
|
bottom: 0.1em;
|
||||||
@ -115,7 +119,7 @@ const props = defineProps({
|
|||||||
right: 0.1em;
|
right: 0.1em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: clamp(1.2vw, 0.6em, 1em);
|
font-size: clamp(1.2vw, 0.6em, 1.2em);
|
||||||
}
|
}
|
||||||
|
|
||||||
.holiday-name {
|
.holiday-name {
|
||||||
|
176
src/components/CalendarGrid.vue
Normal file
176
src/components/CalendarGrid.vue
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<div class="calendar-header">
|
||||||
|
<div class="year-label" @wheel.prevent="handleWheel">{{ calendarStore.viewYear }}</div>
|
||||||
|
<div v-for="day in weekdayNames" :key="day" class="dow" :class="{ weekend: isWeekend(day) }">
|
||||||
|
{{ day }}
|
||||||
|
</div>
|
||||||
|
<div class="overlay-header-spacer"></div>
|
||||||
|
</div>
|
||||||
|
<div class="calendar-viewport" ref="viewportEl" @scroll="handleScroll">
|
||||||
|
<div class="calendar-content" :style="{ height: `${totalVirtualWeeks * rowHeight}px` }">
|
||||||
|
<WeekRow
|
||||||
|
v.for="week in visibleWeeks"
|
||||||
|
:key="week.virtualWeek"
|
||||||
|
:week="week"
|
||||||
|
:style="{ top: `${(week.virtualWeek - minVirtualWeek) * rowHeight}px` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||||
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
|
import {
|
||||||
|
getLocalizedWeekdayNames,
|
||||||
|
getLocaleWeekendDays,
|
||||||
|
getLocaleFirstDay,
|
||||||
|
getISOWeek,
|
||||||
|
getISOWeekYear,
|
||||||
|
fromLocalString,
|
||||||
|
toLocalString,
|
||||||
|
mondayIndex,
|
||||||
|
DEFAULT_TZ,
|
||||||
|
MIN_YEAR,
|
||||||
|
MAX_YEAR,
|
||||||
|
} from '@/utils/date'
|
||||||
|
import { addDays } from 'date-fns'
|
||||||
|
import WeekRow from './WeekRow.vue'
|
||||||
|
|
||||||
|
const calendarStore = useCalendarStore()
|
||||||
|
const viewportEl = ref(null)
|
||||||
|
const rowHeight = ref(64) // Default value, will be computed
|
||||||
|
const totalVirtualWeeks = ref(0)
|
||||||
|
const minVirtualWeek = ref(0)
|
||||||
|
const visibleWeeks = ref([])
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
weekend: getLocaleWeekendDays(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchor Monday (or locale first day) reference date
|
||||||
|
const baseDate = new Date(1970, 0, 4 + getLocaleFirstDay())
|
||||||
|
const WEEK_MS = 7 * 86400000
|
||||||
|
|
||||||
|
const weekdayNames = getLocalizedWeekdayNames()
|
||||||
|
|
||||||
|
const isWeekend = (day) => {
|
||||||
|
const dayIndex = weekdayNames.indexOf(day)
|
||||||
|
return config.weekend[(dayIndex + 1) % 7]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWeekIndex = (date) => {
|
||||||
|
const monday = addDays(date, -mondayIndex(date))
|
||||||
|
return Math.floor((monday.getTime() - baseDate.getTime()) / WEEK_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMondayForVirtualWeek = (virtualWeek) => addDays(baseDate, virtualWeek * 7)
|
||||||
|
|
||||||
|
const computeRowHeight = () => {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.style.position = 'absolute'
|
||||||
|
el.style.visibility = 'hidden'
|
||||||
|
el.style.height = 'var(--cell-h)'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
const h = el.getBoundingClientRect().height || 64
|
||||||
|
el.remove()
|
||||||
|
return Math.round(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateVisibleWeeks = () => {
|
||||||
|
if (!viewportEl.value) return
|
||||||
|
|
||||||
|
const scrollTop = viewportEl.value.scrollTop
|
||||||
|
const viewportH = viewportEl.value.clientHeight
|
||||||
|
|
||||||
|
const topDisplayIndex = Math.floor(scrollTop / rowHeight.value)
|
||||||
|
const topVW = topDisplayIndex + minVirtualWeek.value
|
||||||
|
const monday = getMondayForVirtualWeek(topVW)
|
||||||
|
const year = getISOWeekYear(monday)
|
||||||
|
if (calendarStore.viewYear !== year) {
|
||||||
|
calendarStore.setViewYear(year)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = 10
|
||||||
|
const startIdx = Math.floor((scrollTop - buffer * rowHeight.value) / rowHeight.value)
|
||||||
|
const endIdx = Math.ceil((scrollTop + viewportH + buffer * rowHeight.value) / rowHeight.value)
|
||||||
|
|
||||||
|
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
|
||||||
|
const endVW = Math.min(
|
||||||
|
totalVirtualWeeks.value + minVirtualWeek.value - 1,
|
||||||
|
endIdx + minVirtualWeek.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const newVisibleWeeks = []
|
||||||
|
for (let vw = startVW; vw <= endVW; vw++) {
|
||||||
|
newVisibleWeeks.push({ virtualWeek: vw, monday: getMondayForVirtualWeek(vw) })
|
||||||
|
}
|
||||||
|
visibleWeeks.value = newVisibleWeeks
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
requestAnimationFrame(updateVisibleWeeks)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWheel = (e) => {
|
||||||
|
const currentYear = calendarStore.viewYear
|
||||||
|
const delta = Math.round(e.deltaY * (1 / 3))
|
||||||
|
if (!delta) return
|
||||||
|
const newYear = Math.max(MIN_YEAR, Math.min(MAX_YEAR, currentYear + delta))
|
||||||
|
if (newYear === currentYear) return
|
||||||
|
|
||||||
|
const topDisplayIndex = Math.floor(viewportEl.value.scrollTop / rowHeight.value)
|
||||||
|
const currentWeekIndex = topDisplayIndex + minVirtualWeek.value
|
||||||
|
|
||||||
|
navigateToYear(newYear, currentWeekIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToYear = (targetYear, weekIndex) => {
|
||||||
|
const monday = getMondayForVirtualWeek(weekIndex)
|
||||||
|
const week = getISOWeek(monday)
|
||||||
|
const jan4 = new Date(targetYear, 0, 4)
|
||||||
|
const jan4Monday = addDays(jan4, -mondayIndex(jan4))
|
||||||
|
const targetMonday = addDays(jan4Monday, (week - 1) * 7)
|
||||||
|
scrollToTarget(targetMonday)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToTarget = (target) => {
|
||||||
|
let targetWeekIndex
|
||||||
|
if (target instanceof Date) {
|
||||||
|
targetWeekIndex = getWeekIndex(target)
|
||||||
|
} else {
|
||||||
|
targetWeekIndex = target
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||||
|
viewportEl.value.scrollTop = targetScrollTop
|
||||||
|
updateVisibleWeeks()
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToTodayHandler = () => {
|
||||||
|
const today = new Date()
|
||||||
|
const top = addDays(today, -21)
|
||||||
|
scrollToTarget(top)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
rowHeight.value = computeRowHeight()
|
||||||
|
|
||||||
|
const minYearDate = new Date(MIN_YEAR, 0, 1)
|
||||||
|
const maxYearLastDay = new Date(MAX_YEAR, 11, 31)
|
||||||
|
const lastWeekMonday = addDays(maxYearLastDay, -mondayIndex(maxYearLastDay))
|
||||||
|
|
||||||
|
minVirtualWeek.value = getWeekIndex(minYearDate)
|
||||||
|
const maxVirtualWeek = getWeekIndex(lastWeekMonday)
|
||||||
|
totalVirtualWeeks.value = maxVirtualWeek - minVirtualWeek.value + 1
|
||||||
|
|
||||||
|
const initialDate = fromLocalString(calendarStore.today, DEFAULT_TZ)
|
||||||
|
scrollToTarget(initialDate)
|
||||||
|
|
||||||
|
document.addEventListener('goToToday', goToTodayHandler)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('goToToday', goToTodayHandler)
|
||||||
|
})
|
||||||
|
</script>
|
@ -119,7 +119,7 @@ const weekdayNames = computed(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.calendar-header {
|
.calendar-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--week-w) repeat(7, 1fr) var(--month-w);
|
grid-template-columns: var(--label-w) repeat(7, 1fr) 3rem;
|
||||||
border-bottom: 2px solid var(--muted);
|
border-bottom: 2px solid var(--muted);
|
||||||
align-items: last baseline;
|
align-items: last baseline;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@ -132,11 +132,20 @@ const weekdayNames = computed(() => {
|
|||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.year-label {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.2em;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
.dow {
|
.dow {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 600;
|
padding: 0.5rem;
|
||||||
font-size: 1.2em;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.dow.weekend {
|
.dow.weekend {
|
||||||
color: var(--weekend);
|
color: var(--weekend);
|
||||||
|
@ -3,31 +3,60 @@ import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
|||||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
import CalendarHeader from '@/components/CalendarHeader.vue'
|
import CalendarHeader from '@/components/CalendarHeader.vue'
|
||||||
import CalendarWeek from '@/components/CalendarWeek.vue'
|
import CalendarWeek from '@/components/CalendarWeek.vue'
|
||||||
import HeaderControls from '@/components/HeaderControls.vue'
|
import Jogwheel from '@/components/Jogwheel.vue'
|
||||||
|
import SettingsDialog from '@/components/SettingsDialog.vue'
|
||||||
import {
|
import {
|
||||||
createScrollManager,
|
getLocalizedMonthName,
|
||||||
createWeekColumnScrollManager,
|
monthAbbr,
|
||||||
createMonthScrollManager,
|
lunarPhaseSymbol,
|
||||||
} from '@/plugins/scrollManager'
|
pad,
|
||||||
import { daysInclusive, addDaysStr, MIN_YEAR, MAX_YEAR } from '@/utils/date'
|
daysInclusive,
|
||||||
|
addDaysStr,
|
||||||
|
formatDateRange,
|
||||||
|
formatTodayString,
|
||||||
|
getOccurrenceIndex,
|
||||||
|
getVirtualOccurrenceEndDate,
|
||||||
|
getISOWeek,
|
||||||
|
MIN_YEAR,
|
||||||
|
MAX_YEAR,
|
||||||
|
} from '@/utils/date'
|
||||||
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
|
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
|
||||||
import { addDays, differenceInWeeks } from 'date-fns'
|
import { addDays, differenceInCalendarDays, differenceInWeeks } from 'date-fns'
|
||||||
import { createVirtualWeekManager } from '@/plugins/virtualWeeks'
|
import { getHolidayForDate } from '@/utils/holidays'
|
||||||
|
|
||||||
const calendarStore = useCalendarStore()
|
const calendarStore = useCalendarStore()
|
||||||
const emit = defineEmits(['create-event', 'edit-event'])
|
|
||||||
const viewport = ref(null)
|
const viewport = ref(null)
|
||||||
|
const settingsDialog = ref(null)
|
||||||
|
|
||||||
|
const emit = defineEmits(['create-event', 'edit-event'])
|
||||||
|
|
||||||
|
function createEventFromSelection() {
|
||||||
|
if (!selection.value.startDate || selection.value.dayCount === 0) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: selection.value.startDate,
|
||||||
|
dayCount: selection.value.dayCount,
|
||||||
|
endDate: addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollTop = ref(0)
|
||||||
const viewportHeight = ref(600)
|
const viewportHeight = ref(600)
|
||||||
const rowHeight = ref(64)
|
const rowHeight = ref(64)
|
||||||
|
// Probe element & observer for dynamic var(--cell-h) changes
|
||||||
const rowProbe = ref(null)
|
const rowProbe = ref(null)
|
||||||
let rowProbeObserver = null
|
let rowProbeObserver = null
|
||||||
|
|
||||||
const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))
|
const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))
|
||||||
|
|
||||||
const selection = ref({ startDate: null, dayCount: 0 })
|
const selection = ref({ startDate: null, dayCount: 0 })
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const dragAnchor = ref(null)
|
const dragAnchor = ref(null)
|
||||||
|
|
||||||
const DOUBLE_TAP_DELAY = 300
|
const DOUBLE_TAP_DELAY = 300
|
||||||
const pendingTap = ref({ date: null, time: 0, type: null })
|
const pendingTap = ref({ date: null, time: 0, type: null })
|
||||||
const suppressMouseUntil = ref(0)
|
const suppressMouseUntil = ref(0)
|
||||||
|
|
||||||
function normalizeDate(val) {
|
function normalizeDate(val) {
|
||||||
if (typeof val === 'string') return val
|
if (typeof val === 'string') return val
|
||||||
if (val && typeof val === 'object') {
|
if (val && typeof val === 'object') {
|
||||||
@ -70,54 +99,73 @@ const totalVirtualWeeks = computed(() => {
|
|||||||
return maxVirtualWeek.value - minVirtualWeek.value + 1
|
return maxVirtualWeek.value - minVirtualWeek.value + 1
|
||||||
})
|
})
|
||||||
|
|
||||||
const contentHeight = computed(() => {
|
|
||||||
return totalVirtualWeeks.value * rowHeight.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// Virtual weeks manager (after dependent refs exist)
|
|
||||||
const vwm = createVirtualWeekManager({
|
|
||||||
calendarStore,
|
|
||||||
viewport,
|
|
||||||
viewportHeight,
|
|
||||||
rowHeight,
|
|
||||||
selection,
|
|
||||||
baseDate,
|
|
||||||
minVirtualWeek,
|
|
||||||
maxVirtualWeek,
|
|
||||||
contentHeight,
|
|
||||||
})
|
|
||||||
const visibleWeeks = vwm.visibleWeeks
|
|
||||||
const { scheduleWindowUpdate, resetWeeks, refreshEvents } = vwm
|
|
||||||
|
|
||||||
// Scroll managers (after scheduleWindowUpdate available)
|
|
||||||
const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate })
|
|
||||||
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 { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel } =
|
|
||||||
monthScrollManager
|
|
||||||
|
|
||||||
// Provide scroll refs to virtual week manager
|
|
||||||
vwm.attachScroll(scrollTop, setScrollTop)
|
|
||||||
|
|
||||||
const initialScrollTop = computed(() => {
|
const initialScrollTop = computed(() => {
|
||||||
const nowDate = new Date(calendarStore.now)
|
const nowDate = new Date(calendarStore.now)
|
||||||
const targetWeekIndex = getWeekIndex(nowDate) - 3
|
const targetWeekIndex = getWeekIndex(nowDate) - 3
|
||||||
return (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
return (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectedDateRange = computed(() => {
|
||||||
|
if (!selection.value.start || !selection.value.end) return ''
|
||||||
|
return formatDateRange(
|
||||||
|
fromLocalString(selection.value.start),
|
||||||
|
fromLocalString(selection.value.end),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const todayString = computed(() => {
|
||||||
|
const d = new Date(calendarStore.now)
|
||||||
|
return formatTodayString(d)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
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 endIdx = Math.ceil(
|
||||||
|
(scrollTop.value + 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)
|
||||||
|
if (
|
||||||
|
reason === 'scroll' &&
|
||||||
|
lastScrollRange.startVW === startVW &&
|
||||||
|
lastScrollRange.endVW === endVW &&
|
||||||
|
visibleWeeks.value.length
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const weeks = []
|
||||||
|
for (let vw = startVW; vw <= endVW; vw++) weeks.push(createWeek(vw))
|
||||||
|
visibleWeeks.value = weeks
|
||||||
|
lastScrollRange = { startVW, endVW }
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentHeight = computed(() => {
|
||||||
|
return totalVirtualWeeks.value * rowHeight.value
|
||||||
|
})
|
||||||
|
|
||||||
function computeRowHeight() {
|
function computeRowHeight() {
|
||||||
if (rowProbe.value) {
|
if (rowProbe.value) {
|
||||||
const h = rowProbe.value.getBoundingClientRect().height || 64
|
const h = rowProbe.value.getBoundingClientRect().height || 64
|
||||||
@ -127,7 +175,7 @@ function computeRowHeight() {
|
|||||||
const el = document.createElement('div')
|
const el = document.createElement('div')
|
||||||
el.style.position = 'absolute'
|
el.style.position = 'absolute'
|
||||||
el.style.visibility = 'hidden'
|
el.style.visibility = 'hidden'
|
||||||
el.style.height = 'var(--row-h)'
|
el.style.height = 'var(--cell-h)'
|
||||||
document.body.appendChild(el)
|
document.body.appendChild(el)
|
||||||
const h = el.getBoundingClientRect().height || 64
|
const h = el.getBoundingClientRect().height || 64
|
||||||
el.remove()
|
el.remove()
|
||||||
@ -141,20 +189,186 @@ function measureFromProbe() {
|
|||||||
const newH = Math.round(h)
|
const newH = Math.round(h)
|
||||||
if (newH !== rowHeight.value) {
|
if (newH !== rowHeight.value) {
|
||||||
const oldH = rowHeight.value
|
const oldH = rowHeight.value
|
||||||
// Anchor: keep the same top virtual week visible.
|
const currentTopVW = Math.floor(scrollTop.value / oldH) + minVirtualWeek.value
|
||||||
const topVirtualWeek = Math.floor(scrollTop.value / oldH) + minVirtualWeek.value
|
|
||||||
rowHeight.value = newH
|
rowHeight.value = newH
|
||||||
const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH
|
const newScrollTop = (currentTopVW - minVirtualWeek.value) * newH
|
||||||
setScrollTop(newScrollTop, 'row-height-change')
|
scrollTop.value = newScrollTop
|
||||||
resetWeeks('row-height-change')
|
if (viewport.value) viewport.value.scrollTop = newScrollTop
|
||||||
|
scheduleRebuild('row-height-change')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { getWeekIndex, getFirstDayForVirtualWeek, goToToday, handleHeaderYearChange } = vwm
|
function getWeekIndex(date) {
|
||||||
|
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||||
|
const firstDayOfWeek = addDays(date, -dayOffset)
|
||||||
|
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
||||||
|
}
|
||||||
|
|
||||||
// createWeek logic moved to virtualWeeks plugin
|
function getFirstDayForVirtualWeek(virtualWeek) {
|
||||||
|
return addDays(baseDate.value, virtualWeek * 7)
|
||||||
|
}
|
||||||
|
|
||||||
// goToToday now provided by manager
|
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.isRepeating) repeatingBases.push(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const dateStr = toLocalString(cur, DEFAULT_TZ)
|
||||||
|
const storedEvents = []
|
||||||
|
|
||||||
|
for (const ev of calendarStore.events.values()) {
|
||||||
|
if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) {
|
||||||
|
storedEvents.push(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dayEvents = [...storedEvents]
|
||||||
|
// Expand repeating events into per-day occurrences (including virtual spans) when they cover this date.
|
||||||
|
for (const base of repeatingBases) {
|
||||||
|
// Base event's original span: include it directly as occurrence index 0.
|
||||||
|
if (dateStr >= base.startDate && dateStr <= base.endDate) {
|
||||||
|
dayEvents.push({
|
||||||
|
...base,
|
||||||
|
_recurrenceIndex: 0,
|
||||||
|
_baseId: base.id,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
||||||
|
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
||||||
|
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
|
||||||
|
const currentDate = fromLocalString(dateStr, DEFAULT_TZ)
|
||||||
|
|
||||||
|
let occurrenceFound = false
|
||||||
|
|
||||||
|
// Walk backwards within the base span to locate a matching virtual occurrence start.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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: 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: weeksSpan,
|
||||||
|
height: weeksSpan * rowHeight.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
virtualWeek,
|
||||||
|
weekNumber: pad(weekNumber),
|
||||||
|
days,
|
||||||
|
monthLabel,
|
||||||
|
top: (virtualWeek - minVirtualWeek.value) * rowHeight.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
selection.value = { startDate: null, dayCount: 0 }
|
selection.value = { startDate: null, dayCount: 0 }
|
||||||
@ -193,17 +407,6 @@ function finalizeDragAndCreate() {
|
|||||||
removeGlobalTouchListeners()
|
removeGlobalTouchListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a minimal event creation payload from current selection
|
|
||||||
// Returns null if selection is invalid or empty.
|
|
||||||
function createEventFromSelection() {
|
|
||||||
const sel = selection.value || {}
|
|
||||||
if (!sel.startDate || !sel.dayCount || sel.dayCount <= 0) return null
|
|
||||||
return {
|
|
||||||
startDate: sel.startDate,
|
|
||||||
dayCount: sel.dayCount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDateUnderPoint(x, y) {
|
function getDateUnderPoint(x, y) {
|
||||||
const el = document.elementFromPoint(x, y)
|
const el = document.elementFromPoint(x, y)
|
||||||
let cur = el
|
let cur = el
|
||||||
@ -263,9 +466,10 @@ function getDateFromCoordinates(clientX, clientY) {
|
|||||||
const sampleWeek = viewport.value.querySelector('.week-row')
|
const sampleWeek = viewport.value.querySelector('.week-row')
|
||||||
if (!sampleWeek) return null
|
if (!sampleWeek) return null
|
||||||
const labelEl = sampleWeek.querySelector('.week-label')
|
const labelEl = sampleWeek.querySelector('.week-label')
|
||||||
|
const jogwheelWidth = 48
|
||||||
const wrRect = sampleWeek.getBoundingClientRect()
|
const wrRect = sampleWeek.getBoundingClientRect()
|
||||||
const labelRight = labelEl ? labelEl.getBoundingClientRect().right : wrRect.left
|
const labelRight = labelEl ? labelEl.getBoundingClientRect().right : wrRect.left
|
||||||
const daysAreaRight = wrRect.right
|
const daysAreaRight = wrRect.right - jogwheelWidth
|
||||||
const daysWidth = daysAreaRight - labelRight
|
const daysWidth = daysAreaRight - labelRight
|
||||||
if (clientX < labelRight || clientX > daysAreaRight) return null
|
if (clientX < labelRight || clientX > daysAreaRight) return null
|
||||||
const col = Math.min(6, Math.max(0, Math.floor(((clientX - labelRight) / daysWidth) * 7)))
|
const col = Math.min(6, Math.max(0, Math.floor(((clientX - labelRight) / daysWidth) * 7)))
|
||||||
@ -294,13 +498,15 @@ function calculateSelection(anchorStr, otherStr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Week label column drag scrolling ----------------
|
const onScroll = () => {
|
||||||
function getWeekLabelRect() {
|
if (viewport.value) scrollTop.value = viewport.value.scrollTop
|
||||||
// Prefer header year label width as stable reference
|
scheduleRebuild('scroll')
|
||||||
const headerYear = document.querySelector('.calendar-header .year-label')
|
}
|
||||||
if (headerYear) return headerYear.getBoundingClientRect()
|
|
||||||
const weekLabel = viewport.value?.querySelector('.week-row .week-label')
|
const handleJogwheelScrollTo = (newScrollTop) => {
|
||||||
return weekLabel ? weekLabel.getBoundingClientRect() : null
|
if (viewport.value) {
|
||||||
|
viewport.value.scrollTop = newScrollTop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@ -309,19 +515,16 @@ onMounted(() => {
|
|||||||
|
|
||||||
if (viewport.value) {
|
if (viewport.value) {
|
||||||
viewportHeight.value = viewport.value.clientHeight
|
viewportHeight.value = viewport.value.clientHeight
|
||||||
setScrollTop(initialScrollTop.value, 'initial-mount')
|
viewport.value.scrollTop = initialScrollTop.value
|
||||||
viewport.value.addEventListener('scroll', onScroll)
|
viewport.value.addEventListener('scroll', onScroll)
|
||||||
// Capture mousedown in viewport to allow dragging via week label column
|
|
||||||
viewport.value.addEventListener('mousedown', handleWeekColMouseDown, true)
|
|
||||||
}
|
}
|
||||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
calendarStore.updateCurrentDate()
|
calendarStore.updateCurrentDate()
|
||||||
}, 60000)
|
}, 60000)
|
||||||
|
|
||||||
// Initial incremental build (no existing weeks yet)
|
// Initial build after mount & measurement
|
||||||
scheduleWindowUpdate('init')
|
scheduleRebuild('init')
|
||||||
|
|
||||||
if (window.ResizeObserver && rowProbe.value) {
|
if (window.ResizeObserver && rowProbe.value) {
|
||||||
rowProbeObserver = new ResizeObserver(() => {
|
rowProbeObserver = new ResizeObserver(() => {
|
||||||
@ -338,7 +541,6 @@ onMounted(() => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (viewport.value) {
|
if (viewport.value) {
|
||||||
viewport.value.removeEventListener('scroll', onScroll)
|
viewport.value.removeEventListener('scroll', onScroll)
|
||||||
viewport.value.removeEventListener('mousedown', handleWeekColMouseDown, true)
|
|
||||||
}
|
}
|
||||||
if (rowProbeObserver && rowProbe.value) {
|
if (rowProbeObserver && rowProbe.value) {
|
||||||
try {
|
try {
|
||||||
@ -346,7 +548,6 @@ onBeforeUnmount(() => {
|
|||||||
rowProbeObserver.disconnect()
|
rowProbeObserver.disconnect()
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDayMouseDown = (d) => {
|
const handleDayMouseDown = (d) => {
|
||||||
@ -376,17 +577,31 @@ const handleEventClick = (payload) => {
|
|||||||
emit('edit-event', payload)
|
emit('edit-event', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// header year change delegated to manager
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
settingsDialog.value?.open()
|
||||||
|
}
|
||||||
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
||||||
// We explicitly avoid locale detection; rely solely on characters present.
|
// We explicitly avoid locale detection; rely solely on characters present.
|
||||||
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
|
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
|
||||||
function shouldRotateMonth(label) {
|
function shouldRotateMonth(label) {
|
||||||
if (!label) return false
|
if (!label) return false
|
||||||
return /\p{Script=Latin}/u.test(label)
|
// Rotate ONLY if any Latin script alphabetic character is present.
|
||||||
|
// Prefer Unicode script property when supported.
|
||||||
|
try {
|
||||||
|
if (/\p{Script=Latin}/u.test(label)) return true
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback for environments lacking Unicode property escapes.
|
||||||
|
if (/[A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/u.test(label)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch first day changes (e.g., first_day config update) to adjust scroll
|
|
||||||
// Keep roughly same visible date when first_day setting changes.
|
// Keep roughly same visible date when first_day setting changes.
|
||||||
watch(
|
watch(
|
||||||
() => calendarStore.config.first_day,
|
() => calendarStore.config.first_day,
|
||||||
@ -396,8 +611,9 @@ watch(
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const newTopWeekIndex = getWeekIndex(currentTopDate)
|
const newTopWeekIndex = getWeekIndex(currentTopDate)
|
||||||
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
|
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||||
setScrollTop(newScroll, 'first-day-change')
|
scrollTop.value = newScroll
|
||||||
resetWeeks('first-day-change')
|
if (viewport.value) viewport.value.scrollTop = newScroll
|
||||||
|
scheduleRebuild('first-day-change')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -406,89 +622,111 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => calendarStore.events,
|
() => calendarStore.events,
|
||||||
() => {
|
() => {
|
||||||
refreshEvents('events')
|
scheduleRebuild('events')
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reflect selection & events by rebuilding day objects in-place
|
|
||||||
watch(
|
|
||||||
() => [selection.value.startDate, selection.value.dayCount],
|
|
||||||
() => refreshEvents('selection'),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rebuild if viewport height changes (e.g., resize)
|
// Rebuild if viewport height changes (e.g., resize)
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
if (viewport.value) viewportHeight.value = viewport.value.clientHeight
|
if (viewport.value) viewportHeight.value = viewport.value.clientHeight
|
||||||
measureFromProbe()
|
measureFromProbe()
|
||||||
scheduleWindowUpdate('resize')
|
scheduleRebuild('resize')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="calendar-view-root">
|
<!-- hidden probe for measuring var(--cell-h) -->
|
||||||
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
||||||
<div class="wrap">
|
<!-- existing template root starts below -->
|
||||||
<HeaderControls @go-to-today="goToToday" />
|
<div class="wrap">
|
||||||
<CalendarHeader
|
<header>
|
||||||
:scroll-top="scrollTop"
|
<h1>Calendar</h1>
|
||||||
:row-height="rowHeight"
|
<div class="header-controls">
|
||||||
:min-virtual-week="minVirtualWeek"
|
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
||||||
@year-change="handleHeaderYearChange"
|
<!-- Reference historyTick to ensure reactivity of canUndo/canRedo -->
|
||||||
/>
|
<button
|
||||||
<div class="calendar-container">
|
type="button"
|
||||||
<div class="calendar-viewport" ref="viewport">
|
class="hist-btn"
|
||||||
<!-- Main calendar content (weeks and days) -->
|
:disabled="!calendarStore.historyCanUndo"
|
||||||
<div class="main-calendar-area">
|
@click="calendarStore.$history?.undo()"
|
||||||
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
title="Undo (Ctrl+Z)"
|
||||||
<CalendarWeek
|
aria-label="Undo"
|
||||||
v-for="week in visibleWeeks"
|
>
|
||||||
:key="week.virtualWeek"
|
↶
|
||||||
:week="week"
|
</button>
|
||||||
:dragging="isDragging"
|
<button
|
||||||
:style="{ top: week.top + 'px' }"
|
type="button"
|
||||||
@day-mousedown="handleDayMouseDown"
|
class="hist-btn"
|
||||||
@day-mouseenter="handleDayMouseEnter"
|
:disabled="!calendarStore.historyCanRedo"
|
||||||
@day-mouseup="handleDayMouseUp"
|
@click="calendarStore.$history?.redo()"
|
||||||
@day-touchstart="handleDayTouchStart"
|
title="Redo (Ctrl+Shift+Z)"
|
||||||
@event-click="handleEventClick"
|
aria-label="Redo"
|
||||||
/>
|
>
|
||||||
</div>
|
↷
|
||||||
</div>
|
</button>
|
||||||
<!-- Month column area -->
|
<button
|
||||||
<div class="month-column-area">
|
type="button"
|
||||||
<!-- Month labels -->
|
class="settings-btn"
|
||||||
<div class="month-labels-container" :style="{ height: contentHeight + 'px' }">
|
@click="openSettings"
|
||||||
<template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'">
|
aria-label="Open settings"
|
||||||
<div
|
title="Settings"
|
||||||
v-if="monthWeek && monthWeek.monthLabel"
|
>
|
||||||
class="month-label"
|
⚙
|
||||||
:class="monthWeek.monthLabel?.monthClass"
|
</button>
|
||||||
:style="{
|
</div>
|
||||||
height: `calc(var(--row-h) * ${monthWeek.monthLabel?.weeksSpan || 1})`,
|
</header>
|
||||||
top: (monthWeek.top || 0) + 'px',
|
<CalendarHeader
|
||||||
}"
|
:scroll-top="scrollTop"
|
||||||
@pointerdown="handleMonthScrollPointerDown"
|
:row-height="rowHeight"
|
||||||
@touchstart.prevent="handleMonthScrollTouchStart"
|
:min-virtual-week="minVirtualWeek"
|
||||||
@wheel="handleMonthScrollWheel"
|
@year-change="handleHeaderYearChange"
|
||||||
>
|
/>
|
||||||
<span :class="{ bottomup: shouldRotateMonth(monthWeek.monthLabel?.text) }">{{
|
<div class="calendar-container">
|
||||||
monthWeek.monthLabel?.text || ''
|
<div class="calendar-viewport" ref="viewport">
|
||||||
}}</span>
|
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
||||||
</div>
|
<CalendarWeek
|
||||||
</template>
|
v-for="week in visibleWeeks"
|
||||||
</div>
|
: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"
|
||||||
|
/>
|
||||||
|
<!-- Month labels positioned absolutely -->
|
||||||
|
<div
|
||||||
|
v-for="week in visibleWeeks"
|
||||||
|
:key="`month-${week.virtualWeek}`"
|
||||||
|
v-show="week.monthLabel"
|
||||||
|
class="month-name-label"
|
||||||
|
:class="{ 'no-rotate': !shouldRotateMonth(week.monthLabel?.text) }"
|
||||||
|
:style="{
|
||||||
|
top: week.top + 'px',
|
||||||
|
height: week.monthLabel?.height + 'px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span>{{ week.monthLabel?.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Jogwheel as sibling to calendar-viewport -->
|
||||||
|
<Jogwheel
|
||||||
|
:total-virtual-weeks="totalVirtualWeeks"
|
||||||
|
:row-height="rowHeight"
|
||||||
|
:viewport-height="viewportHeight"
|
||||||
|
:scroll-top="scrollTop"
|
||||||
|
@scroll-to="handleJogwheelScrollTo"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<SettingsDialog ref="settingsDialog" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.calendar-view-root {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
.wrap {
|
.wrap {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -507,6 +745,81 @@ header h1 {
|
|||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.header-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.hist-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
outline: none;
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
}
|
||||||
|
.hist-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.hist-btn:not(:disabled):hover,
|
||||||
|
.hist-btn:not(:disabled):focus-visible {
|
||||||
|
color: var(--strong);
|
||||||
|
}
|
||||||
|
.hist-btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.88);
|
||||||
|
}
|
||||||
|
.settings-btn:hover {
|
||||||
|
color: var(--strong);
|
||||||
|
}
|
||||||
|
.settings-btn:focus-visible {
|
||||||
|
/* Keep visual accessibility without background */
|
||||||
|
outline: 2px solid var(--selected);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.settings-btn:active {
|
||||||
|
transform: scale(0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-date {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--today-btn-bg);
|
||||||
|
color: var(--today-btn-text);
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre-line;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-date:hover {
|
||||||
|
background: var(--today-btn-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-container {
|
.calendar-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -525,13 +838,7 @@ header h1 {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr var(--month-w);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-calendar-area {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-content {
|
.calendar-content {
|
||||||
@ -539,52 +846,33 @@ header h1 {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.month-column-area {
|
.month-name-label {
|
||||||
position: relative;
|
|
||||||
cursor: ns-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.month-labels-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.month-label {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
right: 0;
|
||||||
width: 100%;
|
width: 3rem; /* Match jogwheel width */
|
||||||
background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2));
|
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
z-index: 15;
|
z-index: 15;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
cursor: ns-resize;
|
|
||||||
user-select: none;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.month-label > span {
|
.month-name-label > span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
writing-mode: vertical-rl;
|
writing-mode: vertical-rl;
|
||||||
text-orientation: mixed;
|
text-orientation: mixed;
|
||||||
transform-origin: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottomup {
|
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-height-probe {
|
.month-name-label.no-rotate > span {
|
||||||
position: absolute;
|
transform: none;
|
||||||
visibility: hidden;
|
|
||||||
height: var(--row-h);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-height-probe { position: absolute; visibility: hidden; height: var(--cell-h); pointer-events: none; }
|
||||||
</style>
|
</style>
|
||||||
|
@ -33,16 +33,6 @@ const handleDayTouchStart = (dateStr) => {
|
|||||||
const handleEventClick = (payload) => {
|
const handleEventClick = (payload) => {
|
||||||
emit('event-click', payload)
|
emit('event-click', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only apply upside-down rotation (bottomup) for Latin script month labels
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -67,9 +57,9 @@ function shouldRotateMonth(label) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.week-row {
|
.week-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--week-w) repeat(7, 1fr);
|
grid-template-columns: var(--label-w) repeat(7, 1fr) 3rem;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: var(--row-h);
|
height: var(--cell-h);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,8 +70,13 @@ function shouldRotateMonth(label) {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
/* Prevent text selection */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
height: var(--row-h);
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.days-grid {
|
.days-grid {
|
||||||
@ -91,4 +86,10 @@ function shouldRotateMonth(label) {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fixed heights for cells and labels (from cells.css) */
|
||||||
|
.week-row :deep(.cell),
|
||||||
|
.week-label {
|
||||||
|
height: var(--cell-h);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -23,13 +23,10 @@ const emit = defineEmits(['clear-selection'])
|
|||||||
const calendarStore = useCalendarStore()
|
const calendarStore = useCalendarStore()
|
||||||
|
|
||||||
const showDialog = ref(false)
|
const showDialog = ref(false)
|
||||||
// Anchoring: element of the DayCell representing the event's start date.
|
|
||||||
const anchorElement = ref(null)
|
|
||||||
const dialogMode = ref('create') // 'create' or 'edit'
|
const dialogMode = ref('create') // 'create' or 'edit'
|
||||||
const editingEventId = ref(null)
|
const editingEventId = ref(null)
|
||||||
const unsavedCreateId = ref(null)
|
const unsavedCreateId = ref(null)
|
||||||
const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occurrenceDate }
|
const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occurrenceDate }
|
||||||
const initialWeekday = ref(null)
|
|
||||||
const title = computed({
|
const title = computed({
|
||||||
get() {
|
get() {
|
||||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||||
@ -64,13 +61,10 @@ function getStartingWeekday(selectionData = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fallbackWeekdays = computed(() => {
|
const fallbackWeekdays = computed(() => {
|
||||||
let weekday = initialWeekday.value
|
const startingDay = getStartingWeekday()
|
||||||
if (weekday == null) {
|
const fallback = [false, false, false, false, false, false, false]
|
||||||
weekday = getStartingWeekday()
|
fallback[startingDay] = true
|
||||||
}
|
return fallback
|
||||||
const fb = [false, false, false, false, false, false, false]
|
|
||||||
fb[weekday] = true
|
|
||||||
return fb
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Maps UI frequency display (including years) to store frequency (weeks/months only)
|
// Maps UI frequency display (including years) to store frequency (weeks/months only)
|
||||||
@ -144,24 +138,14 @@ const selectedColor = computed({
|
|||||||
const repeatCountBinding = computed({
|
const repeatCountBinding = computed({
|
||||||
get() {
|
get() {
|
||||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||||
const ev = calendarStore.events.get(editingEventId.value)
|
const rc = calendarStore.events.get(editingEventId.value).repeatCount
|
||||||
const rc = ev.recur?.count ?? 'unlimited'
|
|
||||||
return rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
|
return rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
|
||||||
}
|
}
|
||||||
return recurrenceOccurrences.value
|
return recurrenceOccurrences.value
|
||||||
},
|
},
|
||||||
set(v) {
|
set(v) {
|
||||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||||
const ev = calendarStore.events.get(editingEventId.value)
|
calendarStore.events.get(editingEventId.value).repeatCount = v === 0 ? 'unlimited' : String(v)
|
||||||
if (!ev.recur && v !== 0) {
|
|
||||||
ev.recur = {
|
|
||||||
freq: recurrenceFrequency.value,
|
|
||||||
interval: recurrenceInterval.value,
|
|
||||||
count: 'unlimited',
|
|
||||||
weekdays: recurrenceFrequency.value === 'weeks' ? buildStoreWeekdayPattern() : null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (ev.recur) ev.recur.count = v === 0 ? 'unlimited' : String(v)
|
|
||||||
calendarStore.touchEvents()
|
calendarStore.touchEvents()
|
||||||
}
|
}
|
||||||
recurrenceOccurrences.value = v
|
recurrenceOccurrences.value = v
|
||||||
@ -192,7 +176,14 @@ const repeat = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function buildStoreWeekdayPattern() {
|
function buildStoreWeekdayPattern() {
|
||||||
return [...recurrenceWeekdays.value]
|
let sunFirst = [...recurrenceWeekdays.value]
|
||||||
|
|
||||||
|
if (!sunFirst.some(Boolean)) {
|
||||||
|
const startingDay = getStartingWeekday()
|
||||||
|
sunFirst[startingDay] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return sunFirst
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadWeekdayPatternFromStore(storePattern) {
|
function loadWeekdayPatternFromStore(storePattern) {
|
||||||
@ -200,12 +191,6 @@ function loadWeekdayPatternFromStore(storePattern) {
|
|||||||
recurrenceWeekdays.value = [...storePattern]
|
recurrenceWeekdays.value = [...storePattern]
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAnchorFromDate(dateStr) {
|
|
||||||
if (!dateStr) return null
|
|
||||||
// Expect day cells to have data-date attribute (see CalendarDay / DayCell components)
|
|
||||||
return document.querySelector(`[data-date='${dateStr}']`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDialog(selectionData = null) {
|
function openCreateDialog(selectionData = null) {
|
||||||
calendarStore.$history?.beginCompound()
|
calendarStore.$history?.beginCompound()
|
||||||
if (unsavedCreateId.value && !eventSaved.value) {
|
if (unsavedCreateId.value && !eventSaved.value) {
|
||||||
@ -229,7 +214,6 @@ function openCreateDialog(selectionData = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
occurrenceContext.value = null
|
occurrenceContext.value = null
|
||||||
initialWeekday.value = null
|
|
||||||
dialogMode.value = 'create'
|
dialogMode.value = 'create'
|
||||||
recurrenceEnabled.value = false
|
recurrenceEnabled.value = false
|
||||||
recurrenceInterval.value = 1
|
recurrenceInterval.value = 1
|
||||||
@ -242,34 +226,20 @@ function openCreateDialog(selectionData = null) {
|
|||||||
|
|
||||||
const startingDay = getStartingWeekday({ start, end })
|
const startingDay = getStartingWeekday({ start, end })
|
||||||
recurrenceWeekdays.value[startingDay] = true
|
recurrenceWeekdays.value[startingDay] = true
|
||||||
initialWeekday.value = startingDay
|
|
||||||
|
|
||||||
let days = 1
|
|
||||||
if (start && end && start <= end) {
|
|
||||||
const s = fromLocalString(start, DEFAULT_TZ)
|
|
||||||
const e = fromLocalString(end, DEFAULT_TZ)
|
|
||||||
days = Math.max(1, (e - s) / 86400000 + 1)
|
|
||||||
}
|
|
||||||
editingEventId.value = calendarStore.createEvent({
|
editingEventId.value = calendarStore.createEvent({
|
||||||
title: '',
|
title: '',
|
||||||
startDate: start,
|
startDate: start,
|
||||||
days,
|
endDate: end,
|
||||||
colorId: colorId.value,
|
colorId: colorId.value,
|
||||||
recur:
|
repeat: repeat.value,
|
||||||
recurrenceEnabled.value && repeat.value !== 'none'
|
repeatInterval: recurrenceInterval.value,
|
||||||
? {
|
repeatCount:
|
||||||
freq: recurrenceFrequency.value,
|
recurrenceOccurrences.value === 0 ? 'for now' : String(recurrenceOccurrences.value),
|
||||||
interval: recurrenceInterval.value,
|
repeatWeekdays: buildStoreWeekdayPattern(),
|
||||||
count:
|
|
||||||
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value),
|
|
||||||
weekdays: recurrenceFrequency.value === 'weeks' ? buildStoreWeekdayPattern() : null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
})
|
})
|
||||||
unsavedCreateId.value = editingEventId.value
|
unsavedCreateId.value = editingEventId.value
|
||||||
|
|
||||||
// anchor to the starting day cell
|
|
||||||
anchorElement.value = resolveAnchorFromDate(start)
|
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@ -297,7 +267,6 @@ function openEditDialog(payload) {
|
|||||||
unsavedCreateId.value = null
|
unsavedCreateId.value = null
|
||||||
}
|
}
|
||||||
occurrenceContext.value = null
|
occurrenceContext.value = null
|
||||||
initialWeekday.value = null
|
|
||||||
if (!payload) return
|
if (!payload) return
|
||||||
|
|
||||||
const baseId = payload.id
|
const baseId = payload.id
|
||||||
@ -308,17 +277,16 @@ function openEditDialog(payload) {
|
|||||||
const event = calendarStore.getEventById(baseId)
|
const event = calendarStore.getEventById(baseId)
|
||||||
if (!event) return
|
if (!event) return
|
||||||
|
|
||||||
if (event.recur) {
|
if (event.isRepeating) {
|
||||||
if (event.recur.freq === 'weeks' && occurrenceIndex >= 0) {
|
if (event.repeat === 'weeks' && occurrenceIndex >= 0) {
|
||||||
const pattern = event.recur.weekdays || []
|
const pattern = event.repeatWeekdays || []
|
||||||
const baseStart = fromLocalString(event.startDate, DEFAULT_TZ)
|
const baseStart = fromLocalString(event.startDate, DEFAULT_TZ)
|
||||||
const baseEnd = new Date(fromLocalString(event.startDate, DEFAULT_TZ))
|
const baseEnd = fromLocalString(event.endDate, DEFAULT_TZ)
|
||||||
baseEnd.setDate(baseEnd.getDate() + (event.days || 1) - 1)
|
|
||||||
if (occurrenceIndex === 0) {
|
if (occurrenceIndex === 0) {
|
||||||
occurrenceDate = baseStart
|
occurrenceDate = baseStart
|
||||||
weekday = baseStart.getDay()
|
weekday = baseStart.getDay()
|
||||||
} else {
|
} else {
|
||||||
const interval = event.recur.interval || 1
|
const interval = event.repeatInterval || 1
|
||||||
const WEEK_MS = 7 * 86400000
|
const WEEK_MS = 7 * 86400000
|
||||||
const baseBlockStart = getMondayOfISOWeek(baseStart)
|
const baseBlockStart = getMondayOfISOWeek(baseStart)
|
||||||
function isAligned(d) {
|
function isAligned(d) {
|
||||||
@ -340,24 +308,22 @@ function openEditDialog(payload) {
|
|||||||
occurrenceDate = cur
|
occurrenceDate = cur
|
||||||
weekday = cur.getDay()
|
weekday = cur.getDay()
|
||||||
}
|
}
|
||||||
} else if (event.recur.freq === 'months' && occurrenceIndex >= 0) {
|
} else if (event.repeat === 'months' && occurrenceIndex >= 0) {
|
||||||
const baseDate = fromLocalString(event.startDate, DEFAULT_TZ)
|
const baseDate = fromLocalString(event.startDate, DEFAULT_TZ)
|
||||||
occurrenceDate = addMonths(baseDate, occurrenceIndex)
|
occurrenceDate = addMonths(baseDate, occurrenceIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dialogMode.value = 'edit'
|
dialogMode.value = 'edit'
|
||||||
editingEventId.value = baseId
|
editingEventId.value = baseId
|
||||||
loadWeekdayPatternFromStore(event.recur?.weekdays)
|
loadWeekdayPatternFromStore(event.repeatWeekdays)
|
||||||
initialWeekday.value =
|
repeat.value = event.repeat // triggers setter mapping into recurrence state
|
||||||
weekday != null ? weekday : fromLocalString(event.startDate, DEFAULT_TZ).getDay()
|
if (event.repeatInterval) recurrenceInterval.value = event.repeatInterval
|
||||||
repeat.value = event.recur ? event.recur.freq : 'none'
|
|
||||||
if (event.recur?.interval) recurrenceInterval.value = event.recur.interval
|
|
||||||
|
|
||||||
// Set UI display frequency based on loaded data
|
// Set UI display frequency based on loaded data
|
||||||
if (event.recur?.freq === 'weeks') {
|
if (event.repeat === 'weeks') {
|
||||||
uiDisplayFrequency.value = 'weeks'
|
uiDisplayFrequency.value = 'weeks'
|
||||||
} else if (event.recur?.freq === 'months') {
|
} else if (event.repeat === 'months') {
|
||||||
if (event.recur.interval && event.recur.interval % 12 === 0 && event.recur.interval >= 12) {
|
if (event.repeatInterval && event.repeatInterval % 12 === 0 && event.repeatInterval >= 12) {
|
||||||
uiDisplayFrequency.value = 'years'
|
uiDisplayFrequency.value = 'years'
|
||||||
} else {
|
} else {
|
||||||
uiDisplayFrequency.value = 'months'
|
uiDisplayFrequency.value = 'months'
|
||||||
@ -366,20 +332,18 @@ function openEditDialog(payload) {
|
|||||||
uiDisplayFrequency.value = 'weeks'
|
uiDisplayFrequency.value = 'weeks'
|
||||||
}
|
}
|
||||||
|
|
||||||
const rc = event.recur?.count ?? 'unlimited'
|
const rc = event.repeatCount ?? 'unlimited'
|
||||||
recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
|
recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
|
||||||
colorId.value = event.colorId
|
colorId.value = event.colorId
|
||||||
eventSaved.value = false
|
eventSaved.value = false
|
||||||
|
|
||||||
if (event.recur) {
|
if (event.isRepeating) {
|
||||||
if (event.recur.freq === 'weeks' && occurrenceIndex >= 0) {
|
if (event.repeat === 'weeks' && occurrenceIndex >= 0) {
|
||||||
occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate }
|
occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate }
|
||||||
} else if (event.recur.freq === 'months' && occurrenceIndex > 0) {
|
} else if (event.repeat === 'months' && occurrenceIndex > 0) {
|
||||||
occurrenceContext.value = { baseId, occurrenceIndex, weekday: null, occurrenceDate }
|
occurrenceContext.value = { baseId, occurrenceIndex, weekday: null, occurrenceDate }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// anchor to base event start date
|
|
||||||
anchorElement.value = resolveAnchorFromDate(event.startDate)
|
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@ -403,17 +367,12 @@ function updateEventInStore() {
|
|||||||
if (calendarStore.events?.has(editingEventId.value)) {
|
if (calendarStore.events?.has(editingEventId.value)) {
|
||||||
const event = calendarStore.events.get(editingEventId.value)
|
const event = calendarStore.events.get(editingEventId.value)
|
||||||
event.colorId = colorId.value
|
event.colorId = colorId.value
|
||||||
if (recurrenceEnabled.value && repeat.value !== 'none') {
|
event.repeat = repeat.value
|
||||||
event.recur = {
|
event.repeatInterval = recurrenceInterval.value
|
||||||
freq: recurrenceFrequency.value,
|
event.repeatWeekdays = buildStoreWeekdayPattern()
|
||||||
interval: recurrenceInterval.value,
|
event.repeatCount =
|
||||||
count:
|
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value)
|
||||||
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value),
|
event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none'
|
||||||
weekdays: recurrenceFrequency.value === 'weeks' ? buildStoreWeekdayPattern() : null,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
event.recur = null
|
|
||||||
}
|
|
||||||
calendarStore.touchEvents()
|
calendarStore.touchEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -497,9 +456,11 @@ const isRepeatingBaseEdit = computed(() => isRepeatingEdit.value && !occurrenceC
|
|||||||
const isLastOccurrence = computed(() => {
|
const isLastOccurrence = computed(() => {
|
||||||
if (!occurrenceContext.value || !editingEventId.value) return false
|
if (!occurrenceContext.value || !editingEventId.value) return false
|
||||||
const event = calendarStore.getEventById(editingEventId.value)
|
const event = calendarStore.getEventById(editingEventId.value)
|
||||||
if (!event || !event.recur) return false
|
if (!event || !event.isRepeating) return false
|
||||||
if (event.recur.count === 'unlimited' || recurrenceOccurrences.value === 0) return false
|
|
||||||
const totalCount = parseInt(event.recur.count, 10) || 0
|
if (event.repeatCount === 'unlimited' || recurrenceOccurrences.value === 0) return false
|
||||||
|
|
||||||
|
const totalCount = parseInt(event.repeatCount, 10) || 0
|
||||||
return occurrenceContext.value.occurrenceIndex === totalCount - 1
|
return occurrenceContext.value.occurrenceIndex === totalCount - 1
|
||||||
})
|
})
|
||||||
const formattedOccurrenceShort = computed(() => {
|
const formattedOccurrenceShort = computed(() => {
|
||||||
@ -592,7 +553,7 @@ const recurrenceSummary = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseDialog v-model="showDialog" :anchor-el="anchorElement" @submit="saveEvent">
|
<BaseDialog v-model="showDialog" @submit="saveEvent">
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ dialogMode === 'create' ? 'Create Event' : 'Edit Event'
|
{{ dialogMode === 'create' ? 'Create Event' : 'Edit Event'
|
||||||
}}<template v-if="headerDateShort"> · {{ headerDateShort }}</template>
|
}}<template v-if="headerDateShort"> · {{ headerDateShort }}</template>
|
||||||
@ -735,7 +696,7 @@ const recurrenceSummary = computed(() => {
|
|||||||
outline-offset: 0.125em;
|
outline-offset: 0.125em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 80%;
|
width: 3em;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -750,8 +711,8 @@ const recurrenceSummary = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ec-btn {
|
.ec-btn {
|
||||||
|
border: 0.0625em solid var(--muted);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
padding: 0.5em 0.8em;
|
padding: 0.5em 0.8em;
|
||||||
border-radius: 0.4em;
|
border-radius: 0.4em;
|
||||||
@ -922,6 +883,63 @@ const recurrenceSummary = computed(() => {
|
|||||||
0 0 0 1px var(--input-focus),
|
0 0 0 1px var(--input-focus),
|
||||||
0 0 0 4px rgba(37, 99, 235, 0.15);
|
0 0 0 4px rgba(37, 99, 235, 0.15);
|
||||||
}
|
}
|
||||||
|
.mini-stepper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel-alt);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
}
|
||||||
|
.mini-stepper .step {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0 0.55rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
}
|
||||||
|
.mini-stepper .step:hover:not(:disabled) {
|
||||||
|
background: var(--pill-hover-bg);
|
||||||
|
}
|
||||||
|
.mini-stepper .step:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.mini-stepper .value {
|
||||||
|
min-width: 1.6rem;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.mini-stepper:focus-within {
|
||||||
|
border-color: var(--input-focus);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px var(--input-focus),
|
||||||
|
0 0 0 4px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
.mini-stepper.occ .value {
|
||||||
|
min-width: 2rem;
|
||||||
|
}
|
||||||
|
.occ-stepper.mini-stepper.occ .value {
|
||||||
|
min-width: 2rem;
|
||||||
|
}
|
||||||
|
.mini-stepper .step:focus-visible {
|
||||||
|
outline: 2px solid var(--input-focus);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
|
@ -101,24 +101,6 @@ function handleEventPointerDown(span, event) {
|
|||||||
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
|
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
|
||||||
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
|
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
|
||||||
const isVirtual = hasVirtualMarker
|
const isVirtual = hasVirtualMarker
|
||||||
// Determine which day within the span was grabbed so we maintain relative position
|
|
||||||
let anchorDate = span.startDate
|
|
||||||
try {
|
|
||||||
const spanDays = daysInclusive(span.startDate, span.endDate)
|
|
||||||
const targetEl = event.currentTarget
|
|
||||||
if (targetEl && spanDays > 0) {
|
|
||||||
const rect = targetEl.getBoundingClientRect()
|
|
||||||
const relX = event.clientX - rect.left
|
|
||||||
const dayWidth = rect.width / spanDays
|
|
||||||
let dayIndex = Math.floor(relX / dayWidth)
|
|
||||||
if (!isFinite(dayIndex)) dayIndex = 0
|
|
||||||
if (dayIndex < 0) dayIndex = 0
|
|
||||||
if (dayIndex >= spanDays) dayIndex = spanDays - 1
|
|
||||||
anchorDate = addDaysStr(span.startDate, dayIndex)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to startDate if any calculation fails
|
|
||||||
}
|
|
||||||
startLocalDrag(
|
startLocalDrag(
|
||||||
{
|
{
|
||||||
id: baseId,
|
id: baseId,
|
||||||
@ -127,7 +109,7 @@ function handleEventPointerDown(span, event) {
|
|||||||
mode: 'move',
|
mode: 'move',
|
||||||
pointerStartX: event.clientX,
|
pointerStartX: event.clientX,
|
||||||
pointerStartY: event.clientY,
|
pointerStartY: event.clientY,
|
||||||
anchorDate,
|
anchorDate: span.startDate,
|
||||||
startDate: span.startDate,
|
startDate: span.startDate,
|
||||||
endDate: span.endDate,
|
endDate: span.endDate,
|
||||||
},
|
},
|
||||||
@ -167,24 +149,6 @@ function startLocalDrag(init, evt) {
|
|||||||
else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1
|
else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture original repeating pattern & weekday (for weekly repeats) so we can rotate relative to original
|
|
||||||
let originalWeekday = null
|
|
||||||
let originalPattern = null
|
|
||||||
if (init.mode === 'move') {
|
|
||||||
try {
|
|
||||||
originalWeekday = new Date(init.startDate + 'T00:00:00').getDay()
|
|
||||||
const baseEv = store.getEventById(init.id)
|
|
||||||
if (
|
|
||||||
baseEv &&
|
|
||||||
baseEv.recur &&
|
|
||||||
baseEv.recur.freq === 'weeks' &&
|
|
||||||
Array.isArray(baseEv.recur.weekdays)
|
|
||||||
) {
|
|
||||||
originalPattern = [...baseEv.recur.weekdays]
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
dragState.value = {
|
dragState.value = {
|
||||||
...init,
|
...init,
|
||||||
anchorOffset,
|
anchorOffset,
|
||||||
@ -192,9 +156,6 @@ function startLocalDrag(init, evt) {
|
|||||||
eventMoved: false,
|
eventMoved: false,
|
||||||
tentativeStart: init.startDate,
|
tentativeStart: init.startDate,
|
||||||
tentativeEnd: init.endDate,
|
tentativeEnd: init.endDate,
|
||||||
originalWeekday,
|
|
||||||
originalPattern,
|
|
||||||
realizedId: null, // for virtual occurrence converted to real during drag
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin compound history session (single snapshot after drag completes)
|
// Begin compound history session (single snapshot after drag completes)
|
||||||
@ -259,61 +220,13 @@ function onDragPointerMove(e) {
|
|||||||
if (ns === st.tentativeStart && ne === st.tentativeEnd) return
|
if (ns === st.tentativeStart && ne === st.tentativeEnd) return
|
||||||
st.tentativeStart = ns
|
st.tentativeStart = ns
|
||||||
st.tentativeEnd = ne
|
st.tentativeEnd = ne
|
||||||
if (st.mode === 'move') {
|
// Real-time update only for non-virtual events (avoid repeated split operations)
|
||||||
if (st.isVirtual) {
|
if (!st.isVirtual) {
|
||||||
// On first movement convert virtual occurrence into a real new event (split series)
|
|
||||||
if (!st.realizedId) {
|
|
||||||
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne)
|
|
||||||
if (newId) {
|
|
||||||
st.realizedId = newId
|
|
||||||
st.id = newId
|
|
||||||
st.isVirtual = false
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Subsequent moves: update range without rotating pattern automatically
|
|
||||||
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Normal non-virtual move; rotate handled in setEventRange
|
|
||||||
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
|
|
||||||
}
|
|
||||||
// Manual rotation relative to original pattern (keeps pattern anchored to initially grabbed weekday)
|
|
||||||
if (st.originalPattern && st.originalWeekday != null) {
|
|
||||||
try {
|
|
||||||
const currentWeekday = new Date(ns + 'T00:00:00').getDay()
|
|
||||||
const shift = currentWeekday - st.originalWeekday
|
|
||||||
const rotated = store._rotateWeekdayPattern([...st.originalPattern], shift)
|
|
||||||
const ev = store.getEventById(st.id)
|
|
||||||
if (ev && ev.recur && ev.recur.freq === 'weeks') {
|
|
||||||
ev.recur.weekdays = rotated
|
|
||||||
store.touchEvents()
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
} else if (!st.isVirtual) {
|
|
||||||
// Resizes on real events update immediately
|
|
||||||
applyRangeDuringDrag(
|
applyRangeDuringDrag(
|
||||||
{ id: st.id, isVirtual: st.isVirtual, mode: st.mode, startDate: ns, endDate: ne },
|
{ id: st.id, isVirtual: st.isVirtual, mode: st.mode, startDate: ns, endDate: ne },
|
||||||
ns,
|
ns,
|
||||||
ne,
|
ne,
|
||||||
)
|
)
|
||||||
} else if (st.isVirtual && (st.mode === 'resize-left' || st.mode === 'resize-right')) {
|
|
||||||
// For virtual occurrence resize: convert to real once, then adjust range
|
|
||||||
if (!st.realizedId) {
|
|
||||||
const initialStart = ns
|
|
||||||
const initialEnd = ne
|
|
||||||
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, initialStart, initialEnd)
|
|
||||||
if (newId) {
|
|
||||||
st.realizedId = newId
|
|
||||||
st.id = newId
|
|
||||||
st.isVirtual = false
|
|
||||||
} else return
|
|
||||||
}
|
|
||||||
// Apply range change; rotate if left edge moved and weekday changed
|
|
||||||
const rotate = st.mode === 'resize-left'
|
|
||||||
store.setEventRange(st.id, ns, ne, { mode: st.mode, rotatePattern: rotate })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -416,7 +329,7 @@ function applyRangeDuringDrag(st, startDate, endDate) {
|
|||||||
|
|
||||||
.event-span {
|
.event-span {
|
||||||
padding: 0.1em 0.3em;
|
padding: 0.1em 0.3em;
|
||||||
border-radius: 1em;
|
border-radius: 0.2em;
|
||||||
font-size: clamp(0.45em, 1.8vh, 0.75em);
|
font-size: clamp(0.45em, 1.8vh, 0.75em);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
@ -1,210 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Transition name="header-controls" appear>
|
|
||||||
<div v-if="isVisible" class="header-controls">
|
|
||||||
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="hist-btn"
|
|
||||||
:disabled="!calendarStore.historyCanUndo"
|
|
||||||
@click="calendarStore.$history?.undo()"
|
|
||||||
title="Undo (Ctrl+Z)"
|
|
||||||
aria-label="Undo"
|
|
||||||
>
|
|
||||||
↶
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="hist-btn"
|
|
||||||
:disabled="!calendarStore.historyCanRedo"
|
|
||||||
@click="calendarStore.$history?.redo()"
|
|
||||||
title="Redo (Ctrl+Shift+Z)"
|
|
||||||
aria-label="Redo"
|
|
||||||
>
|
|
||||||
↷
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="settings-btn"
|
|
||||||
@click="openSettings"
|
|
||||||
aria-label="Open settings"
|
|
||||||
title="Settings"
|
|
||||||
>
|
|
||||||
⚙
|
|
||||||
</button>
|
|
||||||
<!-- Settings dialog now lives here -->
|
|
||||||
<SettingsDialog ref="settingsDialog" />
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="toggle-btn"
|
|
||||||
@click="toggleVisibility"
|
|
||||||
:aria-label="isVisible ? 'Hide controls' : 'Show controls'"
|
|
||||||
:title="isVisible ? 'Hide controls' : 'Show controls'"
|
|
||||||
>
|
|
||||||
⋯
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
|
||||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
|
||||||
import { formatTodayString } from '@/utils/date'
|
|
||||||
import SettingsDialog from '@/components/SettingsDialog.vue'
|
|
||||||
|
|
||||||
const calendarStore = useCalendarStore()
|
|
||||||
|
|
||||||
const todayString = computed(() => {
|
|
||||||
const d = new Date(calendarStore.now)
|
|
||||||
return formatTodayString(d)
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['go-to-today'])
|
|
||||||
|
|
||||||
function goToToday() {
|
|
||||||
// Emit the event so the parent can handle the viewport scrolling logic
|
|
||||||
// since this component doesn't have access to viewport refs
|
|
||||||
emit('go-to-today')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Screen size detection and visibility toggle
|
|
||||||
const isVisible = ref(false)
|
|
||||||
|
|
||||||
function checkScreenSize() {
|
|
||||||
const isSmallScreen = window.innerHeight < 600
|
|
||||||
// Default to open on large screens, closed on small screens
|
|
||||||
isVisible.value = !isSmallScreen
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleVisibility() {
|
|
||||||
isVisible.value = !isVisible.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings dialog integration
|
|
||||||
const settingsDialog = ref(null)
|
|
||||||
function openSettings() {
|
|
||||||
settingsDialog.value?.open()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
checkScreenSize()
|
|
||||||
window.addEventListener('resize', checkScreenSize)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener('resize', checkScreenSize)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.header-controls {
|
|
||||||
display: flex;
|
|
||||||
justify-content: end;
|
|
||||||
align-items: center;
|
|
||||||
margin-right: 1.5rem;
|
|
||||||
}
|
|
||||||
.toggle-btn {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 0;
|
|
||||||
margin: 0.5em;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
outline: none;
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.toggle-btn:hover {
|
|
||||||
color: var(--strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn:active {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
.header-controls-enter-active,
|
|
||||||
.header-controls-leave-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-controls-enter-from,
|
|
||||||
.header-controls-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
max-height: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-controls-enter-to,
|
|
||||||
.header-controls-leave-from {
|
|
||||||
opacity: 1;
|
|
||||||
max-height: 100px;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
margin-right: 0.6rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hist-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
outline: none;
|
|
||||||
width: 1.9rem;
|
|
||||||
height: 1.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hist-btn:disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hist-btn:not(:disabled):hover,
|
|
||||||
.hist-btn:not(:disabled):focus-visible {
|
|
||||||
color: var(--strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hist-btn:active:not(:disabled) {
|
|
||||||
transform: scale(0.88);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-btn:hover {
|
|
||||||
color: var(--strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-date {
|
|
||||||
white-space: pre-line;
|
|
||||||
text-align: center;
|
|
||||||
margin-right: 2rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,21 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll">
|
<div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll">
|
||||||
<div
|
<div class="jogwheel-content" ref="jogwheelContent" :style="{ height: jogwheelHeight + 'px' }"></div>
|
||||||
class="jogwheel-content"
|
|
||||||
ref="jogwheelContent"
|
|
||||||
:style="{ height: jogwheelHeight + 'px' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
totalVirtualWeeks: { type: Number, required: true },
|
totalVirtualWeeks: { type: Number, required: true },
|
||||||
rowHeight: { type: Number, required: true },
|
rowHeight: { type: Number, required: true },
|
||||||
viewportHeight: { type: Number, required: true },
|
viewportHeight: { type: Number, required: true },
|
||||||
scrollTop: { type: Number, required: true },
|
scrollTop: { type: Number, required: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['scroll-to'])
|
const emit = defineEmits(['scroll-to'])
|
||||||
@ -23,12 +19,6 @@ const emit = defineEmits(['scroll-to'])
|
|||||||
const jogwheelViewport = ref(null)
|
const jogwheelViewport = ref(null)
|
||||||
const jogwheelContent = ref(null)
|
const jogwheelContent = ref(null)
|
||||||
const syncLock = ref(null)
|
const syncLock = ref(null)
|
||||||
// Drag state (no momentum, 1:1 mapping)
|
|
||||||
const isDragging = ref(false)
|
|
||||||
let mainStartScroll = 0
|
|
||||||
let dragScale = 1 // mainScrollPixels per mouse pixel
|
|
||||||
let accumDelta = 0
|
|
||||||
let pointerLocked = false
|
|
||||||
|
|
||||||
// Jogwheel content height is 1/10th of main calendar
|
// Jogwheel content height is 1/10th of main calendar
|
||||||
const jogwheelHeight = computed(() => {
|
const jogwheelHeight = computed(() => {
|
||||||
@ -40,100 +30,21 @@ const handleJogwheelScroll = () => {
|
|||||||
syncFromJogwheel()
|
syncFromJogwheel()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragMouseDown(e) {
|
|
||||||
if (e.button !== 0) return
|
|
||||||
isDragging.value = true
|
|
||||||
mainStartScroll = props.scrollTop
|
|
||||||
accumDelta = 0
|
|
||||||
// Precompute scale between jogwheel scrollable range and main scrollable range
|
|
||||||
const mainScrollable = Math.max(
|
|
||||||
0,
|
|
||||||
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
|
||||||
)
|
|
||||||
let jogScrollable = 0
|
|
||||||
if (jogwheelViewport.value && jogwheelContent.value) {
|
|
||||||
jogScrollable = Math.max(
|
|
||||||
0,
|
|
||||||
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
dragScale = jogScrollable > 0 ? mainScrollable / jogScrollable : 1
|
|
||||||
if (!isFinite(dragScale) || dragScale <= 0) dragScale = 1
|
|
||||||
// Attempt pointer lock for relative movement
|
|
||||||
if (jogwheelViewport.value && jogwheelViewport.value.requestPointerLock) {
|
|
||||||
jogwheelViewport.value.requestPointerLock()
|
|
||||||
}
|
|
||||||
window.addEventListener('mousemove', onDragMouseMove, { passive: false })
|
|
||||||
window.addEventListener('mouseup', onDragMouseUp, { passive: false })
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragMouseMove(e) {
|
|
||||||
if (!isDragging.value) return
|
|
||||||
const dy = pointerLocked ? e.movementY : e.clientY // movementY only valid in pointer lock
|
|
||||||
accumDelta += dy
|
|
||||||
let desired = mainStartScroll - accumDelta * dragScale
|
|
||||||
if (desired < 0) desired = 0
|
|
||||||
const maxScroll = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
|
||||||
if (desired > maxScroll) desired = maxScroll
|
|
||||||
emit('scroll-to', desired)
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragMouseUp(e) {
|
|
||||||
if (!isDragging.value) return
|
|
||||||
isDragging.value = false
|
|
||||||
window.removeEventListener('mousemove', onDragMouseMove)
|
|
||||||
window.removeEventListener('mouseup', onDragMouseUp)
|
|
||||||
if (pointerLocked && document.exitPointerLock) document.exitPointerLock()
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerLockChange() {
|
|
||||||
pointerLocked = document.pointerLockElement === jogwheelViewport.value
|
|
||||||
if (!pointerLocked && isDragging.value) {
|
|
||||||
// Pointer lock lost (Esc) -> end drag gracefully
|
|
||||||
onDragMouseUp(new MouseEvent('mouseup'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (jogwheelViewport.value) {
|
|
||||||
jogwheelViewport.value.addEventListener('mousedown', onDragMouseDown)
|
|
||||||
}
|
|
||||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (jogwheelViewport.value) {
|
|
||||||
jogwheelViewport.value.removeEventListener('mousedown', onDragMouseDown)
|
|
||||||
}
|
|
||||||
window.removeEventListener('mousemove', onDragMouseMove)
|
|
||||||
window.removeEventListener('mouseup', onDragMouseUp)
|
|
||||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
||||||
})
|
|
||||||
|
|
||||||
const syncFromJogwheel = () => {
|
const syncFromJogwheel = () => {
|
||||||
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
||||||
|
|
||||||
syncLock.value = 'main'
|
syncLock.value = 'main'
|
||||||
|
|
||||||
const jogScrollable = Math.max(
|
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
||||||
0,
|
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
||||||
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
|
||||||
)
|
|
||||||
const mainScrollable = Math.max(
|
|
||||||
0,
|
|
||||||
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (jogScrollable > 0) {
|
if (jogScrollable > 0) {
|
||||||
const ratio = jogwheelViewport.value.scrollTop / jogScrollable
|
const ratio = jogwheelViewport.value.scrollTop / jogScrollable
|
||||||
|
|
||||||
// Emit scroll event to parent to update main viewport
|
// Emit scroll event to parent to update main viewport
|
||||||
emit('scroll-to', ratio * mainScrollable)
|
emit('scroll-to', ratio * mainScrollable)
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (syncLock.value === 'main') syncLock.value = null
|
if (syncLock.value === 'main') syncLock.value = null
|
||||||
}, 50)
|
}, 50)
|
||||||
@ -142,38 +53,29 @@ const syncFromJogwheel = () => {
|
|||||||
const syncFromMain = (mainScrollTop) => {
|
const syncFromMain = (mainScrollTop) => {
|
||||||
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
||||||
if (syncLock.value === 'main') return
|
if (syncLock.value === 'main') return
|
||||||
|
|
||||||
syncLock.value = 'jogwheel'
|
syncLock.value = 'jogwheel'
|
||||||
|
|
||||||
const mainScrollable = Math.max(
|
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
||||||
0,
|
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
||||||
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
|
||||||
)
|
|
||||||
const jogScrollable = Math.max(
|
|
||||||
0,
|
|
||||||
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (mainScrollable > 0) {
|
if (mainScrollable > 0) {
|
||||||
const ratio = mainScrollTop / mainScrollable
|
const ratio = mainScrollTop / mainScrollable
|
||||||
jogwheelViewport.value.scrollTop = ratio * jogScrollable
|
jogwheelViewport.value.scrollTop = ratio * jogScrollable
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (syncLock.value === 'jogwheel') syncLock.value = null
|
if (syncLock.value === 'jogwheel') syncLock.value = null
|
||||||
}, 50)
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for main calendar scroll changes
|
// Watch for main calendar scroll changes
|
||||||
watch(
|
watch(() => props.scrollTop, (newScrollTop) => {
|
||||||
() => props.scrollTop,
|
syncFromMain(newScrollTop)
|
||||||
(newScrollTop) => {
|
})
|
||||||
syncFromMain(newScrollTop)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
syncFromMain,
|
syncFromMain
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -183,12 +85,20 @@ defineExpose({
|
|||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: var(--month-w);
|
width: 3rem; /* Use fixed width since minmax() doesn't work for absolute positioning */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
|
background: rgba(0, 0, 0, 0.05); /* Temporary background to see the area */
|
||||||
|
/* Prevent text selection */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jogwheel-viewport::-webkit-scrollbar {
|
.jogwheel-viewport::-webkit-scrollbar {
|
||||||
|
@ -2,25 +2,27 @@
|
|||||||
<div
|
<div
|
||||||
ref="rootEl"
|
ref="rootEl"
|
||||||
class="mini-stepper drag-mode"
|
class="mini-stepper drag-mode"
|
||||||
:class="[extraClass, { dragging }]"
|
:class="[extraClass, { dragging, 'pointer-locked': pointerLocked }]"
|
||||||
:aria-label="ariaLabel"
|
:aria-label="ariaLabel"
|
||||||
role="spinbutton"
|
role="spinbutton"
|
||||||
:aria-valuemin="minValue"
|
:aria-valuemin="minValue"
|
||||||
:aria-valuemax="maxValue"
|
:aria-valuemax="maxValue"
|
||||||
:aria-valuenow="isPrefix(model) ? undefined : model"
|
:aria-valuenow="isPrefix(current) ? undefined : current"
|
||||||
:aria-valuetext="display"
|
:aria-valuetext="display"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@pointerdown="onPointerDown"
|
@pointerdown="onPointerDown"
|
||||||
@keydown="onKey"
|
@keydown="onKey"
|
||||||
@wheel.prevent="onWheel"
|
@wheel.prevent="onWheel"
|
||||||
>
|
>
|
||||||
<span class="value" :title="String(model)">{{ display }}</span>
|
<span class="value" :title="String(current)">{{ display }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, onBeforeUnmount } from 'vue'
|
||||||
const model = defineModel({ default: 0 })
|
|
||||||
|
const model = defineModel({ type: Number, default: 0 })
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
min: { type: Number, default: 0 },
|
min: { type: Number, default: 0 },
|
||||||
max: { type: Number, default: 999 },
|
max: { type: Number, default: 999 },
|
||||||
@ -35,41 +37,85 @@ const props = defineProps({
|
|||||||
numberPostfix: { type: String, default: '' },
|
numberPostfix: { type: String, default: '' },
|
||||||
clamp: { type: Boolean, default: true },
|
clamp: { type: Boolean, default: true },
|
||||||
pixelsPerStep: { type: Number, default: 16 },
|
pixelsPerStep: { type: Number, default: 16 },
|
||||||
|
// Movement now restricted to horizontal (x). Prop retained for compatibility but ignored.
|
||||||
axis: { type: String, default: 'x' },
|
axis: { type: String, default: 'x' },
|
||||||
ariaLabel: { type: String, default: '' },
|
ariaLabel: { type: String, default: '' },
|
||||||
extraClass: { type: String, default: '' },
|
extraClass: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const minValue = computed(() => props.min)
|
const minValue = computed(() => props.min)
|
||||||
const maxValue = computed(() => props.max)
|
const maxValue = computed(() => props.max)
|
||||||
const isPrefix = (value) => props.prefixValues.some((p) => p.value === value)
|
|
||||||
const getPrefixDisplay = (value) =>
|
// Helper to check if a value is in the prefix values
|
||||||
props.prefixValues.find((p) => p.value === value)?.display ?? null
|
const isPrefix = (value) => {
|
||||||
|
return props.prefixValues.some((prefix) => prefix.value === value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get the display for a prefix value
|
||||||
|
const getPrefixDisplay = (value) => {
|
||||||
|
const prefix = props.prefixValues.find((p) => p.value === value)
|
||||||
|
return prefix ? prefix.display : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all valid values in order: prefixValues, then min to max
|
||||||
const allValidValues = computed(() => {
|
const allValidValues = computed(() => {
|
||||||
const prefixVals = props.prefixValues.map((p) => p.value)
|
const prefixVals = props.prefixValues.map((p) => p.value)
|
||||||
const numericVals = []
|
const numericVals = []
|
||||||
for (let i = props.min; i <= props.max; i += props.step) numericVals.push(i)
|
for (let i = props.min; i <= props.max; i += props.step) {
|
||||||
|
numericVals.push(i)
|
||||||
|
}
|
||||||
return [...prefixVals, ...numericVals]
|
return [...prefixVals, ...numericVals]
|
||||||
})
|
})
|
||||||
const display = computed(() => {
|
|
||||||
const prefixDisplay = getPrefixDisplay(model.value)
|
const current = computed({
|
||||||
if (prefixDisplay !== null) return prefixDisplay
|
get() {
|
||||||
return `${props.numberPrefix}${String(model.value)}${props.numberPostfix}`
|
return model.value
|
||||||
|
},
|
||||||
|
set(v) {
|
||||||
|
if (props.clamp) {
|
||||||
|
// If it's a prefix value, allow it
|
||||||
|
if (isPrefix(v)) {
|
||||||
|
model.value = v
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Otherwise clamp to numeric range
|
||||||
|
if (v < props.min) v = props.min
|
||||||
|
if (v > props.max) v = props.max
|
||||||
|
}
|
||||||
|
model.value = v
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const display = computed(() => {
|
||||||
|
const prefixDisplay = getPrefixDisplay(current.value)
|
||||||
|
if (prefixDisplay !== null) {
|
||||||
|
// For prefix values, show only the display text without number prefix/postfix
|
||||||
|
return prefixDisplay
|
||||||
|
}
|
||||||
|
// For numeric values, include prefix and postfix
|
||||||
|
const numericValue = String(current.value)
|
||||||
|
return `${props.numberPrefix}${numericValue}${props.numberPostfix}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Drag handling
|
||||||
const dragging = ref(false)
|
const dragging = ref(false)
|
||||||
const rootEl = ref(null)
|
const rootEl = ref(null)
|
||||||
let startX = 0
|
let startX = 0
|
||||||
let startY = 0
|
let startY = 0
|
||||||
let accumX = 0
|
let accumX = 0 // accumulated horizontal movement since last applied step (works for both locked & unlocked)
|
||||||
let lastClientX = 0
|
let lastClientX = 0 // previous clientX when not pointer locked
|
||||||
const pointerLocked = ref(false)
|
const pointerLocked = ref(false)
|
||||||
|
|
||||||
function updatePointerLocked() {
|
function updatePointerLocked() {
|
||||||
pointerLocked.value =
|
pointerLocked.value =
|
||||||
typeof document !== 'undefined' && document.pointerLockElement === rootEl.value
|
typeof document !== 'undefined' && document.pointerLockElement === rootEl.value
|
||||||
|
// Reset baseline if lock just engaged
|
||||||
if (pointerLocked.value) {
|
if (pointerLocked.value) {
|
||||||
accumX = 0
|
accumX = 0
|
||||||
startX = 0
|
startX = 0 // not used while locked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addPointerLockListeners() {
|
function addPointerLockListeners() {
|
||||||
if (typeof document === 'undefined') return
|
if (typeof document === 'undefined') return
|
||||||
document.addEventListener('pointerlockchange', updatePointerLocked)
|
document.addEventListener('pointerlockchange', updatePointerLocked)
|
||||||
@ -80,6 +126,7 @@ function removePointerLockListeners() {
|
|||||||
document.removeEventListener('pointerlockchange', updatePointerLocked)
|
document.removeEventListener('pointerlockchange', updatePointerLocked)
|
||||||
document.removeEventListener('pointerlockerror', updatePointerLocked)
|
document.removeEventListener('pointerlockerror', updatePointerLocked)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerDown(e) {
|
function onPointerDown(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
startX = e.clientX
|
startX = e.clientX
|
||||||
@ -103,44 +150,33 @@ function onPointerDown(e) {
|
|||||||
function onPointerMove(e) {
|
function onPointerMove(e) {
|
||||||
if (!dragging.value) return
|
if (!dragging.value) return
|
||||||
if (e.pointerType === 'touch') e.preventDefault()
|
if (e.pointerType === 'touch') e.preventDefault()
|
||||||
let dx = pointerLocked.value ? e.movementX || 0 : e.clientX - lastClientX
|
let dx
|
||||||
if (!pointerLocked.value) lastClientX = e.clientX
|
if (pointerLocked.value) {
|
||||||
|
dx = e.movementX || 0
|
||||||
|
} else {
|
||||||
|
dx = e.clientX - lastClientX
|
||||||
|
lastClientX = e.clientX
|
||||||
|
}
|
||||||
if (!dx) return
|
if (!dx) return
|
||||||
accumX += dx
|
accumX += dx
|
||||||
const stepSize = props.pixelsPerStep || 1
|
const stepSize = props.pixelsPerStep || 1
|
||||||
let steps = Math.trunc(accumX / stepSize)
|
let steps = Math.trunc(accumX / stepSize)
|
||||||
if (steps === 0) return
|
if (steps === 0) return
|
||||||
|
// Apply steps relative to current value index each time (dynamic baseline) and keep leftover pixels
|
||||||
const applySteps = (count) => {
|
const applySteps = (count) => {
|
||||||
if (!count) return
|
const currentIndex = allValidValues.value.indexOf(current.value)
|
||||||
let direction = count > 0 ? 1 : -1
|
if (currentIndex === -1) return
|
||||||
let remaining = Math.abs(count)
|
let targetIndex = currentIndex + count
|
||||||
let curVal = model.value
|
if (props.clamp) {
|
||||||
const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
|
targetIndex = Math.max(0, Math.min(targetIndex, allValidValues.value.length - 1))
|
||||||
let idx = allValidValues.value.indexOf(curVal)
|
|
||||||
if (idx === -1) {
|
|
||||||
if (!isNumeric) {
|
|
||||||
curVal = props.prefixValues.length ? props.prefixValues[0].value : props.min
|
|
||||||
} else {
|
|
||||||
if (direction > 0) curVal = props.min
|
|
||||||
else
|
|
||||||
curVal = props.prefixValues.length
|
|
||||||
? props.prefixValues[props.prefixValues.length - 1].value
|
|
||||||
: props.min
|
|
||||||
}
|
|
||||||
remaining--
|
|
||||||
}
|
}
|
||||||
while (remaining > 0) {
|
if (targetIndex >= 0 && targetIndex < allValidValues.value.length) {
|
||||||
idx = allValidValues.value.indexOf(curVal)
|
const next = allValidValues.value[targetIndex]
|
||||||
if (idx === -1) break
|
if (next !== current.value) current.value = next
|
||||||
let targetIdx = idx + direction
|
|
||||||
if (props.clamp) targetIdx = Math.max(0, Math.min(targetIdx, allValidValues.value.length - 1))
|
|
||||||
if (targetIdx < 0 || targetIdx >= allValidValues.value.length || targetIdx === idx) break
|
|
||||||
curVal = allValidValues.value[targetIdx]
|
|
||||||
remaining--
|
|
||||||
}
|
}
|
||||||
model.value = curVal
|
|
||||||
}
|
}
|
||||||
applySteps(steps)
|
applySteps(steps)
|
||||||
|
// Remove consumed distance (works even if clamped; leftover ensures responsiveness keeps consistent feel)
|
||||||
accumX -= steps * stepSize
|
accumX -= steps * stepSize
|
||||||
}
|
}
|
||||||
function endDragListeners() {
|
function endDragListeners() {
|
||||||
@ -160,43 +196,52 @@ function onPointerCancel() {
|
|||||||
dragging.value = false
|
dragging.value = false
|
||||||
endDragListeners()
|
endDragListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKey(e) {
|
function onKey(e) {
|
||||||
const key = e.key
|
const key = e.key
|
||||||
let handled = false
|
let handled = false
|
||||||
let newValue = null
|
let newValue = null
|
||||||
const currentIndex = allValidValues.value.indexOf(model.value)
|
|
||||||
|
// Find current value index in all valid values
|
||||||
|
const currentIndex = allValidValues.value.indexOf(current.value)
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1)
|
if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1) {
|
||||||
newValue = allValidValues.value[currentIndex + 1]
|
newValue = allValidValues.value[currentIndex + 1]
|
||||||
else if (currentIndex === -1) {
|
} else if (currentIndex === -1) {
|
||||||
const curVal = model.value
|
// Current value not in list, try to increment normally
|
||||||
const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
|
newValue = current.value + props.step
|
||||||
if (!isNumeric && props.prefixValues.length) newValue = props.prefixValues[0].value
|
|
||||||
else newValue = props.min
|
|
||||||
}
|
}
|
||||||
handled = true
|
handled = true
|
||||||
break
|
break
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
if (currentIndex !== -1 && currentIndex > 0) newValue = allValidValues.value[currentIndex - 1]
|
if (currentIndex !== -1 && currentIndex > 0) {
|
||||||
else if (currentIndex === -1)
|
newValue = allValidValues.value[currentIndex - 1]
|
||||||
newValue = props.prefixValues.length
|
} else if (currentIndex === -1) {
|
||||||
? props.prefixValues[props.prefixValues.length - 1].value
|
// Current value not in list, try to decrement normally
|
||||||
: props.min
|
newValue = current.value - props.step
|
||||||
|
}
|
||||||
handled = true
|
handled = true
|
||||||
break
|
break
|
||||||
case 'PageUp':
|
case 'PageUp':
|
||||||
if (currentIndex !== -1)
|
if (currentIndex !== -1) {
|
||||||
newValue =
|
const newIndex = Math.min(currentIndex + 10, allValidValues.value.length - 1)
|
||||||
allValidValues.value[Math.min(currentIndex + 10, allValidValues.value.length - 1)]
|
newValue = allValidValues.value[newIndex]
|
||||||
else newValue = model.value + props.step * 10
|
} else {
|
||||||
|
newValue = current.value + props.step * 10
|
||||||
|
}
|
||||||
handled = true
|
handled = true
|
||||||
break
|
break
|
||||||
case 'PageDown':
|
case 'PageDown':
|
||||||
if (currentIndex !== -1) newValue = allValidValues.value[Math.max(currentIndex - 10, 0)]
|
if (currentIndex !== -1) {
|
||||||
else newValue = model.value - props.step * 10
|
const newIndex = Math.max(currentIndex - 10, 0)
|
||||||
|
newValue = allValidValues.value[newIndex]
|
||||||
|
} else {
|
||||||
|
newValue = current.value - props.step * 10
|
||||||
|
}
|
||||||
handled = true
|
handled = true
|
||||||
break
|
break
|
||||||
case 'Home':
|
case 'Home':
|
||||||
@ -208,30 +253,31 @@ function onKey(e) {
|
|||||||
handled = true
|
handled = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (newValue !== null) model.value = newValue
|
|
||||||
|
if (newValue !== null) {
|
||||||
|
current.value = newValue
|
||||||
|
}
|
||||||
|
|
||||||
if (handled) {
|
if (handled) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWheel(e) {
|
function onWheel(e) {
|
||||||
|
// Inverted: deltaY < 0 => decrement, > 0 => increment
|
||||||
const direction = e.deltaY < 0 ? -1 : e.deltaY > 0 ? 1 : 0
|
const direction = e.deltaY < 0 ? -1 : e.deltaY > 0 ? 1 : 0
|
||||||
if (direction === 0) return
|
if (direction === 0) return
|
||||||
const idx = allValidValues.value.indexOf(model.value)
|
// Use current index in allValidValues list (prefix values included)
|
||||||
|
const idx = allValidValues.value.indexOf(current.value)
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
const nextIdx = idx + direction
|
const nextIdx = idx + direction
|
||||||
if (nextIdx >= 0 && nextIdx < allValidValues.value.length)
|
if (nextIdx >= 0 && nextIdx < allValidValues.value.length) {
|
||||||
model.value = allValidValues.value[nextIdx]
|
current.value = allValidValues.value[nextIdx]
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const curVal = model.value
|
// Fallback numeric adjustment if not found
|
||||||
const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
|
current.value = current.value + direction * props.step
|
||||||
if (!isNumeric)
|
|
||||||
model.value = props.prefixValues.length ? props.prefixValues[0].value : props.min
|
|
||||||
else if (direction > 0) model.value = props.min
|
|
||||||
else
|
|
||||||
model.value = props.prefixValues.length
|
|
||||||
? props.prefixValues[props.prefixValues.length - 1].value
|
|
||||||
: props.min
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@ -243,12 +289,17 @@ function onWheel(e) {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 0 0.4rem;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
background: none;
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
min-height: 1.8rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
.mini-stepper.drag-mode:focus-visible {
|
.mini-stepper.drag-mode:focus-visible {
|
||||||
|
border-color: var(--input-border, var(--muted));
|
||||||
box-shadow: 0 0 0 2px var(--input-focus, #2563eb);
|
box-shadow: 0 0 0 2px var(--input-focus, #2563eb);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
@ -261,4 +312,7 @@ function onWheel(e) {
|
|||||||
.mini-stepper.drag-mode.dragging {
|
.mini-stepper.drag-mode.dragging {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
.mini-stepper.drag-mode.pointer-locked.dragging {
|
||||||
|
cursor: none; /* hide cursor for infinite drag */
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -306,4 +306,13 @@ select {
|
|||||||
.ec-btn.delete-btn:hover {
|
.ec-btn.delete-btn:hover {
|
||||||
background: hsl(0, 70%, 45%);
|
background: hsl(0, 70%, 45%);
|
||||||
}
|
}
|
||||||
|
/* Global override to ensure settings dialog appears near top by default */
|
||||||
|
.ec-modal.settings-modal {
|
||||||
|
top: 4.5rem !important;
|
||||||
|
right: 2rem !important;
|
||||||
|
bottom: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<!-- settings dialog -->
|
||||||
|
@ -3,7 +3,9 @@
|
|||||||
<div class="week-label">W{{ weekNumber }}</div>
|
<div class="week-label">W{{ weekNumber }}</div>
|
||||||
<div class="days-grid">
|
<div class="days-grid">
|
||||||
<DayCell v-for="day in days" :key="day.dateStr" :day="day" />
|
<DayCell v-for="day in days" :key="day.dateStr" :day="day" />
|
||||||
<div class="week-overlay"></div>
|
<div class="week-overlay">
|
||||||
|
<!-- Event spans will be rendered here -->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="monthLabel"
|
v-if="monthLabel"
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
getLocalizedWeekdayNames,
|
getLocalizedWeekdayNames,
|
||||||
getLocaleFirstDay,
|
getLocaleFirstDay,
|
||||||
@ -44,10 +44,7 @@ import {
|
|||||||
const model = defineModel({
|
const model = defineModel({
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [false, false, false, false, false, false, false],
|
default: () => [false, false, false, false, false, false, false],
|
||||||
}) // external value consumers see
|
})
|
||||||
|
|
||||||
// Internal state preserves the user's explicit picks even if all false
|
|
||||||
const internal = ref([false, false, false, false, false, false, false])
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
weekend: { type: Array, default: undefined },
|
weekend: { type: Array, default: undefined },
|
||||||
@ -58,11 +55,12 @@ const props = defineProps({
|
|||||||
firstDay: { type: Number, default: null },
|
firstDay: { type: Number, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize internal from external if it has any true; else keep empty (fallback handled on emit)
|
// If external model provided is entirely false, keep as-is (user will see fallback styling),
|
||||||
if (model.value?.some?.(Boolean)) internal.value = [...model.value]
|
// only overwrite if null/undefined.
|
||||||
|
if (!model.value) model.value = [...props.fallback]
|
||||||
const labelsMondayFirst = getLocalizedWeekdayNames()
|
const labelsMondayFirst = getLocalizedWeekdayNames()
|
||||||
const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)]
|
const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)]
|
||||||
const anySelected = computed(() => internal.value.some(Boolean))
|
const anySelected = computed(() => model.value.some(Boolean))
|
||||||
const localeFirst = getLocaleFirstDay()
|
const localeFirst = getLocaleFirstDay()
|
||||||
const localeWeekend = getLocaleWeekendDays()
|
const localeWeekend = getLocaleWeekendDays()
|
||||||
const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7)
|
const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7)
|
||||||
@ -73,38 +71,10 @@ const weekendDays = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const displayLabels = computed(() => reorderByFirstDay(labels, firstDay.value))
|
const displayLabels = computed(() => reorderByFirstDay(labels, firstDay.value))
|
||||||
const displayValuesCommitted = computed(() => reorderByFirstDay(internal.value, firstDay.value))
|
const displayValuesCommitted = computed(() => reorderByFirstDay(model.value, firstDay.value))
|
||||||
const displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value))
|
const displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value))
|
||||||
const displayDefault = computed(() => reorderByFirstDay(props.fallback, firstDay.value))
|
const displayDefault = computed(() => reorderByFirstDay(props.fallback, firstDay.value))
|
||||||
|
|
||||||
// Expose a normalized pattern (Sunday-first) that substitutes the fallback day if none selected.
|
|
||||||
// This keeps UI visually showing fallback (muted) but downstream logic can opt-in by reading this.
|
|
||||||
function computeFallbackPattern() {
|
|
||||||
const fb = props.fallback && props.fallback.length === 7 ? props.fallback : null
|
|
||||||
if (fb && fb.some(Boolean)) return [...fb]
|
|
||||||
const arr = [false, false, false, false, false, false, false]
|
|
||||||
const idx = fb ? fb.findIndex(Boolean) : -1
|
|
||||||
if (idx >= 0) arr[idx] = true
|
|
||||||
else arr[0] = true
|
|
||||||
return arr
|
|
||||||
}
|
|
||||||
function emitExternal() {
|
|
||||||
model.value = internal.value.some(Boolean) ? [...internal.value] : computeFallbackPattern()
|
|
||||||
}
|
|
||||||
emitExternal()
|
|
||||||
watch(
|
|
||||||
() => model.value,
|
|
||||||
(nv) => {
|
|
||||||
if (!nv) return
|
|
||||||
if (!nv.some(Boolean)) return
|
|
||||||
const fb = computeFallbackPattern()
|
|
||||||
const isFallback = fb.every((v, i) => v === nv[i])
|
|
||||||
// If internal is empty and model only reflects fallback, do not sync into internal
|
|
||||||
if (isFallback && !internal.value.some(Boolean)) return
|
|
||||||
internal.value = [...nv]
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mapping from display index to original model index
|
// Mapping from display index to original model index
|
||||||
const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7))
|
const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7))
|
||||||
|
|
||||||
@ -165,8 +135,8 @@ function isPressing(di) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPointerDown(di) {
|
function onPointerDown(di) {
|
||||||
originalValues = [...internal.value]
|
originalValues = [...model.value]
|
||||||
dragVal.value = !internal.value[(di + firstDay.value) % 7]
|
dragVal.value = !model.value[(di + firstDay.value) % 7]
|
||||||
dragStart.value = di
|
dragStart.value = di
|
||||||
previewEnd.value = di
|
previewEnd.value = di
|
||||||
dragging.value = true
|
dragging.value = true
|
||||||
@ -185,8 +155,7 @@ function onPointerUp() {
|
|||||||
// simple click: toggle single
|
// simple click: toggle single
|
||||||
const next = [...originalValues]
|
const next = [...originalValues]
|
||||||
next[(dragStart.value + firstDay.value) % 7] = dragVal.value
|
next[(dragStart.value + firstDay.value) % 7] = dragVal.value
|
||||||
internal.value = next
|
model.value = next
|
||||||
emitExternal()
|
|
||||||
cleanupDrag()
|
cleanupDrag()
|
||||||
} else {
|
} else {
|
||||||
commitDrag()
|
commitDrag()
|
||||||
@ -200,8 +169,7 @@ function commitDrag() {
|
|||||||
: [previewEnd.value, dragStart.value]
|
: [previewEnd.value, dragStart.value]
|
||||||
const next = [...originalValues]
|
const next = [...originalValues]
|
||||||
for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value
|
for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value
|
||||||
internal.value = next
|
model.value = next
|
||||||
emitExternal()
|
|
||||||
cleanupDrag()
|
cleanupDrag()
|
||||||
}
|
}
|
||||||
function cancelDrag() {
|
function cancelDrag() {
|
||||||
@ -217,15 +185,14 @@ function cleanupDrag() {
|
|||||||
function toggleWeekend(work) {
|
function toggleWeekend(work) {
|
||||||
const base = weekendDays.value
|
const base = weekendDays.value
|
||||||
const target = work ? base : base.map((v) => !v)
|
const target = work ? base : base.map((v) => !v)
|
||||||
const current = internal.value
|
const current = model.value
|
||||||
const allOn = current.every(Boolean)
|
const allOn = current.every(Boolean)
|
||||||
const isTargetActive = current.every((v, i) => v === target[i])
|
const isTargetActive = current.every((v, i) => v === target[i])
|
||||||
if (allOn || isTargetActive) {
|
if (allOn || isTargetActive) {
|
||||||
internal.value = [false, false, false, false, false, false, false]
|
model.value = [false, false, false, false, false, false, false]
|
||||||
} else {
|
} else {
|
||||||
internal.value = [...target]
|
model.value = [...target]
|
||||||
}
|
}
|
||||||
emitExternal()
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -1,331 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
@ -1,400 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
@ -37,10 +37,6 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
_rotateWeekdayPattern(pattern, shift) {
|
|
||||||
const k = (7 - (shift % 7)) % 7
|
|
||||||
return pattern.slice(k).concat(pattern.slice(0, k))
|
|
||||||
},
|
|
||||||
_resolveCountry(code) {
|
_resolveCountry(code) {
|
||||||
if (!code || code !== 'auto') return code
|
if (!code || code !== 'auto') return code
|
||||||
const locale = navigator.language || navigator.languages?.[0]
|
const locale = navigator.language || navigator.languages?.[0]
|
||||||
@ -127,33 +123,23 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
createEvent(eventData) {
|
createEvent(eventData) {
|
||||||
let days = 1
|
const singleDay = eventData.startDate === eventData.endDate
|
||||||
if (typeof eventData.days === 'number') {
|
|
||||||
days = Math.max(1, Math.floor(eventData.days))
|
|
||||||
}
|
|
||||||
const singleDay = days === 1
|
|
||||||
const event = {
|
const event = {
|
||||||
id: this.generateId(),
|
id: this.generateId(),
|
||||||
title: eventData.title,
|
title: eventData.title,
|
||||||
startDate: eventData.startDate,
|
startDate: eventData.startDate,
|
||||||
days,
|
endDate: eventData.endDate,
|
||||||
colorId:
|
colorId:
|
||||||
eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.startDate),
|
eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
|
||||||
startTime: singleDay ? eventData.startTime || '09:00' : null,
|
startTime: singleDay ? eventData.startTime || '09:00' : null,
|
||||||
durationMinutes: singleDay ? eventData.durationMinutes || 60 : null,
|
durationMinutes: singleDay ? eventData.durationMinutes || 60 : null,
|
||||||
recur:
|
repeat: ['weeks', 'months'].includes(eventData.repeat) ? eventData.repeat : 'none',
|
||||||
eventData.recur && ['weeks', 'months'].includes(eventData.recur.freq)
|
repeatInterval: eventData.repeatInterval || 1,
|
||||||
? {
|
repeatCount: eventData.repeatCount || 'unlimited',
|
||||||
freq: eventData.recur.freq,
|
repeatWeekdays: eventData.repeatWeekdays,
|
||||||
interval: eventData.recur.interval || 1,
|
isRepeating: eventData.repeat && eventData.repeat !== 'none',
|
||||||
count: eventData.recur.count ?? 'unlimited',
|
|
||||||
weekdays: Array.isArray(eventData.recur.weekdays)
|
|
||||||
? [...eventData.recur.weekdays]
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}
|
}
|
||||||
this.events.set(event.id, { ...event, isSpanning: event.days > 1 })
|
this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate })
|
||||||
this.notifyEventsChanged()
|
this.notifyEventsChanged()
|
||||||
return event.id
|
return event.id
|
||||||
},
|
},
|
||||||
@ -168,7 +154,7 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
const endDate = fromLocalString(endDateStr, DEFAULT_TZ)
|
const endDate = fromLocalString(endDateStr, DEFAULT_TZ)
|
||||||
for (const ev of this.events.values()) {
|
for (const ev of this.events.values()) {
|
||||||
const evStart = fromLocalString(ev.startDate)
|
const evStart = fromLocalString(ev.startDate)
|
||||||
const evEnd = addDays(evStart, (ev.days || 1) - 1)
|
const evEnd = fromLocalString(ev.endDate)
|
||||||
if (evEnd < startDate || evStart > endDate) continue
|
if (evEnd < startDate || evStart > endDate) continue
|
||||||
if (ev.colorId >= 0 && ev.colorId < 8) colorCounts[ev.colorId]++
|
if (ev.colorId >= 0 && ev.colorId < 8) colorCounts[ev.colorId]++
|
||||||
}
|
}
|
||||||
@ -191,12 +177,12 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
deleteFirstOccurrence(baseId) {
|
deleteFirstOccurrence(baseId) {
|
||||||
const base = this.getEventById(baseId)
|
const base = this.getEventById(baseId)
|
||||||
if (!base) return
|
if (!base) return
|
||||||
if (!base.recur) {
|
if (!base.isRepeating) {
|
||||||
this.deleteEvent(baseId)
|
this.deleteEvent(baseId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const numericCount =
|
const numericCount =
|
||||||
base.recur.count === 'unlimited' ? Infinity : parseInt(base.recur.count, 10)
|
base.repeatCount === 'unlimited' ? Infinity : parseInt(base.repeatCount, 10)
|
||||||
if (numericCount <= 1) {
|
if (numericCount <= 1) {
|
||||||
this.deleteEvent(baseId)
|
this.deleteEvent(baseId)
|
||||||
return
|
return
|
||||||
@ -206,10 +192,17 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
this.deleteEvent(baseId)
|
this.deleteEvent(baseId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const oldStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
||||||
|
const oldEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
||||||
|
const durationDays = Math.max(0, differenceInCalendarDays(oldEnd, oldStart))
|
||||||
|
const newEndStr = toLocalString(
|
||||||
|
addDays(fromLocalString(nextStartStr, DEFAULT_TZ), durationDays),
|
||||||
|
DEFAULT_TZ,
|
||||||
|
)
|
||||||
base.startDate = nextStartStr
|
base.startDate = nextStartStr
|
||||||
// keep same days length
|
base.endDate = newEndStr
|
||||||
if (numericCount !== Infinity) base.recur.count = String(Math.max(1, numericCount - 1))
|
if (numericCount !== Infinity) base.repeatCount = String(Math.max(1, numericCount - 1))
|
||||||
this.events.set(baseId, { ...base, isSpanning: base.days > 1 })
|
this.events.set(baseId, { ...base, isSpanning: base.startDate < base.endDate })
|
||||||
this.notifyEventsChanged()
|
this.notifyEventsChanged()
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -218,7 +211,7 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
if (occurrenceIndex == null) return
|
if (occurrenceIndex == null) return
|
||||||
const base = this.getEventById(baseId)
|
const base = this.getEventById(baseId)
|
||||||
if (!base) return
|
if (!base) return
|
||||||
if (!base.recur) {
|
if (!base.isRepeating) {
|
||||||
if (occurrenceIndex === 0) this.deleteEvent(baseId)
|
if (occurrenceIndex === 0) this.deleteEvent(baseId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -227,12 +220,19 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const snapshot = { ...base }
|
const snapshot = { ...base }
|
||||||
snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null
|
base.repeatCount = occurrenceIndex
|
||||||
base.recur.count = occurrenceIndex
|
|
||||||
const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ)
|
const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ)
|
||||||
if (!nextStartStr) return
|
if (!nextStartStr) return
|
||||||
|
const durationDays = Math.max(
|
||||||
|
0,
|
||||||
|
differenceInCalendarDays(
|
||||||
|
fromLocalString(snapshot.endDate),
|
||||||
|
fromLocalString(snapshot.startDate),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const newEndStr = toLocalString(addDays(fromLocalString(nextStartStr), durationDays))
|
||||||
const originalNumeric =
|
const originalNumeric =
|
||||||
snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10)
|
snapshot.repeatCount === 'unlimited' ? Infinity : parseInt(snapshot.repeatCount, 10)
|
||||||
let remainingCount = 'unlimited'
|
let remainingCount = 'unlimited'
|
||||||
if (originalNumeric !== Infinity) {
|
if (originalNumeric !== Infinity) {
|
||||||
const rem = originalNumeric - (occurrenceIndex + 1)
|
const rem = originalNumeric - (occurrenceIndex + 1)
|
||||||
@ -242,16 +242,12 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
this.createEvent({
|
this.createEvent({
|
||||||
title: snapshot.title,
|
title: snapshot.title,
|
||||||
startDate: nextStartStr,
|
startDate: nextStartStr,
|
||||||
days: snapshot.days,
|
endDate: newEndStr,
|
||||||
colorId: snapshot.colorId,
|
colorId: snapshot.colorId,
|
||||||
recur: snapshot.recur
|
repeat: snapshot.repeat,
|
||||||
? {
|
repeatInterval: snapshot.repeatInterval,
|
||||||
freq: snapshot.recur.freq,
|
repeatCount: remainingCount,
|
||||||
interval: snapshot.recur.interval,
|
repeatWeekdays: snapshot.repeatWeekdays,
|
||||||
count: remainingCount,
|
|
||||||
weekdays: snapshot.recur.weekdays ? [...snapshot.recur.weekdays] : null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
})
|
})
|
||||||
this.notifyEventsChanged()
|
this.notifyEventsChanged()
|
||||||
},
|
},
|
||||||
@ -259,7 +255,7 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
deleteFromOccurrence(ctx) {
|
deleteFromOccurrence(ctx) {
|
||||||
const { baseId, occurrenceIndex } = ctx
|
const { baseId, occurrenceIndex } = ctx
|
||||||
const base = this.getEventById(baseId)
|
const base = this.getEventById(baseId)
|
||||||
if (!base || !base.recur) return
|
if (!base || !base.isRepeating) return
|
||||||
if (occurrenceIndex === 0) {
|
if (occurrenceIndex === 0) {
|
||||||
this.deleteEvent(baseId)
|
this.deleteEvent(baseId)
|
||||||
return
|
return
|
||||||
@ -268,11 +264,12 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
this.notifyEventsChanged()
|
this.notifyEventsChanged()
|
||||||
},
|
},
|
||||||
|
|
||||||
setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto', rotatePattern = true } = {}) {
|
setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) {
|
||||||
const snapshot = this.events.get(eventId)
|
const snapshot = this.events.get(eventId)
|
||||||
if (!snapshot) return
|
if (!snapshot) return
|
||||||
const prevStart = fromLocalString(snapshot.startDate, DEFAULT_TZ)
|
const prevStart = fromLocalString(snapshot.startDate, DEFAULT_TZ)
|
||||||
const prevDurationDays = (snapshot.days || 1) - 1
|
const prevEnd = fromLocalString(snapshot.endDate, DEFAULT_TZ)
|
||||||
|
const prevDurationDays = Math.max(0, differenceInCalendarDays(prevEnd, prevStart))
|
||||||
const newStart = fromLocalString(newStartStr, DEFAULT_TZ)
|
const newStart = fromLocalString(newStartStr, DEFAULT_TZ)
|
||||||
const newEnd = fromLocalString(newEndStr, DEFAULT_TZ)
|
const newEnd = fromLocalString(newEndStr, DEFAULT_TZ)
|
||||||
const proposedDurationDays = Math.max(0, differenceInCalendarDays(newEnd, newStart))
|
const proposedDurationDays = Math.max(0, differenceInCalendarDays(newEnd, newStart))
|
||||||
@ -280,54 +277,49 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
if (mode === 'resize-left' || mode === 'resize-right')
|
if (mode === 'resize-left' || mode === 'resize-right')
|
||||||
finalDurationDays = proposedDurationDays
|
finalDurationDays = proposedDurationDays
|
||||||
snapshot.startDate = newStartStr
|
snapshot.startDate = newStartStr
|
||||||
snapshot.days = finalDurationDays + 1
|
snapshot.endDate = toLocalString(
|
||||||
|
addDays(fromLocalString(newStartStr, DEFAULT_TZ), finalDurationDays),
|
||||||
|
DEFAULT_TZ,
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
rotatePattern &&
|
mode === 'move' &&
|
||||||
(mode === 'move' || mode === 'resize-left') &&
|
snapshot.isRepeating &&
|
||||||
snapshot.recur &&
|
snapshot.repeat === 'weeks' &&
|
||||||
snapshot.recur.freq === 'weeks' &&
|
Array.isArray(snapshot.repeatWeekdays)
|
||||||
Array.isArray(snapshot.recur.weekdays)
|
|
||||||
) {
|
) {
|
||||||
const oldDow = prevStart.getDay()
|
const oldDow = prevStart.getDay()
|
||||||
const newDow = newStart.getDay()
|
const newDow = newStart.getDay()
|
||||||
const shift = newDow - oldDow
|
const shift = newDow - oldDow
|
||||||
if (shift !== 0) {
|
if (shift !== 0) {
|
||||||
snapshot.recur.weekdays = this._rotateWeekdayPattern(snapshot.recur.weekdays, shift)
|
const rotated = [false, false, false, false, false, false, false]
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
if (snapshot.repeatWeekdays[i]) {
|
||||||
|
let ni = (i + shift) % 7
|
||||||
|
if (ni < 0) ni += 7
|
||||||
|
rotated[ni] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapshot.repeatWeekdays = rotated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.events.set(eventId, { ...snapshot, isSpanning: snapshot.days > 1 })
|
this.events.set(eventId, { ...snapshot, isSpanning: snapshot.startDate < snapshot.endDate })
|
||||||
this.notifyEventsChanged()
|
this.notifyEventsChanged()
|
||||||
},
|
},
|
||||||
|
|
||||||
splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) {
|
splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) {
|
||||||
const base = this.events.get(baseId)
|
const base = this.events.get(baseId)
|
||||||
if (!base || !base.recur) return
|
if (!base || !base.isRepeating) return
|
||||||
const originalCountRaw = base.recur.count
|
const originalCountRaw = base.repeatCount
|
||||||
const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ)
|
const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ)
|
||||||
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
||||||
// If series effectively has <=1 occurrence, treat as simple move (no split) and flatten
|
|
||||||
let totalOccurrences = Infinity
|
|
||||||
if (originalCountRaw !== 'unlimited') {
|
|
||||||
const parsed = parseInt(originalCountRaw, 10)
|
|
||||||
if (!isNaN(parsed)) totalOccurrences = parsed
|
|
||||||
}
|
|
||||||
if (totalOccurrences <= 1) {
|
|
||||||
// Flatten to non-repeating if not already
|
|
||||||
if (base.recur) {
|
|
||||||
base.recur = null
|
|
||||||
this.events.set(baseId, { ...base })
|
|
||||||
}
|
|
||||||
this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move', rotatePattern: true })
|
|
||||||
return baseId
|
|
||||||
}
|
|
||||||
if (occurrenceDate <= baseStart) {
|
if (occurrenceDate <= baseStart) {
|
||||||
this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' })
|
this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' })
|
||||||
return baseId
|
return
|
||||||
}
|
}
|
||||||
let keptOccurrences = 0
|
let keptOccurrences = 0
|
||||||
if (base.recur.freq === 'weeks') {
|
if (base.repeat === 'weeks') {
|
||||||
const interval = base.recur.interval || 1
|
const interval = base.repeatInterval || 1
|
||||||
const pattern = base.recur.weekdays || []
|
const pattern = base.repeatWeekdays || []
|
||||||
if (!pattern.some(Boolean)) return
|
if (!pattern.some(Boolean)) return
|
||||||
const WEEK_MS = 7 * 86400000
|
const WEEK_MS = 7 * 86400000
|
||||||
const blockStartBase = getMondayOfISOWeek(baseStart)
|
const blockStartBase = getMondayOfISOWeek(baseStart)
|
||||||
@ -341,27 +333,17 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
if (pattern[cursor.getDay()] && isAligned(cursor)) keptOccurrences++
|
if (pattern[cursor.getDay()] && isAligned(cursor)) keptOccurrences++
|
||||||
cursor = addDays(cursor, 1)
|
cursor = addDays(cursor, 1)
|
||||||
}
|
}
|
||||||
} else if (base.recur.freq === 'months') {
|
} else if (base.repeat === 'months') {
|
||||||
const diffMonths =
|
const diffMonths =
|
||||||
(occurrenceDate.getFullYear() - baseStart.getFullYear()) * 12 +
|
(occurrenceDate.getFullYear() - baseStart.getFullYear()) * 12 +
|
||||||
(occurrenceDate.getMonth() - baseStart.getMonth())
|
(occurrenceDate.getMonth() - baseStart.getMonth())
|
||||||
const interval = base.recur.interval || 1
|
const interval = base.repeatInterval || 1
|
||||||
if (diffMonths <= 0 || diffMonths % interval !== 0) return
|
if (diffMonths <= 0 || diffMonths % interval !== 0) return
|
||||||
keptOccurrences = diffMonths
|
keptOccurrences = diffMonths
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this._terminateRepeatSeriesAtIndex(baseId, keptOccurrences)
|
this._terminateRepeatSeriesAtIndex(baseId, keptOccurrences)
|
||||||
// After truncation compute base kept count
|
|
||||||
const truncated = this.events.get(baseId)
|
|
||||||
if (
|
|
||||||
truncated &&
|
|
||||||
truncated.recur &&
|
|
||||||
truncated.recur.count &&
|
|
||||||
truncated.recur.count !== 'unlimited'
|
|
||||||
) {
|
|
||||||
// keptOccurrences already reflects number before split; adjust not needed further
|
|
||||||
}
|
|
||||||
let remainingCount = 'unlimited'
|
let remainingCount = 'unlimited'
|
||||||
if (originalCountRaw !== 'unlimited') {
|
if (originalCountRaw !== 'unlimited') {
|
||||||
const total = parseInt(originalCountRaw, 10)
|
const total = parseInt(originalCountRaw, 10)
|
||||||
@ -371,54 +353,40 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
remainingCount = String(rem)
|
remainingCount = String(rem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let weekdays = base.recur.weekdays
|
let repeatWeekdays = base.repeatWeekdays
|
||||||
if (base.recur.freq === 'weeks' && Array.isArray(base.recur.weekdays)) {
|
if (base.repeat === 'weeks' && Array.isArray(base.repeatWeekdays)) {
|
||||||
const origWeekday = occurrenceDate.getDay()
|
const origWeekday = occurrenceDate.getDay()
|
||||||
const newWeekday = fromLocalString(newStartStr, DEFAULT_TZ).getDay()
|
const newWeekday = fromLocalString(newStartStr, DEFAULT_TZ).getDay()
|
||||||
const shift = newWeekday - origWeekday
|
const shift = newWeekday - origWeekday
|
||||||
if (shift !== 0) {
|
if (shift !== 0) {
|
||||||
weekdays = this._rotateWeekdayPattern(base.recur.weekdays, shift)
|
const rotated = [false, false, false, false, false, false, false]
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
if (base.repeatWeekdays[i]) {
|
||||||
|
let ni = (i + shift) % 7
|
||||||
|
if (ni < 0) ni += 7
|
||||||
|
rotated[ni] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repeatWeekdays = rotated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const newId = this.createEvent({
|
this.createEvent({
|
||||||
title: base.title,
|
title: base.title,
|
||||||
startDate: newStartStr,
|
startDate: newStartStr,
|
||||||
days: base.days,
|
endDate: newEndStr,
|
||||||
colorId: base.colorId,
|
colorId: base.colorId,
|
||||||
recur: {
|
repeat: base.repeat,
|
||||||
freq: base.recur.freq,
|
repeatInterval: base.repeatInterval,
|
||||||
interval: base.recur.interval,
|
repeatCount: remainingCount,
|
||||||
count: remainingCount,
|
repeatWeekdays,
|
||||||
weekdays,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
// Flatten base if single occurrence now
|
|
||||||
if (truncated && truncated.recur) {
|
|
||||||
const baseCountNum =
|
|
||||||
truncated.recur.count === 'unlimited' ? Infinity : parseInt(truncated.recur.count, 10)
|
|
||||||
if (baseCountNum <= 1) {
|
|
||||||
truncated.recur = null
|
|
||||||
this.events.set(baseId, { ...truncated })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Flatten new if single occurrence only
|
|
||||||
const newly = this.events.get(newId)
|
|
||||||
if (newly && newly.recur) {
|
|
||||||
const newCountNum =
|
|
||||||
newly.recur.count === 'unlimited' ? Infinity : parseInt(newly.recur.count, 10)
|
|
||||||
if (newCountNum <= 1) {
|
|
||||||
newly.recur = null
|
|
||||||
this.events.set(newId, { ...newly })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.notifyEventsChanged()
|
this.notifyEventsChanged()
|
||||||
return newId
|
|
||||||
},
|
},
|
||||||
|
|
||||||
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, _newEndStr) {
|
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr) {
|
||||||
const base = this.events.get(baseId)
|
const base = this.events.get(baseId)
|
||||||
if (!base || !base.recur) return null
|
if (!base || !base.isRepeating) return null
|
||||||
const originalCountRaw = base.recur.count
|
const originalCountRaw = base.repeatCount
|
||||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||||
let newSeriesCount = 'unlimited'
|
let newSeriesCount = 'unlimited'
|
||||||
if (originalCountRaw !== 'unlimited') {
|
if (originalCountRaw !== 'unlimited') {
|
||||||
@ -431,27 +399,23 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
return this.createEvent({
|
return this.createEvent({
|
||||||
title: base.title,
|
title: base.title,
|
||||||
startDate: newStartStr,
|
startDate: newStartStr,
|
||||||
days: base.days,
|
endDate: newEndStr,
|
||||||
colorId: base.colorId,
|
colorId: base.colorId,
|
||||||
recur: base.recur
|
repeat: base.repeat,
|
||||||
? {
|
repeatInterval: base.repeatInterval,
|
||||||
freq: base.recur.freq,
|
repeatCount: newSeriesCount,
|
||||||
interval: base.recur.interval,
|
repeatWeekdays: base.repeatWeekdays,
|
||||||
count: newSeriesCount,
|
|
||||||
weekdays: base.recur.weekdays ? [...base.recur.weekdays] : null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
_terminateRepeatSeriesAtIndex(baseId, index) {
|
_terminateRepeatSeriesAtIndex(baseId, index) {
|
||||||
const ev = this.events.get(baseId)
|
const ev = this.events.get(baseId)
|
||||||
if (!ev || !ev.recur) return
|
if (!ev || !ev.isRepeating) return
|
||||||
if (ev.recur.count === 'unlimited') {
|
if (ev.repeatCount === 'unlimited') {
|
||||||
ev.recur.count = String(index)
|
ev.repeatCount = String(index)
|
||||||
} else {
|
} else {
|
||||||
const rc = parseInt(ev.recur.count, 10)
|
const rc = parseInt(ev.repeatCount, 10)
|
||||||
if (!isNaN(rc)) ev.recur.count = String(Math.min(rc, index))
|
if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index))
|
||||||
}
|
}
|
||||||
this.notifyEventsChanged()
|
this.notifyEventsChanged()
|
||||||
},
|
},
|
||||||
@ -469,12 +433,11 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
deserialize(value) {
|
deserialize(value) {
|
||||||
const revived = JSON.parse(value, (_k, v) => {
|
return JSON.parse(value, (_k, v) => {
|
||||||
if (v && v.__map) return new Map(v.data)
|
if (v && v.__map) return new Map(v.data)
|
||||||
if (v && v.__set) return new Set(v.data)
|
if (v && v.__set) return new Set(v.data)
|
||||||
return v
|
return v
|
||||||
})
|
})
|
||||||
return revived
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -23,8 +23,9 @@ const monthAbbr = [
|
|||||||
'nov',
|
'nov',
|
||||||
'dec',
|
'dec',
|
||||||
]
|
]
|
||||||
const MIN_YEAR = 100 // less than 100 is interpreted as 19xx
|
// Calendar year bounds (used instead of config.min_year / config.max_year)
|
||||||
const MAX_YEAR = 9999
|
const MIN_YEAR = 1901
|
||||||
|
const MAX_YEAR = 2100
|
||||||
|
|
||||||
// Core helpers ------------------------------------------------------------
|
// Core helpers ------------------------------------------------------------
|
||||||
/**
|
/**
|
||||||
@ -80,14 +81,9 @@ function countPatternDaysInInterval(startDate, endDate, patternArr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Recurrence: Weekly ------------------------------------------------------
|
// Recurrence: Weekly ------------------------------------------------------
|
||||||
function _getRecur(event) {
|
|
||||||
return event?.recur ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
|
function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
|
||||||
const recur = _getRecur(event)
|
if (!event?.isRepeating || event.repeat !== 'weeks') return null
|
||||||
if (!recur || recur.freq !== 'weeks') return null
|
const pattern = event.repeatWeekdays || []
|
||||||
const pattern = recur.weekdays || []
|
|
||||||
if (!pattern.some(Boolean)) return null
|
if (!pattern.some(Boolean)) return null
|
||||||
|
|
||||||
const target = fromLocalString(dateStr, timeZone)
|
const target = fromLocalString(dateStr, timeZone)
|
||||||
@ -97,22 +93,17 @@ function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
|
|||||||
const dow = dateFns.getDay(target)
|
const dow = dateFns.getDay(target)
|
||||||
if (!pattern[dow]) return null // target not active
|
if (!pattern[dow]) return null // target not active
|
||||||
|
|
||||||
const interval = recur.interval || 1
|
const interval = event.repeatInterval || 1
|
||||||
const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone)
|
const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone)
|
||||||
const currentBlockStart = getMondayOfISOWeek(target, timeZone)
|
const currentBlockStart = getMondayOfISOWeek(target, timeZone)
|
||||||
// Number of weeks between block starts (each block start is a Monday)
|
// Number of weeks between block starts (each block start is a Monday)
|
||||||
const weekDiff = dateFns.differenceInCalendarWeeks(currentBlockStart, baseBlockStart)
|
const weekDiff = dateFns.differenceInCalendarWeeks(currentBlockStart, baseBlockStart)
|
||||||
if (weekDiff < 0 || weekDiff % interval !== 0) return null
|
if (weekDiff < 0 || weekDiff % interval !== 0) return null
|
||||||
|
|
||||||
const baseDow = dateFns.getDay(baseStart)
|
|
||||||
const baseCountsAsPattern = !!pattern[baseDow]
|
|
||||||
|
|
||||||
// Same ISO week as base: count pattern days from baseStart up to target (inclusive)
|
// Same ISO week as base: count pattern days from baseStart up to target (inclusive)
|
||||||
if (weekDiff === 0) {
|
if (weekDiff === 0) {
|
||||||
let n = countPatternDaysInInterval(baseStart, target, pattern) - 1
|
const n = countPatternDaysInInterval(baseStart, target, pattern) - 1
|
||||||
if (!baseCountsAsPattern) n += 1
|
return n < 0 || n >= event.repeatCount ? null : n
|
||||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
|
||||||
return n < 0 || n >= maxCount ? null : n
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseWeekEnd = dateFns.addDays(baseBlockStart, 6)
|
const baseWeekEnd = dateFns.addDays(baseBlockStart, 6)
|
||||||
@ -123,59 +114,47 @@ function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
|
|||||||
const middleWeeksCount = alignedWeeksBetween > 0 ? alignedWeeksBetween * fullPatternWeekCount : 0
|
const middleWeeksCount = alignedWeeksBetween > 0 ? alignedWeeksBetween * fullPatternWeekCount : 0
|
||||||
// Count pattern days in the current (possibly partial) week from currentBlockStart..target
|
// Count pattern days in the current (possibly partial) week from currentBlockStart..target
|
||||||
const currentWeekCount = countPatternDaysInInterval(currentBlockStart, target, pattern)
|
const currentWeekCount = countPatternDaysInInterval(currentBlockStart, target, pattern)
|
||||||
let n = firstWeekCount + middleWeeksCount + currentWeekCount - 1
|
const n = firstWeekCount + middleWeeksCount + currentWeekCount - 1
|
||||||
if (!baseCountsAsPattern) n += 1
|
return n >= event.repeatCount ? null : n
|
||||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
|
||||||
return n >= maxCount ? null : n
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recurrence: Monthly -----------------------------------------------------
|
// Recurrence: Monthly -----------------------------------------------------
|
||||||
function getMonthlyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
|
function getMonthlyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
|
||||||
const recur = _getRecur(event)
|
if (!event?.isRepeating || event.repeat !== 'months') return null
|
||||||
if (!recur || recur.freq !== 'months') return null
|
|
||||||
const baseStart = fromLocalString(event.startDate, timeZone)
|
const baseStart = fromLocalString(event.startDate, timeZone)
|
||||||
const d = fromLocalString(dateStr, timeZone)
|
const d = fromLocalString(dateStr, timeZone)
|
||||||
const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart)
|
const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart)
|
||||||
if (diffMonths < 0) return null
|
if (diffMonths < 0) return null
|
||||||
const interval = recur.interval || 1
|
const interval = event.repeatInterval || 1
|
||||||
if (diffMonths % interval !== 0) return null
|
if (diffMonths % interval !== 0) return null
|
||||||
const baseDay = dateFns.getDate(baseStart)
|
const baseDay = dateFns.getDate(baseStart)
|
||||||
const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d))
|
const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d))
|
||||||
if (dateFns.getDate(d) !== effectiveDay) return null
|
if (dateFns.getDate(d) !== effectiveDay) return null
|
||||||
const n = diffMonths / interval
|
const n = diffMonths / interval
|
||||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
return n >= event.repeatCount ? null : n
|
||||||
return n >= maxCount ? null : n
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
|
function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
|
||||||
const recur = _getRecur(event)
|
if (!event?.isRepeating || event.repeat === 'none') return null
|
||||||
if (!recur) return null
|
|
||||||
if (dateStr < event.startDate) return null
|
if (dateStr < event.startDate) return null
|
||||||
if (recur.freq === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone)
|
if (event.repeat === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone)
|
||||||
if (recur.freq === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone)
|
if (event.repeat === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reverse lookup: given a recurrence index (0-based) return the occurrence start date string.
|
// Reverse lookup: given a recurrence index (0-based) return the occurrence start date string.
|
||||||
// Returns null if the index is out of range or the event is not repeating.
|
// Returns null if the index is out of range or the event is not repeating.
|
||||||
function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
|
function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
|
||||||
const recur = _getRecur(event)
|
if (!event?.isRepeating || event.repeat !== 'weeks') return null
|
||||||
if (!recur || recur.freq !== 'weeks') return null
|
|
||||||
if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
|
if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
|
||||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
if (event.repeatCount !== 'unlimited' && occurrenceIndex >= event.repeatCount) return null
|
||||||
if (occurrenceIndex >= maxCount) return null
|
const pattern = event.repeatWeekdays || []
|
||||||
const pattern = recur.weekdays || []
|
|
||||||
if (!pattern.some(Boolean)) return null
|
if (!pattern.some(Boolean)) return null
|
||||||
const interval = recur.interval || 1
|
const interval = event.repeatInterval || 1
|
||||||
const baseStart = fromLocalString(event.startDate, timeZone)
|
const baseStart = fromLocalString(event.startDate, timeZone)
|
||||||
if (occurrenceIndex === 0) return toLocalString(baseStart, timeZone)
|
if (occurrenceIndex === 0) return toLocalString(baseStart, timeZone)
|
||||||
const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone)
|
const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone)
|
||||||
const baseDow = dateFns.getDay(baseStart)
|
const baseDow = dateFns.getDay(baseStart)
|
||||||
const baseCountsAsPattern = !!pattern[baseDow]
|
|
||||||
// Adjust index if base weekday is not part of the pattern (pattern occurrences shift by +1)
|
|
||||||
let occ = occurrenceIndex
|
|
||||||
if (!baseCountsAsPattern) occ -= 1
|
|
||||||
if (occ < 0) return null
|
|
||||||
// Sorted list of active weekday indices
|
// Sorted list of active weekday indices
|
||||||
const patternDays = []
|
const patternDays = []
|
||||||
for (let d = 0; d < 7; d++) if (pattern[d]) patternDays.push(d)
|
for (let d = 0; d < 7; d++) if (pattern[d]) patternDays.push(d)
|
||||||
@ -188,10 +167,10 @@ function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ)
|
|||||||
firstWeekDates.push(date)
|
firstWeekDates.push(date)
|
||||||
}
|
}
|
||||||
const F = firstWeekDates.length
|
const F = firstWeekDates.length
|
||||||
if (occ < F) {
|
if (occurrenceIndex < F) {
|
||||||
return toLocalString(firstWeekDates[occ], timeZone)
|
return toLocalString(firstWeekDates[occurrenceIndex], timeZone)
|
||||||
}
|
}
|
||||||
const remaining = occ - F
|
const remaining = occurrenceIndex - F
|
||||||
const P = patternDays.length
|
const P = patternDays.length
|
||||||
if (P === 0) return null
|
if (P === 0) return null
|
||||||
// Determine aligned week group (k >= 1) in which the remaining-th occurrence lies
|
// Determine aligned week group (k >= 1) in which the remaining-th occurrence lies
|
||||||
@ -203,12 +182,10 @@ function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
|
function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
|
||||||
const recur = _getRecur(event)
|
if (!event?.isRepeating || event.repeat !== 'months') return null
|
||||||
if (!recur || recur.freq !== 'months') return null
|
|
||||||
if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
|
if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
|
||||||
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
|
if (event.repeatCount !== 'unlimited' && occurrenceIndex >= event.repeatCount) return null
|
||||||
if (occurrenceIndex >= maxCount) return null
|
const interval = event.repeatInterval || 1
|
||||||
const interval = recur.interval || 1
|
|
||||||
const baseStart = fromLocalString(event.startDate, timeZone)
|
const baseStart = fromLocalString(event.startDate, timeZone)
|
||||||
const targetMonthOffset = occurrenceIndex * interval
|
const targetMonthOffset = occurrenceIndex * interval
|
||||||
const monthDate = dateFns.addMonths(baseStart, targetMonthOffset)
|
const monthDate = dateFns.addMonths(baseStart, targetMonthOffset)
|
||||||
@ -221,15 +198,16 @@ function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
|
function getOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
|
||||||
const recur = _getRecur(event)
|
if (!event?.isRepeating || event.repeat === 'none') return null
|
||||||
if (!recur) return null
|
if (event.repeat === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone)
|
||||||
if (recur.freq === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone)
|
if (event.repeat === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone)
|
||||||
if (recur.freq === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) {
|
function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) {
|
||||||
const spanDays = Math.max(0, (event.days || 1) - 1)
|
const baseStart = fromLocalString(event.startDate, timeZone)
|
||||||
|
const baseEnd = fromLocalString(event.endDate, timeZone)
|
||||||
|
const spanDays = Math.max(0, dateFns.differenceInCalendarDays(baseEnd, baseStart))
|
||||||
const occurrenceStart = fromLocalString(occurrenceStartDate, timeZone)
|
const occurrenceStart = fromLocalString(occurrenceStartDate, timeZone)
|
||||||
return toLocalString(dateFns.addDays(occurrenceStart, spanDays), timeZone)
|
return toLocalString(dateFns.addDays(occurrenceStart, spanDays), timeZone)
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user