Major new version (#2)
Release Notes Architecture - Component refactor: removed monoliths (`Calendar.vue`, `CalendarGrid.vue`); expanded granular view + header/control components. - Dialog system introduced (`BaseDialog`, `SettingsDialog`). State & Data - Store redesigned: Map-based events + recurrence map; mutation counters. - Local persistence + undo/redo history (custom plugins). Date & Holidays - Migrated all date logic to `date-fns` (+ tz). - Added national holiday support (toggle + loading utilities). Recurrence & Events - Consolidated recurrence handling; fixes for monthly edge days (29–31), annual, multi‑day, and complex weekly repeats. - Reliable splitting/moving/resizing/deletion of repeating and multi‑day events. Interaction & UX - Double‑tap to create events; improved drag (multi‑day + position retention). - Scroll & inertial/momentum navigation; year change via numeric scroller. - Movable event dialog; live settings application. Performance - Progressive / virtual week rendering, reduced off‑screen buffer. - Targeted repaint strategy; minimized full re-renders. Plugins Added - History, undo normalization, persistence, scroll manager, virtual weeks. Styling & Layout - Responsive + compact layout refinements; header restructured. - Simplified visual elements (removed dots/overflow text); holiday styling adjustments. Reliability / Fixes - Numerous recurrence, deletion, orientation/rotation, and event indexing corrections. - Cross-browser fallback (Firefox week info). Dependencies Added - date-fns, date-fns-tz, date-holidays, pinia-plugin-persistedstate. Net Change - 28 files modified; ~4.4K insertions / ~2.2K deletions (major refactor + feature set).
This commit is contained in:
264
src/components/BaseDialog.vue
Normal file
264
src/components/BaseDialog.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, useAttrs } from 'vue'
|
||||
|
||||
// Disable automatic attr inheritance so we can forward class/style specifically to the modal element
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
draggable: { type: Boolean, default: true },
|
||||
autoFocus: { type: Boolean, default: true },
|
||||
// Optional external anchor element (e.g., a day cell) to position the dialog below.
|
||||
// If not provided, falls back to internal anchorRef span (original behavior).
|
||||
anchorEl: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'opened', 'closed', 'submit'])
|
||||
|
||||
const modalRef = ref(null)
|
||||
const anchorRef = ref(null)
|
||||
const isDragging = ref(false)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
const modalPosition = ref({ x: 0, y: 0 })
|
||||
const dialogWidth = ref(null)
|
||||
const dialogHeight = ref(null)
|
||||
const hasMoved = ref(false)
|
||||
const margin = 8 // viewport margin in px to keep dialog from touching edges
|
||||
|
||||
// Collect incoming non-prop attributes (e.g., class / style from usage site)
|
||||
const attrs = useAttrs()
|
||||
|
||||
function clamp(val, min, max) {
|
||||
return Math.min(Math.max(val, min), max)
|
||||
}
|
||||
|
||||
function startDrag(event) {
|
||||
if (!props.draggable || !modalRef.value) return
|
||||
const rect = modalRef.value.getBoundingClientRect()
|
||||
// Lock current size so moving doesn't cause reflow / resize
|
||||
dialogWidth.value = rect.width
|
||||
dialogHeight.value = rect.height
|
||||
// Initialize position to current on-screen coordinates BEFORE enabling moved mode
|
||||
modalPosition.value = { x: rect.left, y: rect.top }
|
||||
isDragging.value = true
|
||||
hasMoved.value = true
|
||||
dragOffset.value = { x: event.clientX - rect.left, y: event.clientY - rect.top }
|
||||
if (event.pointerId !== undefined) {
|
||||
try {
|
||||
event.target.setPointerCapture(event.pointerId)
|
||||
} catch {}
|
||||
}
|
||||
document.addEventListener('pointermove', handleDrag, { passive: false })
|
||||
document.addEventListener('pointerup', stopDrag)
|
||||
document.addEventListener('pointercancel', stopDrag)
|
||||
event.preventDefault()
|
||||
}
|
||||
function handleDrag(event) {
|
||||
if (!isDragging.value) return
|
||||
let x = event.clientX - dragOffset.value.x
|
||||
let y = event.clientY - dragOffset.value.y
|
||||
const w = dialogWidth.value || modalRef.value?.offsetWidth || 0
|
||||
const h = dialogHeight.value || modalRef.value?.offsetHeight || 0
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
x = clamp(x, margin, Math.max(margin, vw - w - margin))
|
||||
y = clamp(y, margin, Math.max(margin, vh - h - margin))
|
||||
modalPosition.value = { x, y }
|
||||
event.preventDefault()
|
||||
}
|
||||
function stopDrag() {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('pointermove', handleDrag)
|
||||
document.removeEventListener('pointerup', stopDrag)
|
||||
document.removeEventListener('pointercancel', stopDrag)
|
||||
}
|
||||
|
||||
const modalStyle = computed(() => {
|
||||
// Always position relative to calculated modalPosition once opened
|
||||
if (modalRef.value && props.modelValue) {
|
||||
const style = {
|
||||
transform: 'none',
|
||||
left: modalPosition.value.x + 'px',
|
||||
top: modalPosition.value.y + 'px',
|
||||
bottom: 'auto',
|
||||
right: 'auto',
|
||||
}
|
||||
if (hasMoved.value) {
|
||||
style.width = dialogWidth.value ? dialogWidth.value + 'px' : undefined
|
||||
style.height = dialogHeight.value ? dialogHeight.value + 'px' : undefined
|
||||
}
|
||||
return style
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
// Merge external class / style with internal ones so parent usage like
|
||||
// <BaseDialog class="settings-modal" :style="{ top: '...' }" /> works even with fragment root.
|
||||
const modalAttrs = computed(() => {
|
||||
const { class: extClass, style: extStyle, ...rest } = attrs
|
||||
return {
|
||||
...rest,
|
||||
class: ['ec-modal', extClass].filter(Boolean),
|
||||
style: [modalStyle.value, extStyle].filter(Boolean), // external style overrides internal
|
||||
}
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
emit('closed')
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Escape' && props.modelValue) close()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||
|
||||
function positionNearAnchor() {
|
||||
const anchor = props.anchorEl || anchorRef.value
|
||||
if (!anchor) return
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const offsetY = 8 // vertical gap below the anchor
|
||||
const w = modalRef.value?.offsetWidth || dialogWidth.value || 320
|
||||
const h = modalRef.value?.offsetHeight || dialogHeight.value || 200
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
let x = rect.left
|
||||
let y = rect.bottom + offsetY
|
||||
// If anchor is wider than dialog and would overflow right edge, clamp; otherwise keep left align
|
||||
x = clamp(x, margin, Math.max(margin, vw - w - margin))
|
||||
y = clamp(y, margin, Math.max(margin, vh - h - margin))
|
||||
modalPosition.value = { x, y }
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (v) => {
|
||||
if (v) {
|
||||
emit('opened')
|
||||
await nextTick()
|
||||
// Reset movement state each time opened
|
||||
hasMoved.value = false
|
||||
dialogWidth.value = null
|
||||
dialogHeight.value = null
|
||||
positionNearAnchor()
|
||||
if (props.autoFocus) {
|
||||
const el = modalRef.value?.querySelector('[autofocus]')
|
||||
if (el) el.focus()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Reposition if anchorEl changes while open and user hasn't dragged dialog yet
|
||||
watch(
|
||||
() => props.anchorEl,
|
||||
() => {
|
||||
if (props.modelValue && !hasMoved.value) {
|
||||
nextTick(() => positionNearAnchor())
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function handleResize() {
|
||||
if (!props.modelValue) return
|
||||
// Re-clamp current position, and if not moved recalc near anchor
|
||||
if (!hasMoved.value) positionNearAnchor()
|
||||
else if (modalRef.value) {
|
||||
const w = modalRef.value.offsetWidth
|
||||
const h = modalRef.value.offsetHeight
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
modalPosition.value = {
|
||||
x: clamp(modalPosition.value.x, margin, Math.max(margin, vw - w - margin)),
|
||||
y: clamp(modalPosition.value.y, margin, Math.max(margin, vh - h - margin)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="anchorRef" class="ec-modal-anchor" aria-hidden="true"></span>
|
||||
<div v-if="modelValue" ref="modalRef" v-bind="modalAttrs">
|
||||
<form class="ec-form" @submit.prevent="emit('submit')">
|
||||
<header class="ec-header" @pointerdown="startDrag">
|
||||
<h2 class="ec-title">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</h2>
|
||||
<div class="ec-header-extra"><slot name="header-extra" /></div>
|
||||
</header>
|
||||
<div class="ec-body">
|
||||
<slot />
|
||||
</div>
|
||||
<footer v-if="$slots.footer" class="ec-footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ec-modal {
|
||||
position: fixed; /* still fixed for overlay & dragging, but now top/left are set dynamically */
|
||||
background: color-mix(in srgb, var(--panel) 85%, transparent);
|
||||
backdrop-filter: blur(0.625em);
|
||||
-webkit-backdrop-filter: blur(0.625em);
|
||||
color: var(--ink);
|
||||
border-radius: 0.6em;
|
||||
min-height: 23em;
|
||||
min-width: 26em;
|
||||
max-width: min(34em, 90vw);
|
||||
box-shadow: 0 0.6em 1.8em rgba(0, 0, 0, 0.35);
|
||||
border: 0.0625em solid color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ec-modal-anchor {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.ec-form {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
min-height: 23em;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.ec-header {
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
padding: 0.75em 1em 0.5em 1em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
.ec-title {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.ec-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
padding: 0 1em 0.5em 1em;
|
||||
overflow: auto;
|
||||
}
|
||||
.ec-footer {
|
||||
padding: 0.5em 1em 1em 1em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<AppHeader />
|
||||
<div class="calendar-container" ref="containerEl">
|
||||
<CalendarGrid />
|
||||
<Jogwheel />
|
||||
</div>
|
||||
<EventDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import CalendarGrid from './CalendarGrid.vue'
|
||||
import Jogwheel from './Jogwheel.vue'
|
||||
import EventDialog from './EventDialog.vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
const containerEl = ref(null)
|
||||
|
||||
let intervalId
|
||||
|
||||
onMounted(() => {
|
||||
calendarStore.setToday()
|
||||
intervalId = setInterval(() => {
|
||||
calendarStore.setToday()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(intervalId)
|
||||
})
|
||||
</script>
|
||||
@@ -1,18 +1,14 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
day: Object,
|
||||
dragging: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['event-click'])
|
||||
|
||||
const handleEventClick = (eventId) => {
|
||||
emit('event-click', eventId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="cell"
|
||||
:style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'"
|
||||
:class="[
|
||||
props.day.monthClass,
|
||||
{
|
||||
@@ -20,6 +16,7 @@ const handleEventClick = (eventId) => {
|
||||
weekend: props.day.isWeekend,
|
||||
firstday: props.day.isFirstDay,
|
||||
selected: props.day.isSelected,
|
||||
holiday: props.day.isHoliday,
|
||||
},
|
||||
]"
|
||||
:data-date="props.day.date"
|
||||
@@ -27,19 +24,10 @@ const handleEventClick = (eventId) => {
|
||||
<h1>{{ props.day.displayText }}</h1>
|
||||
<span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span>
|
||||
|
||||
<!-- Simple event display for now -->
|
||||
<div v-if="props.day.events && props.day.events.length > 0" class="day-events">
|
||||
<div
|
||||
v-for="event in props.day.events.slice(0, 3)"
|
||||
:key="event.id"
|
||||
class="event-dot"
|
||||
:class="`event-color-${event.colorId}`"
|
||||
:title="event.title"
|
||||
@click.stop="handleEventClick(event.id)"
|
||||
></div>
|
||||
<div v-if="props.day.events.length > 3" class="event-more">
|
||||
+{{ props.day.events.length - 3 }}
|
||||
</div>
|
||||
<div v-if="props.day.holiday" class="holiday-info">
|
||||
<span class="holiday-name" :title="props.day.holiday.name">
|
||||
{{ props.day.holiday.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -50,7 +38,6 @@ const handleEventClick = (eventId) => {
|
||||
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;
|
||||
@@ -58,7 +45,7 @@ const handleEventClick = (eventId) => {
|
||||
padding: 0.25em;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: var(--cell-h);
|
||||
height: var(--row-h);
|
||||
font-weight: 700;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
@@ -72,20 +59,6 @@ const handleEventClick = (eventId) => {
|
||||
color: var(--ink);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.cell.today h1 {
|
||||
border-radius: 2em;
|
||||
background: var(--today);
|
||||
border: 0.2em solid var(--today);
|
||||
margin: -0.2em;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cell:hover h1 {
|
||||
text-shadow: 0 0 0.2em var(--shadow);
|
||||
}
|
||||
|
||||
.cell.weekend h1 {
|
||||
color: var(--weekend);
|
||||
}
|
||||
@@ -93,18 +66,64 @@ const handleEventClick = (eventId) => {
|
||||
color: var(--firstday);
|
||||
text-shadow: 0 0 0.1em var(--strong);
|
||||
}
|
||||
.cell.today h1 {
|
||||
border-radius: 2em;
|
||||
background: var(--today);
|
||||
border: 0.2em solid var(--today);
|
||||
margin: -0.2em;
|
||||
color: var(--strong);
|
||||
font-weight: bold;
|
||||
}
|
||||
.cell.selected {
|
||||
filter: hue-rotate(180deg);
|
||||
}
|
||||
.cell.selected h1 {
|
||||
color: var(--strong);
|
||||
}
|
||||
|
||||
.lunar-phase {
|
||||
position: absolute;
|
||||
top: 0.1em;
|
||||
right: 0.1em;
|
||||
top: 0.5em;
|
||||
right: 0.2em;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.cell.holiday {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
var(--holiday-grad-start, rgba(255, 255, 255, 0.5)) 0%,
|
||||
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
|
||||
);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cell.holiday {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
var(--holiday-grad-start, rgba(255, 255, 255, 0.1)) 0%,
|
||||
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
|
||||
);
|
||||
}
|
||||
}
|
||||
.cell.holiday h1 {
|
||||
/* Slight emphasis without forcing a specific hue */
|
||||
color: var(--holiday);
|
||||
text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.holiday-info {
|
||||
position: absolute;
|
||||
bottom: 0.1em;
|
||||
left: 0.1em;
|
||||
right: 0.1em;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
font-size: clamp(1.2vw, 0.6em, 1em);
|
||||
}
|
||||
|
||||
.holiday-name {
|
||||
display: block;
|
||||
color: var(--holiday-label);
|
||||
padding: 0.15em 0.35em 0.15em 0.25em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
<template>
|
||||
<div class="calendar-header">
|
||||
<div class="year-label" @wheel.prevent="handleWheel">{{ calendarStore.viewYear }}</div>
|
||||
<div v-for="day in weekdayNames" :key="day" class="dow" :class="{ weekend: isWeekend(day) }">
|
||||
{{ day }}
|
||||
</div>
|
||||
<div class="overlay-header-spacer"></div>
|
||||
</div>
|
||||
<div class="calendar-viewport" ref="viewportEl" @scroll="handleScroll">
|
||||
<div class="calendar-content" :style="{ height: `${totalVirtualWeeks * rowHeight}px` }">
|
||||
<WeekRow
|
||||
v.for="week in visibleWeeks"
|
||||
:key="week.virtualWeek"
|
||||
:week="week"
|
||||
:style="{ top: `${(week.virtualWeek - minVirtualWeek) * rowHeight}px` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import {
|
||||
getLocalizedWeekdayNames,
|
||||
getLocaleWeekendDays,
|
||||
getLocaleFirstDay,
|
||||
isoWeekInfo,
|
||||
fromLocalString,
|
||||
toLocalString,
|
||||
mondayIndex,
|
||||
} from '@/utils/date'
|
||||
import WeekRow from './WeekRow.vue'
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
const viewportEl = ref(null)
|
||||
const rowHeight = ref(64) // Default value, will be computed
|
||||
const totalVirtualWeeks = ref(0)
|
||||
const minVirtualWeek = ref(0)
|
||||
const visibleWeeks = ref([])
|
||||
|
||||
const config = {
|
||||
min_year: 1900,
|
||||
max_year: 2100,
|
||||
weekend: getLocaleWeekendDays(),
|
||||
}
|
||||
|
||||
const baseDate = new Date(1970, 0, 4 + getLocaleFirstDay())
|
||||
const WEEK_MS = 7 * 86400000
|
||||
|
||||
const weekdayNames = getLocalizedWeekdayNames()
|
||||
|
||||
const isWeekend = (day) => {
|
||||
const dayIndex = weekdayNames.indexOf(day)
|
||||
return config.weekend[(dayIndex + 1) % 7]
|
||||
}
|
||||
|
||||
const getWeekIndex = (date) => {
|
||||
const monday = new Date(date)
|
||||
monday.setDate(date.getDate() - mondayIndex(date))
|
||||
return Math.floor((monday - baseDate) / WEEK_MS)
|
||||
}
|
||||
|
||||
const getMondayForVirtualWeek = (virtualWeek) => {
|
||||
const monday = new Date(baseDate)
|
||||
monday.setDate(monday.getDate() + virtualWeek * 7)
|
||||
return monday
|
||||
}
|
||||
|
||||
const computeRowHeight = () => {
|
||||
const el = document.createElement('div')
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = 'var(--cell-h)'
|
||||
document.body.appendChild(el)
|
||||
const h = el.getBoundingClientRect().height || 64
|
||||
el.remove()
|
||||
return Math.round(h)
|
||||
}
|
||||
|
||||
const updateVisibleWeeks = () => {
|
||||
if (!viewportEl.value) return
|
||||
|
||||
const scrollTop = viewportEl.value.scrollTop
|
||||
const viewportH = viewportEl.value.clientHeight
|
||||
|
||||
const topDisplayIndex = Math.floor(scrollTop / rowHeight.value)
|
||||
const topVW = topDisplayIndex + minVirtualWeek.value
|
||||
const monday = getMondayForVirtualWeek(topVW)
|
||||
const { year } = isoWeekInfo(monday)
|
||||
if (calendarStore.viewYear !== year) {
|
||||
calendarStore.setViewYear(year)
|
||||
}
|
||||
|
||||
const buffer = 10
|
||||
const startIdx = Math.floor((scrollTop - buffer * rowHeight.value) / rowHeight.value)
|
||||
const endIdx = Math.ceil((scrollTop + viewportH + buffer * rowHeight.value) / rowHeight.value)
|
||||
|
||||
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
|
||||
const endVW = Math.min(
|
||||
totalVirtualWeeks.value + minVirtualWeek.value - 1,
|
||||
endIdx + minVirtualWeek.value,
|
||||
)
|
||||
|
||||
const newVisibleWeeks = []
|
||||
for (let vw = startVW; vw <= endVW; vw++) {
|
||||
newVisibleWeeks.push({
|
||||
virtualWeek: vw,
|
||||
monday: getMondayForVirtualWeek(vw),
|
||||
})
|
||||
}
|
||||
visibleWeeks.value = newVisibleWeeks
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
requestAnimationFrame(updateVisibleWeeks)
|
||||
}
|
||||
|
||||
const handleWheel = (e) => {
|
||||
const currentYear = calendarStore.viewYear
|
||||
const delta = Math.round(e.deltaY * (1 / 3))
|
||||
if (!delta) return
|
||||
const newYear = Math.max(config.min_year, Math.min(config.max_year, currentYear + delta))
|
||||
if (newYear === currentYear) return
|
||||
|
||||
const topDisplayIndex = Math.floor(viewportEl.value.scrollTop / rowHeight.value)
|
||||
const currentWeekIndex = topDisplayIndex + minVirtualWeek.value
|
||||
|
||||
navigateToYear(newYear, currentWeekIndex)
|
||||
}
|
||||
|
||||
const navigateToYear = (targetYear, weekIndex) => {
|
||||
const monday = getMondayForVirtualWeek(weekIndex)
|
||||
const { week } = isoWeekInfo(monday)
|
||||
const jan4 = new Date(targetYear, 0, 4)
|
||||
const jan4Monday = new Date(jan4)
|
||||
jan4Monday.setDate(jan4.getDate() - mondayIndex(jan4))
|
||||
const targetMonday = new Date(jan4Monday)
|
||||
targetMonday.setDate(jan4Monday.getDate() + (week - 1) * 7)
|
||||
scrollToTarget(targetMonday)
|
||||
}
|
||||
|
||||
const scrollToTarget = (target) => {
|
||||
let targetWeekIndex
|
||||
if (target instanceof Date) {
|
||||
targetWeekIndex = getWeekIndex(target)
|
||||
} else {
|
||||
targetWeekIndex = target
|
||||
}
|
||||
|
||||
const targetScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
viewportEl.value.scrollTop = targetScrollTop
|
||||
updateVisibleWeeks()
|
||||
}
|
||||
|
||||
const goToTodayHandler = () => {
|
||||
const today = new Date()
|
||||
const top = new Date(today)
|
||||
top.setDate(top.getDate() - 21)
|
||||
scrollToTarget(top)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
rowHeight.value = computeRowHeight()
|
||||
|
||||
const minYearDate = new Date(config.min_year, 0, 1)
|
||||
const maxYearLastDay = new Date(config.max_year, 11, 31)
|
||||
const lastWeekMonday = new Date(maxYearLastDay)
|
||||
lastWeekMonday.setDate(maxYearLastDay.getDate() - mondayIndex(maxYearLastDay))
|
||||
|
||||
minVirtualWeek.value = getWeekIndex(minYearDate)
|
||||
const maxVirtualWeek = getWeekIndex(lastWeekMonday)
|
||||
totalVirtualWeeks.value = maxVirtualWeek - minVirtualWeek.value + 1
|
||||
|
||||
const initialDate = fromLocalString(calendarStore.today)
|
||||
scrollToTarget(initialDate)
|
||||
|
||||
document.addEventListener('goToToday', goToTodayHandler)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('goToToday', goToTodayHandler)
|
||||
})
|
||||
</script>
|
||||
@@ -1,7 +1,16 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import { getLocalizedWeekdayNames, reorderByFirstDay, isoWeekInfo } from '@/utils/date'
|
||||
import {
|
||||
getLocalizedWeekdayNames,
|
||||
reorderByFirstDay,
|
||||
getISOWeek,
|
||||
getISOWeekYear,
|
||||
MIN_YEAR,
|
||||
MAX_YEAR,
|
||||
} from '@/utils/date'
|
||||
import Numeric from '@/components/Numeric.vue'
|
||||
import { addDays } from 'date-fns'
|
||||
|
||||
const props = defineProps({
|
||||
scrollTop: { type: Number, default: 0 },
|
||||
@@ -11,17 +20,64 @@ const props = defineProps({
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
|
||||
const yearLabel = computed(() => {
|
||||
// Emits year-change events
|
||||
const emit = defineEmits(['year-change'])
|
||||
|
||||
const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
const topVirtualWeek = computed(() => {
|
||||
const topDisplayIndex = Math.floor(props.scrollTop / props.rowHeight)
|
||||
const topVW = topDisplayIndex + props.minVirtualWeek
|
||||
const baseDate = new Date(1970, 0, 4 + calendarStore.config.first_day)
|
||||
const firstDay = new Date(baseDate)
|
||||
firstDay.setDate(firstDay.getDate() + topVW * 7)
|
||||
return isoWeekInfo(firstDay).year
|
||||
return topDisplayIndex + props.minVirtualWeek
|
||||
})
|
||||
|
||||
const currentYear = computed(() => {
|
||||
const weekStart = addDays(baseDate.value, topVirtualWeek.value * 7)
|
||||
const anchor = addDays(weekStart, (4 - weekStart.getDay() + 7) % 7)
|
||||
return getISOWeekYear(anchor)
|
||||
})
|
||||
|
||||
function virtualWeekOf(d) {
|
||||
const o = (d.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
const fd = addDays(d, -o)
|
||||
return Math.floor((fd.getTime() - baseDate.value.getTime()) / WEEK_MS)
|
||||
}
|
||||
|
||||
function isoWeekMonday(isoYear, isoWeek) {
|
||||
const jan4 = new Date(isoYear, 0, 4)
|
||||
const week1Mon = addDays(jan4, -((jan4.getDay() + 6) % 7))
|
||||
return addDays(week1Mon, (isoWeek - 1) * 7)
|
||||
}
|
||||
|
||||
function changeYear(y) {
|
||||
if (y == null) return
|
||||
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
|
||||
const weekStartScroll = (vw - props.minVirtualWeek) * props.rowHeight
|
||||
const frac = Math.max(0, Math.min(1, (props.scrollTop - weekStartScroll) / props.rowHeight))
|
||||
// Anchor Thursday of current calendar week
|
||||
const curCalWeekStart = addDays(baseDate.value, vw * 7)
|
||||
const curAnchorThu = addDays(curCalWeekStart, (4 - curCalWeekStart.getDay() + 7) % 7)
|
||||
let isoW = getISOWeek(curAnchorThu)
|
||||
// Build Monday of ISO week
|
||||
let weekMon = isoWeekMonday(y, isoW)
|
||||
if (getISOWeekYear(weekMon) !== y) {
|
||||
isoW--
|
||||
weekMon = isoWeekMonday(y, isoW)
|
||||
}
|
||||
// Align to configured first day
|
||||
const shift = (weekMon.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
const calWeekStart = addDays(weekMon, -shift)
|
||||
const targetVW = virtualWeekOf(calWeekStart)
|
||||
let scrollTop = (targetVW - props.minVirtualWeek) * props.rowHeight + frac * props.rowHeight
|
||||
if (Math.abs(scrollTop - props.scrollTop) < 0.5) scrollTop += 0.25 * props.rowHeight
|
||||
emit('year-change', { year: y, scrollTop })
|
||||
}
|
||||
|
||||
const weekdayNames = computed(() => {
|
||||
// Get Monday-first names, then reorder by first day, then add weekend info
|
||||
// Reorder names & weekend flags
|
||||
const mondayFirstNames = getLocalizedWeekdayNames()
|
||||
const sundayFirstNames = [mondayFirstNames[6], ...mondayFirstNames.slice(0, 6)]
|
||||
const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day)
|
||||
@@ -36,7 +92,18 @@ const weekdayNames = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="calendar-header">
|
||||
<div class="year-label">{{ yearLabel }}</div>
|
||||
<div class="year-label">
|
||||
<Numeric
|
||||
:model-value="currentYear"
|
||||
@update:modelValue="changeYear"
|
||||
:min="MIN_YEAR"
|
||||
:max="MAX_YEAR"
|
||||
:step="1"
|
||||
aria-label="Year"
|
||||
number-prefix=""
|
||||
number-postfix=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="day in weekdayNames"
|
||||
:key="day.name"
|
||||
@@ -52,7 +119,7 @@ const weekdayNames = computed(() => {
|
||||
<style scoped>
|
||||
.calendar-header {
|
||||
display: grid;
|
||||
grid-template-columns: var(--label-w) repeat(7, 1fr) 3rem;
|
||||
grid-template-columns: var(--week-w) repeat(7, 1fr) var(--month-w);
|
||||
border-bottom: 2px solid var(--muted);
|
||||
align-items: last baseline;
|
||||
flex-shrink: 0;
|
||||
@@ -65,20 +132,11 @@ const weekdayNames = computed(() => {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.year-label {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
color: var(--muted);
|
||||
font-size: 1.2em;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.dow {
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.dow.weekend {
|
||||
color: var(--weekend);
|
||||
|
||||
@@ -1,235 +1,172 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import CalendarHeader from '@/components/CalendarHeader.vue'
|
||||
import CalendarWeek from '@/components/CalendarWeek.vue'
|
||||
import Jogwheel from '@/components/Jogwheel.vue'
|
||||
import HeaderControls from '@/components/HeaderControls.vue'
|
||||
import {
|
||||
isoWeekInfo,
|
||||
getLocalizedMonthName,
|
||||
monthAbbr,
|
||||
lunarPhaseSymbol,
|
||||
pad,
|
||||
daysInclusive,
|
||||
addDaysStr,
|
||||
formatDateRange,
|
||||
} from '@/utils/date'
|
||||
import { toLocalString, fromLocalString } from '@/utils/date'
|
||||
createScrollManager,
|
||||
createWeekColumnScrollManager,
|
||||
createMonthScrollManager,
|
||||
} from '@/plugins/scrollManager'
|
||||
import { daysInclusive, addDaysStr, MIN_YEAR, MAX_YEAR } from '@/utils/date'
|
||||
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
|
||||
import { addDays, differenceInWeeks } from 'date-fns'
|
||||
import { createVirtualWeekManager } from '@/plugins/virtualWeeks'
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
const viewport = ref(null)
|
||||
|
||||
const emit = defineEmits(['create-event', 'edit-event'])
|
||||
|
||||
function createEventFromSelection() {
|
||||
if (!selection.value.startDate || selection.value.dayCount === 0) return null
|
||||
|
||||
return {
|
||||
startDate: selection.value.startDate,
|
||||
dayCount: selection.value.dayCount,
|
||||
endDate: addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
||||
}
|
||||
}
|
||||
|
||||
const scrollTop = ref(0)
|
||||
const viewport = ref(null)
|
||||
const viewportHeight = ref(600)
|
||||
const rowHeight = ref(64)
|
||||
const baseDate = new Date(1970, 0, 4 + calendarStore.config.first_day)
|
||||
|
||||
const rowProbe = ref(null)
|
||||
let rowProbeObserver = null
|
||||
const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))
|
||||
const selection = ref({ startDate: null, dayCount: 0 })
|
||||
const isDragging = ref(false)
|
||||
const dragAnchor = ref(null)
|
||||
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)
|
||||
}
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
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 firstDayOfWeek = new Date(date)
|
||||
const date = new Date(MIN_YEAR, 0, 1)
|
||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
firstDayOfWeek.setDate(date.getDate() - dayOffset)
|
||||
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS)
|
||||
const firstDayOfWeek = addDays(date, -dayOffset)
|
||||
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
||||
})
|
||||
|
||||
const maxVirtualWeek = computed(() => {
|
||||
const date = new Date(calendarStore.maxYear, 11, 31)
|
||||
const firstDayOfWeek = new Date(date)
|
||||
const date = new Date(MAX_YEAR, 11, 31)
|
||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
firstDayOfWeek.setDate(date.getDate() - dayOffset)
|
||||
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS)
|
||||
const firstDayOfWeek = addDays(date, -dayOffset)
|
||||
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
||||
})
|
||||
|
||||
const totalVirtualWeeks = computed(() => {
|
||||
return maxVirtualWeek.value - minVirtualWeek.value + 1
|
||||
})
|
||||
|
||||
const initialScrollTop = computed(() => {
|
||||
const targetWeekIndex = getWeekIndex(calendarStore.now) - 3
|
||||
return (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
})
|
||||
|
||||
const selectedDateRange = computed(() => {
|
||||
if (!selection.value.start || !selection.value.end) return ''
|
||||
return formatDateRange(
|
||||
fromLocalString(selection.value.start),
|
||||
fromLocalString(selection.value.end),
|
||||
)
|
||||
})
|
||||
|
||||
const todayString = computed(() => {
|
||||
const t = calendarStore.now
|
||||
.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
|
||||
.replace(/,? /, '\n')
|
||||
return t.charAt(0).toUpperCase() + t.slice(1)
|
||||
})
|
||||
|
||||
const visibleWeeks = computed(() => {
|
||||
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))
|
||||
}
|
||||
return weeks
|
||||
})
|
||||
|
||||
const contentHeight = computed(() => {
|
||||
return totalVirtualWeeks.value * rowHeight.value
|
||||
})
|
||||
|
||||
// Virtual weeks manager (after dependent refs exist)
|
||||
const vwm = createVirtualWeekManager({
|
||||
calendarStore,
|
||||
viewport,
|
||||
viewportHeight,
|
||||
rowHeight,
|
||||
selection,
|
||||
baseDate,
|
||||
minVirtualWeek,
|
||||
maxVirtualWeek,
|
||||
contentHeight,
|
||||
})
|
||||
const visibleWeeks = vwm.visibleWeeks
|
||||
const { scheduleWindowUpdate, resetWeeks, refreshEvents } = vwm
|
||||
|
||||
// Scroll managers (after scheduleWindowUpdate available)
|
||||
const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate })
|
||||
const { scrollTop, setScrollTop, onScroll } = scrollManager
|
||||
const weekColumnScrollManager = createWeekColumnScrollManager({
|
||||
viewport,
|
||||
viewportHeight,
|
||||
contentHeight,
|
||||
setScrollTop,
|
||||
})
|
||||
const { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } =
|
||||
weekColumnScrollManager
|
||||
const monthScrollManager = createMonthScrollManager({
|
||||
viewport,
|
||||
viewportHeight,
|
||||
contentHeight,
|
||||
setScrollTop,
|
||||
})
|
||||
const { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel } =
|
||||
monthScrollManager
|
||||
|
||||
// Provide scroll refs to virtual week manager
|
||||
vwm.attachScroll(scrollTop, setScrollTop)
|
||||
|
||||
const initialScrollTop = computed(() => {
|
||||
const nowDate = new Date(calendarStore.now)
|
||||
const targetWeekIndex = getWeekIndex(nowDate) - 3
|
||||
return (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
})
|
||||
|
||||
function computeRowHeight() {
|
||||
if (rowProbe.value) {
|
||||
const h = rowProbe.value.getBoundingClientRect().height || 64
|
||||
rowHeight.value = Math.round(h)
|
||||
return rowHeight.value
|
||||
}
|
||||
const el = document.createElement('div')
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = 'var(--cell-h)'
|
||||
el.style.height = 'var(--row-h)'
|
||||
document.body.appendChild(el)
|
||||
const h = el.getBoundingClientRect().height || 64
|
||||
el.remove()
|
||||
rowHeight.value = Math.round(h)
|
||||
return rowHeight.value
|
||||
}
|
||||
|
||||
function getWeekIndex(date) {
|
||||
const firstDayOfWeek = new Date(date)
|
||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
firstDayOfWeek.setDate(date.getDate() - dayOffset)
|
||||
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS)
|
||||
}
|
||||
|
||||
function getFirstDayForVirtualWeek(virtualWeek) {
|
||||
const firstDay = new Date(baseDate)
|
||||
firstDay.setDate(firstDay.getDate() + virtualWeek * 7)
|
||||
return firstDay
|
||||
}
|
||||
|
||||
function createWeek(virtualWeek) {
|
||||
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
|
||||
const weekNumber = isoWeekInfo(firstDay).week
|
||||
const days = []
|
||||
const cur = new Date(firstDay)
|
||||
let hasFirst = false
|
||||
let monthToLabel = null
|
||||
let labelYear = null
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dateStr = toLocalString(cur)
|
||||
const eventsForDay = calendarStore.events.get(dateStr) || []
|
||||
const dow = cur.getDay()
|
||||
const isFirst = cur.getDate() === 1
|
||||
|
||||
if (isFirst) {
|
||||
hasFirst = true
|
||||
monthToLabel = cur.getMonth()
|
||||
labelYear = cur.getFullYear()
|
||||
}
|
||||
|
||||
let displayText = String(cur.getDate())
|
||||
if (isFirst) {
|
||||
if (cur.getMonth() === 0) {
|
||||
displayText = cur.getFullYear()
|
||||
} else {
|
||||
displayText = monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
days.push({
|
||||
date: dateStr,
|
||||
dayOfMonth: cur.getDate(),
|
||||
displayText,
|
||||
monthClass: monthAbbr[cur.getMonth()],
|
||||
isToday: dateStr === calendarStore.today,
|
||||
isWeekend: calendarStore.weekend[dow],
|
||||
isFirstDay: isFirst,
|
||||
lunarPhase: lunarPhaseSymbol(cur),
|
||||
isSelected:
|
||||
selection.value.startDate &&
|
||||
selection.value.dayCount > 0 &&
|
||||
dateStr >= selection.value.startDate &&
|
||||
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
||||
events: eventsForDay,
|
||||
})
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
|
||||
let monthLabel = null
|
||||
if (hasFirst && monthToLabel !== null) {
|
||||
if (labelYear && labelYear <= calendarStore.config.max_year) {
|
||||
let weeksSpan = 0
|
||||
const d = new Date(cur)
|
||||
d.setDate(cur.getDate() - 1)
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
d.setDate(cur.getDate() - 1 + i * 7)
|
||||
if (d.getMonth() === monthToLabel) weeksSpan++
|
||||
}
|
||||
|
||||
const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1)
|
||||
weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))
|
||||
|
||||
const year = String(labelYear).slice(-2)
|
||||
monthLabel = {
|
||||
text: `${getLocalizedMonthName(monthToLabel)} '${year}`,
|
||||
month: monthToLabel,
|
||||
weeksSpan: weeksSpan,
|
||||
height: weeksSpan * rowHeight.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
virtualWeek,
|
||||
weekNumber: pad(weekNumber),
|
||||
days,
|
||||
monthLabel,
|
||||
top: (virtualWeek - minVirtualWeek.value) * rowHeight.value,
|
||||
function measureFromProbe() {
|
||||
if (!rowProbe.value) return
|
||||
const h = rowProbe.value.getBoundingClientRect().height
|
||||
if (!h) return
|
||||
const newH = Math.round(h)
|
||||
if (newH !== rowHeight.value) {
|
||||
const oldH = rowHeight.value
|
||||
// Anchor: keep the same top virtual week visible.
|
||||
const topVirtualWeek = Math.floor(scrollTop.value / oldH) + minVirtualWeek.value
|
||||
rowHeight.value = newH
|
||||
const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH
|
||||
setScrollTop(newScrollTop, 'row-height-change')
|
||||
resetWeeks('row-height-change')
|
||||
}
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
const top = new Date(calendarStore.now)
|
||||
top.setDate(top.getDate() - 21)
|
||||
const targetWeekIndex = getWeekIndex(top)
|
||||
scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
if (viewport.value) {
|
||||
viewport.value.scrollTop = scrollTop.value
|
||||
}
|
||||
}
|
||||
const { getWeekIndex, getFirstDayForVirtualWeek, goToToday, handleHeaderYearChange } = vwm
|
||||
|
||||
// createWeek logic moved to virtualWeeks plugin
|
||||
|
||||
// goToToday now provided by manager
|
||||
|
||||
function clearSelection() {
|
||||
selection.value = { startDate: null, dayCount: 0 }
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -245,10 +182,102 @@ 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()
|
||||
}
|
||||
|
||||
// Build a minimal event creation payload from current selection
|
||||
// Returns null if selection is invalid or empty.
|
||||
function createEventFromSelection() {
|
||||
const sel = selection.value || {}
|
||||
if (!sel.startDate || !sel.dayCount || sel.dayCount <= 0) return null
|
||||
return {
|
||||
startDate: sel.startDate,
|
||||
dayCount: sel.dayCount,
|
||||
}
|
||||
}
|
||||
|
||||
function getDateUnderPoint(x, y) {
|
||||
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 wrRect = sampleWeek.getBoundingClientRect()
|
||||
const labelRight = labelEl ? labelEl.getBoundingClientRect().right : wrRect.left
|
||||
const daysAreaRight = wrRect.right
|
||||
const daysWidth = daysAreaRight - labelRight
|
||||
if (clientX < labelRight || clientX > daysAreaRight) return null
|
||||
const col = Math.min(6, Math.max(0, Math.floor(((clientX - labelRight) / daysWidth) * 7)))
|
||||
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)
|
||||
const otherDate = fromLocalString(otherStr)
|
||||
const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
|
||||
const otherDate = fromLocalString(otherStr, DEFAULT_TZ)
|
||||
const forward = otherDate >= anchorDate
|
||||
const span = daysInclusive(anchorStr, otherStr)
|
||||
|
||||
@@ -260,21 +289,18 @@ function calculateSelection(anchorStr, otherStr) {
|
||||
if (forward) {
|
||||
return { startDate: anchorStr, dayCount: limit }
|
||||
} else {
|
||||
const startDate = addDaysStr(anchorStr, -(limit - 1))
|
||||
const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ)
|
||||
return { startDate, dayCount: limit }
|
||||
}
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
if (viewport.value) {
|
||||
scrollTop.value = viewport.value.scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
const handleJogwheelScrollTo = (newScrollTop) => {
|
||||
if (viewport.value) {
|
||||
viewport.value.scrollTop = newScrollTop
|
||||
}
|
||||
// ---------------- Week label column drag scrolling ----------------
|
||||
function getWeekLabelRect() {
|
||||
// Prefer header year label width as stable reference
|
||||
const headerYear = document.querySelector('.calendar-header .year-label')
|
||||
if (headerYear) return headerYear.getBoundingClientRect()
|
||||
const weekLabel = viewport.value?.querySelector('.week-row .week-label')
|
||||
return weekLabel ? weekLabel.getBoundingClientRect() : null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -283,14 +309,27 @@ onMounted(() => {
|
||||
|
||||
if (viewport.value) {
|
||||
viewportHeight.value = viewport.value.clientHeight
|
||||
viewport.value.scrollTop = initialScrollTop.value
|
||||
setScrollTop(initialScrollTop.value, 'initial-mount')
|
||||
viewport.value.addEventListener('scroll', onScroll)
|
||||
// Capture mousedown in viewport to allow dragging via week label column
|
||||
viewport.value.addEventListener('mousedown', handleWeekColMouseDown, true)
|
||||
}
|
||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
||||
|
||||
const timer = setInterval(() => {
|
||||
calendarStore.updateCurrentDate()
|
||||
}, 60000)
|
||||
|
||||
// Initial incremental build (no existing weeks yet)
|
||||
scheduleWindowUpdate('init')
|
||||
|
||||
if (window.ResizeObserver && rowProbe.value) {
|
||||
rowProbeObserver = new ResizeObserver(() => {
|
||||
measureFromProbe()
|
||||
})
|
||||
rowProbeObserver.observe(rowProbe.value)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer)
|
||||
})
|
||||
@@ -299,113 +338,157 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
if (viewport.value) {
|
||||
viewport.value.removeEventListener('scroll', onScroll)
|
||||
viewport.value.removeEventListener('mousedown', handleWeekColMouseDown, true)
|
||||
}
|
||||
if (rowProbeObserver && rowProbe.value) {
|
||||
try {
|
||||
rowProbeObserver.unobserve(rowProbe.value)
|
||||
rowProbeObserver.disconnect()
|
||||
} catch (e) {}
|
||||
}
|
||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
||||
})
|
||||
|
||||
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 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', ev)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDayMouseUp = (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 handleDayTouchStart = (dateStr) => {
|
||||
startDrag(dateStr)
|
||||
const handleEventClick = (payload) => {
|
||||
emit('edit-event', payload)
|
||||
}
|
||||
|
||||
const handleDayTouchMove = (dateStr) => {
|
||||
if (isDragging.value) {
|
||||
updateDrag(dateStr)
|
||||
}
|
||||
// header year change delegated to manager
|
||||
|
||||
// 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
|
||||
return /\p{Script=Latin}/u.test(label)
|
||||
}
|
||||
|
||||
const handleDayTouchEnd = (dateStr) => {
|
||||
if (isDragging.value) {
|
||||
endDrag(dateStr)
|
||||
const eventData = createEventFromSelection()
|
||||
if (eventData) {
|
||||
clearSelection()
|
||||
emit('create-event', eventData)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Watch first day changes (e.g., first_day config update) to adjust scroll
|
||||
// Keep roughly same visible date when first_day setting changes.
|
||||
watch(
|
||||
() => calendarStore.config.first_day,
|
||||
() => {
|
||||
const currentTopVW = Math.floor(scrollTop.value / rowHeight.value) + minVirtualWeek.value
|
||||
const currentTopDate = getFirstDayForVirtualWeek(currentTopVW)
|
||||
requestAnimationFrame(() => {
|
||||
const newTopWeekIndex = getWeekIndex(currentTopDate)
|
||||
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
setScrollTop(newScroll, 'first-day-change')
|
||||
resetWeeks('first-day-change')
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const handleEventClick = (eventInstanceId) => {
|
||||
emit('edit-event', eventInstanceId)
|
||||
}
|
||||
// Watch lightweight mutation counter only (not deep events map) and rebuild lazily
|
||||
watch(
|
||||
() => calendarStore.events,
|
||||
() => {
|
||||
refreshEvents('events')
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// Reflect selection & events by rebuilding day objects in-place
|
||||
watch(
|
||||
() => [selection.value.startDate, selection.value.dayCount],
|
||||
() => refreshEvents('selection'),
|
||||
)
|
||||
|
||||
// Rebuild if viewport height changes (e.g., resize)
|
||||
window.addEventListener('resize', () => {
|
||||
if (viewport.value) viewportHeight.value = viewport.value.clientHeight
|
||||
measureFromProbe()
|
||||
scheduleWindowUpdate('resize')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>Calendar</h1>
|
||||
<div class="header-controls">
|
||||
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
||||
</div>
|
||||
</header>
|
||||
<CalendarHeader
|
||||
:scroll-top="scrollTop"
|
||||
:row-height="rowHeight"
|
||||
:min-virtual-week="minVirtualWeek"
|
||||
/>
|
||||
<div class="calendar-container">
|
||||
<div class="calendar-viewport" ref="viewport">
|
||||
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
||||
<CalendarWeek
|
||||
v-for="week in visibleWeeks"
|
||||
:key="week.virtualWeek"
|
||||
:week="week"
|
||||
: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 -->
|
||||
<div
|
||||
v-for="week in visibleWeeks"
|
||||
:key="`month-${week.virtualWeek}`"
|
||||
v-show="week.monthLabel"
|
||||
class="month-name-label"
|
||||
:style="{
|
||||
top: week.top + 'px',
|
||||
height: week.monthLabel?.height + 'px',
|
||||
}"
|
||||
>
|
||||
<span>{{ week.monthLabel?.text }}</span>
|
||||
<div class="calendar-view-root">
|
||||
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
||||
<div class="wrap">
|
||||
<HeaderControls @go-to-today="goToToday" />
|
||||
<CalendarHeader
|
||||
:scroll-top="scrollTop"
|
||||
:row-height="rowHeight"
|
||||
:min-virtual-week="minVirtualWeek"
|
||||
@year-change="handleHeaderYearChange"
|
||||
/>
|
||||
<div class="calendar-container">
|
||||
<div class="calendar-viewport" ref="viewport">
|
||||
<!-- Main calendar content (weeks and days) -->
|
||||
<div class="main-calendar-area">
|
||||
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
||||
<CalendarWeek
|
||||
v-for="week in visibleWeeks"
|
||||
:key="week.virtualWeek"
|
||||
:week="week"
|
||||
:dragging="isDragging"
|
||||
:style="{ top: week.top + 'px' }"
|
||||
@day-mousedown="handleDayMouseDown"
|
||||
@day-mouseenter="handleDayMouseEnter"
|
||||
@day-mouseup="handleDayMouseUp"
|
||||
@day-touchstart="handleDayTouchStart"
|
||||
@event-click="handleEventClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Month column area -->
|
||||
<div class="month-column-area">
|
||||
<!-- Month labels -->
|
||||
<div class="month-labels-container" :style="{ height: contentHeight + 'px' }">
|
||||
<template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'">
|
||||
<div
|
||||
v-if="monthWeek && monthWeek.monthLabel"
|
||||
class="month-label"
|
||||
:class="monthWeek.monthLabel?.monthClass"
|
||||
:style="{
|
||||
height: `calc(var(--row-h) * ${monthWeek.monthLabel?.weeksSpan || 1})`,
|
||||
top: (monthWeek.top || 0) + 'px',
|
||||
}"
|
||||
@pointerdown="handleMonthScrollPointerDown"
|
||||
@touchstart.prevent="handleMonthScrollTouchStart"
|
||||
@wheel="handleMonthScrollWheel"
|
||||
>
|
||||
<span :class="{ bottomup: shouldRotateMonth(monthWeek.monthLabel?.text) }">{{
|
||||
monthWeek.monthLabel?.text || ''
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Jogwheel as sibling to calendar-viewport -->
|
||||
<Jogwheel
|
||||
:total-virtual-weeks="totalVirtualWeeks"
|
||||
:row-height="rowHeight"
|
||||
:viewport-height="viewportHeight"
|
||||
:scroll-top="scrollTop"
|
||||
@scroll-to="handleJogwheelScrollTo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-view-root {
|
||||
display: contents;
|
||||
}
|
||||
.wrap {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
@@ -414,33 +497,15 @@ const handleEventClick = (eventInstanceId) => {
|
||||
|
||||
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;
|
||||
}
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.today-date {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
background: var(--today-btn-bg);
|
||||
color: var(--today-btn-text);
|
||||
border-radius: 4px;
|
||||
white-space: pre-line;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.today-date:hover {
|
||||
background: var(--today-btn-hover-bg);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-container {
|
||||
@@ -460,7 +525,13 @@ header h1 {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr var(--month-w);
|
||||
}
|
||||
|
||||
.main-calendar-area {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-content {
|
||||
@@ -468,27 +539,52 @@ header h1 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.month-name-label {
|
||||
.month-column-area {
|
||||
position: relative;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.month-labels-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 3rem; /* Match jogwheel width */
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2));
|
||||
font-size: 2em;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 15;
|
||||
overflow: visible;
|
||||
overflow: hidden;
|
||||
cursor: ns-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.month-name-label > span {
|
||||
.month-label > span {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
transform: rotate(180deg);
|
||||
transform-origin: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bottomup {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.row-height-probe {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
height: var(--row-h);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
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'])
|
||||
const emit = defineEmits([
|
||||
'day-mousedown',
|
||||
'day-mouseenter',
|
||||
'day-mouseup',
|
||||
'day-touchstart',
|
||||
'event-click',
|
||||
])
|
||||
|
||||
const handleDayMouseDown = (dateStr) => {
|
||||
emit('day-mousedown', dateStr)
|
||||
@@ -24,42 +28,38 @@ const handleDayTouchStart = (dateStr) => {
|
||||
emit('day-touchstart', dateStr)
|
||||
}
|
||||
|
||||
const handleDayTouchMove = (dateStr) => {
|
||||
emit('day-touchmove', dateStr)
|
||||
// touchmove & touchend handled globally in CalendarView
|
||||
|
||||
const handleEventClick = (payload) => {
|
||||
emit('event-click', payload)
|
||||
}
|
||||
|
||||
const handleDayTouchEnd = (dateStr) => {
|
||||
emit('day-touchend', dateStr)
|
||||
}
|
||||
|
||||
const handleEventClick = (eventId) => {
|
||||
emit('event-click', eventId)
|
||||
// Only apply upside-down rotation (bottomup) for Latin script month labels
|
||||
function shouldRotateMonth(label) {
|
||||
if (!label) return false
|
||||
try {
|
||||
return /\p{Script=Latin}/u.test(label)
|
||||
} catch (e) {
|
||||
return /[A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/u.test(label)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="week-row"
|
||||
:style="{ top: `${props.week.top}px` }"
|
||||
>
|
||||
<div class="week-row" :style="{ top: `${props.week.top}px` }">
|
||||
<div class="week-label">W{{ props.week.weekNumber }}</div>
|
||||
<div class="days-grid">
|
||||
<CalendarDay
|
||||
v-for="day in props.week.days"
|
||||
:key="day.date"
|
||||
<CalendarDay
|
||||
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)"
|
||||
@event-click="handleEventClick"
|
||||
/>
|
||||
<EventOverlay
|
||||
:week="props.week"
|
||||
@event-click="handleEventClick"
|
||||
/>
|
||||
<EventOverlay :week="props.week" @event-click="handleEventClick" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -67,9 +67,9 @@ const handleEventClick = (eventId) => {
|
||||
<style scoped>
|
||||
.week-row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--label-w) repeat(7, 1fr) 3rem;
|
||||
grid-template-columns: var(--week-w) repeat(7, 1fr);
|
||||
position: absolute;
|
||||
height: var(--cell-h);
|
||||
height: var(--row-h);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -80,13 +80,8 @@ const handleEventClick = (eventId) => {
|
||||
color: var(--muted);
|
||||
font-size: 1.2em;
|
||||
font-weight: 500;
|
||||
/* Prevent text selection */
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
height: var(--row-h);
|
||||
}
|
||||
|
||||
.days-grid {
|
||||
@@ -96,10 +91,4 @@ const handleEventClick = (eventId) => {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Fixed heights for cells and labels (from cells.css) */
|
||||
.week-row :deep(.cell),
|
||||
.week-label {
|
||||
height: var(--cell-h);
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@
|
||||
:key="span.id"
|
||||
class="event-span"
|
||||
:class="[`event-color-${span.colorId}`]"
|
||||
:data-id="span.id"
|
||||
:data-n="span._recurrenceIndex != null ? span._recurrenceIndex : 0"
|
||||
:style="{
|
||||
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`,
|
||||
gridRow: `${span.row}`,
|
||||
@@ -24,174 +26,104 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import { toLocalString, fromLocalString, daysInclusive, addDaysStr } from '@/utils/date'
|
||||
import { daysInclusive, addDaysStr } from '@/utils/date'
|
||||
|
||||
const props = defineProps({
|
||||
week: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
week: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['event-click'])
|
||||
const store = useCalendarStore()
|
||||
|
||||
// Local drag state
|
||||
// Drag state
|
||||
const dragState = ref(null)
|
||||
const justDragged = ref(false)
|
||||
|
||||
// Generate repeat occurrences for a specific date
|
||||
function generateRepeatOccurrencesForDate(targetDateStr) {
|
||||
const occurrences = []
|
||||
|
||||
// Get all events from the store and check for repeating ones
|
||||
for (const [, eventList] of store.events) {
|
||||
for (const baseEvent of eventList) {
|
||||
if (!baseEvent.isRepeating || baseEvent.repeat === 'none') {
|
||||
continue
|
||||
}
|
||||
|
||||
const targetDate = new Date(fromLocalString(targetDateStr))
|
||||
const baseStartDate = new Date(fromLocalString(baseEvent.startDate))
|
||||
const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
|
||||
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
|
||||
|
||||
if (baseEvent.repeat === 'weeks') {
|
||||
const repeatWeekdays = baseEvent.repeatWeekdays
|
||||
if (targetDate < baseStartDate) continue
|
||||
const maxOccurrences =
|
||||
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
|
||||
if (maxOccurrences === 0) continue
|
||||
const interval = baseEvent.repeatInterval || 1
|
||||
const msPerDay = 24 * 60 * 60 * 1000
|
||||
|
||||
// Determine if targetDate lies within some occurrence span. We look backwards up to spanDays to find a start day.
|
||||
let occStart = null
|
||||
for (let back = 0; back <= spanDays; back++) {
|
||||
const cand = new Date(targetDate)
|
||||
cand.setDate(cand.getDate() - back)
|
||||
if (cand < baseStartDate) break
|
||||
const daysDiff = Math.floor((cand - baseStartDate) / msPerDay)
|
||||
const weeksDiff = Math.floor(daysDiff / 7)
|
||||
if (weeksDiff % interval !== 0) continue
|
||||
if (repeatWeekdays[cand.getDay()]) {
|
||||
// candidate start must produce span covering targetDate
|
||||
const candEnd = new Date(cand)
|
||||
candEnd.setDate(candEnd.getDate() + spanDays)
|
||||
if (targetDate <= candEnd) {
|
||||
occStart = cand
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!occStart) continue
|
||||
// Skip base occurrence if this is within its span (base already physically stored)
|
||||
if (occStart.getTime() === baseStartDate.getTime()) continue
|
||||
// Compute occurrence index (number of previous start days)
|
||||
let occIdx = 0
|
||||
const cursor = new Date(baseStartDate)
|
||||
while (cursor < occStart && occIdx < maxOccurrences) {
|
||||
const cDaysDiff = Math.floor((cursor - baseStartDate) / msPerDay)
|
||||
const cWeeksDiff = Math.floor(cDaysDiff / 7)
|
||||
if (cWeeksDiff % interval === 0 && repeatWeekdays[cursor.getDay()]) occIdx++
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
}
|
||||
if (occIdx >= maxOccurrences) continue
|
||||
const occEnd = new Date(occStart)
|
||||
occEnd.setDate(occStart.getDate() + spanDays)
|
||||
const occStartStr = toLocalString(occStart)
|
||||
const occEndStr = toLocalString(occEnd)
|
||||
occurrences.push({
|
||||
...baseEvent,
|
||||
id: `${baseEvent.id}_repeat_${occIdx}_${occStart.getDay()}`,
|
||||
startDate: occStartStr,
|
||||
endDate: occEndStr,
|
||||
isRepeatOccurrence: true,
|
||||
repeatIndex: occIdx,
|
||||
})
|
||||
continue
|
||||
// Consolidate already-provided day.events into contiguous spans (no recurrence generation)
|
||||
const eventSpans = computed(() => {
|
||||
const weekEvents = new Map()
|
||||
props.week.days.forEach((day, dayIndex) => {
|
||||
day.events.forEach((ev) => {
|
||||
const key = ev.id
|
||||
if (!weekEvents.has(key)) {
|
||||
weekEvents.set(key, { ...ev, startIdx: dayIndex, endIdx: dayIndex })
|
||||
} else {
|
||||
// Handle other repeat types (months)
|
||||
let intervalsPassed = 0
|
||||
const timeDiff = targetDate - baseStartDate
|
||||
if (baseEvent.repeat === 'months') {
|
||||
intervalsPassed =
|
||||
(targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
|
||||
(targetDate.getMonth() - baseStartDate.getMonth())
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
const interval = baseEvent.repeatInterval || 1
|
||||
if (intervalsPassed < 0 || intervalsPassed % interval !== 0) continue
|
||||
|
||||
// Check a few occurrences around the target date
|
||||
const maxOccurrences =
|
||||
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
|
||||
if (maxOccurrences === 0) continue
|
||||
const i = intervalsPassed
|
||||
if (i >= maxOccurrences) continue
|
||||
const currentStart = new Date(baseStartDate)
|
||||
currentStart.setMonth(baseStartDate.getMonth() + i)
|
||||
const currentEnd = new Date(currentStart)
|
||||
currentEnd.setDate(currentStart.getDate() + spanDays)
|
||||
// If target day lies within base (i===0) we skip because base is stored already
|
||||
if (i === 0) {
|
||||
// only skip if targetDate within base span
|
||||
if (targetDate >= baseStartDate && targetDate <= baseEndDate) continue
|
||||
}
|
||||
const currentStartStr = toLocalString(currentStart)
|
||||
const currentEndStr = toLocalString(currentEnd)
|
||||
if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) {
|
||||
occurrences.push({
|
||||
...baseEvent,
|
||||
id: `${baseEvent.id}_repeat_${i}`,
|
||||
startDate: currentStartStr,
|
||||
endDate: currentEndStr,
|
||||
isRepeatOccurrence: true,
|
||||
repeatIndex: i,
|
||||
})
|
||||
}
|
||||
const ref = weekEvents.get(key)
|
||||
ref.endIdx = Math.max(ref.endIdx, dayIndex)
|
||||
}
|
||||
})
|
||||
})
|
||||
const arr = Array.from(weekEvents.values())
|
||||
arr.sort((a, b) => {
|
||||
const spanA = a.endIdx - a.startIdx
|
||||
const spanB = b.endIdx - b.startIdx
|
||||
if (spanA !== spanB) return spanB - spanA
|
||||
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
|
||||
// For one-day events that are otherwise equal, sort by color (0 first)
|
||||
if (spanA === 0 && spanB === 0 && a.startIdx === b.startIdx) {
|
||||
const colorA = a.colorId || 0
|
||||
const colorB = b.colorId || 0
|
||||
if (colorA !== colorB) return colorA - colorB
|
||||
}
|
||||
}
|
||||
return String(a.id).localeCompare(String(b.id))
|
||||
})
|
||||
// Assign non-overlapping rows
|
||||
const rowsLastEnd = []
|
||||
arr.forEach((ev) => {
|
||||
let row = 0
|
||||
while (row < rowsLastEnd.length && !(ev.startIdx > rowsLastEnd[row])) row++
|
||||
if (row === rowsLastEnd.length) rowsLastEnd.push(-1)
|
||||
rowsLastEnd[row] = ev.endIdx
|
||||
ev.row = row + 1
|
||||
})
|
||||
return arr
|
||||
})
|
||||
|
||||
return occurrences
|
||||
}
|
||||
|
||||
// Extract original event ID from repeat occurrence ID
|
||||
function getOriginalEventId(eventId) {
|
||||
if (typeof eventId === 'string' && eventId.includes('_repeat_')) {
|
||||
return eventId.split('_repeat_')[0]
|
||||
}
|
||||
return eventId
|
||||
}
|
||||
|
||||
// Handle event click
|
||||
function handleEventClick(span) {
|
||||
if (justDragged.value) return
|
||||
// Emit the actual span id (may include repeat suffix) so edit dialog knows occurrence context
|
||||
emit('event-click', span.id)
|
||||
// Emit composite payload: base id (without virtual marker), instance id, occurrence index (data-n)
|
||||
const idStr = span.id
|
||||
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
|
||||
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
|
||||
emit('event-click', {
|
||||
id: baseId,
|
||||
instanceId: span.id,
|
||||
occurrenceIndex: span._recurrenceIndex != null ? span._recurrenceIndex : 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle event pointer down for dragging
|
||||
function handleEventPointerDown(span, event) {
|
||||
// Don't start drag if clicking on resize handle
|
||||
if (event.target.classList.contains('resize-handle')) return
|
||||
|
||||
event.stopPropagation()
|
||||
// Do not preventDefault here to allow click unless drag threshold is passed
|
||||
|
||||
// Get the date under the pointer
|
||||
const hit = getDateUnderPointer(event.clientX, event.clientY, event.currentTarget)
|
||||
const anchorDate = hit ? hit.date : span.startDate
|
||||
|
||||
const idStr = span.id
|
||||
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
|
||||
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
|
||||
const isVirtual = hasVirtualMarker
|
||||
// Determine which day within the span was grabbed so we maintain relative position
|
||||
let anchorDate = span.startDate
|
||||
try {
|
||||
const spanDays = daysInclusive(span.startDate, span.endDate)
|
||||
const targetEl = event.currentTarget
|
||||
if (targetEl && spanDays > 0) {
|
||||
const rect = targetEl.getBoundingClientRect()
|
||||
const relX = event.clientX - rect.left
|
||||
const dayWidth = rect.width / spanDays
|
||||
let dayIndex = Math.floor(relX / dayWidth)
|
||||
if (!isFinite(dayIndex)) dayIndex = 0
|
||||
if (dayIndex < 0) dayIndex = 0
|
||||
if (dayIndex >= spanDays) dayIndex = spanDays - 1
|
||||
anchorDate = addDaysStr(span.startDate, dayIndex)
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to startDate if any calculation fails
|
||||
}
|
||||
startLocalDrag(
|
||||
{
|
||||
id: span.id,
|
||||
id: baseId,
|
||||
originalId: span.id,
|
||||
isVirtual,
|
||||
mode: 'move',
|
||||
pointerStartX: event.clientX,
|
||||
pointerStartY: event.clientY,
|
||||
@@ -203,13 +135,17 @@ function handleEventPointerDown(span, event) {
|
||||
)
|
||||
}
|
||||
|
||||
// Handle resize handle pointer down
|
||||
function handleResizePointerDown(span, mode, event) {
|
||||
event.stopPropagation()
|
||||
// Start drag from the current edge; anchorDate not needed for resize
|
||||
const idStr = span.id
|
||||
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
|
||||
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
|
||||
const isVirtual = hasVirtualMarker
|
||||
startLocalDrag(
|
||||
{
|
||||
id: span.id,
|
||||
id: baseId,
|
||||
originalId: span.id,
|
||||
isVirtual,
|
||||
mode,
|
||||
pointerStartX: event.clientX,
|
||||
pointerStartY: event.clientY,
|
||||
@@ -221,94 +157,6 @@ function handleResizePointerDown(span, mode, event) {
|
||||
)
|
||||
}
|
||||
|
||||
// Get date under pointer coordinates
|
||||
function getDateUnderPointer(clientX, clientY, targetEl) {
|
||||
// First try to find a day cell directly under the pointer
|
||||
let element = document.elementFromPoint(clientX, clientY)
|
||||
|
||||
// If we hit an event element, temporarily hide it and try again
|
||||
const hiddenElements = []
|
||||
while (element && element.classList.contains('event-span')) {
|
||||
element.style.pointerEvents = 'none'
|
||||
hiddenElements.push(element)
|
||||
element = document.elementFromPoint(clientX, clientY)
|
||||
}
|
||||
|
||||
// Restore pointer events for hidden elements
|
||||
hiddenElements.forEach((el) => (el.style.pointerEvents = 'auto'))
|
||||
|
||||
if (element) {
|
||||
// Look for a day cell with data-date attribute
|
||||
const dayElement = element.closest('[data-date]')
|
||||
if (dayElement && dayElement.dataset.date) {
|
||||
return { date: dayElement.dataset.date }
|
||||
}
|
||||
|
||||
// Also check if we're over a week element and can calculate position
|
||||
const weekElement = element.closest('.week-row')
|
||||
if (weekElement) {
|
||||
const rect = weekElement.getBoundingClientRect()
|
||||
const relativeX = clientX - rect.left
|
||||
const dayWidth = rect.width / 7
|
||||
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
|
||||
|
||||
const daysGrid = weekElement.querySelector('.days-grid')
|
||||
if (daysGrid && daysGrid.children[dayIndex]) {
|
||||
const dayEl = daysGrid.children[dayIndex]
|
||||
const date = dayEl?.dataset?.date
|
||||
if (date) return { date }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to find the week overlay and calculate position
|
||||
const overlayEl = targetEl?.closest('.week-overlay')
|
||||
const weekElement = overlayEl ? overlayEl.parentElement : null
|
||||
if (!weekElement) {
|
||||
// If we're outside this week, try to find any week element under the pointer
|
||||
const allWeekElements = document.querySelectorAll('.week-row')
|
||||
let bestWeek = null
|
||||
let bestDistance = Infinity
|
||||
|
||||
for (const week of allWeekElements) {
|
||||
const rect = week.getBoundingClientRect()
|
||||
if (clientY >= rect.top && clientY <= rect.bottom) {
|
||||
const distance = Math.abs(clientY - (rect.top + rect.height / 2))
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance
|
||||
bestWeek = week
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestWeek) {
|
||||
const rect = bestWeek.getBoundingClientRect()
|
||||
const relativeX = clientX - rect.left
|
||||
const dayWidth = rect.width / 7
|
||||
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
|
||||
|
||||
const daysGrid = bestWeek.querySelector('.days-grid')
|
||||
if (daysGrid && daysGrid.children[dayIndex]) {
|
||||
const dayEl = daysGrid.children[dayIndex]
|
||||
const date = dayEl?.dataset?.date
|
||||
if (date) return { date }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const rect = weekElement.getBoundingClientRect()
|
||||
const relativeX = clientX - rect.left
|
||||
const dayWidth = rect.width / 7
|
||||
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
|
||||
|
||||
if (props.week.days[dayIndex]) {
|
||||
return { date: props.week.days[dayIndex].date }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Local drag handling
|
||||
function startLocalDrag(init, evt) {
|
||||
const spanDays = daysInclusive(init.startDate, init.endDate)
|
||||
@@ -319,13 +167,39 @@ function startLocalDrag(init, evt) {
|
||||
else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1
|
||||
}
|
||||
|
||||
// Capture original repeating pattern & weekday (for weekly repeats) so we can rotate relative to original
|
||||
let originalWeekday = null
|
||||
let originalPattern = null
|
||||
if (init.mode === 'move') {
|
||||
try {
|
||||
originalWeekday = new Date(init.startDate + 'T00:00:00').getDay()
|
||||
const baseEv = store.getEventById(init.id)
|
||||
if (
|
||||
baseEv &&
|
||||
baseEv.recur &&
|
||||
baseEv.recur.freq === 'weeks' &&
|
||||
Array.isArray(baseEv.recur.weekdays)
|
||||
) {
|
||||
originalPattern = [...baseEv.recur.weekdays]
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
dragState.value = {
|
||||
...init,
|
||||
anchorOffset,
|
||||
originSpanDays: spanDays,
|
||||
eventMoved: false,
|
||||
tentativeStart: init.startDate,
|
||||
tentativeEnd: init.endDate,
|
||||
originalWeekday,
|
||||
originalPattern,
|
||||
realizedId: null, // for virtual occurrence converted to real during drag
|
||||
}
|
||||
|
||||
// Begin compound history session (single snapshot after drag completes)
|
||||
store.$history?.beginCompound()
|
||||
|
||||
// Capture pointer events globally
|
||||
if (evt.currentTarget && evt.pointerId !== undefined) {
|
||||
try {
|
||||
@@ -335,14 +209,35 @@ function startLocalDrag(init, evt) {
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent default to avoid text selection and other interference
|
||||
evt.preventDefault()
|
||||
// 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 })
|
||||
window.addEventListener('pointercancel', onDragPointerUp, { passive: false })
|
||||
}
|
||||
|
||||
// Determine date under pointer: traverse DOM to find day cell carrying data-date attribute
|
||||
function getDateUnderPointer(x, y, el) {
|
||||
let cur = el
|
||||
while (cur) {
|
||||
if (cur.dataset && cur.dataset.date) {
|
||||
return { date: cur.dataset.date }
|
||||
}
|
||||
cur = cur.parentElement
|
||||
}
|
||||
// Fallback: elementFromPoint scan
|
||||
const probe = document.elementFromPoint(x, y)
|
||||
let p = probe
|
||||
while (p) {
|
||||
if (p.dataset && p.dataset.date) return { date: p.dataset.date }
|
||||
p = p.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function onDragPointerMove(e) {
|
||||
const st = dragState.value
|
||||
if (!st) return
|
||||
@@ -360,7 +255,66 @@ 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
|
||||
if (st.mode === 'move') {
|
||||
if (st.isVirtual) {
|
||||
// On first movement convert virtual occurrence into a real new event (split series)
|
||||
if (!st.realizedId) {
|
||||
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne)
|
||||
if (newId) {
|
||||
st.realizedId = newId
|
||||
st.id = newId
|
||||
st.isVirtual = false
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Subsequent moves: update range without rotating pattern automatically
|
||||
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
|
||||
}
|
||||
} else {
|
||||
// Normal non-virtual move; rotate handled in setEventRange
|
||||
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
|
||||
}
|
||||
// Manual rotation relative to original pattern (keeps pattern anchored to initially grabbed weekday)
|
||||
if (st.originalPattern && st.originalWeekday != null) {
|
||||
try {
|
||||
const currentWeekday = new Date(ns + 'T00:00:00').getDay()
|
||||
const shift = currentWeekday - st.originalWeekday
|
||||
const rotated = store._rotateWeekdayPattern([...st.originalPattern], shift)
|
||||
const ev = store.getEventById(st.id)
|
||||
if (ev && ev.recur && ev.recur.freq === 'weeks') {
|
||||
ev.recur.weekdays = rotated
|
||||
store.touchEvents()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} else if (!st.isVirtual) {
|
||||
// Resizes on real events update immediately
|
||||
applyRangeDuringDrag(
|
||||
{ id: st.id, isVirtual: st.isVirtual, mode: st.mode, startDate: ns, endDate: ne },
|
||||
ns,
|
||||
ne,
|
||||
)
|
||||
} else if (st.isVirtual && (st.mode === 'resize-left' || st.mode === 'resize-right')) {
|
||||
// For virtual occurrence resize: convert to real once, then adjust range
|
||||
if (!st.realizedId) {
|
||||
const initialStart = ns
|
||||
const initialEnd = ne
|
||||
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, initialStart, initialEnd)
|
||||
if (newId) {
|
||||
st.realizedId = newId
|
||||
st.id = newId
|
||||
st.isVirtual = false
|
||||
} else return
|
||||
}
|
||||
// Apply range change; rotate if left edge moved and weekday changed
|
||||
const rotate = st.mode === 'resize-left'
|
||||
store.setEventRange(st.id, ns, ne, { mode: st.mode, rotatePattern: rotate })
|
||||
}
|
||||
}
|
||||
|
||||
function onDragPointerUp(e) {
|
||||
@@ -377,6 +331,8 @@ function onDragPointerUp(e) {
|
||||
}
|
||||
|
||||
const moved = !!st.eventMoved
|
||||
const finalStart = st.tentativeStart
|
||||
const finalEnd = st.tentativeEnd
|
||||
dragState.value = null
|
||||
|
||||
window.removeEventListener('pointermove', onDragPointerMove)
|
||||
@@ -384,11 +340,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) {
|
||||
@@ -416,133 +388,13 @@ function normalizeDateOrder(aStr, bStr) {
|
||||
}
|
||||
|
||||
function applyRangeDuringDrag(st, startDate, endDate) {
|
||||
let ev = store.getEventById(st.id)
|
||||
let isRepeatOccurrence = false
|
||||
let baseId = st.id
|
||||
let repeatIndex = 0
|
||||
let grabbedWeekday = null
|
||||
|
||||
// If not found (repeat occurrences aren't stored) parse synthetic id
|
||||
if (!ev && typeof st.id === 'string' && st.id.includes('_repeat_')) {
|
||||
const [bid, suffix] = st.id.split('_repeat_')
|
||||
baseId = bid
|
||||
ev = store.getEventById(baseId)
|
||||
if (ev) {
|
||||
const parts = suffix.split('_')
|
||||
repeatIndex = parseInt(parts[0], 10) || 0
|
||||
grabbedWeekday = parts.length > 1 ? parseInt(parts[1], 10) : null
|
||||
isRepeatOccurrence = repeatIndex >= 0
|
||||
}
|
||||
if (st.isVirtual) {
|
||||
if (st.mode !== 'move') return // no resize for virtual occurrence
|
||||
// Split-move: occurrence being dragged treated as first of new series
|
||||
store.splitMoveVirtualOccurrence(st.id, st.startDate, startDate, endDate)
|
||||
return
|
||||
}
|
||||
|
||||
if (!ev) return
|
||||
|
||||
const mode = st.mode === 'resize-left' || st.mode === 'resize-right' ? st.mode : 'move'
|
||||
if (isRepeatOccurrence) {
|
||||
if (repeatIndex === 0) {
|
||||
store.setEventRange(baseId, startDate, endDate, { mode })
|
||||
} else {
|
||||
if (!st.splitNewBaseId) {
|
||||
const newId = store.splitRepeatSeries(
|
||||
baseId,
|
||||
repeatIndex,
|
||||
startDate,
|
||||
endDate,
|
||||
grabbedWeekday,
|
||||
)
|
||||
if (newId) {
|
||||
st.splitNewBaseId = newId
|
||||
st.id = newId
|
||||
st.startDate = startDate
|
||||
st.endDate = endDate
|
||||
}
|
||||
} else {
|
||||
store.setEventRange(st.splitNewBaseId, startDate, endDate, { mode })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
store.setEventRange(st.id, startDate, endDate, { mode })
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate event spans for this week
|
||||
const eventSpans = computed(() => {
|
||||
const spans = []
|
||||
const weekEvents = new Map()
|
||||
|
||||
// Collect events from all days in this week, including repeat occurrences
|
||||
props.week.days.forEach((day, dayIndex) => {
|
||||
// Get base events for this day
|
||||
day.events.forEach((event) => {
|
||||
if (!weekEvents.has(event.id)) {
|
||||
weekEvents.set(event.id, {
|
||||
...event,
|
||||
startIdx: dayIndex,
|
||||
endIdx: dayIndex,
|
||||
})
|
||||
} else {
|
||||
const existing = weekEvents.get(event.id)
|
||||
existing.endIdx = dayIndex
|
||||
}
|
||||
})
|
||||
|
||||
// Generate repeat occurrences for this day
|
||||
const repeatOccurrences = generateRepeatOccurrencesForDate(day.date)
|
||||
repeatOccurrences.forEach((event) => {
|
||||
if (!weekEvents.has(event.id)) {
|
||||
weekEvents.set(event.id, {
|
||||
...event,
|
||||
startIdx: dayIndex,
|
||||
endIdx: dayIndex,
|
||||
})
|
||||
} else {
|
||||
const existing = weekEvents.get(event.id)
|
||||
existing.endIdx = dayIndex
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Convert to array and sort
|
||||
const eventArray = Array.from(weekEvents.values())
|
||||
eventArray.sort((a, b) => {
|
||||
// Sort by span length (longer first)
|
||||
const spanA = a.endIdx - a.startIdx
|
||||
const spanB = b.endIdx - b.startIdx
|
||||
if (spanA !== spanB) return spanB - spanA
|
||||
|
||||
// Then by start position
|
||||
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
|
||||
|
||||
// Then by start time if available
|
||||
const timeA = a.startTime ? timeToMinutes(a.startTime) : 0
|
||||
const timeB = b.startTime ? timeToMinutes(b.startTime) : 0
|
||||
if (timeA !== timeB) return timeA - timeB
|
||||
|
||||
// Fallback to ID
|
||||
return String(a.id).localeCompare(String(b.id))
|
||||
})
|
||||
|
||||
// Assign rows to avoid overlaps
|
||||
const rowsLastEnd = []
|
||||
eventArray.forEach((event) => {
|
||||
let placedRow = 0
|
||||
while (placedRow < rowsLastEnd.length && !(event.startIdx > rowsLastEnd[placedRow])) {
|
||||
placedRow++
|
||||
}
|
||||
if (placedRow === rowsLastEnd.length) {
|
||||
rowsLastEnd.push(-1)
|
||||
}
|
||||
rowsLastEnd[placedRow] = event.endIdx
|
||||
event.row = placedRow + 1
|
||||
})
|
||||
|
||||
return eventArray
|
||||
})
|
||||
|
||||
function timeToMinutes(timeStr) {
|
||||
if (!timeStr) return 0
|
||||
const [hours, minutes] = timeStr.split(':').map(Number)
|
||||
return hours * 60 + minutes
|
||||
store.setEventRange(st.id, startDate, endDate, { mode: st.mode })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -564,7 +416,7 @@ function timeToMinutes(timeStr) {
|
||||
|
||||
.event-span {
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 0.2em;
|
||||
border-radius: 1em;
|
||||
font-size: clamp(0.45em, 1.8vh, 0.75em);
|
||||
font-weight: 600;
|
||||
cursor: grab;
|
||||
|
||||
210
src/components/HeaderControls.vue
Normal file
210
src/components/HeaderControls.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<Transition name="header-controls" appear>
|
||||
<div v-if="isVisible" class="header-controls">
|
||||
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="hist-btn"
|
||||
:disabled="!calendarStore.historyCanUndo"
|
||||
@click="calendarStore.$history?.undo()"
|
||||
title="Undo (Ctrl+Z)"
|
||||
aria-label="Undo"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="hist-btn"
|
||||
:disabled="!calendarStore.historyCanRedo"
|
||||
@click="calendarStore.$history?.redo()"
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
aria-label="Redo"
|
||||
>
|
||||
↷
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-btn"
|
||||
@click="openSettings"
|
||||
aria-label="Open settings"
|
||||
title="Settings"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<!-- Settings dialog now lives here -->
|
||||
<SettingsDialog ref="settingsDialog" />
|
||||
</div>
|
||||
</Transition>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-btn"
|
||||
@click="toggleVisibility"
|
||||
:aria-label="isVisible ? 'Hide controls' : 'Show controls'"
|
||||
:title="isVisible ? 'Hide controls' : 'Show controls'"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import { formatTodayString } from '@/utils/date'
|
||||
import SettingsDialog from '@/components/SettingsDialog.vue'
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
|
||||
const todayString = computed(() => {
|
||||
const d = new Date(calendarStore.now)
|
||||
return formatTodayString(d)
|
||||
})
|
||||
|
||||
const emit = defineEmits(['go-to-today'])
|
||||
|
||||
function goToToday() {
|
||||
// Emit the event so the parent can handle the viewport scrolling logic
|
||||
// since this component doesn't have access to viewport refs
|
||||
emit('go-to-today')
|
||||
}
|
||||
|
||||
// Screen size detection and visibility toggle
|
||||
const isVisible = ref(false)
|
||||
|
||||
function checkScreenSize() {
|
||||
const isSmallScreen = window.innerHeight < 600
|
||||
// Default to open on large screens, closed on small screens
|
||||
isVisible.value = !isSmallScreen
|
||||
}
|
||||
|
||||
function toggleVisibility() {
|
||||
isVisible.value = !isVisible.value
|
||||
}
|
||||
|
||||
// Settings dialog integration
|
||||
const settingsDialog = ref(null)
|
||||
function openSettings() {
|
||||
settingsDialog.value?.open()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkScreenSize()
|
||||
window.addEventListener('resize', checkScreenSize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', checkScreenSize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-controls {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
.toggle-btn {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
padding: 0;
|
||||
margin: 0.5em;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
color: var(--strong);
|
||||
}
|
||||
|
||||
.toggle-btn:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.header-controls-enter-active,
|
||||
.header-controls-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-controls-enter-from,
|
||||
.header-controls-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
.header-controls-enter-to,
|
||||
.header-controls-leave-from {
|
||||
opacity: 1;
|
||||
max-height: 100px;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-right: 0.6rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hist-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
}
|
||||
|
||||
.hist-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.hist-btn:not(:disabled):hover,
|
||||
.hist-btn:not(:disabled):focus-visible {
|
||||
color: var(--strong);
|
||||
}
|
||||
|
||||
.hist-btn:active:not(:disabled) {
|
||||
transform: scale(0.88);
|
||||
}
|
||||
|
||||
.settings-btn:hover {
|
||||
color: var(--strong);
|
||||
}
|
||||
|
||||
.today-date {
|
||||
white-space: pre-line;
|
||||
text-align: center;
|
||||
margin-right: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,21 @@
|
||||
<template>
|
||||
<div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll">
|
||||
<div class="jogwheel-content" ref="jogwheelContent" :style="{ height: jogwheelHeight + 'px' }"></div>
|
||||
<div
|
||||
class="jogwheel-content"
|
||||
ref="jogwheelContent"
|
||||
:style="{ height: jogwheelHeight + 'px' }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
totalVirtualWeeks: { type: Number, required: true },
|
||||
rowHeight: { type: Number, required: true },
|
||||
viewportHeight: { type: Number, required: true },
|
||||
scrollTop: { type: Number, required: true }
|
||||
scrollTop: { type: Number, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['scroll-to'])
|
||||
@@ -19,6 +23,12 @@ const emit = defineEmits(['scroll-to'])
|
||||
const jogwheelViewport = ref(null)
|
||||
const jogwheelContent = ref(null)
|
||||
const syncLock = ref(null)
|
||||
// Drag state (no momentum, 1:1 mapping)
|
||||
const isDragging = ref(false)
|
||||
let mainStartScroll = 0
|
||||
let dragScale = 1 // mainScrollPixels per mouse pixel
|
||||
let accumDelta = 0
|
||||
let pointerLocked = false
|
||||
|
||||
// Jogwheel content height is 1/10th of main calendar
|
||||
const jogwheelHeight = computed(() => {
|
||||
@@ -30,21 +40,100 @@ const handleJogwheelScroll = () => {
|
||||
syncFromJogwheel()
|
||||
}
|
||||
|
||||
function onDragMouseDown(e) {
|
||||
if (e.button !== 0) return
|
||||
isDragging.value = true
|
||||
mainStartScroll = props.scrollTop
|
||||
accumDelta = 0
|
||||
// Precompute scale between jogwheel scrollable range and main scrollable range
|
||||
const mainScrollable = Math.max(
|
||||
0,
|
||||
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
||||
)
|
||||
let jogScrollable = 0
|
||||
if (jogwheelViewport.value && jogwheelContent.value) {
|
||||
jogScrollable = Math.max(
|
||||
0,
|
||||
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
||||
)
|
||||
}
|
||||
dragScale = jogScrollable > 0 ? mainScrollable / jogScrollable : 1
|
||||
if (!isFinite(dragScale) || dragScale <= 0) dragScale = 1
|
||||
// Attempt pointer lock for relative movement
|
||||
if (jogwheelViewport.value && jogwheelViewport.value.requestPointerLock) {
|
||||
jogwheelViewport.value.requestPointerLock()
|
||||
}
|
||||
window.addEventListener('mousemove', onDragMouseMove, { passive: false })
|
||||
window.addEventListener('mouseup', onDragMouseUp, { passive: false })
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onDragMouseMove(e) {
|
||||
if (!isDragging.value) return
|
||||
const dy = pointerLocked ? e.movementY : e.clientY // movementY only valid in pointer lock
|
||||
accumDelta += dy
|
||||
let desired = mainStartScroll - accumDelta * dragScale
|
||||
if (desired < 0) desired = 0
|
||||
const maxScroll = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
||||
if (desired > maxScroll) desired = maxScroll
|
||||
emit('scroll-to', desired)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onDragMouseUp(e) {
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
window.removeEventListener('mousemove', onDragMouseMove)
|
||||
window.removeEventListener('mouseup', onDragMouseUp)
|
||||
if (pointerLocked && document.exitPointerLock) document.exitPointerLock()
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function handlePointerLockChange() {
|
||||
pointerLocked = document.pointerLockElement === jogwheelViewport.value
|
||||
if (!pointerLocked && isDragging.value) {
|
||||
// Pointer lock lost (Esc) -> end drag gracefully
|
||||
onDragMouseUp(new MouseEvent('mouseup'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (jogwheelViewport.value) {
|
||||
jogwheelViewport.value.addEventListener('mousedown', onDragMouseDown)
|
||||
}
|
||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (jogwheelViewport.value) {
|
||||
jogwheelViewport.value.removeEventListener('mousedown', onDragMouseDown)
|
||||
}
|
||||
window.removeEventListener('mousemove', onDragMouseMove)
|
||||
window.removeEventListener('mouseup', onDragMouseUp)
|
||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
||||
})
|
||||
|
||||
const syncFromJogwheel = () => {
|
||||
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
||||
|
||||
|
||||
syncLock.value = 'main'
|
||||
|
||||
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
||||
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
||||
|
||||
|
||||
const jogScrollable = Math.max(
|
||||
0,
|
||||
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
||||
)
|
||||
const mainScrollable = Math.max(
|
||||
0,
|
||||
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
||||
)
|
||||
|
||||
if (jogScrollable > 0) {
|
||||
const ratio = jogwheelViewport.value.scrollTop / jogScrollable
|
||||
|
||||
|
||||
// Emit scroll event to parent to update main viewport
|
||||
emit('scroll-to', ratio * mainScrollable)
|
||||
}
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
if (syncLock.value === 'main') syncLock.value = null
|
||||
}, 50)
|
||||
@@ -53,29 +142,38 @@ const syncFromJogwheel = () => {
|
||||
const syncFromMain = (mainScrollTop) => {
|
||||
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
||||
if (syncLock.value === 'main') return
|
||||
|
||||
|
||||
syncLock.value = 'jogwheel'
|
||||
|
||||
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
||||
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
||||
|
||||
|
||||
const mainScrollable = Math.max(
|
||||
0,
|
||||
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
||||
)
|
||||
const jogScrollable = Math.max(
|
||||
0,
|
||||
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
||||
)
|
||||
|
||||
if (mainScrollable > 0) {
|
||||
const ratio = mainScrollTop / mainScrollable
|
||||
jogwheelViewport.value.scrollTop = ratio * jogScrollable
|
||||
}
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
if (syncLock.value === 'jogwheel') syncLock.value = null
|
||||
}, 50)
|
||||
}
|
||||
|
||||
// Watch for main calendar scroll changes
|
||||
watch(() => props.scrollTop, (newScrollTop) => {
|
||||
syncFromMain(newScrollTop)
|
||||
})
|
||||
watch(
|
||||
() => props.scrollTop,
|
||||
(newScrollTop) => {
|
||||
syncFromMain(newScrollTop)
|
||||
},
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
syncFromMain
|
||||
syncFromMain,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -85,20 +183,12 @@ defineExpose({
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 3rem; /* Use fixed width since minmax() doesn't work for absolute positioning */
|
||||
width: var(--month-w);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
z-index: 20;
|
||||
cursor: ns-resize;
|
||||
background: rgba(0, 0, 0, 0.05); /* Temporary background to see the area */
|
||||
/* Prevent text selection */
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.jogwheel-viewport::-webkit-scrollbar {
|
||||
|
||||
@@ -7,21 +7,20 @@
|
||||
role="spinbutton"
|
||||
:aria-valuemin="minValue"
|
||||
:aria-valuemax="maxValue"
|
||||
:aria-valuenow="isPrefix(current) ? undefined : current"
|
||||
:aria-valuenow="isPrefix(model) ? undefined : model"
|
||||
:aria-valuetext="display"
|
||||
tabindex="0"
|
||||
@pointerdown="onPointerDown"
|
||||
@keydown="onKey"
|
||||
@wheel.prevent="onWheel"
|
||||
>
|
||||
<span class="value" :title="String(current)">{{ display }}</span>
|
||||
<span class="value" :title="String(model)">{{ display }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const model = defineModel({ type: Number, default: 0 })
|
||||
|
||||
const model = defineModel({ default: 0 })
|
||||
const props = defineProps({
|
||||
min: { type: Number, default: 0 },
|
||||
max: { type: Number, default: 999 },
|
||||
@@ -36,111 +35,122 @@ const props = defineProps({
|
||||
numberPostfix: { type: String, default: '' },
|
||||
clamp: { type: Boolean, default: true },
|
||||
pixelsPerStep: { type: Number, default: 16 },
|
||||
// Movement now restricted to horizontal (x). Prop retained for compatibility but ignored.
|
||||
axis: { type: String, default: 'x' },
|
||||
ariaLabel: { type: String, default: '' },
|
||||
extraClass: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const minValue = computed(() => props.min)
|
||||
const maxValue = computed(() => props.max)
|
||||
|
||||
// Helper to check if a value is in the prefix values
|
||||
const isPrefix = (value) => {
|
||||
return props.prefixValues.some((prefix) => prefix.value === value)
|
||||
}
|
||||
|
||||
// Helper to get the display for a prefix value
|
||||
const getPrefixDisplay = (value) => {
|
||||
const prefix = props.prefixValues.find((p) => p.value === value)
|
||||
return prefix ? prefix.display : null
|
||||
}
|
||||
|
||||
// Get all valid values in order: prefixValues, then min to max
|
||||
const isPrefix = (value) => props.prefixValues.some((p) => p.value === value)
|
||||
const getPrefixDisplay = (value) =>
|
||||
props.prefixValues.find((p) => p.value === value)?.display ?? null
|
||||
const allValidValues = computed(() => {
|
||||
const prefixVals = props.prefixValues.map((p) => p.value)
|
||||
const numericVals = []
|
||||
for (let i = props.min; i <= props.max; i += props.step) {
|
||||
numericVals.push(i)
|
||||
}
|
||||
for (let i = props.min; i <= props.max; i += props.step) numericVals.push(i)
|
||||
return [...prefixVals, ...numericVals]
|
||||
})
|
||||
|
||||
const current = computed({
|
||||
get() {
|
||||
return model.value
|
||||
},
|
||||
set(v) {
|
||||
if (props.clamp) {
|
||||
// If it's a prefix value, allow it
|
||||
if (isPrefix(v)) {
|
||||
model.value = v
|
||||
return
|
||||
}
|
||||
// Otherwise clamp to numeric range
|
||||
if (v < props.min) v = props.min
|
||||
if (v > props.max) v = props.max
|
||||
}
|
||||
model.value = v
|
||||
},
|
||||
})
|
||||
|
||||
const display = computed(() => {
|
||||
const prefixDisplay = getPrefixDisplay(current.value)
|
||||
if (prefixDisplay !== null) {
|
||||
// For prefix values, show only the display text without number prefix/postfix
|
||||
return prefixDisplay
|
||||
}
|
||||
// For numeric values, include prefix and postfix
|
||||
const numericValue = String(current.value)
|
||||
return `${props.numberPrefix}${numericValue}${props.numberPostfix}`
|
||||
const prefixDisplay = getPrefixDisplay(model.value)
|
||||
if (prefixDisplay !== null) return prefixDisplay
|
||||
return `${props.numberPrefix}${String(model.value)}${props.numberPostfix}`
|
||||
})
|
||||
|
||||
// Drag handling
|
||||
const dragging = ref(false)
|
||||
const rootEl = ref(null)
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let startVal = 0
|
||||
|
||||
let accumX = 0
|
||||
let lastClientX = 0
|
||||
const pointerLocked = ref(false)
|
||||
function updatePointerLocked() {
|
||||
pointerLocked.value =
|
||||
typeof document !== 'undefined' && document.pointerLockElement === rootEl.value
|
||||
if (pointerLocked.value) {
|
||||
accumX = 0
|
||||
startX = 0
|
||||
}
|
||||
}
|
||||
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
|
||||
} else {
|
||||
if (newIndex >= 0 && newIndex < allValidValues.value.length) {
|
||||
const next = allValidValues.value[newIndex]
|
||||
if (next !== current.value) current.value = next
|
||||
let dx = pointerLocked.value ? e.movementX || 0 : e.clientX - lastClientX
|
||||
if (!pointerLocked.value) lastClientX = e.clientX
|
||||
if (!dx) return
|
||||
accumX += dx
|
||||
const stepSize = props.pixelsPerStep || 1
|
||||
let steps = Math.trunc(accumX / stepSize)
|
||||
if (steps === 0) return
|
||||
const applySteps = (count) => {
|
||||
if (!count) return
|
||||
let direction = count > 0 ? 1 : -1
|
||||
let remaining = Math.abs(count)
|
||||
let curVal = model.value
|
||||
const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
|
||||
let idx = allValidValues.value.indexOf(curVal)
|
||||
if (idx === -1) {
|
||||
if (!isNumeric) {
|
||||
curVal = props.prefixValues.length ? props.prefixValues[0].value : props.min
|
||||
} else {
|
||||
if (direction > 0) curVal = props.min
|
||||
else
|
||||
curVal = props.prefixValues.length
|
||||
? props.prefixValues[props.prefixValues.length - 1].value
|
||||
: props.min
|
||||
}
|
||||
remaining--
|
||||
}
|
||||
while (remaining > 0) {
|
||||
idx = allValidValues.value.indexOf(curVal)
|
||||
if (idx === -1) break
|
||||
let targetIdx = idx + direction
|
||||
if (props.clamp) targetIdx = Math.max(0, Math.min(targetIdx, allValidValues.value.length - 1))
|
||||
if (targetIdx < 0 || targetIdx >= allValidValues.value.length || targetIdx === idx) break
|
||||
curVal = allValidValues.value[targetIdx]
|
||||
remaining--
|
||||
}
|
||||
model.value = curVal
|
||||
}
|
||||
applySteps(steps)
|
||||
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
|
||||
@@ -150,52 +160,43 @@ function onPointerCancel() {
|
||||
dragging.value = false
|
||||
endDragListeners()
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
const key = e.key
|
||||
let handled = false
|
||||
let newValue = null
|
||||
|
||||
// Find current value index in all valid values
|
||||
const currentIndex = allValidValues.value.indexOf(current.value)
|
||||
|
||||
const currentIndex = allValidValues.value.indexOf(model.value)
|
||||
switch (key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1) {
|
||||
if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1)
|
||||
newValue = allValidValues.value[currentIndex + 1]
|
||||
} else if (currentIndex === -1) {
|
||||
// Current value not in list, try to increment normally
|
||||
newValue = current.value + props.step
|
||||
else if (currentIndex === -1) {
|
||||
const curVal = model.value
|
||||
const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
|
||||
if (!isNumeric && props.prefixValues.length) newValue = props.prefixValues[0].value
|
||||
else newValue = props.min
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowDown':
|
||||
if (currentIndex !== -1 && currentIndex > 0) {
|
||||
newValue = allValidValues.value[currentIndex - 1]
|
||||
} else if (currentIndex === -1) {
|
||||
// Current value not in list, try to decrement normally
|
||||
newValue = current.value - props.step
|
||||
}
|
||||
if (currentIndex !== -1 && currentIndex > 0) newValue = allValidValues.value[currentIndex - 1]
|
||||
else if (currentIndex === -1)
|
||||
newValue = props.prefixValues.length
|
||||
? props.prefixValues[props.prefixValues.length - 1].value
|
||||
: props.min
|
||||
handled = true
|
||||
break
|
||||
case 'PageUp':
|
||||
if (currentIndex !== -1) {
|
||||
const newIndex = Math.min(currentIndex + 10, allValidValues.value.length - 1)
|
||||
newValue = allValidValues.value[newIndex]
|
||||
} else {
|
||||
newValue = current.value + props.step * 10
|
||||
}
|
||||
if (currentIndex !== -1)
|
||||
newValue =
|
||||
allValidValues.value[Math.min(currentIndex + 10, allValidValues.value.length - 1)]
|
||||
else newValue = model.value + props.step * 10
|
||||
handled = true
|
||||
break
|
||||
case 'PageDown':
|
||||
if (currentIndex !== -1) {
|
||||
const newIndex = Math.max(currentIndex - 10, 0)
|
||||
newValue = allValidValues.value[newIndex]
|
||||
} else {
|
||||
newValue = current.value - props.step * 10
|
||||
}
|
||||
if (currentIndex !== -1) newValue = allValidValues.value[Math.max(currentIndex - 10, 0)]
|
||||
else newValue = model.value - props.step * 10
|
||||
handled = true
|
||||
break
|
||||
case 'Home':
|
||||
@@ -207,16 +208,32 @@ function onKey(e) {
|
||||
handled = true
|
||||
break
|
||||
}
|
||||
|
||||
if (newValue !== null) {
|
||||
current.value = newValue
|
||||
}
|
||||
|
||||
if (newValue !== null) model.value = newValue
|
||||
if (handled) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
function onWheel(e) {
|
||||
const direction = e.deltaY < 0 ? -1 : e.deltaY > 0 ? 1 : 0
|
||||
if (direction === 0) return
|
||||
const idx = allValidValues.value.indexOf(model.value)
|
||||
if (idx !== -1) {
|
||||
const nextIdx = idx + direction
|
||||
if (nextIdx >= 0 && nextIdx < allValidValues.value.length)
|
||||
model.value = allValidValues.value[nextIdx]
|
||||
} else {
|
||||
const curVal = model.value
|
||||
const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
|
||||
if (!isNumeric)
|
||||
model.value = props.prefixValues.length ? props.prefixValues[0].value : props.min
|
||||
else if (direction > 0) model.value = props.min
|
||||
else
|
||||
model.value = props.prefixValues.length
|
||||
? props.prefixValues[props.prefixValues.length - 1].value
|
||||
: props.min
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -226,18 +243,14 @@ function onKey(e) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.4rem;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--input-border, var(--muted));
|
||||
background: var(--panel-alt);
|
||||
border-radius: 0.4rem;
|
||||
min-height: 1.8rem;
|
||||
background: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
touch-action: none; /* allow custom drag without scrolling */
|
||||
touch-action: none;
|
||||
}
|
||||
.mini-stepper.drag-mode:focus-visible {
|
||||
outline: 2px solid var(--input-focus, #2563eb);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 2px var(--input-focus, #2563eb);
|
||||
outline: none;
|
||||
}
|
||||
.mini-stepper.drag-mode .value {
|
||||
font-weight: 600;
|
||||
|
||||
309
src/components/SettingsDialog.vue
Normal file
309
src/components/SettingsDialog.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import BaseDialog from './BaseDialog.vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import WeekdaySelector from './WeekdaySelector.vue'
|
||||
|
||||
const show = ref(false)
|
||||
const calendarStore = useCalendarStore()
|
||||
|
||||
// Reactive bindings to store
|
||||
const firstDay = computed({
|
||||
get: () => calendarStore.config.first_day,
|
||||
set: (v) => (calendarStore.config.first_day = v),
|
||||
})
|
||||
const weekend = computed({
|
||||
get: () => calendarStore.weekend,
|
||||
set: (v) => (calendarStore.weekend = [...v]),
|
||||
})
|
||||
|
||||
// Holiday settings - simplified
|
||||
const holidayMode = computed({
|
||||
get: () => {
|
||||
if (!calendarStore.config.holidays.enabled) {
|
||||
return 'none'
|
||||
}
|
||||
return calendarStore.config.holidays.country || 'auto'
|
||||
},
|
||||
set: (v) => {
|
||||
if (v === 'none') {
|
||||
calendarStore.config.holidays.enabled = false
|
||||
calendarStore.config.holidays.country = null
|
||||
calendarStore.config.holidays.state = null
|
||||
} else if (v === 'auto') {
|
||||
const detectedCountry = getDetectedCountryCode()
|
||||
if (detectedCountry) {
|
||||
calendarStore.config.holidays.enabled = true
|
||||
calendarStore.config.holidays.country = 'auto'
|
||||
calendarStore.config.holidays.state = null
|
||||
calendarStore.initializeHolidays('auto', null, null)
|
||||
} else {
|
||||
calendarStore.config.holidays.enabled = false
|
||||
calendarStore.config.holidays.country = null
|
||||
calendarStore.config.holidays.state = null
|
||||
}
|
||||
} else {
|
||||
calendarStore.config.holidays.enabled = true
|
||||
calendarStore.config.holidays.country = v
|
||||
calendarStore.config.holidays.state = null
|
||||
calendarStore.initializeHolidays(v, null, null)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const holidayState = computed({
|
||||
get: () => calendarStore.config.holidays.state,
|
||||
set: (v) => {
|
||||
calendarStore.config.holidays.state = v
|
||||
const country =
|
||||
calendarStore.config.holidays.country === 'auto'
|
||||
? 'auto'
|
||||
: calendarStore.config.holidays.country
|
||||
calendarStore.initializeHolidays(country, v, calendarStore.config.holidays.region)
|
||||
},
|
||||
})
|
||||
|
||||
// Get detected country code
|
||||
function getDetectedCountryCode() {
|
||||
const locale = navigator.language || navigator.languages?.[0]
|
||||
if (!locale) return null
|
||||
|
||||
const parts = locale.split('-')
|
||||
if (parts.length < 2) return null
|
||||
|
||||
return parts[parts.length - 1].toUpperCase()
|
||||
} // Get display name for any country code
|
||||
function getCountryDisplayName(countryCode) {
|
||||
if (!countryCode || countryCode.length !== 2) {
|
||||
return countryCode
|
||||
}
|
||||
try {
|
||||
const regionNames = new Intl.DisplayNames([navigator.language || 'en'], { type: 'region' })
|
||||
return regionNames.of(countryCode) || countryCode
|
||||
} catch {
|
||||
return countryCode
|
||||
}
|
||||
}
|
||||
|
||||
// Get display name for auto option
|
||||
const autoDisplayName = computed(() => {
|
||||
const detectedCode = getDetectedCountryCode()
|
||||
if (!detectedCode) return 'Auto'
|
||||
return getCountryDisplayName(detectedCode)
|
||||
})
|
||||
|
||||
// Get state/province name from state code
|
||||
function getStateName(stateCode, countryCode) {
|
||||
return stateCode
|
||||
}
|
||||
|
||||
// Get available countries and states
|
||||
const availableCountries = computed(() => {
|
||||
try {
|
||||
const countries = calendarStore.getAvailableCountries()
|
||||
const countryArray = Array.isArray(countries) ? countries : ['US', 'GB', 'DE', 'FR', 'CA', 'AU']
|
||||
|
||||
return countryArray.sort((a, b) => {
|
||||
const nameA = getCountryDisplayName(a)
|
||||
const nameB = getCountryDisplayName(b)
|
||||
return nameA.localeCompare(nameB, navigator.language || 'en')
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to get available countries:', error)
|
||||
return ['US', 'GB', 'DE', 'FR', 'CA', 'AU']
|
||||
}
|
||||
})
|
||||
const availableStates = computed(() => {
|
||||
try {
|
||||
if (holidayMode.value === 'none') return []
|
||||
let country = holidayMode.value
|
||||
if (holidayMode.value === 'auto') {
|
||||
country = getDetectedCountryCode()
|
||||
if (!country) return []
|
||||
}
|
||||
const states = calendarStore.getAvailableStates(country)
|
||||
return Array.isArray(states) ? states : []
|
||||
} catch (error) {
|
||||
console.warn('Failed to get available states:', error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
function open() {
|
||||
// Toggle behavior: if already open, close instead
|
||||
show.value = !show.value
|
||||
}
|
||||
function close() {
|
||||
show.value = false
|
||||
}
|
||||
function resetAll() {
|
||||
if (confirm('Delete ALL events and reset settings? This cannot be undone.')) {
|
||||
if (typeof calendarStore.$reset === 'function') {
|
||||
calendarStore.$reset()
|
||||
} else {
|
||||
const now = new Date()
|
||||
calendarStore.today = now.toISOString().slice(0, 10)
|
||||
calendarStore.now = now.toISOString()
|
||||
calendarStore.events = new Map()
|
||||
calendarStore.weekend = [6, 0]
|
||||
calendarStore.config.first_day = 1
|
||||
}
|
||||
close()
|
||||
}
|
||||
}
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDialog
|
||||
v-model="show"
|
||||
title="Settings"
|
||||
class="settings-modal"
|
||||
:style="{ top: '4.5rem', right: '2rem', bottom: 'auto', left: 'auto', transform: 'none' }"
|
||||
>
|
||||
<div class="setting-group">
|
||||
<label class="ec-field">
|
||||
<span>First day of week</span>
|
||||
<select v-model.number="firstDay">
|
||||
<option :value="0">Sunday</option>
|
||||
<option :value="1">Monday</option>
|
||||
<option :value="2">Tuesday</option>
|
||||
<option :value="3">Wednesday</option>
|
||||
<option :value="4">Thursday</option>
|
||||
<option :value="5">Friday</option>
|
||||
<option :value="6">Saturday</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="weekend-select ec-field">
|
||||
<span>Weekend days</span>
|
||||
<WeekdaySelector v-model="weekend" :first-day="firstDay" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="ec-field">
|
||||
<span>Holiday Region</span>
|
||||
<div class="holiday-row">
|
||||
<select v-model="holidayMode" class="country-select">
|
||||
<option value="none">Do not show holidays</option>
|
||||
<option v-if="getDetectedCountryCode()" value="auto">
|
||||
{{ autoDisplayName }} (Auto)
|
||||
</option>
|
||||
<option v-for="country in availableCountries" :key="country" :value="country">
|
||||
{{ getCountryDisplayName(country) }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
v-if="holidayMode !== 'none' && availableStates.length > 0"
|
||||
v-model="holidayState"
|
||||
class="state-select"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option v-for="state in availableStates" :key="state" :value="state">
|
||||
{{ state }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="footer-row split">
|
||||
<div class="left">
|
||||
<button type="button" class="ec-btn delete-btn" @click="resetAll">Clear All Data</button>
|
||||
</div>
|
||||
<div class="right">
|
||||
<button type="button" class="ec-btn close-btn" @click="close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-group {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.setting-group h3 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--strong);
|
||||
}
|
||||
.ec-field {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.ec-field > span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.holiday-settings {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-left: 1rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid var(--border-color);
|
||||
}
|
||||
select {
|
||||
border: 1px solid var(--muted);
|
||||
background: var(--panel-alt, transparent);
|
||||
color: var(--ink);
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.holiday-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.country-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.state-select {
|
||||
flex: 0 0 auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
/* WeekdaySelector display tweaks */
|
||||
.footer-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
.footer-row.split {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.footer-row.split .left,
|
||||
.footer-row.split .right {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ec-btn {
|
||||
border: 1px solid var(--muted);
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ec-btn.close-btn {
|
||||
background: var(--panel-alt);
|
||||
border-color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ec-btn.delete-btn {
|
||||
background: hsl(0, 70%, 50%);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ec-btn.delete-btn:hover {
|
||||
background: hsl(0, 70%, 45%);
|
||||
}
|
||||
</style>
|
||||
@@ -3,11 +3,13 @@
|
||||
<div class="week-label">W{{ weekNumber }}</div>
|
||||
<div class="days-grid">
|
||||
<DayCell v-for="day in days" :key="day.dateStr" :day="day" />
|
||||
<div class="week-overlay">
|
||||
<!-- Event spans will be rendered here -->
|
||||
</div>
|
||||
<div class="week-overlay"></div>
|
||||
</div>
|
||||
<div v-if="monthLabel" class="month-name-label" :style="{ height: `${monthLabel.weeksSpan * 64}px` }">
|
||||
<div
|
||||
v-if="monthLabel"
|
||||
class="month-name-label"
|
||||
:style="{ height: `${monthLabel.weeksSpan * 64}px` }"
|
||||
>
|
||||
<span>{{ monthLabel.name }} '{{ monthLabel.year }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,51 +18,56 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import DayCell from './DayCell.vue'
|
||||
import { isoWeekInfo, toLocalString, getLocalizedMonthName, monthAbbr } from '@/utils/date'
|
||||
import {
|
||||
toLocalString,
|
||||
getLocalizedMonthName,
|
||||
monthAbbr,
|
||||
DEFAULT_TZ,
|
||||
getISOWeek,
|
||||
} from '@/utils/date'
|
||||
import { addDays } from 'date-fns'
|
||||
|
||||
const props = defineProps({
|
||||
week: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const weekNumber = computed(() => {
|
||||
return isoWeekInfo(props.week.monday).week
|
||||
})
|
||||
const weekNumber = computed(() => getISOWeek(props.week.monday))
|
||||
|
||||
const days = computed(() => {
|
||||
const d = new Date(props.week.monday)
|
||||
const result = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dateStr = toLocalString(d)
|
||||
const dateStr = toLocalString(d, DEFAULT_TZ)
|
||||
result.push({
|
||||
date: new Date(d),
|
||||
dateStr,
|
||||
dayOfMonth: d.getDate(),
|
||||
month: d.getMonth(),
|
||||
isFirstDayOfMonth: d.getDate() === 1,
|
||||
monthClass: monthAbbr[d.getMonth()]
|
||||
monthClass: monthAbbr[d.getMonth()],
|
||||
})
|
||||
d.setDate(d.getDate() + 1)
|
||||
d.setTime(addDays(d, 1).getTime())
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const monthLabel = computed(() => {
|
||||
const firstDayOfMonth = days.value.find(d => d.isFirstDayOfMonth)
|
||||
const firstDayOfMonth = days.value.find((d) => d.isFirstDayOfMonth)
|
||||
if (!firstDayOfMonth) return null
|
||||
|
||||
const month = firstDayOfMonth.month
|
||||
const year = firstDayOfMonth.date.getFullYear()
|
||||
|
||||
|
||||
// This is a simplified calculation for weeksSpan
|
||||
const weeksSpan = 4
|
||||
const weeksSpan = 4
|
||||
|
||||
return {
|
||||
name: getLocalizedMonthName(month),
|
||||
year: String(year).slice(-2),
|
||||
weeksSpan
|
||||
weeksSpan,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
getLocalizedWeekdayNames,
|
||||
getLocaleFirstDay,
|
||||
@@ -44,7 +44,10 @@ import {
|
||||
const model = defineModel({
|
||||
type: Array,
|
||||
default: () => [false, false, false, false, false, false, false],
|
||||
})
|
||||
}) // external value consumers see
|
||||
|
||||
// Internal state preserves the user's explicit picks even if all false
|
||||
const internal = ref([false, false, false, false, false, false, false])
|
||||
|
||||
const props = defineProps({
|
||||
weekend: { type: Array, default: undefined },
|
||||
@@ -55,12 +58,11 @@ const props = defineProps({
|
||||
firstDay: { type: Number, default: null },
|
||||
})
|
||||
|
||||
// If external model provided is entirely false, keep as-is (user will see fallback styling),
|
||||
// only overwrite if null/undefined.
|
||||
if (!model.value) model.value = [...props.fallback]
|
||||
// Initialize internal from external if it has any true; else keep empty (fallback handled on emit)
|
||||
if (model.value?.some?.(Boolean)) internal.value = [...model.value]
|
||||
const labelsMondayFirst = getLocalizedWeekdayNames()
|
||||
const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)]
|
||||
const anySelected = computed(() => model.value.some(Boolean))
|
||||
const anySelected = computed(() => internal.value.some(Boolean))
|
||||
const localeFirst = getLocaleFirstDay()
|
||||
const localeWeekend = getLocaleWeekendDays()
|
||||
const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7)
|
||||
@@ -71,10 +73,38 @@ const weekendDays = computed(() => {
|
||||
})
|
||||
|
||||
const displayLabels = computed(() => reorderByFirstDay(labels, firstDay.value))
|
||||
const displayValuesCommitted = computed(() => reorderByFirstDay(model.value, firstDay.value))
|
||||
const displayValuesCommitted = computed(() => reorderByFirstDay(internal.value, firstDay.value))
|
||||
const displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value))
|
||||
const displayDefault = computed(() => reorderByFirstDay(props.fallback, firstDay.value))
|
||||
|
||||
// Expose a normalized pattern (Sunday-first) that substitutes the fallback day if none selected.
|
||||
// This keeps UI visually showing fallback (muted) but downstream logic can opt-in by reading this.
|
||||
function computeFallbackPattern() {
|
||||
const fb = props.fallback && props.fallback.length === 7 ? props.fallback : null
|
||||
if (fb && fb.some(Boolean)) return [...fb]
|
||||
const arr = [false, false, false, false, false, false, false]
|
||||
const idx = fb ? fb.findIndex(Boolean) : -1
|
||||
if (idx >= 0) arr[idx] = true
|
||||
else arr[0] = true
|
||||
return arr
|
||||
}
|
||||
function emitExternal() {
|
||||
model.value = internal.value.some(Boolean) ? [...internal.value] : computeFallbackPattern()
|
||||
}
|
||||
emitExternal()
|
||||
watch(
|
||||
() => model.value,
|
||||
(nv) => {
|
||||
if (!nv) return
|
||||
if (!nv.some(Boolean)) return
|
||||
const fb = computeFallbackPattern()
|
||||
const isFallback = fb.every((v, i) => v === nv[i])
|
||||
// If internal is empty and model only reflects fallback, do not sync into internal
|
||||
if (isFallback && !internal.value.some(Boolean)) return
|
||||
internal.value = [...nv]
|
||||
},
|
||||
)
|
||||
|
||||
// Mapping from display index to original model index
|
||||
const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7))
|
||||
|
||||
@@ -135,8 +165,8 @@ function isPressing(di) {
|
||||
}
|
||||
|
||||
function onPointerDown(di) {
|
||||
originalValues = [...model.value]
|
||||
dragVal.value = !model.value[(di + firstDay.value) % 7]
|
||||
originalValues = [...internal.value]
|
||||
dragVal.value = !internal.value[(di + firstDay.value) % 7]
|
||||
dragStart.value = di
|
||||
previewEnd.value = di
|
||||
dragging.value = true
|
||||
@@ -155,7 +185,8 @@ function onPointerUp() {
|
||||
// simple click: toggle single
|
||||
const next = [...originalValues]
|
||||
next[(dragStart.value + firstDay.value) % 7] = dragVal.value
|
||||
model.value = next
|
||||
internal.value = next
|
||||
emitExternal()
|
||||
cleanupDrag()
|
||||
} else {
|
||||
commitDrag()
|
||||
@@ -169,7 +200,8 @@ function commitDrag() {
|
||||
: [previewEnd.value, dragStart.value]
|
||||
const next = [...originalValues]
|
||||
for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value
|
||||
model.value = next
|
||||
internal.value = next
|
||||
emitExternal()
|
||||
cleanupDrag()
|
||||
}
|
||||
function cancelDrag() {
|
||||
@@ -185,14 +217,15 @@ function cleanupDrag() {
|
||||
function toggleWeekend(work) {
|
||||
const base = weekendDays.value
|
||||
const target = work ? base : base.map((v) => !v)
|
||||
const current = model.value
|
||||
const current = internal.value
|
||||
const allOn = current.every(Boolean)
|
||||
const isTargetActive = current.every((v, i) => v === target[i])
|
||||
if (allOn || isTargetActive) {
|
||||
model.value = [false, false, false, false, false, false, false]
|
||||
internal.value = [false, false, false, false, false, false, false]
|
||||
} else {
|
||||
model.value = [...target]
|
||||
internal.value = [...target]
|
||||
}
|
||||
emitExternal()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user