Compare commits
10 Commits
15f7ff4fec
...
1155f712a4
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1155f712a4 | ||
![]() |
130ccc0f73 | ||
![]() |
7ca5b70c1e | ||
![]() |
50c79ff99f | ||
![]() |
cb7a111020 | ||
![]() |
9a4d1c7196 | ||
![]() |
898ec2df00 | ||
![]() |
6b4ea6ea3f | ||
![]() |
71b4db8e10 | ||
![]() |
9721ed3cc9 |
@ -16,9 +16,9 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-holidays": "^3.25.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns-tz": "^3.0.0",
|
||||
"date-holidays": "^3.25.1",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"vue": "^3.5.18"
|
||||
|
31
src/App.vue
31
src/App.vue
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import CalendarView from './components/CalendarView.vue'
|
||||
import EventDialog from './components/EventDialog.vue'
|
||||
import { useCalendarStore } from './stores/CalendarStore'
|
||||
@ -8,8 +8,37 @@ const eventDialog = ref(null)
|
||||
const calendarStore = useCalendarStore()
|
||||
|
||||
// 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(() => {
|
||||
calendarStore.initializeHolidaysFromConfig()
|
||||
document.addEventListener('keydown', handleGlobalKey, { passive: false })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleGlobalKey)
|
||||
})
|
||||
|
||||
const handleCreateEvent = (eventData) => {
|
||||
|
@ -9,45 +9,76 @@
|
||||
}
|
||||
|
||||
/* Layout & typography */
|
||||
* { box-sizing: border-box }
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
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);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: .75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.today-date { cursor: pointer }
|
||||
.today-date::first-line { color: var(--today) }
|
||||
.today-button:hover { opacity: .8 }
|
||||
.today-date {
|
||||
cursor: pointer;
|
||||
}
|
||||
.today-date::first-line {
|
||||
color: var(--today);
|
||||
}
|
||||
.today-button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Header row */
|
||||
.calendar-header, #calendar-header {
|
||||
.calendar-header,
|
||||
#calendar-header {
|
||||
display: grid;
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Main container */
|
||||
.calendar-container, #calendar-container {
|
||||
.calendar-container,
|
||||
#calendar-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
@ -56,7 +87,8 @@ header {
|
||||
}
|
||||
|
||||
/* Viewports (support id or class) */
|
||||
.calendar-viewport, #calendar-viewport {
|
||||
.calendar-viewport,
|
||||
#calendar-viewport {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@ -65,11 +97,16 @@ header {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.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;
|
||||
top: 0; right: 0; bottom: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: var(--overlay-w);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@ -78,10 +115,19 @@ header {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.jogwheel-viewport::-webkit-scrollbar,
|
||||
#jogwheel-viewport::-webkit-scrollbar { display: none }
|
||||
#jogwheel-viewport::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jogwheel-content, #jogwheel-content { position: relative; width: 100% }
|
||||
.calendar-content, #calendar-content { position: relative }
|
||||
.jogwheel-content,
|
||||
#jogwheel-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.calendar-content,
|
||||
#calendar-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Week row: label + 7-day grid + jogwheel column */
|
||||
.week-row {
|
||||
@ -95,7 +141,8 @@ header {
|
||||
}
|
||||
|
||||
/* Label cells */
|
||||
.year-label, .week-label {
|
||||
.year-label,
|
||||
.week-label {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
@ -130,7 +177,8 @@ header {
|
||||
z-index: 15;
|
||||
overflow: visible;
|
||||
position: absolute;
|
||||
top: 0; right: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.month-name-label > span {
|
||||
|
@ -1,12 +1,14 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
day: Object,
|
||||
dragging: { type: Boolean, default: false },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="cell"
|
||||
:style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'"
|
||||
:class="[
|
||||
props.day.monthClass,
|
||||
{
|
||||
@ -37,7 +39,6 @@ const props = defineProps({
|
||||
border-right: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
|
@ -31,6 +31,8 @@ import {
|
||||
toLocalString,
|
||||
mondayIndex,
|
||||
DEFAULT_TZ,
|
||||
MIN_YEAR,
|
||||
MAX_YEAR,
|
||||
} from '@/utils/date'
|
||||
import { addDays } from 'date-fns'
|
||||
import WeekRow from './WeekRow.vue'
|
||||
@ -43,8 +45,6 @@ const minVirtualWeek = ref(0)
|
||||
const visibleWeeks = ref([])
|
||||
|
||||
const config = {
|
||||
min_year: 1900,
|
||||
max_year: 2100,
|
||||
weekend: getLocaleWeekendDays(),
|
||||
}
|
||||
|
||||
@ -116,7 +116,7 @@ const handleWheel = (e) => {
|
||||
const currentYear = calendarStore.viewYear
|
||||
const delta = Math.round(e.deltaY * (1 / 3))
|
||||
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
|
||||
|
||||
const topDisplayIndex = Math.floor(viewportEl.value.scrollTop / rowHeight.value)
|
||||
@ -156,8 +156,8 @@ const goToTodayHandler = () => {
|
||||
onMounted(() => {
|
||||
rowHeight.value = computeRowHeight()
|
||||
|
||||
const minYearDate = new Date(config.min_year, 0, 1)
|
||||
const maxYearLastDay = new Date(config.max_year, 11, 31)
|
||||
const minYearDate = new Date(MIN_YEAR, 0, 1)
|
||||
const maxYearLastDay = new Date(MAX_YEAR, 11, 31)
|
||||
const lastWeekMonday = addDays(maxYearLastDay, -mondayIndex(maxYearLastDay))
|
||||
|
||||
minVirtualWeek.value = getWeekIndex(minYearDate)
|
||||
|
@ -6,6 +6,8 @@ import {
|
||||
reorderByFirstDay,
|
||||
getISOWeek,
|
||||
getISOWeekYear,
|
||||
MIN_YEAR,
|
||||
MAX_YEAR,
|
||||
} from '@/utils/date'
|
||||
import Numeric from '@/components/Numeric.vue'
|
||||
import { addDays } from 'date-fns'
|
||||
@ -49,7 +51,7 @@ function isoWeekMonday(isoYear, isoWeek) {
|
||||
|
||||
function changeYear(y) {
|
||||
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
|
||||
const vw = topVirtualWeek.value
|
||||
// Fraction within current row
|
||||
@ -94,8 +96,8 @@ const weekdayNames = computed(() => {
|
||||
<Numeric
|
||||
:model-value="currentYear"
|
||||
@update:modelValue="changeYear"
|
||||
:min="calendarStore.minYear"
|
||||
:max="calendarStore.maxYear"
|
||||
:min="MIN_YEAR"
|
||||
:max="MAX_YEAR"
|
||||
:step="1"
|
||||
aria-label="Year"
|
||||
number-prefix=""
|
||||
|
@ -17,9 +17,11 @@ import {
|
||||
getOccurrenceIndex,
|
||||
getVirtualOccurrenceEndDate,
|
||||
getISOWeek,
|
||||
MIN_YEAR,
|
||||
MAX_YEAR,
|
||||
} 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'
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
@ -47,20 +49,46 @@ const selection = ref({ startDate: null, dayCount: 0 })
|
||||
const isDragging = ref(false)
|
||||
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 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 firstDayOfWeek = addDays(date, -dayOffset)
|
||||
return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS)
|
||||
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
||||
})
|
||||
|
||||
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 firstDayOfWeek = addDays(date, -dayOffset)
|
||||
return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS)
|
||||
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
||||
})
|
||||
|
||||
const totalVirtualWeeks = computed(() => {
|
||||
@ -86,22 +114,49 @@ const todayString = computed(() => {
|
||||
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 startIdx = Math.floor((scrollTop.value - buffer * rowHeight.value) / rowHeight.value)
|
||||
const endIdx = Math.ceil(
|
||||
(scrollTop.value + viewportHeight.value + buffer * rowHeight.value) / rowHeight.value,
|
||||
)
|
||||
|
||||
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
|
||||
const endVW = Math.min(maxVirtualWeek.value, endIdx + minVirtualWeek.value)
|
||||
|
||||
const weeks = []
|
||||
for (let vw = startVW; vw <= endVW; vw++) {
|
||||
weeks.push(createWeek(vw))
|
||||
if (
|
||||
reason === 'scroll' &&
|
||||
lastScrollRange.startVW === startVW &&
|
||||
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(() => {
|
||||
return totalVirtualWeeks.value * rowHeight.value
|
||||
@ -122,7 +177,7 @@ function computeRowHeight() {
|
||||
function getWeekIndex(date) {
|
||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
const firstDayOfWeek = addDays(date, -dayOffset)
|
||||
return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS)
|
||||
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
||||
}
|
||||
|
||||
function getFirstDayForVirtualWeek(virtualWeek) {
|
||||
@ -150,29 +205,24 @@ function createWeek(virtualWeek) {
|
||||
const dateStr = toLocalString(cur, DEFAULT_TZ)
|
||||
const storedEvents = []
|
||||
|
||||
// Find all non-repeating events that occur on this date
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) {
|
||||
storedEvents.push(ev)
|
||||
}
|
||||
}
|
||||
// Build day events starting with stored (base/spanning) then virtual occurrences
|
||||
const dayEvents = [...storedEvents]
|
||||
// Expand repeating events into per-day occurrences (including virtual spans) when they cover this date.
|
||||
for (const base of repeatingBases) {
|
||||
// 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.
|
||||
// Base event's original span: include it directly as occurrence index 0.
|
||||
if (dateStr >= base.startDate && dateStr <= base.endDate) {
|
||||
dayEvents.push({
|
||||
...base,
|
||||
// Mark explicit recurrence index for consistency with virtual occurrences
|
||||
_recurrenceIndex: 0,
|
||||
_baseId: base.id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if any virtual occurrence spans this date
|
||||
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
||||
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
||||
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
|
||||
@ -180,19 +230,16 @@ function createWeek(virtualWeek) {
|
||||
|
||||
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++) {
|
||||
const candidateStart = addDays(currentDate, -offset)
|
||||
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
|
||||
|
||||
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
|
||||
if (occurrenceIndex !== null) {
|
||||
// Calculate the end date of this occurrence
|
||||
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
|
||||
|
||||
// Check if this occurrence spans through the current date
|
||||
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
|
||||
// Create virtual occurrence (if not already created)
|
||||
const virtualId = base.id + '_v_' + candidateStartStr
|
||||
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
|
||||
if (calendarStore.config.holidays.enabled) {
|
||||
calendarStore._ensureHolidaysInitialized?.()
|
||||
@ -260,7 +305,7 @@ function createWeek(virtualWeek) {
|
||||
|
||||
let monthLabel = null
|
||||
if (hasFirst && monthToLabel !== null) {
|
||||
if (labelYear && labelYear <= calendarStore.config.max_year) {
|
||||
if (labelYear && labelYear <= MAX_YEAR) {
|
||||
let weeksSpan = 0
|
||||
const d = addDays(cur, -1)
|
||||
|
||||
@ -306,10 +351,12 @@ function clearSelection() {
|
||||
}
|
||||
|
||||
function startDrag(dateStr) {
|
||||
dateStr = normalizeDate(dateStr)
|
||||
if (calendarStore.config.select_days === 0) return
|
||||
isDragging.value = true
|
||||
dragAnchor.value = dateStr
|
||||
selection.value = { startDate: dateStr, dayCount: 1 }
|
||||
addGlobalTouchListeners()
|
||||
}
|
||||
|
||||
function updateDrag(dateStr) {
|
||||
@ -325,6 +372,88 @@ function endDrag(dateStr) {
|
||||
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) {
|
||||
const limit = calendarStore.config.select_days
|
||||
const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
|
||||
@ -346,9 +475,8 @@ function calculateSelection(anchorStr, otherStr) {
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
if (viewport.value) {
|
||||
scrollTop.value = viewport.value.scrollTop
|
||||
}
|
||||
if (viewport.value) scrollTop.value = viewport.value.scrollTop
|
||||
scheduleRebuild('scroll')
|
||||
}
|
||||
|
||||
const handleJogwheelScrollTo = (newScrollTop) => {
|
||||
@ -371,6 +499,9 @@ onMounted(() => {
|
||||
calendarStore.updateCurrentDate()
|
||||
}, 60000)
|
||||
|
||||
// Initial build after mount & measurement
|
||||
scheduleRebuild('init')
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer)
|
||||
})
|
||||
@ -382,53 +513,33 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const handleDayMouseDown = (dateStr) => {
|
||||
startDrag(dateStr)
|
||||
const handleDayMouseDown = (d) => {
|
||||
d = normalizeDate(d)
|
||||
if (Date.now() < suppressMouseUntil.value) return
|
||||
if (registerTap(d, 'mouse')) startDrag(d)
|
||||
}
|
||||
|
||||
const handleDayMouseEnter = (dateStr) => {
|
||||
if (isDragging.value) {
|
||||
updateDrag(dateStr)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDayMouseUp = (dateStr) => {
|
||||
if (isDragging.value) {
|
||||
endDrag(dateStr)
|
||||
const eventData = createEventFromSelection()
|
||||
if (eventData) {
|
||||
const handleDayMouseEnter = (d) => updateDrag(normalizeDate(d))
|
||||
const handleDayMouseUp = (d) => {
|
||||
d = normalizeDate(d)
|
||||
if (Date.now() < suppressMouseUntil.value && !isDragging.value) return
|
||||
if (!isDragging.value) return
|
||||
endDrag(d)
|
||||
const ev = createEventFromSelection()
|
||||
if (ev) {
|
||||
clearSelection()
|
||||
emit('create-event', eventData)
|
||||
}
|
||||
emit('create-event', ev)
|
||||
}
|
||||
}
|
||||
|
||||
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 handleDayTouchStart = (d) => {
|
||||
d = normalizeDate(d)
|
||||
suppressMouseUntil.value = Date.now() + 800
|
||||
if (registerTap(d, 'touch')) startDrag(d)
|
||||
}
|
||||
|
||||
const handleEventClick = (payload) => {
|
||||
emit('edit-event', payload)
|
||||
}
|
||||
|
||||
// Handle year change emitted from CalendarHeader: scroll to computed target position
|
||||
const handleHeaderYearChange = ({ scrollTop: st }) => {
|
||||
const maxScroll = contentHeight.value - viewportHeight.value
|
||||
const clamped = Math.max(0, Math.min(st, isFinite(maxScroll) ? maxScroll : st))
|
||||
@ -439,7 +550,22 @@ const handleHeaderYearChange = ({ scrollTop: st }) => {
|
||||
function openSettings() {
|
||||
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(
|
||||
() => calendarStore.config.first_day,
|
||||
() => {
|
||||
@ -450,9 +576,25 @@ watch(
|
||||
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
scrollTop.value = 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>
|
||||
|
||||
<template>
|
||||
@ -461,6 +603,27 @@ watch(
|
||||
<h1>Calendar</h1>
|
||||
<div class="header-controls">
|
||||
<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
|
||||
type="button"
|
||||
class="settings-btn"
|
||||
@ -485,13 +648,12 @@ watch(
|
||||
v-for="week in visibleWeeks"
|
||||
:key="week.virtualWeek"
|
||||
:week="week"
|
||||
:dragging="isDragging"
|
||||
:style="{ top: week.top + 'px' }"
|
||||
@day-mousedown="handleDayMouseDown"
|
||||
@day-mouseenter="handleDayMouseEnter"
|
||||
@day-mouseup="handleDayMouseUp"
|
||||
@day-touchstart="handleDayTouchStart"
|
||||
@day-touchmove="handleDayTouchMove"
|
||||
@day-touchend="handleDayTouchEnd"
|
||||
@event-click="handleEventClick"
|
||||
/>
|
||||
<!-- Month labels positioned absolutely -->
|
||||
@ -500,6 +662,7 @@ watch(
|
||||
:key="`month-${week.virtualWeek}`"
|
||||
v-show="week.monthLabel"
|
||||
class="month-name-label"
|
||||
:class="{ 'no-rotate': !shouldRotateMonth(week.monthLabel?.text) }"
|
||||
:style="{
|
||||
top: week.top + 'px',
|
||||
height: week.monthLabel?.height + 'px',
|
||||
@ -531,18 +694,21 @@ watch(
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 0.75rem 0.5rem 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
@ -560,6 +726,33 @@ header h1 {
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
}
|
||||
.hist-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
}
|
||||
.hist-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.hist-btn:not(:disabled):hover,
|
||||
.hist-btn:not(:disabled):focus-visible {
|
||||
color: var(--strong);
|
||||
}
|
||||
.hist-btn:active:not(:disabled) {
|
||||
transform: scale(0.88);
|
||||
}
|
||||
.settings-btn:hover {
|
||||
color: var(--strong);
|
||||
}
|
||||
@ -635,4 +828,8 @@ header h1 {
|
||||
transform: rotate(180deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.month-name-label.no-rotate > span {
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
|
@ -2,17 +2,13 @@
|
||||
import CalendarDay from './CalendarDay.vue'
|
||||
import EventOverlay from './EventOverlay.vue'
|
||||
|
||||
const props = defineProps({
|
||||
week: Object,
|
||||
})
|
||||
const props = defineProps({ week: Object, dragging: { type: Boolean, default: false } })
|
||||
|
||||
const emit = defineEmits([
|
||||
'day-mousedown',
|
||||
'day-mouseenter',
|
||||
'day-mouseup',
|
||||
'day-touchstart',
|
||||
'day-touchmove',
|
||||
'day-touchend',
|
||||
'event-click',
|
||||
])
|
||||
|
||||
@ -32,13 +28,7 @@ const handleDayTouchStart = (dateStr) => {
|
||||
emit('day-touchstart', dateStr)
|
||||
}
|
||||
|
||||
const handleDayTouchMove = (dateStr) => {
|
||||
emit('day-touchmove', dateStr)
|
||||
}
|
||||
|
||||
const handleDayTouchEnd = (dateStr) => {
|
||||
emit('day-touchend', dateStr)
|
||||
}
|
||||
// touchmove & touchend handled globally in CalendarView
|
||||
|
||||
const handleEventClick = (payload) => {
|
||||
emit('event-click', payload)
|
||||
@ -53,12 +43,11 @@ const handleEventClick = (payload) => {
|
||||
v-for="day in props.week.days"
|
||||
:key="day.date"
|
||||
:day="day"
|
||||
:dragging="props.dragging"
|
||||
@mousedown="handleDayMouseDown(day.date)"
|
||||
@mouseenter="handleDayMouseEnter(day.date)"
|
||||
@mouseup="handleDayMouseUp(day.date)"
|
||||
@touchstart="handleDayTouchStart(day.date)"
|
||||
@touchmove="handleDayTouchMove(day.date)"
|
||||
@touchend="handleDayTouchEnd(day.date)"
|
||||
/>
|
||||
<EventOverlay :week="props.week" @event-click="handleEventClick" />
|
||||
</div>
|
||||
|
@ -37,6 +37,7 @@ const title = computed({
|
||||
set(v) {
|
||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||
calendarStore.events.get(editingEventId.value).title = v
|
||||
calendarStore.touchEvents()
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -127,6 +128,7 @@ const selectedColor = computed({
|
||||
const n = parseInt(v)
|
||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||
calendarStore.events.get(editingEventId.value).colorId = n
|
||||
calendarStore.touchEvents()
|
||||
}
|
||||
colorId.value = n
|
||||
},
|
||||
@ -144,6 +146,7 @@ const repeatCountBinding = computed({
|
||||
set(v) {
|
||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||
calendarStore.events.get(editingEventId.value).repeatCount = v === 0 ? 'unlimited' : String(v)
|
||||
calendarStore.touchEvents()
|
||||
}
|
||||
recurrenceOccurrences.value = v
|
||||
},
|
||||
@ -189,6 +192,7 @@ function loadWeekdayPatternFromStore(storePattern) {
|
||||
}
|
||||
|
||||
function openCreateDialog(selectionData = null) {
|
||||
calendarStore.$history?.beginCompound()
|
||||
if (unsavedCreateId.value && !eventSaved.value) {
|
||||
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
||||
calendarStore.deleteEvent(unsavedCreateId.value)
|
||||
@ -249,6 +253,7 @@ function openCreateDialog(selectionData = null) {
|
||||
}
|
||||
|
||||
function openEditDialog(payload) {
|
||||
calendarStore.$history?.beginCompound()
|
||||
if (
|
||||
dialogMode.value === 'create' &&
|
||||
unsavedCreateId.value &&
|
||||
@ -352,14 +357,8 @@ function openEditDialog(payload) {
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
calendarStore.$history?.endCompound()
|
||||
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() {
|
||||
@ -374,6 +373,7 @@ function updateEventInStore() {
|
||||
event.repeatCount =
|
||||
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value)
|
||||
event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none'
|
||||
calendarStore.touchEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@ -384,11 +384,13 @@ function saveEvent() {
|
||||
unsavedCreateId.value = null
|
||||
}
|
||||
if (dialogMode.value === 'create') emit('clear-selection')
|
||||
calendarStore.$history?.endCompound()
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
function deleteEventAll() {
|
||||
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
|
||||
calendarStore.$history?.endCompound()
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
@ -398,12 +400,14 @@ function deleteEventOne() {
|
||||
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
|
||||
calendarStore.deleteFirstOccurrence(editingEventId.value)
|
||||
}
|
||||
calendarStore.$history?.endCompound()
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
function deleteEventFrom() {
|
||||
if (!occurrenceContext.value) return
|
||||
calendarStore.deleteFromOccurrence(occurrenceContext.value)
|
||||
calendarStore.$history?.endCompound()
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
@ -417,6 +421,19 @@ onUnmounted(() => {
|
||||
watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => {
|
||||
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(
|
||||
recurrenceWeekdays,
|
||||
() => {
|
||||
@ -428,6 +445,7 @@ watch(
|
||||
defineExpose({
|
||||
openCreateDialog,
|
||||
openEditDialog,
|
||||
closeDialog,
|
||||
})
|
||||
|
||||
const isRepeatingEdit = computed(
|
||||
|
@ -154,8 +154,13 @@ function startLocalDrag(init, evt) {
|
||||
anchorOffset,
|
||||
originSpanDays: spanDays,
|
||||
eventMoved: false,
|
||||
tentativeStart: init.startDate,
|
||||
tentativeEnd: init.endDate,
|
||||
}
|
||||
|
||||
// Begin compound history session (single snapshot after drag completes)
|
||||
store.$history?.beginCompound()
|
||||
|
||||
// Capture pointer events globally
|
||||
if (evt.currentTarget && evt.pointerId !== undefined) {
|
||||
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()
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onDragPointerMove, { passive: false })
|
||||
window.addEventListener('pointerup', onDragPointerUp, { passive: false })
|
||||
@ -209,7 +216,18 @@ function onDragPointerMove(e) {
|
||||
|
||||
const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date)
|
||||
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) {
|
||||
@ -226,6 +244,8 @@ function onDragPointerUp(e) {
|
||||
}
|
||||
|
||||
const moved = !!st.eventMoved
|
||||
const finalStart = st.tentativeStart
|
||||
const finalEnd = st.tentativeEnd
|
||||
dragState.value = null
|
||||
|
||||
window.removeEventListener('pointermove', onDragPointerMove)
|
||||
@ -233,11 +253,27 @@ function onDragPointerUp(e) {
|
||||
window.removeEventListener('pointercancel', onDragPointerUp)
|
||||
|
||||
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
|
||||
setTimeout(() => {
|
||||
justDragged.value = false
|
||||
}, 120)
|
||||
}
|
||||
// End compound session (snapshot if changed)
|
||||
store.$history?.endCompound()
|
||||
}
|
||||
|
||||
function computeTentativeRangeFromPointer(st, dropDateStr) {
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="mini-stepper drag-mode"
|
||||
:class="[extraClass, { dragging }]"
|
||||
:class="[extraClass, { dragging, 'pointer-locked': pointerLocked }]"
|
||||
:aria-label="ariaLabel"
|
||||
role="spinbutton"
|
||||
:aria-valuemin="minValue"
|
||||
@ -19,7 +19,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, onBeforeUnmount } from 'vue'
|
||||
|
||||
const model = defineModel({ type: Number, default: 0 })
|
||||
|
||||
@ -102,46 +102,91 @@ const dragging = ref(false)
|
||||
const rootEl = ref(null)
|
||||
let startX = 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) {
|
||||
e.preventDefault()
|
||||
startX = e.clientX
|
||||
startY = e.clientY
|
||||
startVal = current.value
|
||||
lastClientX = e.clientX
|
||||
accumX = 0
|
||||
dragging.value = true
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId)
|
||||
} catch {}
|
||||
rootEl.value?.addEventListener('pointermove', onPointerMove)
|
||||
rootEl.value?.addEventListener('pointerup', onPointerUp, { once: true })
|
||||
rootEl.value?.addEventListener('pointercancel', onPointerCancel, { once: true })
|
||||
if (e.pointerType === 'mouse' && rootEl.value?.requestPointerLock) {
|
||||
addPointerLockListeners()
|
||||
try {
|
||||
rootEl.value.requestPointerLock()
|
||||
} catch {}
|
||||
}
|
||||
document.addEventListener('pointermove', onPointerMove)
|
||||
document.addEventListener('pointerup', onPointerUp, { once: true })
|
||||
document.addEventListener('pointercancel', onPointerCancel, { once: true })
|
||||
}
|
||||
function onPointerMove(e) {
|
||||
if (!dragging.value) return
|
||||
// Prevent page scroll on touch while dragging
|
||||
if (e.pointerType === 'touch') e.preventDefault()
|
||||
const primary = e.clientX - startX // horizontal only
|
||||
const steps = Math.trunc(primary / props.pixelsPerStep)
|
||||
|
||||
// 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
|
||||
let dx
|
||||
if (pointerLocked.value) {
|
||||
dx = e.movementX || 0
|
||||
} else {
|
||||
if (newIndex >= 0 && newIndex < allValidValues.value.length) {
|
||||
const next = allValidValues.value[newIndex]
|
||||
dx = e.clientX - lastClientX
|
||||
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
|
||||
}
|
||||
}
|
||||
applySteps(steps)
|
||||
// Remove consumed distance (works even if clamped; leftover ensures responsiveness keeps consistent feel)
|
||||
accumX -= steps * stepSize
|
||||
}
|
||||
function endDragListeners() {
|
||||
rootEl.value?.removeEventListener('pointermove', onPointerMove)
|
||||
document.removeEventListener('pointermove', onPointerMove)
|
||||
if (pointerLocked.value && document.exitPointerLock) {
|
||||
try {
|
||||
document.exitPointerLock()
|
||||
} catch {}
|
||||
}
|
||||
removePointerLockListeners()
|
||||
}
|
||||
function onPointerUp() {
|
||||
dragging.value = false
|
||||
@ -267,4 +312,7 @@ function onWheel(e) {
|
||||
.mini-stepper.drag-mode.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.mini-stepper.drag-mode.pointer-locked.dragging {
|
||||
cursor: none; /* hide cursor for infinite drag */
|
||||
}
|
||||
</style>
|
||||
|
@ -3,13 +3,16 @@ import './assets/calendar.css'
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import { calendarHistory } from '@/plugins/calendarHistory'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
const pinia = createPinia()
|
||||
// Order: persistence first so snapshots recorded by undo reflect already-hydrated state
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
pinia.use(calendarHistory)
|
||||
app.use(pinia)
|
||||
|
||||
app.mount('#app')
|
||||
|
200
src/plugins/calendarHistory.js
Normal file
200
src/plugins/calendarHistory.js
Normal 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 }
|
||||
},
|
||||
}
|
||||
}
|
57
src/plugins/calendarUndoNormalize.js
Normal file
57
src/plugins/calendarUndoNormalize.js
Normal 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()
|
||||
}
|
@ -10,21 +10,23 @@ import {
|
||||
import { differenceInCalendarDays, addDays } from 'date-fns'
|
||||
import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays'
|
||||
|
||||
const MIN_YEAR = 1900
|
||||
const MAX_YEAR = 2100
|
||||
|
||||
export const useCalendarStore = defineStore('calendar', {
|
||||
state: () => ({
|
||||
today: toLocalString(new Date(), DEFAULT_TZ),
|
||||
now: new Date().toISOString(),
|
||||
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(),
|
||||
_holidayConfigSignature: null,
|
||||
_holidaysInitialized: false,
|
||||
config: {
|
||||
select_days: 14,
|
||||
min_year: MIN_YEAR,
|
||||
max_year: MAX_YEAR,
|
||||
first_day: 1,
|
||||
holidays: {
|
||||
enabled: true,
|
||||
@ -34,12 +36,6 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
getters: {
|
||||
minYear: () => MIN_YEAR,
|
||||
maxYear: () => MAX_YEAR,
|
||||
},
|
||||
|
||||
actions: {
|
||||
_resolveCountry(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)
|
||||
},
|
||||
|
||||
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) {
|
||||
const singleDay = eventData.startDate === eventData.endDate
|
||||
const event = {
|
||||
@ -136,6 +140,7 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
isRepeating: eventData.repeat && eventData.repeat !== 'none',
|
||||
}
|
||||
this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate })
|
||||
this.notifyEventsChanged()
|
||||
return event.id
|
||||
},
|
||||
|
||||
@ -166,6 +171,7 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
|
||||
deleteEvent(eventId) {
|
||||
this.events.delete(eventId)
|
||||
this.notifyEventsChanged()
|
||||
},
|
||||
|
||||
deleteFirstOccurrence(baseId) {
|
||||
@ -197,6 +203,7 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
base.endDate = newEndStr
|
||||
if (numericCount !== Infinity) base.repeatCount = String(Math.max(1, numericCount - 1))
|
||||
this.events.set(baseId, { ...base, isSpanning: base.startDate < base.endDate })
|
||||
this.notifyEventsChanged()
|
||||
},
|
||||
|
||||
deleteSingleOccurrence(ctx) {
|
||||
@ -242,6 +249,7 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
repeatCount: remainingCount,
|
||||
repeatWeekdays: snapshot.repeatWeekdays,
|
||||
})
|
||||
this.notifyEventsChanged()
|
||||
},
|
||||
|
||||
deleteFromOccurrence(ctx) {
|
||||
@ -253,6 +261,7 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
return
|
||||
}
|
||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||
this.notifyEventsChanged()
|
||||
},
|
||||
|
||||
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.notifyEventsChanged()
|
||||
},
|
||||
|
||||
splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) {
|
||||
@ -370,6 +380,7 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
repeatCount: remainingCount,
|
||||
repeatWeekdays,
|
||||
})
|
||||
this.notifyEventsChanged()
|
||||
},
|
||||
|
||||
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr) {
|
||||
@ -406,6 +417,7 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
const rc = parseInt(ev.repeatCount, 10)
|
||||
if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index))
|
||||
}
|
||||
this.notifyEventsChanged()
|
||||
},
|
||||
},
|
||||
persist: {
|
||||
|
@ -23,6 +23,9 @@ const monthAbbr = [
|
||||
'nov',
|
||||
'dec',
|
||||
]
|
||||
// Calendar year bounds (used instead of config.min_year / config.max_year)
|
||||
const MIN_YEAR = 1901
|
||||
const MAX_YEAR = 2100
|
||||
|
||||
// Core helpers ------------------------------------------------------------
|
||||
/**
|
||||
@ -234,13 +237,13 @@ function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) {
|
||||
}
|
||||
|
||||
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() {
|
||||
const wk = new Intl.Locale(navigator.language).weekInfo.weekend || [6, 7]
|
||||
const set = new Set(wk.map((d) => d % 7))
|
||||
return Array.from({ length: 7 }, (_, i) => set.has(i))
|
||||
const wk = new Set(new Intl.Locale(navigator.language).weekInfo?.weekend ?? [6, 7])
|
||||
return Array.from({ length: 7 }, (_, i) => wk.has(1 + ((i + 6) % 7)))
|
||||
}
|
||||
|
||||
function reorderByFirstDay(days, firstDay) {
|
||||
@ -320,6 +323,8 @@ function formatTodayString(date) {
|
||||
export {
|
||||
// constants
|
||||
monthAbbr,
|
||||
MIN_YEAR,
|
||||
MAX_YEAR,
|
||||
DEFAULT_TZ,
|
||||
// core tz helpers
|
||||
makeTZDate,
|
||||
|
Loading…
x
Reference in New Issue
Block a user