Compare commits

..

10 Commits

16 changed files with 814 additions and 169 deletions

View File

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

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted, onBeforeUnmount } 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,8 +8,37 @@ 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) => {

View File

@ -9,45 +9,76 @@
} }
/* Layout & typography */ /* Layout & typography */
* { box-sizing: border-box } * {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body { body {
margin: 0; margin: 0;
font: 500 14px/1.2 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial; font:
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: .75rem; margin-bottom: 0.75rem;
flex-shrink: 0; flex-shrink: 0;
} }
.header-controls { .header-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: .75rem; gap: 0.75rem;
} }
.today-date { cursor: pointer } .today-date {
.today-date::first-line { color: var(--today) } cursor: pointer;
.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: .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;
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;
@ -56,7 +87,8 @@ 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;
@ -65,11 +97,16 @@ header {
scrollbar-width: none; scrollbar-width: none;
} }
.calendar-viewport::-webkit-scrollbar, .calendar-viewport::-webkit-scrollbar,
#calendar-viewport::-webkit-scrollbar { display: none } #calendar-viewport::-webkit-scrollbar {
display: none;
}
.jogwheel-viewport, #jogwheel-viewport { .jogwheel-viewport,
#jogwheel-viewport {
position: absolute; position: absolute;
top: 0; right: 0; bottom: 0; top: 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;
@ -78,10 +115,19 @@ header {
cursor: ns-resize; cursor: ns-resize;
} }
.jogwheel-viewport::-webkit-scrollbar, .jogwheel-viewport::-webkit-scrollbar,
#jogwheel-viewport::-webkit-scrollbar { display: none } #jogwheel-viewport::-webkit-scrollbar {
display: none;
}
.jogwheel-content, #jogwheel-content { position: relative; width: 100% } .jogwheel-content,
.calendar-content, #calendar-content { position: relative } #jogwheel-content {
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 {
@ -95,7 +141,8 @@ header {
} }
/* Label cells */ /* Label cells */
.year-label, .week-label { .year-label,
.week-label {
display: grid; display: grid;
place-items: center; place-items: center;
width: 100%; width: 100%;
@ -130,7 +177,8 @@ header {
z-index: 15; z-index: 15;
overflow: visible; overflow: visible;
position: absolute; position: absolute;
top: 0; right: 0; top: 0;
right: 0;
width: 100%; width: 100%;
} }
.month-name-label > span { .month-name-label > span {

View File

@ -1,12 +1,14 @@
<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,
{ {
@ -37,7 +39,6 @@ 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;

View File

@ -31,6 +31,8 @@ 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'
@ -43,8 +45,6 @@ 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(config.min_year, Math.min(config.max_year, currentYear + delta)) const newYear = Math.max(MIN_YEAR, Math.min(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(config.min_year, 0, 1) const minYearDate = new Date(MIN_YEAR, 0, 1)
const maxYearLastDay = new Date(config.max_year, 11, 31) const maxYearLastDay = new Date(MAX_YEAR, 11, 31)
const lastWeekMonday = addDays(maxYearLastDay, -mondayIndex(maxYearLastDay)) const lastWeekMonday = addDays(maxYearLastDay, -mondayIndex(maxYearLastDay))
minVirtualWeek.value = getWeekIndex(minYearDate) minVirtualWeek.value = getWeekIndex(minYearDate)

View File

@ -6,6 +6,8 @@ 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'
@ -49,7 +51,7 @@ function isoWeekMonday(isoYear, isoWeek) {
function changeYear(y) { function changeYear(y) {
if (y == null) return if (y == null) return
y = Math.round(Math.max(calendarStore.minYear, Math.min(calendarStore.maxYear, y))) y = Math.round(Math.max(MIN_YEAR, Math.min(MAX_YEAR, 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
@ -94,8 +96,8 @@ const weekdayNames = computed(() => {
<Numeric <Numeric
:model-value="currentYear" :model-value="currentYear"
@update:modelValue="changeYear" @update:modelValue="changeYear"
:min="calendarStore.minYear" :min="MIN_YEAR"
:max="calendarStore.maxYear" :max="MAX_YEAR"
:step="1" :step="1"
aria-label="Year" aria-label="Year"
number-prefix="" number-prefix=""

View File

@ -17,9 +17,11 @@ 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 } from 'date-fns' import { addDays, differenceInCalendarDays, differenceInWeeks } from 'date-fns'
import { getHolidayForDate } from '@/utils/holidays' import { getHolidayForDate } from '@/utils/holidays'
const calendarStore = useCalendarStore() const calendarStore = useCalendarStore()
@ -47,20 +49,46 @@ const selection = ref({ startDate: null, dayCount: 0 })
const isDragging = ref(false) const isDragging = ref(false)
const dragAnchor = ref(null) const dragAnchor = ref(null)
const WEEK_MS = 7 * 24 * 60 * 60 * 1000 const DOUBLE_TAP_DELAY = 300
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(calendarStore.minYear, 0, 1) const date = new Date(MIN_YEAR, 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 Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS) return differenceInWeeks(firstDayOfWeek, baseDate.value)
}) })
const maxVirtualWeek = computed(() => { const maxVirtualWeek = computed(() => {
const date = new Date(calendarStore.maxYear, 11, 31) const date = new Date(MAX_YEAR, 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 Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS) return differenceInWeeks(firstDayOfWeek, baseDate.value)
}) })
const totalVirtualWeeks = computed(() => { const totalVirtualWeeks = computed(() => {
@ -86,22 +114,49 @@ const todayString = computed(() => {
return formatTodayString(d) return formatTodayString(d)
}) })
const visibleWeeks = computed(() => { // PERFORMANCE: Maintain a manual cache of computed weeks instead of relying on
// deep reactive tracking of every event & day object. We rebuild lazily when
// (a) scrolling changes the needed range or (b) eventsMutation counter bumps.
const visibleWeeks = ref([])
let lastScrollRange = { startVW: null, endVW: null }
let pendingRebuild = false
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 (
const weeks = [] reason === 'scroll' &&
for (let vw = startVW; vw <= endVW; vw++) { lastScrollRange.startVW === startVW &&
weeks.push(createWeek(vw)) lastScrollRange.endVW === endVW &&
visibleWeeks.value.length
) {
return
} }
return weeks const weeks = []
}) for (let vw = startVW; vw <= endVW; vw++) weeks.push(createWeek(vw))
visibleWeeks.value = weeks
lastScrollRange = { startVW, endVW }
}
const contentHeight = computed(() => { const contentHeight = computed(() => {
return totalVirtualWeeks.value * rowHeight.value return totalVirtualWeeks.value * rowHeight.value
@ -122,7 +177,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 Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS) return differenceInWeeks(firstDayOfWeek, baseDate.value)
} }
function getFirstDayForVirtualWeek(virtualWeek) { function getFirstDayForVirtualWeek(virtualWeek) {
@ -150,29 +205,24 @@ 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) {
// If the current date falls within the base event's original span, include the base // Base event's original span: include it directly as occurrence index 0.
// 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))
@ -180,19 +230,16 @@ function createWeek(virtualWeek) {
let occurrenceFound = false let occurrenceFound = false
// Walk backwards within span to find occurrence start // Walk backwards within the base span to locate a matching virtual 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)
@ -229,8 +276,6 @@ 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?.()
@ -260,7 +305,7 @@ function createWeek(virtualWeek) {
let monthLabel = null let monthLabel = null
if (hasFirst && monthToLabel !== null) { if (hasFirst && monthToLabel !== null) {
if (labelYear && labelYear <= calendarStore.config.max_year) { if (labelYear && labelYear <= MAX_YEAR) {
let weeksSpan = 0 let weeksSpan = 0
const d = addDays(cur, -1) const d = addDays(cur, -1)
@ -306,10 +351,12 @@ 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) {
@ -325,6 +372,88 @@ 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)
@ -346,9 +475,8 @@ function calculateSelection(anchorStr, otherStr) {
} }
const onScroll = () => { const onScroll = () => {
if (viewport.value) { if (viewport.value) scrollTop.value = viewport.value.scrollTop
scrollTop.value = viewport.value.scrollTop scheduleRebuild('scroll')
}
} }
const handleJogwheelScrollTo = (newScrollTop) => { const handleJogwheelScrollTo = (newScrollTop) => {
@ -371,6 +499,9 @@ onMounted(() => {
calendarStore.updateCurrentDate() calendarStore.updateCurrentDate()
}, 60000) }, 60000)
// Initial build after mount & measurement
scheduleRebuild('init')
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(timer) clearInterval(timer)
}) })
@ -382,53 +513,33 @@ onBeforeUnmount(() => {
} }
}) })
const handleDayMouseDown = (dateStr) => { const handleDayMouseDown = (d) => {
startDrag(dateStr) d = normalizeDate(d)
if (Date.now() < suppressMouseUntil.value) return
if (registerTap(d, 'mouse')) startDrag(d)
} }
const handleDayMouseEnter = (d) => updateDrag(normalizeDate(d))
const handleDayMouseEnter = (dateStr) => { const handleDayMouseUp = (d) => {
if (isDragging.value) { d = normalizeDate(d)
updateDrag(dateStr) if (Date.now() < suppressMouseUntil.value && !isDragging.value) return
} if (!isDragging.value) return
} endDrag(d)
const ev = createEventFromSelection()
const handleDayMouseUp = (dateStr) => { if (ev) {
if (isDragging.value) {
endDrag(dateStr)
const eventData = createEventFromSelection()
if (eventData) {
clearSelection() clearSelection()
emit('create-event', eventData) emit('create-event', ev)
}
} }
} }
const handleDayTouchStart = (d) => {
const handleDayTouchStart = (dateStr) => { d = normalizeDate(d)
startDrag(dateStr) suppressMouseUntil.value = Date.now() + 800
} if (registerTap(d, 'touch')) startDrag(d)
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))
@ -439,7 +550,22 @@ const handleHeaderYearChange = ({ scrollTop: st }) => {
function openSettings() { function openSettings() {
settingsDialog.value?.open() settingsDialog.value?.open()
} }
// Preserve approximate top visible date when first_day changes // Heuristic: rotate month label (180deg) only for predominantly Latin text.
// We explicitly avoid locale detection; rely solely on characters present.
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
function shouldRotateMonth(label) {
if (!label) return false
// 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,
() => { () => {
@ -450,9 +576,25 @@ 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>
@ -461,6 +603,27 @@ watch(
<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"
@ -485,13 +648,12 @@ watch(
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 -->
@ -500,6 +662,7 @@ watch(
: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',
@ -531,18 +694,21 @@ watch(
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: 1rem; gap: 0.6rem;
align-items: center; align-items: center;
margin-left: auto;
} }
.settings-btn { .settings-btn {
@ -560,6 +726,33 @@ 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);
} }
@ -635,4 +828,8 @@ header h1 {
transform: rotate(180deg); transform: rotate(180deg);
transform-origin: center; transform-origin: center;
} }
.month-name-label.no-rotate > span {
transform: none;
}
</style> </style>

View File

@ -2,17 +2,13 @@
import CalendarDay from './CalendarDay.vue' import CalendarDay from './CalendarDay.vue'
import EventOverlay from './EventOverlay.vue' import EventOverlay from './EventOverlay.vue'
const props = defineProps({ const props = defineProps({ week: Object, dragging: { type: Boolean, default: false } })
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',
]) ])
@ -32,13 +28,7 @@ const handleDayTouchStart = (dateStr) => {
emit('day-touchstart', dateStr) emit('day-touchstart', dateStr)
} }
const handleDayTouchMove = (dateStr) => { // touchmove & touchend handled globally in CalendarView
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)
@ -53,12 +43,11 @@ 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>

View File

@ -37,6 +37,7 @@ 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()
} }
}, },
}) })
@ -127,6 +128,7 @@ 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
}, },
@ -144,6 +146,7 @@ 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
}, },
@ -189,6 +192,7 @@ 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)
@ -249,6 +253,7 @@ 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 &&
@ -352,14 +357,8 @@ 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() {
@ -374,6 +373,7 @@ 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,11 +384,13 @@ 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()
} }
@ -398,12 +400,14 @@ 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()
} }
@ -417,6 +421,19 @@ 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,
() => { () => {
@ -428,6 +445,7 @@ watch(
defineExpose({ defineExpose({
openCreateDialog, openCreateDialog,
openEditDialog, openEditDialog,
closeDialog,
}) })
const isRepeatingEdit = computed( const isRepeatingEdit = computed(

View File

@ -154,8 +154,13 @@ 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 {
@ -165,8 +170,10 @@ function startLocalDrag(init, evt) {
} }
} }
// Prevent default to avoid text selection and other interference // Prevent default for mouse/pen to avoid text selection. For touch we skip so the user can still scroll.
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 })
@ -209,7 +216,18 @@ 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
applyRangeDuringDrag(st, ns, ne) // Only proceed if changed
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) {
@ -226,6 +244,8 @@ 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)
@ -233,11 +253,27 @@ 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) {

View File

@ -2,7 +2,7 @@
<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"
@ -19,7 +19,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref, onBeforeUnmount } from 'vue'
const model = defineModel({ type: Number, default: 0 }) const model = defineModel({ type: Number, default: 0 })
@ -102,46 +102,91 @@ 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 startVal = 0 let accumX = 0 // accumulated horizontal movement since last applied step (works for both locked & unlocked)
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
startVal = current.value lastClientX = e.clientX
accumX = 0
dragging.value = true dragging.value = true
try { try {
e.currentTarget.setPointerCapture(e.pointerId) e.currentTarget.setPointerCapture?.(e.pointerId)
} catch {} } catch {}
rootEl.value?.addEventListener('pointermove', onPointerMove) if (e.pointerType === 'mouse' && rootEl.value?.requestPointerLock) {
rootEl.value?.addEventListener('pointerup', onPointerUp, { once: true }) addPointerLockListeners()
rootEl.value?.addEventListener('pointercancel', onPointerCancel, { once: true }) try {
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()
const primary = e.clientX - startX // horizontal only let dx
const steps = Math.trunc(primary / props.pixelsPerStep) if (pointerLocked.value) {
dx = e.movementX || 0
// Find current value index in all valid values
const currentIndex = allValidValues.value.indexOf(startVal)
if (currentIndex === -1) return // shouldn't happen
const newIndex = currentIndex + steps
if (props.clamp) {
const clampedIndex = Math.max(0, Math.min(newIndex, allValidValues.value.length - 1))
const next = allValidValues.value[clampedIndex]
if (next !== current.value) current.value = next
} else { } else {
if (newIndex >= 0 && newIndex < allValidValues.value.length) { dx = e.clientX - lastClientX
const next = allValidValues.value[newIndex] lastClientX = e.clientX
}
if (!dx) return
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) {
targetIndex = Math.max(0, Math.min(targetIndex, allValidValues.value.length - 1))
}
if (targetIndex >= 0 && targetIndex < allValidValues.value.length) {
const next = allValidValues.value[targetIndex]
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() {
rootEl.value?.removeEventListener('pointermove', onPointerMove) document.removeEventListener('pointermove', onPointerMove)
if (pointerLocked.value && document.exitPointerLock) {
try {
document.exitPointerLock()
} catch {}
}
removePointerLockListeners()
} }
function onPointerUp() { function onPointerUp() {
dragging.value = false dragging.value = false
@ -267,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>

View File

@ -3,13 +3,16 @@ 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')

View File

@ -0,0 +1,200 @@
// Custom lightweight undo/redo specifically for calendar store with Map support
// Adds store.$history = { undo(), redo(), canUndo, canRedo, clear(), pushManual() }
// Wraps action calls to create history entries only for meaningful mutations.
function deepCloneCalendarState(raw) {
// We only need to snapshot keys we care about; omit volatile fields
const { today, events, config, weekend } = raw
return {
today,
weekend: Array.isArray(weekend) ? [...weekend] : weekend,
config: JSON.parse(JSON.stringify(config)),
events: new Map([...events].map(([k, v]) => [k, { ...v }])),
}
}
function restoreCalendarState(store, snap) {
store.today = snap.today
store.weekend = Array.isArray(snap.weekend) ? [...snap.weekend] : snap.weekend
store.config = JSON.parse(JSON.stringify(snap.config))
store.events = new Map([...snap.events].map(([k, v]) => [k, { ...v }]))
store.eventsMutation = (store.eventsMutation + 1) % 1_000_000_000
}
export function calendarHistory({ store }) {
if (store.$id !== 'calendar') return
const max = 100 // history depth limit
const history = [] // past states
let pointer = -1 // index of current state in history
let isRestoring = false
let lastSerialized = null
// Compound editing session (e.g. event dialog) flags
let compoundActive = false
let compoundBaseSig = null
let compoundChanged = false
function serializeForComparison() {
const evCount = store.events instanceof Map ? store.events.size : 0
const em = store.eventsMutation || 0
return `${em}|${evCount}|${store.today}|${JSON.stringify(store.config)}`
}
function pushSnapshot() {
if (isRestoring) return
const sig = serializeForComparison()
if (sig === lastSerialized) return
// Drop any redo branch
if (pointer < history.length - 1) history.splice(pointer + 1)
history.push(deepCloneCalendarState(store))
if (history.length > max) history.shift()
pointer = history.length - 1
lastSerialized = sig
bumpIndicators()
// console.log('[history] pushed', pointer, sig)
}
function bumpIndicators() {
if (typeof store.historyTick === 'number') {
store.historyTick = (store.historyTick + 1) % 1_000_000_000
}
if (typeof store.historyCanUndo === 'boolean') {
store.historyCanUndo = pointer > 0
}
if (typeof store.historyCanRedo === 'boolean') {
store.historyCanRedo = pointer >= 0 && pointer < history.length - 1
}
}
function markPotentialChange() {
if (isRestoring) return
if (compoundActive) {
const sig = serializeForComparison()
if (sig !== compoundBaseSig) compoundChanged = true
return
}
pushSnapshot()
}
function beginCompound() {
if (compoundActive) return
compoundActive = true
compoundBaseSig = serializeForComparison()
compoundChanged = false
}
function endCompound() {
if (!compoundActive) return
const finalSig = serializeForComparison()
const changed = compoundChanged || finalSig !== compoundBaseSig
compoundActive = false
compoundBaseSig = null
if (changed) pushSnapshot()
else bumpIndicators() // session ended without change still refresh flags
}
function undo() {
// Ensure any active compound changes are finalized before moving back
if (compoundActive) endCompound()
else {
// If current state differs from last snapshot, push it so redo can restore it
const curSig = serializeForComparison()
if (curSig !== lastSerialized) pushSnapshot()
}
if (pointer <= 0) return
pointer--
isRestoring = true
try {
restoreCalendarState(store, history[pointer])
lastSerialized = serializeForComparison()
} finally {
isRestoring = false
}
bumpIndicators()
}
function redo() {
if (compoundActive) endCompound()
else {
const curSig = serializeForComparison()
if (curSig !== lastSerialized) pushSnapshot()
}
if (pointer >= history.length - 1) return
pointer++
isRestoring = true
try {
restoreCalendarState(store, history[pointer])
lastSerialized = serializeForComparison()
} finally {
isRestoring = false
}
bumpIndicators()
}
function clear() {
history.length = 0
pointer = -1
lastSerialized = null
bumpIndicators()
}
// Wrap selected mutating actions to push snapshot AFTER they run if state changed.
const actionNames = [
'createEvent',
'deleteEvent',
'deleteFirstOccurrence',
'deleteSingleOccurrence',
'deleteFromOccurrence',
'setEventRange',
'splitMoveVirtualOccurrence',
'splitRepeatSeries',
'_terminateRepeatSeriesAtIndex',
'toggleHolidays',
'initializeHolidays',
]
for (const name of actionNames) {
if (typeof store[name] === 'function') {
const original = store[name].bind(store)
store[name] = (...args) => {
const beforeSig = serializeForComparison()
const result = original(...args)
const afterSig = serializeForComparison()
if (afterSig !== beforeSig) markPotentialChange()
return result
}
}
}
// Capture direct property edits (e.g., deep field edits signaled via touchEvents())
store.$subscribe((mutation, _state) => {
if (mutation.storeId !== 'calendar') return
markPotentialChange()
})
// Initial snapshot after hydration (next microtask to let persistence load)
Promise.resolve().then(() => pushSnapshot())
store.$history = {
undo,
redo,
clear,
pushManual: pushSnapshot,
beginCompound,
endCompound,
flush() {
pushSnapshot()
},
get canUndo() {
return pointer > 0
},
get canRedo() {
return pointer >= 0 && pointer < history.length - 1
},
get compoundActive() {
return compoundActive
},
_debug() {
return { pointer, length: history.length }
},
}
}

View File

@ -0,0 +1,57 @@
// Pinia plugin to ensure calendar store keeps Map for events after undo/redo snapshots
export function calendarUndoNormalize({ store }) {
if (store.$id !== 'calendar') return
function fixEvents() {
const evs = store.events
if (evs instanceof Map) return
// If serialized form { __map: true, data: [...] }
if (evs && evs.__map && Array.isArray(evs.data)) {
store.events = new Map(evs.data)
return
}
// If an array of [k,v]
if (Array.isArray(evs) && evs.every((x) => Array.isArray(x) && x.length === 2)) {
store.events = new Map(evs)
return
}
// If plain object, convert own enumerable props
if (evs && typeof evs === 'object') {
store.events = new Map(Object.entries(evs))
}
}
// Patch undo/redo if present (after pinia-undo is installed)
const patchFns = ['undo', 'redo']
for (const fn of patchFns) {
if (typeof store[fn] === 'function') {
const original = store[fn].bind(store)
store[fn] = (...args) => {
console.log(`[calendar history] ${fn} invoked`)
const beforeType = store.events && store.events.constructor && store.events.constructor.name
const out = original(...args)
const afterRawType =
store.events && store.events.constructor && store.events.constructor.name
fixEvents()
const finalType = store.events && store.events.constructor && store.events.constructor.name
let size = null
try {
if (store.events instanceof Map) size = store.events.size
else if (Array.isArray(store.events)) size = store.events.length
} catch {}
console.log(
`[calendar history] ${fn} types: before=${beforeType} afterRaw=${afterRawType} final=${finalType} size=${size}`,
)
return out
}
}
}
// Also watch all mutations (includes direct assigns and action commits)
store.$subscribe(() => {
fixEvents()
})
// Initial sanity
fixEvents()
}

View File

@ -10,21 +10,23 @@ 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,
@ -34,12 +36,6 @@ 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
@ -118,6 +114,14 @@ 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 = {
@ -136,6 +140,7 @@ 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
}, },
@ -166,6 +171,7 @@ export const useCalendarStore = defineStore('calendar', {
deleteEvent(eventId) { deleteEvent(eventId) {
this.events.delete(eventId) this.events.delete(eventId)
this.notifyEventsChanged()
}, },
deleteFirstOccurrence(baseId) { deleteFirstOccurrence(baseId) {
@ -197,6 +203,7 @@ 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) {
@ -242,6 +249,7 @@ export const useCalendarStore = defineStore('calendar', {
repeatCount: remainingCount, repeatCount: remainingCount,
repeatWeekdays: snapshot.repeatWeekdays, repeatWeekdays: snapshot.repeatWeekdays,
}) })
this.notifyEventsChanged()
}, },
deleteFromOccurrence(ctx) { deleteFromOccurrence(ctx) {
@ -253,6 +261,7 @@ 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' } = {}) {
@ -294,6 +303,7 @@ 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) {
@ -370,6 +380,7 @@ export const useCalendarStore = defineStore('calendar', {
repeatCount: remainingCount, repeatCount: remainingCount,
repeatWeekdays, repeatWeekdays,
}) })
this.notifyEventsChanged()
}, },
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr) { splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr) {
@ -406,6 +417,7 @@ 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: {

View File

@ -23,6 +23,9 @@ 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 ------------------------------------------------------------
/** /**
@ -234,13 +237,13 @@ function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) {
} }
function getLocaleFirstDay() { function getLocaleFirstDay() {
return new Intl.Locale(navigator.language).weekInfo.firstDay % 7 const day = new Intl.Locale(navigator.language).weekInfo?.firstDay ?? 1
return day % 7
} }
function getLocaleWeekendDays() { function getLocaleWeekendDays() {
const wk = new Intl.Locale(navigator.language).weekInfo.weekend || [6, 7] const wk = new Set(new Intl.Locale(navigator.language).weekInfo?.weekend ?? [6, 7])
const set = new Set(wk.map((d) => d % 7)) return Array.from({ length: 7 }, (_, i) => wk.has(1 + ((i + 6) % 7)))
return Array.from({ length: 7 }, (_, i) => set.has(i))
} }
function reorderByFirstDay(days, firstDay) { function reorderByFirstDay(days, firstDay) {
@ -320,6 +323,8 @@ 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,