Compare commits
No commits in common. "1155f712a4f14b389257fd319fd06b547c77cf8f" and "15f7ff4fec3d94cc0e88813900415e8f579cbec1" have entirely different histories.
1155f712a4
...
15f7ff4fec
@ -16,9 +16,9 @@
|
|||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"date-holidays": "^3.25.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"date-fns-tz": "^3.0.0",
|
"date-fns-tz": "^3.0.0",
|
||||||
"date-holidays": "^3.25.1",
|
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"pinia-plugin-persistedstate": "^4.5.0",
|
"pinia-plugin-persistedstate": "^4.5.0",
|
||||||
"vue": "^3.5.18"
|
"vue": "^3.5.18"
|
||||||
|
31
src/App.vue
31
src/App.vue
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import CalendarView from './components/CalendarView.vue'
|
import CalendarView from './components/CalendarView.vue'
|
||||||
import EventDialog from './components/EventDialog.vue'
|
import EventDialog from './components/EventDialog.vue'
|
||||||
import { useCalendarStore } from './stores/CalendarStore'
|
import { useCalendarStore } from './stores/CalendarStore'
|
||||||
@ -8,37 +8,8 @@ const eventDialog = ref(null)
|
|||||||
const calendarStore = useCalendarStore()
|
const calendarStore = useCalendarStore()
|
||||||
|
|
||||||
// Initialize holidays when app starts
|
// Initialize holidays when app starts
|
||||||
function isEditableElement(el) {
|
|
||||||
if (!el) return false
|
|
||||||
const tag = el.tagName
|
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return true
|
|
||||||
if (el.isContentEditable) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGlobalKey(e) {
|
|
||||||
// Only consider Ctrl/Meta+Z combos
|
|
||||||
if (!(e.ctrlKey || e.metaKey)) return
|
|
||||||
if (e.key !== 'z' && e.key !== 'Z') return
|
|
||||||
// Don't interfere with native undo/redo inside editable fields
|
|
||||||
const target = e.target
|
|
||||||
if (isEditableElement(target)) return
|
|
||||||
// Decide undo vs redo (Shift = redo)
|
|
||||||
if (e.shiftKey) {
|
|
||||||
calendarStore.$history?.redo()
|
|
||||||
} else {
|
|
||||||
calendarStore.$history?.undo()
|
|
||||||
}
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
calendarStore.initializeHolidaysFromConfig()
|
calendarStore.initializeHolidaysFromConfig()
|
||||||
document.addEventListener('keydown', handleGlobalKey, { passive: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('keydown', handleGlobalKey)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleCreateEvent = (eventData) => {
|
const handleCreateEvent = (eventData) => {
|
||||||
|
@ -9,76 +9,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Layout & typography */
|
/* Layout & typography */
|
||||||
* {
|
* { box-sizing: border-box }
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font:
|
font: 500 14px/1.2 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial;
|
||||||
500 14px/1.2 ui-sans-serif,
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
Segoe UI,
|
|
||||||
Roboto,
|
|
||||||
Inter,
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: .75rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: .75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-date {
|
.today-date { cursor: pointer }
|
||||||
cursor: pointer;
|
.today-date::first-line { color: var(--today) }
|
||||||
}
|
.today-button:hover { opacity: .8 }
|
||||||
.today-date::first-line {
|
|
||||||
color: var(--today);
|
|
||||||
}
|
|
||||||
.today-button:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header row */
|
/* Header row */
|
||||||
.calendar-header,
|
.calendar-header, #calendar-header {
|
||||||
#calendar-header {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w);
|
grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w);
|
||||||
border-bottom: 0.2em solid var(--muted);
|
border-bottom: .2em solid var(--muted);
|
||||||
align-items: last baseline;
|
align-items: last baseline;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main container */
|
/* Main container */
|
||||||
.calendar-container,
|
.calendar-container, #calendar-container {
|
||||||
#calendar-container {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -87,8 +56,7 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Viewports (support id or class) */
|
/* Viewports (support id or class) */
|
||||||
.calendar-viewport,
|
.calendar-viewport, #calendar-viewport {
|
||||||
#calendar-viewport {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@ -97,16 +65,11 @@ header {
|
|||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
.calendar-viewport::-webkit-scrollbar,
|
.calendar-viewport::-webkit-scrollbar,
|
||||||
#calendar-viewport::-webkit-scrollbar {
|
#calendar-viewport::-webkit-scrollbar { display: none }
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jogwheel-viewport,
|
.jogwheel-viewport, #jogwheel-viewport {
|
||||||
#jogwheel-viewport {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0; right: 0; bottom: 0;
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: var(--overlay-w);
|
width: var(--overlay-w);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@ -115,19 +78,10 @@ header {
|
|||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
}
|
}
|
||||||
.jogwheel-viewport::-webkit-scrollbar,
|
.jogwheel-viewport::-webkit-scrollbar,
|
||||||
#jogwheel-viewport::-webkit-scrollbar {
|
#jogwheel-viewport::-webkit-scrollbar { display: none }
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jogwheel-content,
|
.jogwheel-content, #jogwheel-content { position: relative; width: 100% }
|
||||||
#jogwheel-content {
|
.calendar-content, #calendar-content { position: relative }
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.calendar-content,
|
|
||||||
#calendar-content {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Week row: label + 7-day grid + jogwheel column */
|
/* Week row: label + 7-day grid + jogwheel column */
|
||||||
.week-row {
|
.week-row {
|
||||||
@ -141,8 +95,7 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Label cells */
|
/* Label cells */
|
||||||
.year-label,
|
.year-label, .week-label {
|
||||||
.week-label {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -177,8 +130,7 @@ header {
|
|||||||
z-index: 15;
|
z-index: 15;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0; right: 0;
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.month-name-label > span {
|
.month-name-label > span {
|
||||||
|
@ -1,14 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
day: Object,
|
day: Object,
|
||||||
dragging: { type: Boolean, default: false },
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="cell"
|
class="cell"
|
||||||
:style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'"
|
|
||||||
:class="[
|
:class="[
|
||||||
props.day.monthClass,
|
props.day.monthClass,
|
||||||
{
|
{
|
||||||
@ -39,6 +37,7 @@ const props = defineProps({
|
|||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
@ -31,8 +31,6 @@ import {
|
|||||||
toLocalString,
|
toLocalString,
|
||||||
mondayIndex,
|
mondayIndex,
|
||||||
DEFAULT_TZ,
|
DEFAULT_TZ,
|
||||||
MIN_YEAR,
|
|
||||||
MAX_YEAR,
|
|
||||||
} from '@/utils/date'
|
} from '@/utils/date'
|
||||||
import { addDays } from 'date-fns'
|
import { addDays } from 'date-fns'
|
||||||
import WeekRow from './WeekRow.vue'
|
import WeekRow from './WeekRow.vue'
|
||||||
@ -45,6 +43,8 @@ const minVirtualWeek = ref(0)
|
|||||||
const visibleWeeks = ref([])
|
const visibleWeeks = ref([])
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
|
min_year: 1900,
|
||||||
|
max_year: 2100,
|
||||||
weekend: getLocaleWeekendDays(),
|
weekend: getLocaleWeekendDays(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,7 +116,7 @@ const handleWheel = (e) => {
|
|||||||
const currentYear = calendarStore.viewYear
|
const currentYear = calendarStore.viewYear
|
||||||
const delta = Math.round(e.deltaY * (1 / 3))
|
const delta = Math.round(e.deltaY * (1 / 3))
|
||||||
if (!delta) return
|
if (!delta) return
|
||||||
const newYear = Math.max(MIN_YEAR, Math.min(MAX_YEAR, currentYear + delta))
|
const newYear = Math.max(config.min_year, Math.min(config.max_year, currentYear + delta))
|
||||||
if (newYear === currentYear) return
|
if (newYear === currentYear) return
|
||||||
|
|
||||||
const topDisplayIndex = Math.floor(viewportEl.value.scrollTop / rowHeight.value)
|
const topDisplayIndex = Math.floor(viewportEl.value.scrollTop / rowHeight.value)
|
||||||
@ -156,8 +156,8 @@ const goToTodayHandler = () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
rowHeight.value = computeRowHeight()
|
rowHeight.value = computeRowHeight()
|
||||||
|
|
||||||
const minYearDate = new Date(MIN_YEAR, 0, 1)
|
const minYearDate = new Date(config.min_year, 0, 1)
|
||||||
const maxYearLastDay = new Date(MAX_YEAR, 11, 31)
|
const maxYearLastDay = new Date(config.max_year, 11, 31)
|
||||||
const lastWeekMonday = addDays(maxYearLastDay, -mondayIndex(maxYearLastDay))
|
const lastWeekMonday = addDays(maxYearLastDay, -mondayIndex(maxYearLastDay))
|
||||||
|
|
||||||
minVirtualWeek.value = getWeekIndex(minYearDate)
|
minVirtualWeek.value = getWeekIndex(minYearDate)
|
||||||
|
@ -6,8 +6,6 @@ import {
|
|||||||
reorderByFirstDay,
|
reorderByFirstDay,
|
||||||
getISOWeek,
|
getISOWeek,
|
||||||
getISOWeekYear,
|
getISOWeekYear,
|
||||||
MIN_YEAR,
|
|
||||||
MAX_YEAR,
|
|
||||||
} from '@/utils/date'
|
} from '@/utils/date'
|
||||||
import Numeric from '@/components/Numeric.vue'
|
import Numeric from '@/components/Numeric.vue'
|
||||||
import { addDays } from 'date-fns'
|
import { addDays } from 'date-fns'
|
||||||
@ -51,7 +49,7 @@ function isoWeekMonday(isoYear, isoWeek) {
|
|||||||
|
|
||||||
function changeYear(y) {
|
function changeYear(y) {
|
||||||
if (y == null) return
|
if (y == null) return
|
||||||
y = Math.round(Math.max(MIN_YEAR, Math.min(MAX_YEAR, y)))
|
y = Math.round(Math.max(calendarStore.minYear, Math.min(calendarStore.maxYear, y)))
|
||||||
if (y === currentYear.value) return
|
if (y === currentYear.value) return
|
||||||
const vw = topVirtualWeek.value
|
const vw = topVirtualWeek.value
|
||||||
// Fraction within current row
|
// Fraction within current row
|
||||||
@ -96,8 +94,8 @@ const weekdayNames = computed(() => {
|
|||||||
<Numeric
|
<Numeric
|
||||||
:model-value="currentYear"
|
:model-value="currentYear"
|
||||||
@update:modelValue="changeYear"
|
@update:modelValue="changeYear"
|
||||||
:min="MIN_YEAR"
|
:min="calendarStore.minYear"
|
||||||
:max="MAX_YEAR"
|
:max="calendarStore.maxYear"
|
||||||
:step="1"
|
:step="1"
|
||||||
aria-label="Year"
|
aria-label="Year"
|
||||||
number-prefix=""
|
number-prefix=""
|
||||||
|
@ -17,11 +17,9 @@ import {
|
|||||||
getOccurrenceIndex,
|
getOccurrenceIndex,
|
||||||
getVirtualOccurrenceEndDate,
|
getVirtualOccurrenceEndDate,
|
||||||
getISOWeek,
|
getISOWeek,
|
||||||
MIN_YEAR,
|
|
||||||
MAX_YEAR,
|
|
||||||
} from '@/utils/date'
|
} from '@/utils/date'
|
||||||
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
|
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
|
||||||
import { addDays, differenceInCalendarDays, differenceInWeeks } from 'date-fns'
|
import { addDays, differenceInCalendarDays } from 'date-fns'
|
||||||
import { getHolidayForDate } from '@/utils/holidays'
|
import { getHolidayForDate } from '@/utils/holidays'
|
||||||
|
|
||||||
const calendarStore = useCalendarStore()
|
const calendarStore = useCalendarStore()
|
||||||
@ -49,46 +47,20 @@ 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 WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
const pendingTap = ref({ date: null, time: 0, type: null })
|
|
||||||
const suppressMouseUntil = ref(0)
|
|
||||||
|
|
||||||
function normalizeDate(val) {
|
|
||||||
if (typeof val === 'string') return val
|
|
||||||
if (val && typeof val === 'object') {
|
|
||||||
if (val.date) return String(val.date)
|
|
||||||
if (val.startDate) return String(val.startDate)
|
|
||||||
}
|
|
||||||
return String(val)
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerTap(rawDate, type) {
|
|
||||||
const dateStr = normalizeDate(rawDate)
|
|
||||||
const now = Date.now()
|
|
||||||
const prev = pendingTap.value
|
|
||||||
const delta = now - prev.time
|
|
||||||
const isDouble =
|
|
||||||
prev.date === dateStr && prev.type === type && delta <= DOUBLE_TAP_DELAY && delta >= 35
|
|
||||||
if (isDouble) {
|
|
||||||
pendingTap.value = { date: null, time: 0, type: null }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
pendingTap.value = { date: dateStr, time: now, type }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const minVirtualWeek = computed(() => {
|
const minVirtualWeek = computed(() => {
|
||||||
const date = new Date(MIN_YEAR, 0, 1)
|
const date = new Date(calendarStore.minYear, 0, 1)
|
||||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||||
const firstDayOfWeek = addDays(date, -dayOffset)
|
const firstDayOfWeek = addDays(date, -dayOffset)
|
||||||
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS)
|
||||||
})
|
})
|
||||||
|
|
||||||
const maxVirtualWeek = computed(() => {
|
const maxVirtualWeek = computed(() => {
|
||||||
const date = new Date(MAX_YEAR, 11, 31)
|
const date = new Date(calendarStore.maxYear, 11, 31)
|
||||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||||
const firstDayOfWeek = addDays(date, -dayOffset)
|
const firstDayOfWeek = addDays(date, -dayOffset)
|
||||||
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS)
|
||||||
})
|
})
|
||||||
|
|
||||||
const totalVirtualWeeks = computed(() => {
|
const totalVirtualWeeks = computed(() => {
|
||||||
@ -114,49 +86,22 @@ const todayString = computed(() => {
|
|||||||
return formatTodayString(d)
|
return formatTodayString(d)
|
||||||
})
|
})
|
||||||
|
|
||||||
// PERFORMANCE: Maintain a manual cache of computed weeks instead of relying on
|
const visibleWeeks = computed(() => {
|
||||||
// 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 buffer = 10
|
||||||
const startIdx = Math.floor((scrollTop.value - buffer * rowHeight.value) / rowHeight.value)
|
const startIdx = Math.floor((scrollTop.value - buffer * rowHeight.value) / rowHeight.value)
|
||||||
const endIdx = Math.ceil(
|
const endIdx = Math.ceil(
|
||||||
(scrollTop.value + viewportHeight.value + buffer * rowHeight.value) / rowHeight.value,
|
(scrollTop.value + viewportHeight.value + buffer * rowHeight.value) / rowHeight.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
|
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
|
||||||
const endVW = Math.min(maxVirtualWeek.value, endIdx + 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 = []
|
const weeks = []
|
||||||
for (let vw = startVW; vw <= endVW; vw++) weeks.push(createWeek(vw))
|
for (let vw = startVW; vw <= endVW; vw++) {
|
||||||
visibleWeeks.value = weeks
|
weeks.push(createWeek(vw))
|
||||||
lastScrollRange = { startVW, endVW }
|
}
|
||||||
}
|
return weeks
|
||||||
|
})
|
||||||
|
|
||||||
const contentHeight = computed(() => {
|
const contentHeight = computed(() => {
|
||||||
return totalVirtualWeeks.value * rowHeight.value
|
return totalVirtualWeeks.value * rowHeight.value
|
||||||
@ -177,7 +122,7 @@ function computeRowHeight() {
|
|||||||
function getWeekIndex(date) {
|
function getWeekIndex(date) {
|
||||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||||
const firstDayOfWeek = addDays(date, -dayOffset)
|
const firstDayOfWeek = addDays(date, -dayOffset)
|
||||||
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirstDayForVirtualWeek(virtualWeek) {
|
function getFirstDayForVirtualWeek(virtualWeek) {
|
||||||
@ -205,24 +150,29 @@ function createWeek(virtualWeek) {
|
|||||||
const dateStr = toLocalString(cur, DEFAULT_TZ)
|
const dateStr = toLocalString(cur, DEFAULT_TZ)
|
||||||
const storedEvents = []
|
const storedEvents = []
|
||||||
|
|
||||||
|
// Find all non-repeating events that occur on this date
|
||||||
for (const ev of calendarStore.events.values()) {
|
for (const ev of calendarStore.events.values()) {
|
||||||
if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) {
|
if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) {
|
||||||
storedEvents.push(ev)
|
storedEvents.push(ev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Build day events starting with stored (base/spanning) then virtual occurrences
|
||||||
const dayEvents = [...storedEvents]
|
const dayEvents = [...storedEvents]
|
||||||
// Expand repeating events into per-day occurrences (including virtual spans) when they cover this date.
|
|
||||||
for (const base of repeatingBases) {
|
for (const base of repeatingBases) {
|
||||||
// Base event's original span: include it directly as occurrence index 0.
|
// If the current date falls within the base event's original span, include the base
|
||||||
|
// event itself as occurrence index 0. Previously this was skipped which caused the
|
||||||
|
// first (n=0) occurrence of repeating events to be missing from the calendar.
|
||||||
if (dateStr >= base.startDate && dateStr <= base.endDate) {
|
if (dateStr >= base.startDate && dateStr <= base.endDate) {
|
||||||
dayEvents.push({
|
dayEvents.push({
|
||||||
...base,
|
...base,
|
||||||
|
// Mark explicit recurrence index for consistency with virtual occurrences
|
||||||
_recurrenceIndex: 0,
|
_recurrenceIndex: 0,
|
||||||
_baseId: base.id,
|
_baseId: base.id,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if any virtual occurrence spans this date
|
||||||
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
||||||
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
||||||
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
|
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
|
||||||
@ -230,16 +180,19 @@ function createWeek(virtualWeek) {
|
|||||||
|
|
||||||
let occurrenceFound = false
|
let occurrenceFound = false
|
||||||
|
|
||||||
// Walk backwards within the base span to locate a matching virtual occurrence start.
|
// Walk backwards within span to find occurrence start
|
||||||
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
|
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
|
||||||
const candidateStart = addDays(currentDate, -offset)
|
const candidateStart = addDays(currentDate, -offset)
|
||||||
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
|
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
|
||||||
|
|
||||||
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
|
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
|
||||||
if (occurrenceIndex !== null) {
|
if (occurrenceIndex !== null) {
|
||||||
|
// Calculate the end date of this occurrence
|
||||||
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
|
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
|
||||||
|
|
||||||
|
// Check if this occurrence spans through the current date
|
||||||
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
|
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
|
||||||
|
// Create virtual occurrence (if not already created)
|
||||||
const virtualId = base.id + '_v_' + candidateStartStr
|
const virtualId = base.id + '_v_' + candidateStartStr
|
||||||
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
|
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
|
||||||
|
|
||||||
@ -276,6 +229,8 @@ function createWeek(virtualWeek) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get holiday info once per day
|
||||||
|
// Ensure holidays initialized lazily
|
||||||
let holiday = null
|
let holiday = null
|
||||||
if (calendarStore.config.holidays.enabled) {
|
if (calendarStore.config.holidays.enabled) {
|
||||||
calendarStore._ensureHolidaysInitialized?.()
|
calendarStore._ensureHolidaysInitialized?.()
|
||||||
@ -305,7 +260,7 @@ function createWeek(virtualWeek) {
|
|||||||
|
|
||||||
let monthLabel = null
|
let monthLabel = null
|
||||||
if (hasFirst && monthToLabel !== null) {
|
if (hasFirst && monthToLabel !== null) {
|
||||||
if (labelYear && labelYear <= MAX_YEAR) {
|
if (labelYear && labelYear <= calendarStore.config.max_year) {
|
||||||
let weeksSpan = 0
|
let weeksSpan = 0
|
||||||
const d = addDays(cur, -1)
|
const d = addDays(cur, -1)
|
||||||
|
|
||||||
@ -351,12 +306,10 @@ function clearSelection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startDrag(dateStr) {
|
function startDrag(dateStr) {
|
||||||
dateStr = normalizeDate(dateStr)
|
|
||||||
if (calendarStore.config.select_days === 0) return
|
if (calendarStore.config.select_days === 0) return
|
||||||
isDragging.value = true
|
isDragging.value = true
|
||||||
dragAnchor.value = dateStr
|
dragAnchor.value = dateStr
|
||||||
selection.value = { startDate: dateStr, dayCount: 1 }
|
selection.value = { startDate: dateStr, dayCount: 1 }
|
||||||
addGlobalTouchListeners()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDrag(dateStr) {
|
function updateDrag(dateStr) {
|
||||||
@ -372,88 +325,6 @@ function endDrag(dateStr) {
|
|||||||
selection.value = { startDate, dayCount }
|
selection.value = { startDate, dayCount }
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalizeDragAndCreate() {
|
|
||||||
if (!isDragging.value) return
|
|
||||||
isDragging.value = false
|
|
||||||
const eventData = createEventFromSelection()
|
|
||||||
if (eventData) {
|
|
||||||
clearSelection()
|
|
||||||
emit('create-event', eventData)
|
|
||||||
}
|
|
||||||
removeGlobalTouchListeners()
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDateUnderPoint(x, y) {
|
|
||||||
const el = document.elementFromPoint(x, y)
|
|
||||||
let cur = el
|
|
||||||
while (cur) {
|
|
||||||
if (cur.dataset && cur.dataset.date) return cur.dataset.date
|
|
||||||
cur = cur.parentElement
|
|
||||||
}
|
|
||||||
return getDateFromCoordinates(x, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onGlobalTouchMove(e) {
|
|
||||||
if (!isDragging.value) return
|
|
||||||
const t = e.touches && e.touches[0]
|
|
||||||
if (!t) return
|
|
||||||
e.preventDefault()
|
|
||||||
const dateStr = getDateUnderPoint(t.clientX, t.clientY)
|
|
||||||
if (dateStr) updateDrag(dateStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onGlobalTouchEnd(e) {
|
|
||||||
if (!isDragging.value) {
|
|
||||||
removeGlobalTouchListeners()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const t = (e.changedTouches && e.changedTouches[0]) || (e.touches && e.touches[0])
|
|
||||||
if (t) {
|
|
||||||
const dateStr = getDateUnderPoint(t.clientX, t.clientY)
|
|
||||||
if (dateStr) {
|
|
||||||
const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
|
|
||||||
selection.value = { startDate, dayCount }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finalizeDragAndCreate()
|
|
||||||
}
|
|
||||||
|
|
||||||
function addGlobalTouchListeners() {
|
|
||||||
window.addEventListener('touchmove', onGlobalTouchMove, { passive: false })
|
|
||||||
window.addEventListener('touchend', onGlobalTouchEnd, { passive: false })
|
|
||||||
window.addEventListener('touchcancel', onGlobalTouchEnd, { passive: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeGlobalTouchListeners() {
|
|
||||||
window.removeEventListener('touchmove', onGlobalTouchMove)
|
|
||||||
window.removeEventListener('touchend', onGlobalTouchEnd)
|
|
||||||
window.removeEventListener('touchcancel', onGlobalTouchEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback hit-test if elementFromPoint doesn't find a day cell (e.g., moving between rows).
|
|
||||||
function getDateFromCoordinates(clientX, clientY) {
|
|
||||||
if (!viewport.value) return null
|
|
||||||
const vpRect = viewport.value.getBoundingClientRect()
|
|
||||||
const yOffset = clientY - vpRect.top + viewport.value.scrollTop
|
|
||||||
if (yOffset < 0) return null
|
|
||||||
const rowIndex = Math.floor(yOffset / rowHeight.value)
|
|
||||||
const virtualWeek = minVirtualWeek.value + rowIndex
|
|
||||||
if (virtualWeek < minVirtualWeek.value || virtualWeek > maxVirtualWeek.value) return null
|
|
||||||
const sampleWeek = viewport.value.querySelector('.week-row')
|
|
||||||
if (!sampleWeek) return null
|
|
||||||
const labelEl = sampleWeek.querySelector('.week-label')
|
|
||||||
const jogwheelWidth = 48
|
|
||||||
const wrRect = sampleWeek.getBoundingClientRect()
|
|
||||||
const labelRight = labelEl ? labelEl.getBoundingClientRect().right : wrRect.left
|
|
||||||
const daysAreaRight = wrRect.right - jogwheelWidth
|
|
||||||
const daysWidth = daysAreaRight - labelRight
|
|
||||||
if (clientX < labelRight || clientX > daysAreaRight) return null
|
|
||||||
const col = Math.min(6, Math.max(0, Math.floor(((clientX - labelRight) / daysWidth) * 7)))
|
|
||||||
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
|
|
||||||
const targetDate = addDays(firstDay, col)
|
|
||||||
return toLocalString(targetDate, DEFAULT_TZ)
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateSelection(anchorStr, otherStr) {
|
function calculateSelection(anchorStr, otherStr) {
|
||||||
const limit = calendarStore.config.select_days
|
const limit = calendarStore.config.select_days
|
||||||
const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
|
const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
|
||||||
@ -475,8 +346,9 @@ function calculateSelection(anchorStr, otherStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (viewport.value) scrollTop.value = viewport.value.scrollTop
|
if (viewport.value) {
|
||||||
scheduleRebuild('scroll')
|
scrollTop.value = viewport.value.scrollTop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleJogwheelScrollTo = (newScrollTop) => {
|
const handleJogwheelScrollTo = (newScrollTop) => {
|
||||||
@ -499,9 +371,6 @@ onMounted(() => {
|
|||||||
calendarStore.updateCurrentDate()
|
calendarStore.updateCurrentDate()
|
||||||
}, 60000)
|
}, 60000)
|
||||||
|
|
||||||
// Initial build after mount & measurement
|
|
||||||
scheduleRebuild('init')
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
})
|
})
|
||||||
@ -513,33 +382,53 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDayMouseDown = (d) => {
|
const handleDayMouseDown = (dateStr) => {
|
||||||
d = normalizeDate(d)
|
startDrag(dateStr)
|
||||||
if (Date.now() < suppressMouseUntil.value) return
|
|
||||||
if (registerTap(d, 'mouse')) startDrag(d)
|
|
||||||
}
|
}
|
||||||
const handleDayMouseEnter = (d) => updateDrag(normalizeDate(d))
|
|
||||||
const handleDayMouseUp = (d) => {
|
const handleDayMouseEnter = (dateStr) => {
|
||||||
d = normalizeDate(d)
|
if (isDragging.value) {
|
||||||
if (Date.now() < suppressMouseUntil.value && !isDragging.value) return
|
updateDrag(dateStr)
|
||||||
if (!isDragging.value) return
|
|
||||||
endDrag(d)
|
|
||||||
const ev = createEventFromSelection()
|
|
||||||
if (ev) {
|
|
||||||
clearSelection()
|
|
||||||
emit('create-event', ev)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const handleDayTouchStart = (d) => {
|
|
||||||
d = normalizeDate(d)
|
const handleDayMouseUp = (dateStr) => {
|
||||||
suppressMouseUntil.value = Date.now() + 800
|
if (isDragging.value) {
|
||||||
if (registerTap(d, 'touch')) startDrag(d)
|
endDrag(dateStr)
|
||||||
|
const eventData = createEventFromSelection()
|
||||||
|
if (eventData) {
|
||||||
|
clearSelection()
|
||||||
|
emit('create-event', eventData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDayTouchStart = (dateStr) => {
|
||||||
|
startDrag(dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDayTouchMove = (dateStr) => {
|
||||||
|
if (isDragging.value) {
|
||||||
|
updateDrag(dateStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDayTouchEnd = (dateStr) => {
|
||||||
|
if (isDragging.value) {
|
||||||
|
endDrag(dateStr)
|
||||||
|
const eventData = createEventFromSelection()
|
||||||
|
if (eventData) {
|
||||||
|
clearSelection()
|
||||||
|
emit('create-event', eventData)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEventClick = (payload) => {
|
const handleEventClick = (payload) => {
|
||||||
emit('edit-event', payload)
|
emit('edit-event', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle year change emitted from CalendarHeader: scroll to computed target position
|
||||||
const handleHeaderYearChange = ({ scrollTop: st }) => {
|
const handleHeaderYearChange = ({ scrollTop: st }) => {
|
||||||
const maxScroll = contentHeight.value - viewportHeight.value
|
const maxScroll = contentHeight.value - viewportHeight.value
|
||||||
const clamped = Math.max(0, Math.min(st, isFinite(maxScroll) ? maxScroll : st))
|
const clamped = Math.max(0, Math.min(st, isFinite(maxScroll) ? maxScroll : st))
|
||||||
@ -550,22 +439,7 @@ const handleHeaderYearChange = ({ scrollTop: st }) => {
|
|||||||
function openSettings() {
|
function openSettings() {
|
||||||
settingsDialog.value?.open()
|
settingsDialog.value?.open()
|
||||||
}
|
}
|
||||||
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
// Preserve approximate top visible date when first_day changes
|
||||||
// We explicitly avoid locale detection; rely solely on characters present.
|
|
||||||
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
|
|
||||||
function shouldRotateMonth(label) {
|
|
||||||
if (!label) return false
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
// Keep roughly same visible date when first_day setting changes.
|
|
||||||
watch(
|
watch(
|
||||||
() => calendarStore.config.first_day,
|
() => calendarStore.config.first_day,
|
||||||
() => {
|
() => {
|
||||||
@ -576,25 +450,9 @@ watch(
|
|||||||
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
|
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||||
scrollTop.value = newScroll
|
scrollTop.value = newScroll
|
||||||
if (viewport.value) viewport.value.scrollTop = newScroll
|
if (viewport.value) viewport.value.scrollTop = newScroll
|
||||||
scheduleRebuild('first-day-change')
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Watch lightweight mutation counter only (not deep events map) and rebuild lazily
|
|
||||||
watch(
|
|
||||||
() => calendarStore.events,
|
|
||||||
() => {
|
|
||||||
scheduleRebuild('events')
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rebuild if viewport height changes (e.g., resize)
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
if (viewport.value) viewportHeight.value = viewport.value.clientHeight
|
|
||||||
scheduleRebuild('resize')
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -603,27 +461,6 @@ window.addEventListener('resize', () => {
|
|||||||
<h1>Calendar</h1>
|
<h1>Calendar</h1>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
||||||
<!-- Reference historyTick to ensure reactivity of canUndo/canRedo -->
|
|
||||||
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="settings-btn"
|
class="settings-btn"
|
||||||
@ -648,12 +485,13 @@ window.addEventListener('resize', () => {
|
|||||||
v-for="week in visibleWeeks"
|
v-for="week in visibleWeeks"
|
||||||
:key="week.virtualWeek"
|
:key="week.virtualWeek"
|
||||||
:week="week"
|
:week="week"
|
||||||
:dragging="isDragging"
|
|
||||||
:style="{ top: week.top + 'px' }"
|
:style="{ top: week.top + 'px' }"
|
||||||
@day-mousedown="handleDayMouseDown"
|
@day-mousedown="handleDayMouseDown"
|
||||||
@day-mouseenter="handleDayMouseEnter"
|
@day-mouseenter="handleDayMouseEnter"
|
||||||
@day-mouseup="handleDayMouseUp"
|
@day-mouseup="handleDayMouseUp"
|
||||||
@day-touchstart="handleDayTouchStart"
|
@day-touchstart="handleDayTouchStart"
|
||||||
|
@day-touchmove="handleDayTouchMove"
|
||||||
|
@day-touchend="handleDayTouchEnd"
|
||||||
@event-click="handleEventClick"
|
@event-click="handleEventClick"
|
||||||
/>
|
/>
|
||||||
<!-- Month labels positioned absolutely -->
|
<!-- Month labels positioned absolutely -->
|
||||||
@ -662,7 +500,6 @@ window.addEventListener('resize', () => {
|
|||||||
:key="`month-${week.virtualWeek}`"
|
:key="`month-${week.virtualWeek}`"
|
||||||
v-show="week.monthLabel"
|
v-show="week.monthLabel"
|
||||||
class="month-name-label"
|
class="month-name-label"
|
||||||
:class="{ 'no-rotate': !shouldRotateMonth(week.monthLabel?.text) }"
|
|
||||||
:style="{
|
:style="{
|
||||||
top: week.top + 'px',
|
top: week.top + 'px',
|
||||||
height: week.monthLabel?.height + 'px',
|
height: week.monthLabel?.height + 'px',
|
||||||
@ -694,21 +531,18 @@ window.addEventListener('resize', () => {
|
|||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.25rem;
|
|
||||||
padding: 0.75rem 0.5rem 0.25rem 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
.header-controls {
|
.header-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.6rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-btn {
|
.settings-btn {
|
||||||
@ -726,33 +560,6 @@ header h1 {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
outline: none;
|
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 {
|
.settings-btn:hover {
|
||||||
color: var(--strong);
|
color: var(--strong);
|
||||||
}
|
}
|
||||||
@ -828,8 +635,4 @@ header h1 {
|
|||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.month-name-label.no-rotate > span {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -2,13 +2,17 @@
|
|||||||
import CalendarDay from './CalendarDay.vue'
|
import CalendarDay from './CalendarDay.vue'
|
||||||
import EventOverlay from './EventOverlay.vue'
|
import EventOverlay from './EventOverlay.vue'
|
||||||
|
|
||||||
const props = defineProps({ week: Object, dragging: { type: Boolean, default: false } })
|
const props = defineProps({
|
||||||
|
week: Object,
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'day-mousedown',
|
'day-mousedown',
|
||||||
'day-mouseenter',
|
'day-mouseenter',
|
||||||
'day-mouseup',
|
'day-mouseup',
|
||||||
'day-touchstart',
|
'day-touchstart',
|
||||||
|
'day-touchmove',
|
||||||
|
'day-touchend',
|
||||||
'event-click',
|
'event-click',
|
||||||
])
|
])
|
||||||
|
|
||||||
@ -28,7 +32,13 @@ const handleDayTouchStart = (dateStr) => {
|
|||||||
emit('day-touchstart', dateStr)
|
emit('day-touchstart', dateStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// touchmove & touchend handled globally in CalendarView
|
const handleDayTouchMove = (dateStr) => {
|
||||||
|
emit('day-touchmove', dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDayTouchEnd = (dateStr) => {
|
||||||
|
emit('day-touchend', dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
const handleEventClick = (payload) => {
|
const handleEventClick = (payload) => {
|
||||||
emit('event-click', payload)
|
emit('event-click', payload)
|
||||||
@ -43,11 +53,12 @@ const handleEventClick = (payload) => {
|
|||||||
v-for="day in props.week.days"
|
v-for="day in props.week.days"
|
||||||
:key="day.date"
|
:key="day.date"
|
||||||
:day="day"
|
:day="day"
|
||||||
:dragging="props.dragging"
|
|
||||||
@mousedown="handleDayMouseDown(day.date)"
|
@mousedown="handleDayMouseDown(day.date)"
|
||||||
@mouseenter="handleDayMouseEnter(day.date)"
|
@mouseenter="handleDayMouseEnter(day.date)"
|
||||||
@mouseup="handleDayMouseUp(day.date)"
|
@mouseup="handleDayMouseUp(day.date)"
|
||||||
@touchstart="handleDayTouchStart(day.date)"
|
@touchstart="handleDayTouchStart(day.date)"
|
||||||
|
@touchmove="handleDayTouchMove(day.date)"
|
||||||
|
@touchend="handleDayTouchEnd(day.date)"
|
||||||
/>
|
/>
|
||||||
<EventOverlay :week="props.week" @event-click="handleEventClick" />
|
<EventOverlay :week="props.week" @event-click="handleEventClick" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -37,7 +37,6 @@ const title = computed({
|
|||||||
set(v) {
|
set(v) {
|
||||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||||
calendarStore.events.get(editingEventId.value).title = v
|
calendarStore.events.get(editingEventId.value).title = v
|
||||||
calendarStore.touchEvents()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -128,7 +127,6 @@ const selectedColor = computed({
|
|||||||
const n = parseInt(v)
|
const n = parseInt(v)
|
||||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||||
calendarStore.events.get(editingEventId.value).colorId = n
|
calendarStore.events.get(editingEventId.value).colorId = n
|
||||||
calendarStore.touchEvents()
|
|
||||||
}
|
}
|
||||||
colorId.value = n
|
colorId.value = n
|
||||||
},
|
},
|
||||||
@ -146,7 +144,6 @@ const repeatCountBinding = computed({
|
|||||||
set(v) {
|
set(v) {
|
||||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||||
calendarStore.events.get(editingEventId.value).repeatCount = v === 0 ? 'unlimited' : String(v)
|
calendarStore.events.get(editingEventId.value).repeatCount = v === 0 ? 'unlimited' : String(v)
|
||||||
calendarStore.touchEvents()
|
|
||||||
}
|
}
|
||||||
recurrenceOccurrences.value = v
|
recurrenceOccurrences.value = v
|
||||||
},
|
},
|
||||||
@ -192,7 +189,6 @@ function loadWeekdayPatternFromStore(storePattern) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateDialog(selectionData = null) {
|
function openCreateDialog(selectionData = null) {
|
||||||
calendarStore.$history?.beginCompound()
|
|
||||||
if (unsavedCreateId.value && !eventSaved.value) {
|
if (unsavedCreateId.value && !eventSaved.value) {
|
||||||
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
||||||
calendarStore.deleteEvent(unsavedCreateId.value)
|
calendarStore.deleteEvent(unsavedCreateId.value)
|
||||||
@ -253,7 +249,6 @@ function openCreateDialog(selectionData = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEditDialog(payload) {
|
function openEditDialog(payload) {
|
||||||
calendarStore.$history?.beginCompound()
|
|
||||||
if (
|
if (
|
||||||
dialogMode.value === 'create' &&
|
dialogMode.value === 'create' &&
|
||||||
unsavedCreateId.value &&
|
unsavedCreateId.value &&
|
||||||
@ -357,8 +352,14 @@ function openEditDialog(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
calendarStore.$history?.endCompound()
|
|
||||||
showDialog.value = false
|
showDialog.value = false
|
||||||
|
if (unsavedCreateId.value && !eventSaved.value) {
|
||||||
|
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
||||||
|
calendarStore.deleteEvent(unsavedCreateId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editingEventId.value = null
|
||||||
|
unsavedCreateId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEventInStore() {
|
function updateEventInStore() {
|
||||||
@ -373,7 +374,6 @@ function updateEventInStore() {
|
|||||||
event.repeatCount =
|
event.repeatCount =
|
||||||
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value)
|
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value)
|
||||||
event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none'
|
event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none'
|
||||||
calendarStore.touchEvents()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -384,13 +384,11 @@ function saveEvent() {
|
|||||||
unsavedCreateId.value = null
|
unsavedCreateId.value = null
|
||||||
}
|
}
|
||||||
if (dialogMode.value === 'create') emit('clear-selection')
|
if (dialogMode.value === 'create') emit('clear-selection')
|
||||||
calendarStore.$history?.endCompound()
|
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEventAll() {
|
function deleteEventAll() {
|
||||||
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
|
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
|
||||||
calendarStore.$history?.endCompound()
|
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -400,14 +398,12 @@ function deleteEventOne() {
|
|||||||
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
|
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
|
||||||
calendarStore.deleteFirstOccurrence(editingEventId.value)
|
calendarStore.deleteFirstOccurrence(editingEventId.value)
|
||||||
}
|
}
|
||||||
calendarStore.$history?.endCompound()
|
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEventFrom() {
|
function deleteEventFrom() {
|
||||||
if (!occurrenceContext.value) return
|
if (!occurrenceContext.value) return
|
||||||
calendarStore.deleteFromOccurrence(occurrenceContext.value)
|
calendarStore.deleteFromOccurrence(occurrenceContext.value)
|
||||||
calendarStore.$history?.endCompound()
|
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -421,19 +417,6 @@ onUnmounted(() => {
|
|||||||
watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => {
|
watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => {
|
||||||
if (editingEventId.value && showDialog.value) updateEventInStore()
|
if (editingEventId.value && showDialog.value) updateEventInStore()
|
||||||
})
|
})
|
||||||
watch(showDialog, (val, oldVal) => {
|
|
||||||
if (oldVal && !val) {
|
|
||||||
// Closed (cancel, escape, outside click) -> end compound session
|
|
||||||
calendarStore.$history?.endCompound()
|
|
||||||
if (dialogMode.value === 'create' && unsavedCreateId.value && !eventSaved.value) {
|
|
||||||
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
|
||||||
calendarStore.deleteEvent(unsavedCreateId.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
editingEventId.value = null
|
|
||||||
unsavedCreateId.value = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
watch(
|
watch(
|
||||||
recurrenceWeekdays,
|
recurrenceWeekdays,
|
||||||
() => {
|
() => {
|
||||||
@ -445,7 +428,6 @@ watch(
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
openCreateDialog,
|
openCreateDialog,
|
||||||
openEditDialog,
|
openEditDialog,
|
||||||
closeDialog,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const isRepeatingEdit = computed(
|
const isRepeatingEdit = computed(
|
||||||
|
@ -154,13 +154,8 @@ function startLocalDrag(init, evt) {
|
|||||||
anchorOffset,
|
anchorOffset,
|
||||||
originSpanDays: spanDays,
|
originSpanDays: spanDays,
|
||||||
eventMoved: false,
|
eventMoved: false,
|
||||||
tentativeStart: init.startDate,
|
|
||||||
tentativeEnd: init.endDate,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin compound history session (single snapshot after drag completes)
|
|
||||||
store.$history?.beginCompound()
|
|
||||||
|
|
||||||
// Capture pointer events globally
|
// Capture pointer events globally
|
||||||
if (evt.currentTarget && evt.pointerId !== undefined) {
|
if (evt.currentTarget && evt.pointerId !== undefined) {
|
||||||
try {
|
try {
|
||||||
@ -170,10 +165,8 @@ function startLocalDrag(init, evt) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent default for mouse/pen to avoid text selection. For touch we skip so the user can still scroll.
|
// Prevent default to avoid text selection and other interference
|
||||||
if (!(evt.pointerType === 'touch')) {
|
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('pointermove', onDragPointerMove, { passive: false })
|
window.addEventListener('pointermove', onDragPointerMove, { passive: false })
|
||||||
window.addEventListener('pointerup', onDragPointerUp, { passive: false })
|
window.addEventListener('pointerup', onDragPointerUp, { passive: false })
|
||||||
@ -216,18 +209,7 @@ function onDragPointerMove(e) {
|
|||||||
|
|
||||||
const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date)
|
const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date)
|
||||||
if (!ns || !ne) return
|
if (!ns || !ne) return
|
||||||
// Only proceed if changed
|
applyRangeDuringDrag(st, ns, ne)
|
||||||
if (ns === st.tentativeStart && ne === st.tentativeEnd) return
|
|
||||||
st.tentativeStart = ns
|
|
||||||
st.tentativeEnd = ne
|
|
||||||
// Real-time update only for non-virtual events (avoid repeated split operations)
|
|
||||||
if (!st.isVirtual) {
|
|
||||||
applyRangeDuringDrag(
|
|
||||||
{ id: st.id, isVirtual: st.isVirtual, mode: st.mode, startDate: ns, endDate: ne },
|
|
||||||
ns,
|
|
||||||
ne,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragPointerUp(e) {
|
function onDragPointerUp(e) {
|
||||||
@ -244,8 +226,6 @@ function onDragPointerUp(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const moved = !!st.eventMoved
|
const moved = !!st.eventMoved
|
||||||
const finalStart = st.tentativeStart
|
|
||||||
const finalEnd = st.tentativeEnd
|
|
||||||
dragState.value = null
|
dragState.value = null
|
||||||
|
|
||||||
window.removeEventListener('pointermove', onDragPointerMove)
|
window.removeEventListener('pointermove', onDragPointerMove)
|
||||||
@ -253,27 +233,11 @@ function onDragPointerUp(e) {
|
|||||||
window.removeEventListener('pointercancel', onDragPointerUp)
|
window.removeEventListener('pointercancel', onDragPointerUp)
|
||||||
|
|
||||||
if (moved) {
|
if (moved) {
|
||||||
// Apply final mutation if virtual (we deferred) or if non-virtual no further change (rare)
|
|
||||||
if (st.isVirtual) {
|
|
||||||
applyRangeDuringDrag(
|
|
||||||
{
|
|
||||||
id: st.id,
|
|
||||||
isVirtual: st.isVirtual,
|
|
||||||
mode: st.mode,
|
|
||||||
startDate: finalStart,
|
|
||||||
endDate: finalEnd,
|
|
||||||
},
|
|
||||||
finalStart,
|
|
||||||
finalEnd,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
justDragged.value = true
|
justDragged.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
justDragged.value = false
|
justDragged.value = false
|
||||||
}, 120)
|
}, 120)
|
||||||
}
|
}
|
||||||
// End compound session (snapshot if changed)
|
|
||||||
store.$history?.endCompound()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeTentativeRangeFromPointer(st, dropDateStr) {
|
function computeTentativeRangeFromPointer(st, dropDateStr) {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<div
|
<div
|
||||||
ref="rootEl"
|
ref="rootEl"
|
||||||
class="mini-stepper drag-mode"
|
class="mini-stepper drag-mode"
|
||||||
:class="[extraClass, { dragging, 'pointer-locked': pointerLocked }]"
|
:class="[extraClass, { dragging }]"
|
||||||
:aria-label="ariaLabel"
|
:aria-label="ariaLabel"
|
||||||
role="spinbutton"
|
role="spinbutton"
|
||||||
:aria-valuemin="minValue"
|
:aria-valuemin="minValue"
|
||||||
@ -19,7 +19,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, onBeforeUnmount } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
const model = defineModel({ type: Number, default: 0 })
|
const model = defineModel({ type: Number, default: 0 })
|
||||||
|
|
||||||
@ -102,91 +102,46 @@ 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 // accumulated horizontal movement since last applied step (works for both locked & unlocked)
|
let startVal = 0
|
||||||
let lastClientX = 0 // previous clientX when not pointer locked
|
|
||||||
const pointerLocked = ref(false)
|
|
||||||
|
|
||||||
function updatePointerLocked() {
|
|
||||||
pointerLocked.value =
|
|
||||||
typeof document !== 'undefined' && document.pointerLockElement === rootEl.value
|
|
||||||
// Reset baseline if lock just engaged
|
|
||||||
if (pointerLocked.value) {
|
|
||||||
accumX = 0
|
|
||||||
startX = 0 // not used while locked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addPointerLockListeners() {
|
|
||||||
if (typeof document === 'undefined') return
|
|
||||||
document.addEventListener('pointerlockchange', updatePointerLocked)
|
|
||||||
document.addEventListener('pointerlockerror', updatePointerLocked)
|
|
||||||
}
|
|
||||||
function removePointerLockListeners() {
|
|
||||||
if (typeof document === 'undefined') return
|
|
||||||
document.removeEventListener('pointerlockchange', updatePointerLocked)
|
|
||||||
document.removeEventListener('pointerlockerror', updatePointerLocked)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerDown(e) {
|
function onPointerDown(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
startX = e.clientX
|
startX = e.clientX
|
||||||
startY = e.clientY
|
startY = e.clientY
|
||||||
lastClientX = e.clientX
|
startVal = current.value
|
||||||
accumX = 0
|
|
||||||
dragging.value = true
|
dragging.value = true
|
||||||
try {
|
try {
|
||||||
e.currentTarget.setPointerCapture?.(e.pointerId)
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
} catch {}
|
} catch {}
|
||||||
if (e.pointerType === 'mouse' && rootEl.value?.requestPointerLock) {
|
rootEl.value?.addEventListener('pointermove', onPointerMove)
|
||||||
addPointerLockListeners()
|
rootEl.value?.addEventListener('pointerup', onPointerUp, { once: true })
|
||||||
try {
|
rootEl.value?.addEventListener('pointercancel', onPointerCancel, { once: true })
|
||||||
rootEl.value.requestPointerLock()
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
document.addEventListener('pointermove', onPointerMove)
|
|
||||||
document.addEventListener('pointerup', onPointerUp, { once: true })
|
|
||||||
document.addEventListener('pointercancel', onPointerCancel, { once: true })
|
|
||||||
}
|
}
|
||||||
function onPointerMove(e) {
|
function onPointerMove(e) {
|
||||||
if (!dragging.value) return
|
if (!dragging.value) return
|
||||||
|
// Prevent page scroll on touch while dragging
|
||||||
if (e.pointerType === 'touch') e.preventDefault()
|
if (e.pointerType === 'touch') e.preventDefault()
|
||||||
let dx
|
const primary = e.clientX - startX // horizontal only
|
||||||
if (pointerLocked.value) {
|
const steps = Math.trunc(primary / props.pixelsPerStep)
|
||||||
dx = e.movementX || 0
|
|
||||||
} else {
|
// Find current value index in all valid values
|
||||||
dx = e.clientX - lastClientX
|
const currentIndex = allValidValues.value.indexOf(startVal)
|
||||||
lastClientX = e.clientX
|
if (currentIndex === -1) return // shouldn't happen
|
||||||
}
|
|
||||||
if (!dx) return
|
const newIndex = currentIndex + steps
|
||||||
accumX += dx
|
|
||||||
const stepSize = props.pixelsPerStep || 1
|
|
||||||
let steps = Math.trunc(accumX / stepSize)
|
|
||||||
if (steps === 0) return
|
|
||||||
// Apply steps relative to current value index each time (dynamic baseline) and keep leftover pixels
|
|
||||||
const applySteps = (count) => {
|
|
||||||
const currentIndex = allValidValues.value.indexOf(current.value)
|
|
||||||
if (currentIndex === -1) return
|
|
||||||
let targetIndex = currentIndex + count
|
|
||||||
if (props.clamp) {
|
if (props.clamp) {
|
||||||
targetIndex = Math.max(0, Math.min(targetIndex, allValidValues.value.length - 1))
|
const clampedIndex = Math.max(0, Math.min(newIndex, allValidValues.value.length - 1))
|
||||||
}
|
const next = allValidValues.value[clampedIndex]
|
||||||
if (targetIndex >= 0 && targetIndex < allValidValues.value.length) {
|
if (next !== current.value) current.value = next
|
||||||
const next = allValidValues.value[targetIndex]
|
} else {
|
||||||
|
if (newIndex >= 0 && newIndex < allValidValues.value.length) {
|
||||||
|
const next = allValidValues.value[newIndex]
|
||||||
if (next !== current.value) current.value = next
|
if (next !== current.value) current.value = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
applySteps(steps)
|
|
||||||
// Remove consumed distance (works even if clamped; leftover ensures responsiveness keeps consistent feel)
|
|
||||||
accumX -= steps * stepSize
|
|
||||||
}
|
}
|
||||||
function endDragListeners() {
|
function endDragListeners() {
|
||||||
document.removeEventListener('pointermove', onPointerMove)
|
rootEl.value?.removeEventListener('pointermove', onPointerMove)
|
||||||
if (pointerLocked.value && document.exitPointerLock) {
|
|
||||||
try {
|
|
||||||
document.exitPointerLock()
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
removePointerLockListeners()
|
|
||||||
}
|
}
|
||||||
function onPointerUp() {
|
function onPointerUp() {
|
||||||
dragging.value = false
|
dragging.value = false
|
||||||
@ -312,7 +267,4 @@ 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>
|
||||||
|
@ -3,16 +3,13 @@ import './assets/calendar.css'
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||||
import { calendarHistory } from '@/plugins/calendarHistory'
|
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
// Order: persistence first so snapshots recorded by undo reflect already-hydrated state
|
|
||||||
pinia.use(piniaPluginPersistedstate)
|
pinia.use(piniaPluginPersistedstate)
|
||||||
pinia.use(calendarHistory)
|
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
@ -1,200 +0,0 @@
|
|||||||
// Custom lightweight undo/redo specifically for calendar store with Map support
|
|
||||||
// Adds store.$history = { undo(), redo(), canUndo, canRedo, clear(), pushManual() }
|
|
||||||
// Wraps action calls to create history entries only for meaningful mutations.
|
|
||||||
|
|
||||||
function deepCloneCalendarState(raw) {
|
|
||||||
// We only need to snapshot keys we care about; omit volatile fields
|
|
||||||
const { today, events, config, weekend } = raw
|
|
||||||
return {
|
|
||||||
today,
|
|
||||||
weekend: Array.isArray(weekend) ? [...weekend] : weekend,
|
|
||||||
config: JSON.parse(JSON.stringify(config)),
|
|
||||||
events: new Map([...events].map(([k, v]) => [k, { ...v }])),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreCalendarState(store, snap) {
|
|
||||||
store.today = snap.today
|
|
||||||
store.weekend = Array.isArray(snap.weekend) ? [...snap.weekend] : snap.weekend
|
|
||||||
store.config = JSON.parse(JSON.stringify(snap.config))
|
|
||||||
store.events = new Map([...snap.events].map(([k, v]) => [k, { ...v }]))
|
|
||||||
store.eventsMutation = (store.eventsMutation + 1) % 1_000_000_000
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calendarHistory({ store }) {
|
|
||||||
if (store.$id !== 'calendar') return
|
|
||||||
|
|
||||||
const max = 100 // history depth limit
|
|
||||||
const history = [] // past states
|
|
||||||
let pointer = -1 // index of current state in history
|
|
||||||
let isRestoring = false
|
|
||||||
let lastSerialized = null
|
|
||||||
// Compound editing session (e.g. event dialog) flags
|
|
||||||
let compoundActive = false
|
|
||||||
let compoundBaseSig = null
|
|
||||||
let compoundChanged = false
|
|
||||||
|
|
||||||
function serializeForComparison() {
|
|
||||||
const evCount = store.events instanceof Map ? store.events.size : 0
|
|
||||||
const em = store.eventsMutation || 0
|
|
||||||
return `${em}|${evCount}|${store.today}|${JSON.stringify(store.config)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushSnapshot() {
|
|
||||||
if (isRestoring) return
|
|
||||||
const sig = serializeForComparison()
|
|
||||||
if (sig === lastSerialized) return
|
|
||||||
// Drop any redo branch
|
|
||||||
if (pointer < history.length - 1) history.splice(pointer + 1)
|
|
||||||
history.push(deepCloneCalendarState(store))
|
|
||||||
if (history.length > max) history.shift()
|
|
||||||
pointer = history.length - 1
|
|
||||||
lastSerialized = sig
|
|
||||||
bumpIndicators()
|
|
||||||
// console.log('[history] pushed', pointer, sig)
|
|
||||||
}
|
|
||||||
|
|
||||||
function bumpIndicators() {
|
|
||||||
if (typeof store.historyTick === 'number') {
|
|
||||||
store.historyTick = (store.historyTick + 1) % 1_000_000_000
|
|
||||||
}
|
|
||||||
if (typeof store.historyCanUndo === 'boolean') {
|
|
||||||
store.historyCanUndo = pointer > 0
|
|
||||||
}
|
|
||||||
if (typeof store.historyCanRedo === 'boolean') {
|
|
||||||
store.historyCanRedo = pointer >= 0 && pointer < history.length - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function markPotentialChange() {
|
|
||||||
if (isRestoring) return
|
|
||||||
if (compoundActive) {
|
|
||||||
const sig = serializeForComparison()
|
|
||||||
if (sig !== compoundBaseSig) compoundChanged = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pushSnapshot()
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginCompound() {
|
|
||||||
if (compoundActive) return
|
|
||||||
compoundActive = true
|
|
||||||
compoundBaseSig = serializeForComparison()
|
|
||||||
compoundChanged = false
|
|
||||||
}
|
|
||||||
function endCompound() {
|
|
||||||
if (!compoundActive) return
|
|
||||||
const finalSig = serializeForComparison()
|
|
||||||
const changed = compoundChanged || finalSig !== compoundBaseSig
|
|
||||||
compoundActive = false
|
|
||||||
compoundBaseSig = null
|
|
||||||
if (changed) pushSnapshot()
|
|
||||||
else bumpIndicators() // session ended without change – still refresh flags
|
|
||||||
}
|
|
||||||
|
|
||||||
function undo() {
|
|
||||||
// Ensure any active compound changes are finalized before moving back
|
|
||||||
if (compoundActive) endCompound()
|
|
||||||
else {
|
|
||||||
// If current state differs from last snapshot, push it so redo can restore it
|
|
||||||
const curSig = serializeForComparison()
|
|
||||||
if (curSig !== lastSerialized) pushSnapshot()
|
|
||||||
}
|
|
||||||
if (pointer <= 0) return
|
|
||||||
pointer--
|
|
||||||
isRestoring = true
|
|
||||||
try {
|
|
||||||
restoreCalendarState(store, history[pointer])
|
|
||||||
lastSerialized = serializeForComparison()
|
|
||||||
} finally {
|
|
||||||
isRestoring = false
|
|
||||||
}
|
|
||||||
bumpIndicators()
|
|
||||||
}
|
|
||||||
|
|
||||||
function redo() {
|
|
||||||
if (compoundActive) endCompound()
|
|
||||||
else {
|
|
||||||
const curSig = serializeForComparison()
|
|
||||||
if (curSig !== lastSerialized) pushSnapshot()
|
|
||||||
}
|
|
||||||
if (pointer >= history.length - 1) return
|
|
||||||
pointer++
|
|
||||||
isRestoring = true
|
|
||||||
try {
|
|
||||||
restoreCalendarState(store, history[pointer])
|
|
||||||
lastSerialized = serializeForComparison()
|
|
||||||
} finally {
|
|
||||||
isRestoring = false
|
|
||||||
}
|
|
||||||
bumpIndicators()
|
|
||||||
}
|
|
||||||
|
|
||||||
function clear() {
|
|
||||||
history.length = 0
|
|
||||||
pointer = -1
|
|
||||||
lastSerialized = null
|
|
||||||
bumpIndicators()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap selected mutating actions to push snapshot AFTER they run if state changed.
|
|
||||||
const actionNames = [
|
|
||||||
'createEvent',
|
|
||||||
'deleteEvent',
|
|
||||||
'deleteFirstOccurrence',
|
|
||||||
'deleteSingleOccurrence',
|
|
||||||
'deleteFromOccurrence',
|
|
||||||
'setEventRange',
|
|
||||||
'splitMoveVirtualOccurrence',
|
|
||||||
'splitRepeatSeries',
|
|
||||||
'_terminateRepeatSeriesAtIndex',
|
|
||||||
'toggleHolidays',
|
|
||||||
'initializeHolidays',
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const name of actionNames) {
|
|
||||||
if (typeof store[name] === 'function') {
|
|
||||||
const original = store[name].bind(store)
|
|
||||||
store[name] = (...args) => {
|
|
||||||
const beforeSig = serializeForComparison()
|
|
||||||
const result = original(...args)
|
|
||||||
const afterSig = serializeForComparison()
|
|
||||||
if (afterSig !== beforeSig) markPotentialChange()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture direct property edits (e.g., deep field edits signaled via touchEvents())
|
|
||||||
store.$subscribe((mutation, _state) => {
|
|
||||||
if (mutation.storeId !== 'calendar') return
|
|
||||||
markPotentialChange()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Initial snapshot after hydration (next microtask to let persistence load)
|
|
||||||
Promise.resolve().then(() => pushSnapshot())
|
|
||||||
|
|
||||||
store.$history = {
|
|
||||||
undo,
|
|
||||||
redo,
|
|
||||||
clear,
|
|
||||||
pushManual: pushSnapshot,
|
|
||||||
beginCompound,
|
|
||||||
endCompound,
|
|
||||||
flush() {
|
|
||||||
pushSnapshot()
|
|
||||||
},
|
|
||||||
get canUndo() {
|
|
||||||
return pointer > 0
|
|
||||||
},
|
|
||||||
get canRedo() {
|
|
||||||
return pointer >= 0 && pointer < history.length - 1
|
|
||||||
},
|
|
||||||
get compoundActive() {
|
|
||||||
return compoundActive
|
|
||||||
},
|
|
||||||
_debug() {
|
|
||||||
return { pointer, length: history.length }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,57 +0,0 @@
|
|||||||
// Pinia plugin to ensure calendar store keeps Map for events after undo/redo snapshots
|
|
||||||
export function calendarUndoNormalize({ store }) {
|
|
||||||
if (store.$id !== 'calendar') return
|
|
||||||
|
|
||||||
function fixEvents() {
|
|
||||||
const evs = store.events
|
|
||||||
if (evs instanceof Map) return
|
|
||||||
// If serialized form { __map: true, data: [...] }
|
|
||||||
if (evs && evs.__map && Array.isArray(evs.data)) {
|
|
||||||
store.events = new Map(evs.data)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// If an array of [k,v]
|
|
||||||
if (Array.isArray(evs) && evs.every((x) => Array.isArray(x) && x.length === 2)) {
|
|
||||||
store.events = new Map(evs)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// If plain object, convert own enumerable props
|
|
||||||
if (evs && typeof evs === 'object') {
|
|
||||||
store.events = new Map(Object.entries(evs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch undo/redo if present (after pinia-undo is installed)
|
|
||||||
const patchFns = ['undo', 'redo']
|
|
||||||
for (const fn of patchFns) {
|
|
||||||
if (typeof store[fn] === 'function') {
|
|
||||||
const original = store[fn].bind(store)
|
|
||||||
store[fn] = (...args) => {
|
|
||||||
console.log(`[calendar history] ${fn} invoked`)
|
|
||||||
const beforeType = store.events && store.events.constructor && store.events.constructor.name
|
|
||||||
const out = original(...args)
|
|
||||||
const afterRawType =
|
|
||||||
store.events && store.events.constructor && store.events.constructor.name
|
|
||||||
fixEvents()
|
|
||||||
const finalType = store.events && store.events.constructor && store.events.constructor.name
|
|
||||||
let size = null
|
|
||||||
try {
|
|
||||||
if (store.events instanceof Map) size = store.events.size
|
|
||||||
else if (Array.isArray(store.events)) size = store.events.length
|
|
||||||
} catch {}
|
|
||||||
console.log(
|
|
||||||
`[calendar history] ${fn} types: before=${beforeType} afterRaw=${afterRawType} final=${finalType} size=${size}`,
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also watch all mutations (includes direct assigns and action commits)
|
|
||||||
store.$subscribe(() => {
|
|
||||||
fixEvents()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Initial sanity
|
|
||||||
fixEvents()
|
|
||||||
}
|
|
@ -10,23 +10,21 @@ import {
|
|||||||
import { differenceInCalendarDays, addDays } from 'date-fns'
|
import { differenceInCalendarDays, addDays } from 'date-fns'
|
||||||
import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays'
|
import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays'
|
||||||
|
|
||||||
|
const MIN_YEAR = 1900
|
||||||
|
const MAX_YEAR = 2100
|
||||||
|
|
||||||
export const useCalendarStore = defineStore('calendar', {
|
export const useCalendarStore = defineStore('calendar', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
today: toLocalString(new Date(), DEFAULT_TZ),
|
today: toLocalString(new Date(), DEFAULT_TZ),
|
||||||
now: new Date().toISOString(),
|
now: new Date().toISOString(),
|
||||||
events: new Map(),
|
events: new Map(),
|
||||||
// Lightweight mutation counter so views can rebuild in a throttled / idle way
|
|
||||||
// without tracking deep reactivity on every event object.
|
|
||||||
eventsMutation: 0,
|
|
||||||
// Incremented internally by history plugin to force reactive updates for canUndo/canRedo
|
|
||||||
historyTick: 0,
|
|
||||||
historyCanUndo: false,
|
|
||||||
historyCanRedo: false,
|
|
||||||
weekend: getLocaleWeekendDays(),
|
weekend: getLocaleWeekendDays(),
|
||||||
_holidayConfigSignature: null,
|
_holidayConfigSignature: null,
|
||||||
_holidaysInitialized: false,
|
_holidaysInitialized: false,
|
||||||
config: {
|
config: {
|
||||||
select_days: 14,
|
select_days: 14,
|
||||||
|
min_year: MIN_YEAR,
|
||||||
|
max_year: MAX_YEAR,
|
||||||
first_day: 1,
|
first_day: 1,
|
||||||
holidays: {
|
holidays: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -36,6 +34,12 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
minYear: () => MIN_YEAR,
|
||||||
|
maxYear: () => MAX_YEAR,
|
||||||
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
_resolveCountry(code) {
|
_resolveCountry(code) {
|
||||||
if (!code || code !== 'auto') return code
|
if (!code || code !== 'auto') return code
|
||||||
@ -114,14 +118,6 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36)
|
return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36)
|
||||||
},
|
},
|
||||||
|
|
||||||
notifyEventsChanged() {
|
|
||||||
// Bump simple counter (wrapping to avoid overflow in extreme long sessions)
|
|
||||||
this.eventsMutation = (this.eventsMutation + 1) % 1_000_000_000
|
|
||||||
},
|
|
||||||
touchEvents() {
|
|
||||||
this.notifyEventsChanged()
|
|
||||||
},
|
|
||||||
|
|
||||||
createEvent(eventData) {
|
createEvent(eventData) {
|
||||||
const singleDay = eventData.startDate === eventData.endDate
|
const singleDay = eventData.startDate === eventData.endDate
|
||||||
const event = {
|
const event = {
|
||||||
@ -140,7 +136,6 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
isRepeating: eventData.repeat && eventData.repeat !== 'none',
|
isRepeating: eventData.repeat && eventData.repeat !== 'none',
|
||||||
}
|
}
|
||||||
this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate })
|
this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate })
|
||||||
this.notifyEventsChanged()
|
|
||||||
return event.id
|
return event.id
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -171,7 +166,6 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
|
|
||||||
deleteEvent(eventId) {
|
deleteEvent(eventId) {
|
||||||
this.events.delete(eventId)
|
this.events.delete(eventId)
|
||||||
this.notifyEventsChanged()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteFirstOccurrence(baseId) {
|
deleteFirstOccurrence(baseId) {
|
||||||
@ -203,7 +197,6 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
base.endDate = newEndStr
|
base.endDate = newEndStr
|
||||||
if (numericCount !== Infinity) base.repeatCount = String(Math.max(1, numericCount - 1))
|
if (numericCount !== Infinity) base.repeatCount = String(Math.max(1, numericCount - 1))
|
||||||
this.events.set(baseId, { ...base, isSpanning: base.startDate < base.endDate })
|
this.events.set(baseId, { ...base, isSpanning: base.startDate < base.endDate })
|
||||||
this.notifyEventsChanged()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteSingleOccurrence(ctx) {
|
deleteSingleOccurrence(ctx) {
|
||||||
@ -249,7 +242,6 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
repeatCount: remainingCount,
|
repeatCount: remainingCount,
|
||||||
repeatWeekdays: snapshot.repeatWeekdays,
|
repeatWeekdays: snapshot.repeatWeekdays,
|
||||||
})
|
})
|
||||||
this.notifyEventsChanged()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteFromOccurrence(ctx) {
|
deleteFromOccurrence(ctx) {
|
||||||
@ -261,7 +253,6 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||||
this.notifyEventsChanged()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) {
|
setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) {
|
||||||
@ -303,7 +294,6 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.events.set(eventId, { ...snapshot, isSpanning: snapshot.startDate < snapshot.endDate })
|
this.events.set(eventId, { ...snapshot, isSpanning: snapshot.startDate < snapshot.endDate })
|
||||||
this.notifyEventsChanged()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) {
|
splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) {
|
||||||
@ -380,7 +370,6 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
repeatCount: remainingCount,
|
repeatCount: remainingCount,
|
||||||
repeatWeekdays,
|
repeatWeekdays,
|
||||||
})
|
})
|
||||||
this.notifyEventsChanged()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr) {
|
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr) {
|
||||||
@ -417,7 +406,6 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
const rc = parseInt(ev.repeatCount, 10)
|
const rc = parseInt(ev.repeatCount, 10)
|
||||||
if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index))
|
if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index))
|
||||||
}
|
}
|
||||||
this.notifyEventsChanged()
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
persist: {
|
persist: {
|
||||||
|
@ -23,9 +23,6 @@ const monthAbbr = [
|
|||||||
'nov',
|
'nov',
|
||||||
'dec',
|
'dec',
|
||||||
]
|
]
|
||||||
// Calendar year bounds (used instead of config.min_year / config.max_year)
|
|
||||||
const MIN_YEAR = 1901
|
|
||||||
const MAX_YEAR = 2100
|
|
||||||
|
|
||||||
// Core helpers ------------------------------------------------------------
|
// Core helpers ------------------------------------------------------------
|
||||||
/**
|
/**
|
||||||
@ -237,13 +234,13 @@ function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getLocaleFirstDay() {
|
function getLocaleFirstDay() {
|
||||||
const day = new Intl.Locale(navigator.language).weekInfo?.firstDay ?? 1
|
return new Intl.Locale(navigator.language).weekInfo.firstDay % 7
|
||||||
return day % 7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLocaleWeekendDays() {
|
function getLocaleWeekendDays() {
|
||||||
const wk = new Set(new Intl.Locale(navigator.language).weekInfo?.weekend ?? [6, 7])
|
const wk = new Intl.Locale(navigator.language).weekInfo.weekend || [6, 7]
|
||||||
return Array.from({ length: 7 }, (_, i) => wk.has(1 + ((i + 6) % 7)))
|
const set = new Set(wk.map((d) => d % 7))
|
||||||
|
return Array.from({ length: 7 }, (_, i) => set.has(i))
|
||||||
}
|
}
|
||||||
|
|
||||||
function reorderByFirstDay(days, firstDay) {
|
function reorderByFirstDay(days, firstDay) {
|
||||||
@ -323,8 +320,6 @@ function formatTodayString(date) {
|
|||||||
export {
|
export {
|
||||||
// constants
|
// constants
|
||||||
monthAbbr,
|
monthAbbr,
|
||||||
MIN_YEAR,
|
|
||||||
MAX_YEAR,
|
|
||||||
DEFAULT_TZ,
|
DEFAULT_TZ,
|
||||||
// core tz helpers
|
// core tz helpers
|
||||||
makeTZDate,
|
makeTZDate,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user