Compare commits
	
		
			38 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | bc16473715 | ||
|   | b30618031a | ||
|   | cb60c589e3 | ||
|   | 3c5cad0afe | ||
|   | 6d91833f0f | ||
|   | a3e9e13b29 | ||
|   | 73ce1b1be2 | ||
|   | 93fc600a7a | ||
|   | 09df4bed5e | ||
|   | 86a1a4d772 | ||
|   | 159bbf816d | ||
|   | c41a3b84f4 | ||
|   | 6c396bab61 | ||
|   | 8a508f273d | ||
|   | 704773dc8a | ||
|   | 0859e77b6a | ||
|   | d461a42ae5 | ||
|   | ade17b80b1 | ||
|   | a0b140d54b | ||
|   | 365d9e1be2 | ||
|   | e210babe29 | ||
|   | 31c5551535 | ||
|   | 9b2354fd91 | ||
|   | 43aa8db650 | ||
|   | debeececaf | ||
|   | 258d0ba02c | ||
|   | c134d8875c | ||
|   | dca3e21843 | ||
|   | d11c551636 | ||
|   | eaa55c94fd | ||
|   | 0d4094826d | ||
|   | 983826b5a6 | ||
|   | 3a902a9dfa | ||
|   | 0dfccb7b34 | ||
|   | f20a54da57 | ||
|   | b3b19832b4 | ||
|   | 151566ba22 | ||
|   | 7816ccd196 | 
| @@ -37,8 +37,6 @@ onMounted(() => { | ||||
|   document.addEventListener('keydown', handleGlobalKey, { passive: false }) | ||||
|   // Set document language via shared util | ||||
|   if (lang) document.documentElement.setAttribute('lang', lang) | ||||
|   // Initialize title | ||||
|   document.title = formatTodayString(new Date(calendarStore.now)) | ||||
| }) | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
| @@ -49,7 +47,7 @@ onBeforeUnmount(() => { | ||||
| watch( | ||||
|   () => calendarStore.now, | ||||
|   (val) => { | ||||
|     document.title = formatTodayString(new Date(val)) | ||||
|     document.title = formatTodayString(new Date(val), "short", "short") | ||||
|   }, | ||||
|   { immediate: false }, | ||||
| ) | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| /* Color tokens */ | ||||
| /* Light mode & common */ | ||||
| :root { | ||||
|   --panel: #ffffff; | ||||
|   --panel-alt: #f6f8fa; | ||||
| @@ -8,19 +8,17 @@ | ||||
|   --strong: #000; | ||||
|   --muted: #6a6f76; | ||||
|   --muted-alt: #9aa2ad; | ||||
|   --accent: #2563eb; /* blue */ | ||||
|   --accent: #2563eb; | ||||
|   --accent-soft: #dbeafe; | ||||
|   --accent-hover: #1d4ed8; | ||||
|   --danger: #dc2626; | ||||
|   --danger-hover: #b91c1c; | ||||
|   --weekend: #888; | ||||
|   --weekend: #555; | ||||
|   --firstday: #000; | ||||
|   --select: #aaf; | ||||
|   --shadow: #fff; | ||||
|   --label-bg: #fafbfe; | ||||
|   --label-bg-rgb: 250, 251, 254; | ||||
|  | ||||
|   /* Holiday colors */ | ||||
|   --holiday: #da0; | ||||
|   --holiday-label: var(--strong); | ||||
|  | ||||
| @@ -35,73 +33,11 @@ | ||||
|   /* Vue component color mappings */ | ||||
|   --bg: var(--panel); | ||||
|   --border-color: #ddd; | ||||
|  | ||||
|   /* Event transparency */ | ||||
|   --event-alpha: 0.7; | ||||
| } | ||||
|  | ||||
| /* Month tints (light) */ | ||||
| .dec { | ||||
|   background: hsl(220 50% 95%); | ||||
| } | ||||
| .jan { | ||||
|   background: hsl(220 50% 92%); | ||||
| } | ||||
| .feb { | ||||
|   background: hsl(220 50% 95%); | ||||
| } | ||||
| .mar { | ||||
|   background: hsl(125 60% 92%); | ||||
| } | ||||
| .apr { | ||||
|   background: hsl(125 60% 95%); | ||||
| } | ||||
| .may { | ||||
|   background: hsl(125 60% 92%); | ||||
| } | ||||
| .jun { | ||||
|   background: hsl(45 85% 95%); | ||||
| } | ||||
| .jul { | ||||
|   background: hsl(45 85% 92%); | ||||
| } | ||||
| .aug { | ||||
|   background: hsl(45 85% 95%); | ||||
| } | ||||
| .sep { | ||||
|   background: hsl(18 78% 92%); | ||||
| } | ||||
| .oct { | ||||
|   background: hsl(18 78% 95%); | ||||
| } | ||||
| .nov { | ||||
|   background: hsl(18 78% 92%); | ||||
| } | ||||
|  | ||||
| /* Light mode — gray shades and colors */ | ||||
| .event-color-0 { | ||||
|   background: hsl(0, 0%, 85%); | ||||
| } /* lightest grey */ | ||||
| .event-color-1 { | ||||
|   background: hsl(0, 0%, 75%); | ||||
| } /* light grey */ | ||||
| .event-color-2 { | ||||
|   background: hsl(0, 0%, 65%); | ||||
| } /* medium grey */ | ||||
| .event-color-3 { | ||||
|   background: hsl(0, 0%, 55%); | ||||
| } /* dark grey */ | ||||
| .event-color-4 { | ||||
|   background: hsl(0, 70%, 70%); | ||||
| } /* red */ | ||||
| .event-color-5 { | ||||
|   background: hsl(90, 70%, 70%); | ||||
| } /* green */ | ||||
| .event-color-6 { | ||||
|   background: hsl(230, 70%, 70%); | ||||
| } /* blue */ | ||||
| .event-color-7 { | ||||
|   background: hsl(280, 70%, 70%); | ||||
| } /* purple */ | ||||
|  | ||||
| /* Color tokens (dark) */ | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   :root { | ||||
|     --panel: #121417; | ||||
| @@ -138,67 +74,61 @@ | ||||
|     /* Holiday colors (dark mode) */ | ||||
|     --holiday: #ffc107; | ||||
|     --holiday-label: #fff8e1; | ||||
|   } | ||||
|  | ||||
|   .dec { | ||||
|     background: hsl(220 50% 8%); | ||||
|   } | ||||
|   .jan { | ||||
|     background: hsl(220 50% 6%); | ||||
|   } | ||||
|   .feb { | ||||
|     background: hsl(220 50% 8%); | ||||
|   } | ||||
|   .mar { | ||||
|     background: hsl(125 60% 6%); | ||||
|   } | ||||
|   .apr { | ||||
|     background: hsl(125 60% 8%); | ||||
|   } | ||||
|   .may { | ||||
|     background: hsl(125 60% 6%); | ||||
|   } | ||||
|   .jun { | ||||
|     background: hsl(45 85% 8%); | ||||
|   } | ||||
|   .jul { | ||||
|     background: hsl(45 85% 6%); | ||||
|   } | ||||
|   .aug { | ||||
|     background: hsl(45 85% 8%); | ||||
|   } | ||||
|   .sep { | ||||
|     background: hsl(18 78% 6%); | ||||
|   } | ||||
|   .oct { | ||||
|     background: hsl(18 78% 8%); | ||||
|   } | ||||
|   .nov { | ||||
|     background: hsl(18 78% 6%); | ||||
|   } | ||||
|     --weekend: #aaa; | ||||
|  | ||||
|   .event-color-0 { | ||||
|     background: hsl(0, 0%, 50%); | ||||
|   } /* lightest grey */ | ||||
|   .event-color-1 { | ||||
|     background: hsl(0, 0%, 40%); | ||||
|   } /* light grey */ | ||||
|   .event-color-2 { | ||||
|     background: hsl(0, 0%, 30%); | ||||
|   } /* medium grey */ | ||||
|   .event-color-3 { | ||||
|     background: hsl(0, 0%, 20%); | ||||
|   } /* dark grey */ | ||||
|   .event-color-4 { | ||||
|     background: hsl(0, 70%, 40%); | ||||
|   } /* red */ | ||||
|   .event-color-5 { | ||||
|     background: hsl(90, 70%, 30%); | ||||
|   } /* green - darker for perceptional purposes */ | ||||
|   .event-color-6 { | ||||
|     background: hsl(230, 70%, 40%); | ||||
|   } /* blue */ | ||||
|   .event-color-7 { | ||||
|     background: hsl(280, 70%, 40%); | ||||
|   } /* purple */ | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* Month tints (light) */ | ||||
| .dec { background: hsl(220 50% 77%) } | ||||
| .jan { background: hsl(220 50% 60%) } | ||||
| .feb { background: hsl(220 50% 77%) } | ||||
| .mar { background: hsl(130 40% 85%) } | ||||
| .apr { background: hsl(130 65% 75%) } | ||||
| .may { background: hsl(130 80% 65%) } | ||||
| .jun { background: hsl(50 85% 70%) } | ||||
| .jul { background: hsl(50 85% 85%) } | ||||
| .aug { background: hsl(50 85% 70%) } | ||||
| .sep { background: hsl(22 100% 75%) } | ||||
| .oct { background: hsl(22 40% 65%) } | ||||
| .nov { background: hsl(22 15% 55%) } | ||||
|  | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   .dec { background: hsl(220 50% 12%) } | ||||
|   .jan { background: hsl(220 50% 8%) } | ||||
|   .feb { background: hsl(220 50% 12%) } | ||||
|   .mar { background: hsl(130 40% 20%) } | ||||
|   .apr { background: hsl(130 60% 15%) } | ||||
|   .may { background: hsl(130 80% 10%) } | ||||
|   .jun { background: hsl(50 85% 16%) } | ||||
|   .jul { background: hsl(50 85% 20%) } | ||||
|   .aug { background: hsl(50 85% 16%) } | ||||
|   .sep { background: hsl(22 100% 14%) } | ||||
|   .oct { background: hsl(22 90% 10%) } | ||||
|   .nov { background: hsl(22 80% 7%) } | ||||
| } | ||||
|  | ||||
| /* Light mode — gray shades and colors */ | ||||
| .event-color-0 { background: hsla(0, 0%, 85%, var(--event-alpha)); } /* lightest grey */ | ||||
| .event-color-1 { background: hsla(0, 0%, 75%, var(--event-alpha)); } /* light grey */ | ||||
| .event-color-2 { background: hsla(0, 0%, 65%, var(--event-alpha)); } /* medium grey */ | ||||
| .event-color-3 { background: hsla(0, 0%, 55%, var(--event-alpha)); } /* dark grey */ | ||||
| .event-color-4 { background: hsla(0, 100%, 70%, var(--event-alpha)); } /* red */ | ||||
| .event-color-5 { background: hsla(90, 100%, 50%, var(--event-alpha)); } /* green - darker for perceptional purposes */ | ||||
| .event-color-6 { background: hsla(220, 100%, 70%, var(--event-alpha)); } /* blue */ | ||||
| .event-color-7 { background: hsla(280, 100%, 70%, var(--event-alpha)); } /* purple */ | ||||
|  | ||||
| /* Dark-mode event colors are grouped right after the light-mode equivalents */ | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   .event-color-0 { background: hsla(0, 0%, 50%, var(--event-alpha)); } /* lightest grey */ | ||||
|   .event-color-1 { background: hsla(0, 0%, 40%, var(--event-alpha)); } /* light grey */ | ||||
|   .event-color-2 { background: hsla(0, 0%, 30%, var(--event-alpha)); } /* medium grey */ | ||||
|   .event-color-3 { background: hsla(0, 0%, 20%, var(--event-alpha)); } /* dark grey */ | ||||
|   .event-color-4 { background: hsla(0, 80%, 40%, var(--event-alpha)); } /* red */ | ||||
|   .event-color-5 { background: hsla(90, 80%, 30%, var(--event-alpha)); } /* green - darker for perceptional purposes */ | ||||
|   .event-color-6 { background: hsla(220, 80%, 40%, var(--event-alpha)); } /* blue */ | ||||
|   .event-color-7 { background: hsla(280, 80%, 40%, var(--event-alpha)); } /* purple */ | ||||
| } | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1,13 +1,15 @@ | ||||
| :root { | ||||
|   --week-w: 3rem; | ||||
|   --day-w: 1fr; | ||||
|   --month-w: 2rem; | ||||
|   --month-w: 3rem; | ||||
|   --row-h: 15vh; | ||||
| } | ||||
| * { | ||||
|   box-sizing: border-box; | ||||
| } | ||||
|  | ||||
| html { | ||||
|   font-size: min(3vmin, 16px); | ||||
| } | ||||
| html, | ||||
| body { | ||||
|   height: 100%; | ||||
| @@ -16,7 +18,7 @@ body { | ||||
| body { | ||||
|   margin: 0; | ||||
|   font: | ||||
|     500 14px/1.2 ui-sans-serif, | ||||
|     500 1rem/1.2 ui-sans-serif, | ||||
|     system-ui, | ||||
|     -apple-system, | ||||
|     Segoe UI, | ||||
| @@ -82,17 +84,6 @@ header { | ||||
| #calendar-content { | ||||
|   position: relative; | ||||
| } | ||||
| /* Week row: label + 7-day grid + jogwheel column */ | ||||
| .week-row { | ||||
|   display: grid; | ||||
|   grid-template-columns: var(--week-w) repeat(7, var(--day-w)) var(--month-w); | ||||
|   position: relative; | ||||
|   overflow: visible; | ||||
|   height: var(--row-h); | ||||
|   scroll-snap-align: start; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| /* Label cells */ | ||||
| .year-label, | ||||
| .week-label { | ||||
| @@ -101,7 +92,7 @@ header { | ||||
|   width: 100%; | ||||
|   color: var(--muted); | ||||
|   cursor: ns-resize; | ||||
|   font-size: 1.2em; | ||||
|   font-size: 1.2rem; | ||||
| } | ||||
|  | ||||
| .week-label { | ||||
| @@ -109,8 +100,8 @@ header { | ||||
| } | ||||
| /* 7-day grid inside each week row */ | ||||
| .week-row > .days-grid { | ||||
|   grid-column: 2 / span 7; | ||||
|   display: grid; | ||||
|   grid-column: 2 / span 7; | ||||
|   grid-template-columns: repeat(7, 1fr); | ||||
|   grid-auto-rows: 1fr; | ||||
|   position: relative; | ||||
| @@ -120,7 +111,7 @@ header { | ||||
|  | ||||
| .month-name-label { | ||||
|   grid-column: -2 / -1; | ||||
|   font-size: 2em; | ||||
|   font-size: 2rem; | ||||
|   font-weight: 700; | ||||
|   color: var(--muted); | ||||
|   display: flex; | ||||
|   | ||||
| @@ -24,7 +24,6 @@ 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() | ||||
| @@ -62,8 +61,8 @@ function handleDrag(event) { | ||||
|   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)) | ||||
|   x = clamp(x, 0, Math.max(0, vw - w - 0)) | ||||
|   y = clamp(y, 0, Math.max(0, vh - h - 0)) | ||||
|   modalPosition.value = { x, y } | ||||
|   event.preventDefault() | ||||
| } | ||||
| @@ -97,10 +96,14 @@ const modalStyle = computed(() => { | ||||
| // <BaseDialog class="settings-modal" :style="{ top: '...' }" /> works even with fragment root. | ||||
| const modalAttrs = computed(() => { | ||||
|   const { class: extClass, style: extStyle, ...rest } = attrs | ||||
|   // When dialog has been moved (dragged), internal positioning styles must override external ones | ||||
|   const mergedStyle = hasMoved.value | ||||
|     ? [extStyle, modalStyle.value].filter(Boolean) | ||||
|     : [modalStyle.value, extStyle].filter(Boolean) | ||||
|   return { | ||||
|     ...rest, | ||||
|     class: ['ec-modal', extClass].filter(Boolean), | ||||
|     style: [modalStyle.value, extStyle].filter(Boolean), // external style overrides internal | ||||
|     style: mergedStyle, | ||||
|   } | ||||
| }) | ||||
|  | ||||
| @@ -120,7 +123,8 @@ function positionNearAnchor() { | ||||
|   const anchor = props.anchorEl || anchorRef.value | ||||
|   if (!anchor) return | ||||
|   const rect = anchor.getBoundingClientRect() | ||||
|   const offsetY = 8 // vertical gap below the anchor | ||||
|   const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) | ||||
|   const offsetY = 0.5 * rootFontSize // vertical gap below the anchor in rem converted to pixels | ||||
|   const w = modalRef.value?.offsetWidth || dialogWidth.value || 320 | ||||
|   const h = modalRef.value?.offsetHeight || dialogHeight.value || 200 | ||||
|   const vw = window.innerWidth | ||||
| @@ -128,8 +132,8 @@ function positionNearAnchor() { | ||||
|   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)) | ||||
|   x = clamp(x, 0, Math.max(0, vw - w - 0)) | ||||
|   y = clamp(y, 0, Math.max(0, vh - h - 0)) | ||||
|   modalPosition.value = { x, y } | ||||
| } | ||||
|  | ||||
| @@ -172,8 +176,8 @@ function handleResize() { | ||||
|     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)), | ||||
|       x: clamp(modalPosition.value.x, 0, Math.max(0, vw - w - 0)), | ||||
|       y: clamp(modalPosition.value.y, 0, Math.max(0, vh - h - 0)), | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -206,19 +210,18 @@ onUnmounted(() => { | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped> | ||||
| <style> | ||||
| .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); | ||||
|   border-radius: 0.6rem; | ||||
|   min-height: 23rem; | ||||
|   min-width: 26rem; | ||||
|   max-width: min(34rem, 90vw); | ||||
|   box-shadow: 0 0.6rem 1.8rem rgba(0, 0, 0, 0.35); | ||||
|   border: 0.0625rem solid color-mix(in srgb, var(--muted) 40%, transparent); | ||||
|   z-index: 1000; | ||||
|   overflow: hidden; | ||||
| } | ||||
| @@ -230,35 +233,36 @@ onUnmounted(() => { | ||||
| .ec-form { | ||||
|   display: grid; | ||||
|   grid-template-rows: auto 1fr auto; | ||||
|   min-height: 23em; | ||||
|   min-height: 23rem; | ||||
|   height: 100%; | ||||
|   width: 100%; | ||||
| } | ||||
| .ec-header { | ||||
|   cursor: move; | ||||
|   user-select: none; | ||||
|   padding: 0.75em 1em 0.5em 1em; | ||||
|   touch-action: none; | ||||
|   padding: 0.75rem 1rem 0.5rem 1rem; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   gap: 1em; | ||||
|   gap: 1rem; | ||||
| } | ||||
| .ec-title { | ||||
|   margin: 0; | ||||
|   font-size: 1.1em; | ||||
|   font-size: 1.1rem; | ||||
| } | ||||
| .ec-body { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 1em; | ||||
|   padding: 0 1em 0.5em 1em; | ||||
|   gap: 1rem; | ||||
|   padding: 0 1rem 0.5rem 1rem; | ||||
|   overflow: auto; | ||||
| } | ||||
| .ec-footer { | ||||
|   padding: 0.5em 1em 1em 1em; | ||||
|   padding: 0.5rem 1rem 1rem 1rem; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   gap: 1em; | ||||
|   gap: 1rem; | ||||
|   flex-wrap: wrap; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,29 +1,59 @@ | ||||
| <script setup> | ||||
| import { computed, ref, onMounted, onBeforeUnmount } from 'vue' | ||||
| import { fromLocalString } from '@/utils/date' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   day: Object, | ||||
|   dragging: { type: Boolean, default: false }, | ||||
| }) | ||||
|  | ||||
| // Reactive viewport width detection | ||||
| const isNarrowView = ref(false) | ||||
| const isSmallView = ref(false) | ||||
|  | ||||
| function checkViewportWidth() { | ||||
|   const width = window.innerWidth | ||||
|   isSmallView.value = width < 800 | ||||
|   isNarrowView.value = width < 600 | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
|   checkViewportWidth() | ||||
|   window.addEventListener('resize', checkViewportWidth) | ||||
| }) | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
|   window.removeEventListener('resize', checkViewportWidth) | ||||
| }) | ||||
|  | ||||
| const formattedDate = computed(() => { | ||||
|   const date = fromLocalString(props.day.date) | ||||
|   let options = { weekday: 'short', day: 'numeric', month: 'short' } | ||||
|   // Remove weekday on very small viewports | ||||
|   if (isNarrowView.value) options = { day: 'numeric', month: 'short' } | ||||
|   let formatted = date.toLocaleDateString(undefined, options) | ||||
|   // Split between weekday and day/month on small viewports | ||||
|   if (isSmallView.value) formatted = formatted.replace(/\s/, '\n') | ||||
|   // Replace the last space (between month and day) with nbsp to prevent breaking there | ||||
|   // but keep the space after weekday (if present) as regular space to allow wrapping | ||||
|   formatted = formatted.replace(/\s+(?=\S+$)/, '\u00A0') | ||||
|    | ||||
|   return formatted | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div | ||||
|     class="cell" | ||||
|     :style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'" | ||||
|     :class="[ | ||||
|       props.day.monthClass, | ||||
|       { | ||||
|         today: props.day.isToday, | ||||
|         weekend: props.day.isWeekend, | ||||
|         firstday: props.day.isFirstDay, | ||||
|         selected: props.day.isSelected, | ||||
|         holiday: props.day.isHoliday, | ||||
|       }, | ||||
|     ]" | ||||
|     :class="[props.day.monthClass, { today: props.day.isToday, weekend: props.day.isWeekend, firstday: props.day.isFirstDay, selected: props.day.isSelected, holiday: props.day.isHoliday }]" | ||||
|     :data-date="props.day.date" | ||||
|     :title="props.day.holiday?.name" | ||||
|   > | ||||
|     <span class="compact-date">{{ formattedDate }}</span> | ||||
|     <h1 class="day-number">{{ props.day.displayText }}</h1> | ||||
|     <span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span> | ||||
|     <div v-if="props.day.holiday" class="holiday-info" dir="auto" :title="props.day.holiday.name"> | ||||
|     <div v-if="props.day.holiday" class="holiday-info" dir="auto"> | ||||
|       {{ props.day.holiday.name }} | ||||
|     </div> | ||||
|   </div> | ||||
| @@ -32,102 +62,137 @@ const props = defineProps({ | ||||
| <style scoped> | ||||
| .cell { | ||||
|   position: relative; | ||||
|   border-inline-end: 1px solid var(--border-color); | ||||
|   border-bottom: 1px solid var(--border-color); | ||||
|   user-select: none; | ||||
|   display: grid; | ||||
|   /* 3 columns: day number, flexible space, lunar phase */ | ||||
|   grid-template-columns: min-content 1fr min-content; | ||||
|   /* 3 rows: header, flexible filler, holiday label */ | ||||
|   grid-template-rows: auto 1fr auto; | ||||
|   /* Named grid areas (only ones actually used) */ | ||||
|   grid-template-columns: 1fr; | ||||
|   grid-template-rows: 1fr auto; | ||||
|   grid-template-areas: | ||||
|     'day-number . lunar-phase' | ||||
|     'day-number . lunar-phase' | ||||
|     'holiday-info holiday-info holiday-info'; | ||||
|   /* Explicit areas mainly for clarity */ | ||||
|   grid-auto-flow: row; | ||||
|     'day-number' | ||||
|     'holiday-info'; | ||||
|   padding: 0.25em; | ||||
|   overflow: hidden; | ||||
|   overflow: visible; | ||||
|   width: 100%; | ||||
|   height: var(--row-h); | ||||
|   font-weight: 700; | ||||
|   transition: background-color 0.15s ease; | ||||
|   align-items: start; | ||||
|   align-items: center; | ||||
|   justify-items: center; | ||||
| } | ||||
| .cell h1.day-number { | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   min-width: 1.5em; | ||||
|   font-size: 1em; | ||||
|   font-weight: 700; | ||||
|   position: absolute; | ||||
|   font-size: 5vmin; | ||||
|   font-weight: 800; | ||||
|   color: var(--ink); | ||||
|   transition: background-color 0.15s ease; | ||||
|   grid-area: day-number; | ||||
|   transition: all 0.15s ease; | ||||
| } | ||||
| .cell.firstday h1.day-number { | ||||
|   font-weight: 400; | ||||
| } | ||||
| .cell.weekend h1.day-number { | ||||
|   color: var(--weekend); | ||||
| } | ||||
| .cell.firstday h1.day-number { | ||||
|   color: var(--firstday); | ||||
|   text-shadow: 0 0 0.1em var(--strong); | ||||
| } | ||||
| .cell.today h1.day-number { | ||||
|   border-radius: 2em; | ||||
|   background: var(--today); | ||||
|   border: 0.2em solid var(--today); | ||||
|   margin: -0.2em; | ||||
|   color: var(--strong); | ||||
|   font-weight: bold; | ||||
| .cell.today::before { | ||||
|   content: ''; | ||||
|   position: absolute; | ||||
|   top: 50%; | ||||
|   left: 50%; | ||||
|   transform: translate(-50%, -50%); | ||||
|   width: calc(100% + .2rem); | ||||
|   height: calc(100% + .2rem); | ||||
|   border-radius: 1rem; | ||||
|   background: transparent; | ||||
|   border: 0.3em solid var(--today); | ||||
|   z-index: 15; | ||||
|   pointer-events: none; | ||||
| } | ||||
| .cell.selected { | ||||
|   filter: hue-rotate(180deg); | ||||
|  | ||||
| /* Search highlight animation */ | ||||
| .cell.search-highlight-flash::after { | ||||
|   content: ''; | ||||
|   position: absolute; | ||||
|   top: 50%; | ||||
|   left: 50%; | ||||
|   transform: translate(-50%, -50%); | ||||
|   width: calc(100% + .2rem); | ||||
|   height: calc(100% + .2rem); | ||||
|   border-radius: 1rem; | ||||
|   background: transparent; | ||||
|   border: 0.3em solid var(--strong); | ||||
|   z-index: 16; | ||||
|   pointer-events: none; | ||||
| } | ||||
|  | ||||
| .cell.search-highlight-flash::after { animation: search-highlight-flash 1.5s ease-out forwards; } | ||||
|  | ||||
| @keyframes search-highlight-flash {0%{opacity:0;transform:translate(-50%,-50%) scale(.8);border-width:.1em}15%{opacity:1;transform:translate(-50%,-50%) scale(1.05);border-width:.4em}30%{opacity:1;transform:translate(-50%,-50%) scale(1);border-width:.3em}100%{opacity:0;transform:translate(-50%,-50%) scale(1);border-width:.3em}} | ||||
| .cell.selected h1.day-number { | ||||
|   color: var(--strong); | ||||
|   opacity: 0.3; | ||||
|   filter: brightness(1.2); | ||||
| } | ||||
| .cell.holiday { | ||||
| .cell { | ||||
|   background-image: linear-gradient( | ||||
|     135deg, | ||||
|     var(--holiday-grad-start, rgba(255, 255, 255, 0.5)) 0%, | ||||
|     var(--holiday-grad-start, rgba(255, 255, 255, 0.3)) 0%, | ||||
|     var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70% | ||||
|   ); | ||||
| } | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   .cell.holiday { | ||||
|   .cell { | ||||
|     background-image: linear-gradient( | ||||
|       135deg, | ||||
|       var(--holiday-grad-start, rgba(255, 255, 255, 0.1)) 0%, | ||||
|       var(--holiday-grad-start, rgba(255, 255, 255, 0.05)) 0%, | ||||
|       var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70% | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| .cell.holiday h1.day-number { | ||||
|   /* Slight emphasis without forcing a specific hue */ | ||||
|   color: var(--holiday); | ||||
|   text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4); | ||||
| } | ||||
| .lunar-phase { | ||||
|   grid-area: lunar-phase; | ||||
|   align-self: start; | ||||
|   justify-self: end; | ||||
|   margin-top: 0.5em; | ||||
|   margin-inline-end: 0.2em; | ||||
|   font-size: 0.8em; | ||||
|   opacity: 0.7; | ||||
|   position: absolute; | ||||
|   inset-block-start: 0.1em; | ||||
|   inset-inline-end: 0.1em; | ||||
|   font-size: 0.8rem; | ||||
| } | ||||
|  | ||||
| .compact-date { | ||||
|   position: absolute; | ||||
|   top: 0.25em; | ||||
|   left: 0.25em; | ||||
|   inset-inline-end: 1rem; /* Space for lunar phase */ | ||||
|   font-weight: 300; | ||||
|   font-size: 0.8rem; | ||||
|   color: var(--ink); | ||||
|   line-height: 1; | ||||
|   pointer-events: none; | ||||
|   white-space: pre-wrap; | ||||
| } | ||||
|  | ||||
| .cell.weekend .compact-date { | ||||
|   color: var(--weekend); | ||||
| } | ||||
| .cell.firstday .compact-date { | ||||
|   color: var(--firstday); | ||||
| } | ||||
| .cell.today .compact-date { | ||||
|   color: var(--strong); | ||||
| } | ||||
| .cell.selected .compact-date { | ||||
|   color: var(--strong); | ||||
| } | ||||
|  | ||||
| .holiday-info { | ||||
|   grid-area: holiday-info; | ||||
|   align-self: end; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   color: var(--holiday-label); | ||||
|   font-size: clamp(1.2vw, 0.6em, 1em); | ||||
|   line-height: 1; | ||||
|   max-width: 100%; | ||||
|   color: var(--holiday); | ||||
|   font-size: 0.8rem; | ||||
|   font-weight: 400; | ||||
|   line-height: 1.0; | ||||
|   padding-inline: 0.15em; | ||||
|   padding-block-end: 0.05em; | ||||
|   padding-block: 0; | ||||
|   pointer-events: auto; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -119,7 +119,7 @@ const weekdayNames = computed(() => { | ||||
| .calendar-header { | ||||
|   display: grid; | ||||
|   grid-template-columns: var(--week-w) repeat(7, 1fr) var(--month-w); | ||||
|   border-bottom: 2px solid var(--muted); | ||||
|   border-bottom: .1rem solid var(--muted); | ||||
|   align-items: last baseline; | ||||
|   flex-shrink: 0; | ||||
|   width: 100%; | ||||
| @@ -135,7 +135,7 @@ const weekdayNames = computed(() => { | ||||
|   text-transform: uppercase; | ||||
|   text-align: center; | ||||
|   font-weight: 600; | ||||
|   font-size: 1.2em; | ||||
|   font-size: 1.2rem; | ||||
| } | ||||
| .dow.weekend { | ||||
|   color: var(--weekend); | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| <script setup> | ||||
| import { ref, onMounted, onBeforeUnmount, computed, watch, nextTick } 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 HeaderControls from '@/components/HeaderControls.vue' | ||||
| import Jogwheel from '@/components/Jogwheel.vue' | ||||
| import { | ||||
|   createScrollManager, | ||||
|   createWeekColumnScrollManager, | ||||
| @@ -25,7 +26,9 @@ function openCreateEventDialog(eventData) { | ||||
|   // Capture baseline before dialog opens (new event creation flow) | ||||
|   try { | ||||
|     calendarStore.$history?._baselineIfNeeded?.(true) | ||||
|   } catch {} | ||||
|   } catch { | ||||
|     /* noop */ | ||||
|   } | ||||
|   const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount } | ||||
|   setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30) | ||||
| } | ||||
| @@ -33,7 +36,9 @@ function openEditEventDialog(eventClickPayload) { | ||||
|   // Capture baseline before editing existing event | ||||
|   try { | ||||
|     calendarStore.$history?._baselineIfNeeded?.(true) | ||||
|   } catch {} | ||||
|   } catch { | ||||
|     /* noop */ | ||||
|   } | ||||
|   eventDialogRef.value?.openEditDialog(eventClickPayload) | ||||
| } | ||||
| const viewport = ref(null) | ||||
| @@ -41,6 +46,25 @@ const viewportHeight = ref(600) | ||||
| const rowHeight = ref(64) | ||||
| const rowProbe = ref(null) | ||||
| let rowProbeObserver = null | ||||
|  | ||||
| // Scrolling blur effect | ||||
| const blurAmount = ref(0) // pixels | ||||
| let _lastBlurPos = 0 | ||||
| let _blurFrame = null | ||||
|  | ||||
| function _updateMotionBlur() { | ||||
|   const pos = scrollTop.value || 0 | ||||
|   if (_lastBlurPos) blurAmount.value = 0.1 * blurAmount.value + 0.9 * Math.min(20, 0.5 * Math.abs(pos - _lastBlurPos)) | ||||
|   if (!_lastBlurPos || blurAmount.value < 5) blurAmount.value = 0 | ||||
|   _lastBlurPos = pos | ||||
|   _blurFrame = requestAnimationFrame(_updateMotionBlur) | ||||
| } | ||||
|  | ||||
| const viewportBlurStyle = computed(() => { | ||||
|   return blurAmount.value > 0 | ||||
|     ? { filter: 'url(#cal-vert-blur)', willChange: 'filter' } | ||||
|     : { filter: 'none' } | ||||
| }) | ||||
| const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day)) | ||||
| const selection = ref({ startDate: null, dayCount: 0 }) | ||||
| const isDragging = ref(false) | ||||
| @@ -172,11 +196,24 @@ function measureFromProbe() { | ||||
| const { | ||||
|   getWeekIndex, | ||||
|   getFirstDayForVirtualWeek, | ||||
|   goToToday, | ||||
|   handleHeaderYearChange, | ||||
|   scrollToWeekCentered, | ||||
|   scrollToWeek, | ||||
| } = vwm | ||||
|  | ||||
| function showDay(input) { | ||||
|   const dateStr = input instanceof Date ? toLocalString(input, DEFAULT_TZ) : String(input) | ||||
|   const weekIndex = getWeekIndex(fromLocalString(dateStr, DEFAULT_TZ)) | ||||
|   scrollToWeek(weekIndex, 'nav', true) | ||||
|   const diff = Math.abs(weekIndex - centerVisibleWeek.value) | ||||
|   const delay = Math.min(800, diff * 40) | ||||
|   setTimeout(() => { | ||||
|     const el = document.querySelector(`[data-date="${dateStr}"]`) | ||||
|     if (!el) return | ||||
|     el.classList.add('search-highlight-flash') | ||||
|     setTimeout(() => el.classList.remove('search-highlight-flash'), 1500) | ||||
|   }, delay) | ||||
| } | ||||
|  | ||||
| // Reference date for search: center of the current viewport (virtual week at vertical midpoint) | ||||
| const centerVisibleWeek = computed(() => { | ||||
|   const midRow = (scrollTop.value + viewportHeight.value / 2) / rowHeight.value | ||||
| @@ -191,10 +228,6 @@ const centerVisibleDateStr = computed(() => { | ||||
|   } | ||||
| }) | ||||
|  | ||||
| // createWeek logic moved to virtualWeeks plugin | ||||
|  | ||||
| // goToToday now provided by manager | ||||
|  | ||||
| function clearSelection() { | ||||
|   selection.value = { startDate: null, dayCount: 0 } | ||||
| } | ||||
| @@ -207,7 +240,7 @@ watch( | ||||
|     calendarStore.config.holidays.state, | ||||
|     calendarStore.config.holidays.region, | ||||
|   ], | ||||
|   (_newVals, _oldVals) => { | ||||
|   () => { | ||||
|     // If weeks already built, just refresh holiday info | ||||
|     if (visibleWeeks.value.length) { | ||||
|       refreshHolidays('config-change') | ||||
| @@ -220,7 +253,6 @@ watch( | ||||
|  | ||||
| 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 } | ||||
| @@ -333,23 +365,13 @@ function getDateFromCoordinates(clientX, clientY) { | ||||
| } | ||||
|  | ||||
| function calculateSelection(anchorStr, otherStr) { | ||||
|   const limit = calendarStore.config.select_days | ||||
|   const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ) | ||||
|   const otherDate = fromLocalString(otherStr, DEFAULT_TZ) | ||||
|   const forward = otherDate >= anchorDate | ||||
|   const span = daysInclusive(anchorStr, otherStr) | ||||
|  | ||||
|   if (span <= limit) { | ||||
|     const startDate = forward ? anchorStr : otherStr | ||||
|     return { startDate, dayCount: span } | ||||
|   } | ||||
|  | ||||
|   if (forward) { | ||||
|     return { startDate: anchorStr, dayCount: limit } | ||||
|   } else { | ||||
|     const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ) | ||||
|     return { startDate, dayCount: limit } | ||||
|   } | ||||
|   const startDate = forward ? anchorStr : otherStr | ||||
|   return { startDate, dayCount: span } | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
| @@ -382,6 +404,10 @@ onMounted(() => { | ||||
|   onBeforeUnmount(() => { | ||||
|     clearInterval(timer) | ||||
|   }) | ||||
|  | ||||
|   // Start motion blur loop | ||||
|   _lastBlurPos = scrollTop.value || 0 | ||||
|   _blurFrame = requestAnimationFrame(_updateMotionBlur) | ||||
| }) | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
| @@ -393,9 +419,12 @@ onBeforeUnmount(() => { | ||||
|     try { | ||||
|       rowProbeObserver.unobserve(rowProbe.value) | ||||
|       rowProbeObserver.disconnect() | ||||
|     } catch (e) {} | ||||
|     } catch { | ||||
|       /* noop */ | ||||
|     } | ||||
|   } | ||||
|   document.removeEventListener('pointerlockchange', handlePointerLockChange) | ||||
|   if (_blurFrame) cancelAnimationFrame(_blurFrame) | ||||
| }) | ||||
|  | ||||
| const handleDayMouseDown = (d) => { | ||||
| @@ -425,23 +454,14 @@ const handleEventClick = (payload) => { | ||||
|   openEditEventDialog(payload) | ||||
| } | ||||
|  | ||||
| function scrollToEventStart(startDate, smooth = true) { | ||||
|   try { | ||||
|     const dateObj = fromLocalString(startDate, DEFAULT_TZ) | ||||
|     const weekIndex = getWeekIndex(dateObj) | ||||
|     scrollToWeekCentered(weekIndex, 'search-jump', smooth) | ||||
|   } catch {} | ||||
| } | ||||
| function handleHeaderSearchPreview(result) { | ||||
|   if (!result) return | ||||
|   scrollToEventStart(result.startDate, true) | ||||
| } | ||||
| function handleHeaderSearchActivate(result) { | ||||
|   if (!result) return | ||||
|   scrollToEventStart(result.startDate, true) | ||||
|   // Open edit dialog for the event | ||||
|   const ev = calendarStore.getEventById(result.id) | ||||
|   if (ev) openEditEventDialog({ id: ev.id, event: ev }) | ||||
| function handleHeaderSearchPreview(r) { if (r) showDay(r.startDate) } | ||||
| function handleHeaderSearchActivate(r) { | ||||
|   if (!r) return | ||||
|   showDay(r.startDate) | ||||
|   if (!r._goto && !r._holiday) { | ||||
|     const ev = calendarStore.getEventById(r.id) | ||||
|     if (ev) openEditEventDialog({ id: ev.id, event: ev }) | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Heuristic: rotate month label (180deg) only for predominantly Latin text. | ||||
| @@ -497,10 +517,19 @@ window.addEventListener('resize', () => { | ||||
| <template> | ||||
|   <div class="calendar-view-root" :dir="rtl && 'rtl'"> | ||||
|     <div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div> | ||||
|     <!-- Inline SVG filter for vertical motion blur --> | ||||
|     <svg width="0" height="0" aria-hidden="true" focusable="false" class="motion-blur-defs"> | ||||
|       <defs> | ||||
|         <!-- stdDeviation: x y; keep a tiny epsilon on X so some browsers don't drop the filter entirely --> | ||||
|         <filter id="cal-vert-blur" color-interpolation-filters="sRGB" x="-10%" width="120%" y="-10%" height="120%"> | ||||
|           <feGaussianBlur :stdDeviation="`${0.001} ${blurAmount.toFixed(2)}`" edgeMode="duplicate" /> | ||||
|         </filter> | ||||
|       </defs> | ||||
|     </svg> | ||||
|     <div class="wrap"> | ||||
|       <HeaderControls | ||||
|         :reference-date="centerVisibleDateStr" | ||||
|         @go-to-today="goToToday" | ||||
|         @go-to-today="() => showDay(calendarStore.today)" | ||||
|         @search-preview="handleHeaderSearchPreview" | ||||
|         @search-activate="handleHeaderSearchActivate" | ||||
|       /> | ||||
| @@ -511,44 +540,63 @@ window.addEventListener('resize', () => { | ||||
|         @year-change="handleHeaderYearChange" | ||||
|       /> | ||||
|       <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" | ||||
|               :dragging="isDragging" | ||||
|               :style="{ top: week.top + 'px' }" | ||||
|               @day-mousedown="handleDayMouseDown" | ||||
|               @day-mouseenter="handleDayMouseEnter" | ||||
|               @day-mouseup="handleDayMouseUp" | ||||
|               @day-touchstart="handleDayTouchStart" | ||||
|               @event-click="handleEventClick" | ||||
|             /> | ||||
|           </div> | ||||
|         <div class="calendar-viewport" ref="viewport" :style="viewportBlurStyle"> | ||||
|           <div class="month-column-area" :style="{ height: contentHeight + 'px' }"> | ||||
|             <div class="month-labels-container" :style="{ height: '100%' }"> | ||||
|               <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 | ||||
|                 class="month-labels-wrapper" | ||||
|                 :style="{ | ||||
|                   transform: `translateY(${visibleWeeks.length ? visibleWeeks[0].top : 0}px)`, | ||||
|                   gridTemplateRows: `repeat(${visibleWeeks.length}, var(--row-h))`, | ||||
|                 }" | ||||
|               > | ||||
|                 <template v-for="(monthWeek, i) in visibleWeeks" :key="monthWeek.virtualWeek + '-month'"> | ||||
|                   <div | ||||
|                     v-if="monthWeek && monthWeek.monthLabel" | ||||
|                     class="month-label" | ||||
|                     :class="monthWeek.monthLabel?.monthClass" | ||||
|                     :style="{ gridRow: `${i + 1} / span ${monthWeek.monthLabel?.weeksSpan || 1}` }" | ||||
|                     @pointerdown="handleMonthScrollPointerDown" | ||||
|                     @touchstart.prevent="handleMonthScrollTouchStart" | ||||
|                     @wheel="handleMonthScrollWheel" | ||||
|                   > | ||||
|                     <span :class="{ bottomup: shouldRotateMonth(monthWeek.monthLabel?.text) }">{{ | ||||
|                       monthWeek.monthLabel?.text || '' | ||||
|                     }}</span> | ||||
|                   </div> | ||||
|                 </template> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="calendar-content" :style="{ height: contentHeight + 'px' }"> | ||||
|             <div | ||||
|               class="weeks-wrapper" | ||||
|               :style="{ | ||||
|                 transform: `translateY(${visibleWeeks.length ? visibleWeeks[0].top : 0}px)`, | ||||
|               }" | ||||
|             > | ||||
|               <CalendarWeek | ||||
|                 v-for="week in visibleWeeks" | ||||
|                 :key="week.virtualWeek" | ||||
|                 :week="week" | ||||
|                 :dragging="isDragging" | ||||
|                 @day-mousedown="handleDayMouseDown" | ||||
|                 @day-mouseenter="handleDayMouseEnter" | ||||
|                 @day-mouseup="handleDayMouseUp" | ||||
|                 @day-touchstart="handleDayTouchStart" | ||||
|                 @event-click="handleEventClick" | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <!-- Jogwheel overlay captures drag + wheel over month name column --> | ||||
|         <Jogwheel | ||||
|           :total-virtual-weeks="totalVirtualWeeks" | ||||
|           :row-height="rowHeight" | ||||
|           :viewport-height="viewportHeight" | ||||
|           :scroll-top="scrollTop" | ||||
|           @scroll-to="(v) => setScrollTop(v, 'jogwheel')" | ||||
|         /> | ||||
|       </div> | ||||
|       <EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" /> | ||||
|     </div> | ||||
| @@ -602,11 +650,22 @@ header h1 { | ||||
| .calendar-content { | ||||
|   position: relative; | ||||
|   width: 100%; | ||||
|   grid-column: 1; | ||||
|   grid-row: 1; | ||||
| } | ||||
|  | ||||
| .weeks-wrapper { | ||||
|   position: absolute; | ||||
|   inset: 0 auto auto 0; | ||||
|   width: 100%; | ||||
|   will-change: transform; | ||||
| } | ||||
|  | ||||
| .month-column-area { | ||||
|   position: relative; | ||||
|   cursor: ns-resize; | ||||
|   grid-column: 2; | ||||
|   grid-row: 1; | ||||
| } | ||||
|  | ||||
| .month-labels-container { | ||||
| @@ -615,18 +674,23 @@ header h1 { | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| .month-label { | ||||
| .month-labels-wrapper { | ||||
|   position: absolute; | ||||
|   inset-inline-start: 0; | ||||
|   inset: 0 auto auto 0; | ||||
|   width: 100%; | ||||
|   background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2)); | ||||
|   font-size: 2em; | ||||
|   will-change: transform; | ||||
|   display: grid; | ||||
|   grid-auto-flow: row; | ||||
| } | ||||
|  | ||||
| .month-label { | ||||
|   width: 100%; | ||||
|   opacity: 0.8; | ||||
|   font-size: 2.2rem; | ||||
|   font-weight: 700; | ||||
|   color: var(--muted); | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   z-index: 15; | ||||
|   justify-content: start; | ||||
|   overflow: hidden; | ||||
|   cursor: ns-resize; | ||||
|   user-select: none; | ||||
|   | ||||
| @@ -33,20 +33,10 @@ const handleDayTouchStart = (dateStr) => { | ||||
| const handleEventClick = (payload) => { | ||||
|   emit('event-click', payload) | ||||
| } | ||||
|  | ||||
| // Only apply upside-down rotation (bottomup) for Latin script month labels | ||||
| function shouldRotateMonth(label) { | ||||
|   if (!label) return false | ||||
|   try { | ||||
|     return /\p{Script=Latin}/u.test(label) | ||||
|   } catch (e) { | ||||
|     return /[A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/u.test(label) | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div class="week-row" :style="{ top: `${props.week.top}px` }"> | ||||
|   <div class="week-row"> | ||||
|     <div class="week-label">W{{ props.week.weekNumber }}</div> | ||||
|     <div class="days-grid"> | ||||
|       <CalendarDay | ||||
| @@ -68,7 +58,6 @@ function shouldRotateMonth(label) { | ||||
| .week-row { | ||||
|   display: grid; | ||||
|   grid-template-columns: var(--week-w) repeat(7, 1fr); | ||||
|   position: absolute; | ||||
|   height: var(--row-h); | ||||
|   width: 100%; | ||||
| } | ||||
| @@ -78,7 +67,7 @@ function shouldRotateMonth(label) { | ||||
|   place-items: center; | ||||
|   width: 100%; | ||||
|   color: var(--muted); | ||||
|   font-size: 1.2em; | ||||
|   font-size: 1.2rem; | ||||
|   font-weight: 500; | ||||
|   user-select: none; | ||||
|   height: var(--row-h); | ||||
|   | ||||
| @@ -6,7 +6,6 @@ import WeekdaySelector from './WeekdaySelector.vue' | ||||
| import Numeric from './Numeric.vue' | ||||
| import { | ||||
|   addDaysStr, | ||||
|   getMondayOfISOWeek, | ||||
|   fromLocalString, | ||||
|   formatDateShort, | ||||
|   formatDateLong, | ||||
| @@ -675,7 +674,7 @@ const recurrenceSummary = computed(() => { | ||||
| } | ||||
|  | ||||
| .ec-field > span { | ||||
|   font-size: 0.85em; | ||||
|   font-size: 0.85rem; | ||||
|   color: var(--muted); | ||||
| } | ||||
|  | ||||
| @@ -683,12 +682,13 @@ const recurrenceSummary = computed(() => { | ||||
| .ec-field input[type='time'], | ||||
| .ec-field input[type='number'], | ||||
| .ec-field select { | ||||
|   border: 1px solid var(--muted); | ||||
|   border: .1rem solid var(--muted); | ||||
|   border-radius: 0.4rem; | ||||
|   padding: 0.5rem 0.6rem; | ||||
|   width: 100%; | ||||
|   background: transparent; | ||||
|   color: var(--ink); | ||||
|   font-size: 1rem; | ||||
| } | ||||
|  | ||||
| .ec-color-swatches { | ||||
| @@ -726,6 +726,7 @@ const recurrenceSummary = computed(() => { | ||||
|   padding: 0.5em 0.8em; | ||||
|   border-radius: 0.4em; | ||||
|   cursor: pointer; | ||||
|   font-size: 1rem; | ||||
|   transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| @@ -771,7 +772,7 @@ const recurrenceSummary = computed(() => { | ||||
| } | ||||
|  | ||||
| .ec-field-label { | ||||
|   font-size: 0.85em; | ||||
|   font-size: 0.85rem; | ||||
|   color: var(--muted); | ||||
| } | ||||
|  | ||||
| @@ -801,7 +802,7 @@ const recurrenceSummary = computed(() => { | ||||
| } | ||||
|  | ||||
| .ec-weekday-text { | ||||
|   font-size: 0.8em; | ||||
|   font-size: 0.8rem; | ||||
|   font-weight: 500; | ||||
|   text-align: center; | ||||
| } | ||||
| @@ -848,12 +849,12 @@ const recurrenceSummary = computed(() => { | ||||
|   align-items: center; | ||||
|   gap: 0.5em; | ||||
|   flex-wrap: wrap; | ||||
|   font-size: 0.75em; | ||||
|   font-size: 0.75rem; | ||||
| } | ||||
| .freq-select { | ||||
|   padding: 0.4rem 0.55rem; | ||||
|   font-size: 0.75rem; | ||||
|   border: 1px solid var(--input-border); | ||||
|   border: .1rem solid var(--input-border); | ||||
|   background: var(--panel-alt); | ||||
|   color: var(--ink); | ||||
|   border-radius: 0.45rem; | ||||
| @@ -867,18 +868,19 @@ const recurrenceSummary = computed(() => { | ||||
|   background: var(--panel-accent); | ||||
|   color: var(--ink); | ||||
|   box-shadow: | ||||
|     0 0 0 1px var(--input-focus), | ||||
|     0 0 0 4px rgba(37, 99, 235, 0.15); | ||||
|     0 0 0 .1rem var(--input-focus), | ||||
|     0 0 0 .4rem rgba(37, 99, 235, 0.15); | ||||
| } | ||||
| .interval-input, | ||||
| .occ-input { | ||||
|   display: none; | ||||
| } | ||||
| .ec-field input[type='text'] { | ||||
|   border: 1px solid var(--input-border); | ||||
|   border: .1rem solid var(--input-border); | ||||
|   background: var(--panel-alt); | ||||
|   border-radius: 0.45rem; | ||||
|   padding: 0.4rem 0.5rem; | ||||
|   font-size: 1rem; | ||||
|   transition: | ||||
|     border-color 0.18s ease, | ||||
|     background-color 0.18s ease, | ||||
| @@ -889,8 +891,8 @@ const recurrenceSummary = computed(() => { | ||||
|   border-color: var(--input-focus); | ||||
|   background: var(--panel-accent); | ||||
|   box-shadow: | ||||
|     0 0 0 1px var(--input-focus), | ||||
|     0 0 0 4px rgba(37, 99, 235, 0.15); | ||||
|     0 0 0 .1rem var(--input-focus), | ||||
|     0 0 0 .4rem rgba(37, 99, 235, 0.15); | ||||
| } | ||||
| .hint { | ||||
|   font-size: 0.65rem; | ||||
| @@ -908,7 +910,7 @@ const recurrenceSummary = computed(() => { | ||||
|   align-items: center; | ||||
|   width: 100%; | ||||
|   padding: 0.6rem 0.8rem; | ||||
|   border: 1px solid var(--muted); | ||||
|   border: .1rem solid var(--muted); | ||||
|   background: var(--panel); | ||||
|   border-radius: 0.4rem; | ||||
|   cursor: pointer; | ||||
| @@ -931,7 +933,7 @@ const recurrenceSummary = computed(() => { | ||||
|   display: grid; | ||||
|   gap: 0.6rem; | ||||
|   padding: 0.6rem; | ||||
|   border: 1px solid var(--muted); | ||||
|   border: .1rem solid var(--muted); | ||||
|   border-radius: 0.4rem; | ||||
|   background: color-mix(in srgb, var(--muted) 20%, transparent); | ||||
| } | ||||
| @@ -945,7 +947,7 @@ const recurrenceSummary = computed(() => { | ||||
| .ec-repeat-modes .mode-btn { | ||||
|   flex: 1 1 auto; | ||||
|   padding: 0.4rem 0.6rem; | ||||
|   border: 1px solid var(--muted); | ||||
|   border: .1rem solid var(--muted); | ||||
|   background: var(--panel); | ||||
|   border-radius: 0.4rem; | ||||
|   cursor: pointer; | ||||
|   | ||||
| @@ -3,8 +3,12 @@ | ||||
|     <div | ||||
|       v-for="seg in eventSegments" | ||||
|       :key="'seg-' + seg.startIdx + '-' + seg.endIdx" | ||||
|       :class="['segment-grid', { compress: isSegmentCompressed(seg) }]" | ||||
|       :style="segmentStyle(seg)" | ||||
|       class="segment-grid" | ||||
|       :style="{ | ||||
|         ...segmentStyle(seg), | ||||
|         '--segment-row-height': getSegmentRowHeight(seg), | ||||
|         height: getSegmentTotalHeight(seg) | ||||
|       }" | ||||
|     > | ||||
|       <div | ||||
|         v-for="span in seg.events" | ||||
| @@ -179,8 +183,14 @@ function segmentKey(seg) { | ||||
|   return seg.startIdx + '-' + seg.endIdx | ||||
| } | ||||
|  | ||||
| function isSegmentCompressed(seg) { | ||||
|   return !!segmentCompression.value[segmentKey(seg)] | ||||
| function getSegmentRowHeight(seg) { | ||||
|   const data = segmentCompression.value[segmentKey(seg)] | ||||
|   return data && typeof data.rowHeight === 'number' ? `${data.rowHeight}px` : '1.5rem' | ||||
| } | ||||
|  | ||||
| function getSegmentTotalHeight(seg) { | ||||
|   const data = segmentCompression.value[segmentKey(seg)] | ||||
|   return data && typeof data.totalHeight === 'number' ? `${data.totalHeight}px` : 'auto' | ||||
| } | ||||
|  | ||||
| function recomputeCompression() { | ||||
| @@ -190,13 +200,36 @@ function recomputeCompression() { | ||||
|   if (!available) return | ||||
|   const cs = getComputedStyle(el) | ||||
|   const fontSize = parseFloat(cs.fontSize) || 16 | ||||
|   const baseRowPx = fontSize * 1.5 // desired row height (matches CSS 1.5em) | ||||
|   const baseRowPx = fontSize // desired row height (matches CSS 1.5em) | ||||
|   const marginTop = 0 // already applied outside height | ||||
|   const usable = Math.max(0, available - marginTop) | ||||
|   const nextMap = {} | ||||
|  | ||||
|   for (const seg of eventSegments.value) { | ||||
|     const desired = (seg.rowsCount || 1) * baseRowPx | ||||
|     nextMap[segmentKey(seg)] = desired > usable | ||||
|     const rowCount = seg.rowsCount || 1 | ||||
|     const desired = rowCount * baseRowPx | ||||
|     const needsScaling = desired > usable | ||||
|  | ||||
|     // Row height may be reduced to fit segment within available vertical space | ||||
|     let finalRowHeight = baseRowPx | ||||
|     if (needsScaling) { | ||||
|       const scaledRowHeight = usable / rowCount | ||||
|       finalRowHeight = Math.min(scaledRowHeight, baseRowPx) | ||||
|     } | ||||
|  | ||||
|     // Event-level scaling not applied for horizontal fitting in this task | ||||
|     const segmentData = { | ||||
|       rowHeight: finalRowHeight, | ||||
|       totalHeight: needsScaling ? usable : desired, | ||||
|       events: {} | ||||
|     } | ||||
|  | ||||
|     // Populate per-event map (reserved for future use) | ||||
|     for (const event of seg.events) { | ||||
|       segmentData.events[event.id + '-' + (event.n || 0)] = {} | ||||
|     } | ||||
|  | ||||
|     nextMap[segmentKey(seg)] = segmentData | ||||
|   } | ||||
|   segmentCompression.value = nextMap | ||||
| } | ||||
| @@ -288,18 +321,16 @@ function startLocalDrag(init, evt) { | ||||
|   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 {} | ||||
|     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] | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   dragState.value = { | ||||
| @@ -532,41 +563,45 @@ function applyRangeDuringDrag(st, startDate, endDate) { | ||||
|   inset: 0; | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(7, 1fr); | ||||
|   margin-top: 1.8em; | ||||
|   margin-top: 1.0rem; | ||||
|   pointer-events: none; | ||||
| } | ||||
| .segment-grid { | ||||
|   display: grid; | ||||
|   gap: 2px; | ||||
|   align-content: start; | ||||
|   pointer-events: none; | ||||
|   overflow: hidden; | ||||
|   grid-auto-columns: 1fr; | ||||
|   grid-auto-rows: 1.5em; | ||||
| } | ||||
| .segment-grid.compress { | ||||
|   grid-auto-rows: 1fr; | ||||
|   grid-auto-rows: var(--segment-row-height); | ||||
| } | ||||
|  | ||||
| .event-span { | ||||
|   padding: 0.1em 0.3em; | ||||
|   border-radius: 1em; | ||||
|   font-size: clamp(0.45em, 1.8vh, 0.75em); | ||||
|   font-weight: 600; | ||||
|   padding: 0; | ||||
|   border-radius: 1rem; | ||||
|   /* Font-size so that ascender+descender exactly fills the row height: | ||||
|     given total = asc+desc at 1em (hardcoded 1.15), font-size = rowHeight / total */ | ||||
|   font-size: calc(var(--segment-row-height, 1.5rem) / 1.15); | ||||
|   font-weight: 500; | ||||
|   cursor: grab; | ||||
|   pointer-events: auto; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   /* Use unitless 1 so line box = font-size; combined with computed font-size above, | ||||
|     this makes the text box (asc+desc) fill the available row height */ | ||||
|   line-height: 1; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   /* Vertically anchor to top so baselines align across rows; we'll center text vertically by | ||||
|     using cap/descender metrics inside the child */ | ||||
|   align-items: flex-start; | ||||
|   justify-content: center; | ||||
|   position: relative; | ||||
|   user-select: none; | ||||
|   z-index: 1; | ||||
|   z-index: 10; | ||||
|   text-align: center; | ||||
|   /* Ensure touch pointer events aren't turned into a scroll gesture; needed for reliable drag on mobile */ | ||||
|   touch-action: none; | ||||
|   backdrop-filter: blur(.05rem); | ||||
|   max-width: 100%; | ||||
| } | ||||
|  | ||||
| .event-span.cont-prev { | ||||
| @@ -579,17 +614,21 @@ function applyRangeDuringDrag(st, startDate, endDate) { | ||||
|   border-bottom-right-radius: 0; | ||||
| } | ||||
|  | ||||
| /* Inner title wrapper ensures proper ellipsis within flex/grid constraints */ | ||||
| .event-title { | ||||
|   display: block; | ||||
|   flex: 1 1 0%; | ||||
|   flex: 0 1 auto; | ||||
|   min-width: 0; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   text-align: center; | ||||
|   pointer-events: none; | ||||
|   position: relative; | ||||
|   z-index: 1; | ||||
|   max-width: 100%; | ||||
|   line-height: inherit; | ||||
| } | ||||
|  | ||||
| /* Resize handles */ | ||||
| @@ -597,7 +636,7 @@ function applyRangeDuringDrag(st, startDate, endDate) { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   bottom: 0; | ||||
|   width: 6px; | ||||
|   width: 1rem; | ||||
|   background: transparent; | ||||
|   z-index: 2; | ||||
|   cursor: ew-resize; | ||||
|   | ||||
| @@ -1,13 +1,34 @@ | ||||
| <template> | ||||
|   <div class="header-controls-wrapper"> | ||||
|     <Transition name="header-controls" appear> | ||||
|       <div v-if="isVisible" class="header-controls"> | ||||
|         <EventSearch | ||||
|           ref="eventSearchRef" | ||||
|           :reference-date="referenceDate" | ||||
|           @activate="handleSearchActivate" | ||||
|           @preview="(r) => emit('search-preview', r)" | ||||
|         /> | ||||
|       <div | ||||
|         v-if="isVisible" | ||||
|         ref="headerControlsRef" | ||||
|         class="header-controls" | ||||
|         @focusin="handleFocusIn" | ||||
|         @focusout="handleFocusOut" | ||||
|       > | ||||
|         <div class="search-with-spacer"> | ||||
|           <!-- Shrinkable spacer to align search with week label column; smoothly shrinks as needed --> | ||||
|           <div class="pre-search-spacer" aria-hidden="true"></div> | ||||
|           <EventSearch | ||||
|             ref="eventSearchRef" | ||||
|             :reference-date="referenceDate" | ||||
|             @activate="handleSearchActivate" | ||||
|             @preview="(r) => emit('search-preview', r)" | ||||
|           /> | ||||
|         </div> | ||||
|         <div | ||||
|           class="current-time" | ||||
|           aria-label="Current time (click to go to today)" | ||||
|           role="button" | ||||
|           tabindex="-1" | ||||
|           @click="goToToday" | ||||
|           @keydown.enter="goToToday" | ||||
|           @keydown.space.prevent="goToToday" | ||||
|         > | ||||
|           {{ timeString }} | ||||
|         </div> | ||||
|         <div class="today-date" @click="goToToday">{{ todayString }}</div> | ||||
|         <button | ||||
|           type="button" | ||||
| @@ -54,7 +75,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { computed, ref, onMounted, onBeforeUnmount, defineExpose, nextTick } from 'vue' | ||||
| import { computed, ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
| import { formatTodayString } from '@/utils/date' | ||||
| import EventSearch from '@/components/Search.vue' | ||||
| @@ -62,11 +83,40 @@ import SettingsDialog from '@/components/SettingsDialog.vue' | ||||
|  | ||||
| const calendarStore = useCalendarStore() | ||||
|  | ||||
| // Today label: derive from local ticking clock so it flips right at midnight | ||||
| const todayString = computed(() => { | ||||
|   const d = new Date(calendarStore.now) | ||||
|   const d = new Date(localNowMs?.value ?? Date.now()) | ||||
|   return formatTodayString(d) | ||||
| }) | ||||
|  | ||||
| // Local ticking clock: update every second without thrashing global store | ||||
| const localNowMs = ref(Date.now()) | ||||
| let clockTimer = null | ||||
|  | ||||
| onMounted(() => { | ||||
|   // Start a 1s ticker for the header clock (independent from store's minute tick) | ||||
|   clockTimer = setInterval(() => { | ||||
|     localNowMs.value = Date.now() | ||||
|   }, 1000) | ||||
| }) | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
|   if (clockTimer) clearInterval(clockTimer) | ||||
| }) | ||||
|  | ||||
| // Current time (24h, NBSP padding for single-digit hours, with day/night emoji) | ||||
| const timeString = computed(() => { | ||||
|   const d = new Date(localNowMs.value) | ||||
|   const h = d.getHours() | ||||
|   const m = d.getMinutes() | ||||
|   const hh = h < 10 ? '\u00A0' + h : String(h) | ||||
|   const mm = m < 10 ? '0' + m : String(m) | ||||
|   // Day at 6-18, otherwise night (TODO: sunrise/sunset) | ||||
|   const isDay = h >= 6 && h < 18 | ||||
|   const emoji = isDay ? '🌞' : '🌙' | ||||
|   return `${hh}:${mm}${emoji}` | ||||
| }) | ||||
|  | ||||
| const emit = defineEmits(['go-to-today', 'search-activate', 'search-preview']) | ||||
| const { referenceDate = null } = defineProps({ referenceDate: { type: String, default: null } }) | ||||
|  | ||||
| @@ -78,18 +128,19 @@ function goToToday() { | ||||
|  | ||||
| // Screen size detection and visibility toggle | ||||
| const isVisible = ref(false) | ||||
| // Track if we auto-opened due to a find (Ctrl/Cmd+F) | ||||
| const autoOpenedForSearch = ref(false) | ||||
| const headerControlsRef = ref(null) | ||||
| const hasFocusWithin = ref(false) | ||||
|  | ||||
| function checkScreenSize() { | ||||
|   const isSmallScreen = window.innerHeight < 600 | ||||
|   // Default to open on large screens, closed on small screens | ||||
|   isVisible.value = !isSmallScreen | ||||
|   isVisible.value = !isSmallScreen || hasFocusWithin.value | ||||
| } | ||||
|  | ||||
| function toggleVisibility() { | ||||
|   isVisible.value = !isVisible.value | ||||
|   if (!isVisible.value) autoOpenedForSearch.value = false | ||||
|   if (!isVisible.value) { | ||||
|     hasFocusWithin.value = false | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Settings dialog integration | ||||
| @@ -98,12 +149,33 @@ function openSettings() { | ||||
|   // Capture baseline before opening settings | ||||
|   try { | ||||
|     calendarStore.$history?._baselineIfNeeded?.(true) | ||||
|   } catch {} | ||||
|   } catch { | ||||
|     /* no-op */ | ||||
|   } | ||||
|   settingsDialog.value?.open() | ||||
| } | ||||
|  | ||||
| // Search component ref exposure | ||||
| const eventSearchRef = ref(null) | ||||
|  | ||||
| function handleFocusIn() { | ||||
|   hasFocusWithin.value = true | ||||
|   if (!isVisible.value) isVisible.value = true | ||||
| } | ||||
|  | ||||
| function handleFocusOut(event) { | ||||
|   const container = headerControlsRef.value | ||||
|   if (!container) { | ||||
|     hasFocusWithin.value = false | ||||
|     return | ||||
|   } | ||||
|   const nextTarget = event.relatedTarget ?? document.activeElement | ||||
|   if (nextTarget && container.contains(nextTarget)) return | ||||
|   hasFocusWithin.value = false | ||||
|   if (window.innerHeight < 600) { | ||||
|     checkScreenSize() | ||||
|   } | ||||
| } | ||||
| function focusSearch(selectAll = true) { | ||||
|   eventSearchRef.value?.focusSearch(selectAll) | ||||
| } | ||||
| @@ -121,24 +193,21 @@ function handleGlobalFind(e) { | ||||
|     e.preventDefault() | ||||
|     if (!isVisible.value) { | ||||
|       isVisible.value = true | ||||
|       autoOpenedForSearch.value = true | ||||
|     } else { | ||||
|       autoOpenedForSearch.value = false | ||||
|     } | ||||
|     // Defer focus until after transition renders input | ||||
|     nextTick(() => requestAnimationFrame(() => focusSearch(true))) | ||||
|     nextTick(() => focusSearch(true)) | ||||
|   } | ||||
| } | ||||
|  | ||||
| function handleSearchActivate(r) { | ||||
|   emit('search-activate', r) | ||||
|   // Auto close only if we auto-opened for search shortcut | ||||
|   if (autoOpenedForSearch.value) { | ||||
|     isVisible.value = false | ||||
|   } | ||||
|   autoOpenedForSearch.value = false | ||||
| } | ||||
|  | ||||
| watch(isVisible, (visible) => { | ||||
|   if (visible) nextTick(() => focusSearch(true)) | ||||
|   else hasFocusWithin.value = false | ||||
| }) | ||||
|  | ||||
| onMounted(() => { | ||||
|   checkScreenSize() | ||||
|   window.addEventListener('resize', checkScreenSize) | ||||
| @@ -156,16 +225,38 @@ onBeforeUnmount(() => { | ||||
|   position: relative; | ||||
|   display: flex; | ||||
|   align-items: flex-start; | ||||
|   gap: 0.75rem; | ||||
|   padding: 0.4rem 0.5rem 0 0.5rem; | ||||
| } | ||||
| .header-controls { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 0.75rem; | ||||
|   width: 100%; | ||||
|   padding-inline-end: 2rem; | ||||
|   gap: 1rem; | ||||
| } | ||||
|  | ||||
| @media (max-width: 600px) { | ||||
|   .header-controls { gap: 0.1rem; } | ||||
| } | ||||
| /* Group search + spacer so outer gap doesn't create unwanted space */ | ||||
| .search-with-spacer { | ||||
|   display: flex; | ||||
|   flex: 1; | ||||
|   min-width: 0; | ||||
|   align-items: stretch; | ||||
| } | ||||
| .search-with-spacer > .search-bar { | ||||
|   flex: 1 1 auto; | ||||
|   min-width: 6rem; /* allow spacer to give up space first */ | ||||
| } | ||||
|  | ||||
| .pre-search-spacer { | ||||
|   flex: 0 1000 var(--week-w); | ||||
|   width: var(--week-w); | ||||
|   min-width: .5rem; | ||||
|   pointer-events: none; | ||||
|   transition: flex-basis 0.35s ease, width 0.35s ease; | ||||
| } | ||||
|  | ||||
| .toggle-btn { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
| @@ -176,7 +267,7 @@ onBeforeUnmount(() => { | ||||
|   padding: 0; | ||||
|   margin: 0.5em; | ||||
|   cursor: pointer; | ||||
|   font-size: 1em; | ||||
|   font-size: 1rem; | ||||
|   font-weight: 700; | ||||
|   line-height: 1; | ||||
|   display: inline-flex; | ||||
| @@ -204,13 +295,13 @@ onBeforeUnmount(() => { | ||||
| .header-controls-leave-to { | ||||
|   opacity: 0; | ||||
|   max-height: 0; | ||||
|   transform: translateY(-20px); | ||||
|   transform: translateY(-1rem); | ||||
| } | ||||
|  | ||||
| .header-controls-enter-to, | ||||
| .header-controls-leave-from { | ||||
|   opacity: 1; | ||||
|   max-height: 100px; | ||||
|   max-height: 4rem; | ||||
|   transform: translateY(0); | ||||
| } | ||||
|  | ||||
| @@ -265,9 +356,27 @@ onBeforeUnmount(() => { | ||||
| } | ||||
|  | ||||
| .today-date { | ||||
|   font-size: 1.5em; | ||||
|   font-size: 1.5rem; | ||||
|   white-space: pre-line; | ||||
|   text-align: center; | ||||
|   margin-inline-end: 2rem; | ||||
| } | ||||
|  | ||||
| .current-time { | ||||
|   font-family: ui-monospace, SF Mono, Consolas, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace; | ||||
|   font-size: 3.6rem; | ||||
|   white-space: nowrap; | ||||
|   text-align: center; | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| .current-time:hover, | ||||
| .current-time:focus-visible { | ||||
|   color: var(--strong); | ||||
| } | ||||
|  | ||||
| @media (max-width: 770px) { | ||||
|   .current-time { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,15 +1,7 @@ | ||||
| <template> | ||||
|   <div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll"> | ||||
|     <div | ||||
|       class="jogwheel-content" | ||||
|       ref="jogwheelContent" | ||||
|       :style="{ height: jogwheelHeight + 'px' }" | ||||
|     ></div> | ||||
|   </div> | ||||
| </template> | ||||
| <template><div class="jogwheel-viewport" ref="jogwheelViewport" /></template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' | ||||
| import { ref, onMounted, onBeforeUnmount } from 'vue' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   totalVirtualWeeks: { type: Number, required: true }, | ||||
| @@ -21,160 +13,66 @@ const props = defineProps({ | ||||
| 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 | ||||
| let lastClientY = null | ||||
|  | ||||
| // Jogwheel content height is 1/10th of main calendar | ||||
| const jogwheelHeight = computed(() => { | ||||
|   return (props.totalVirtualWeeks * props.rowHeight) / 10 | ||||
| }) | ||||
| const SPEED_DRAG = 4 | ||||
|  | ||||
| const handleJogwheelScroll = () => { | ||||
|   if (syncLock.value === 'jogwheel') return | ||||
|   syncFromJogwheel() | ||||
| } | ||||
| const WEEKS_PER_MONTH = 30.4375 / 7 | ||||
| const MONTH_SCROLL = () => props.rowHeight * WEEKS_PER_MONTH | ||||
| const ANIM_DURATION = 420 // ms | ||||
| let animActive = false | ||||
| let animFrom = 0 | ||||
| let animTo = 0 | ||||
| let animStart = 0 | ||||
| let animFrame = null | ||||
|  | ||||
| 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() | ||||
| } | ||||
| // Drag momentum (independent from month-step animation) | ||||
| let dragMomentumActive = false | ||||
| let dragMomentumFrame = null | ||||
| let dragMomentumVelocity = 0 | ||||
| let dragMomentumPos = 0 | ||||
| const DRAG_FRICTION_PER_MS = 0.0018 | ||||
| const DRAG_MIN_V = 0.03 | ||||
| let dragSamples = [] // { t, s } sampled scroll positions during drag | ||||
|  | ||||
| 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 MIN_WHEEL_ABS = 2 | ||||
| function easeOutCubic(t){return 1-Math.pow(1-t,3)} | ||||
|  | ||||
| function clampScroll(x) { | ||||
|   const maxScroll = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight) | ||||
|   if (desired > maxScroll) desired = maxScroll | ||||
|   emit('scroll-to', desired) | ||||
|   e.preventDefault() | ||||
|   if (x < 0) return 0 | ||||
|   if (x > maxScroll) return maxScroll | ||||
|   return x | ||||
| } | ||||
|  | ||||
| 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 animateTo(target){target=clampScroll(target);const now=performance.now();if(animActive){const p=Math.min(1,(now-animStart)/ANIM_DURATION);animFrom=animFrom+(animTo-animFrom)*easeOutCubic(p);animTo=target;animStart=now;}else{animFrom=props.scrollTop;animTo=target;animStart=now;animActive=true;animFrame=requestAnimationFrame(stepAnim);return}if(!animFrame)animFrame=requestAnimationFrame(stepAnim)} | ||||
| function stepAnim(){if(!animActive)return;const t=Math.min(1,(performance.now()-animStart)/ANIM_DURATION);const val=animFrom+(animTo-animFrom)*easeOutCubic(t);emit('scroll-to',clampScroll(val));if(t>=1){animActive=false;animFrame=null;return}animFrame=requestAnimationFrame(stepAnim)} | ||||
|  | ||||
| function handlePointerLockChange() { | ||||
|   pointerLocked = document.pointerLockElement === jogwheelViewport.value | ||||
|   if (!pointerLocked && isDragging.value) { | ||||
|     // Pointer lock lost (Esc) -> end drag gracefully | ||||
|     onDragMouseUp(new MouseEvent('mouseup')) | ||||
|   } | ||||
| } | ||||
| function onDragPointerDown(e){if(e.button!==0)return;if(animActive){const now=performance.now();const p=Math.min(1,(now-animStart)/ANIM_DURATION);const cur=animFrom+(animTo-animFrom)*easeOutCubic(p);animActive=false;animFrame&&cancelAnimationFrame(animFrame);animFrame=null;emit('scroll-to',clampScroll(cur));}cancelDragMomentum();isDragging.value=true;mainStartScroll=props.scrollTop;accumDelta=0;lastClientY=e.clientY;dragSamples=[{t:performance.now(),s:mainStartScroll}];if(jogwheelViewport.value&&jogwheelViewport.value.requestPointerLock)jogwheelViewport.value.requestPointerLock();window.addEventListener('pointermove',onDragPointerMove,{passive:false});window.addEventListener('pointerup',onDragPointerUp,{passive:false});window.addEventListener('pointercancel',onDragPointerUp,{passive:false});e.preventDefault()} | ||||
|  | ||||
| onMounted(() => { | ||||
|   if (jogwheelViewport.value) { | ||||
|     jogwheelViewport.value.addEventListener('mousedown', onDragMouseDown) | ||||
|   } | ||||
|   document.addEventListener('pointerlockchange', handlePointerLockChange) | ||||
| }) | ||||
| function onDragPointerMove(e){if(!isDragging.value) return;let dy=typeof e.movementY==='number'?e.movementY:0;if(!pointerLocked){if(lastClientY!=null)dy=e.clientY-lastClientY;lastClientY=e.clientY;}accumDelta+=dy;let desired=mainStartScroll-accumDelta*SPEED_DRAG;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);dragSamples.push({t:performance.now(),s:desired});e.preventDefault()} | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
|   if (jogwheelViewport.value) { | ||||
|     jogwheelViewport.value.removeEventListener('mousedown', onDragMouseDown) | ||||
|   } | ||||
|   window.removeEventListener('mousemove', onDragMouseMove) | ||||
|   window.removeEventListener('mouseup', onDragMouseUp) | ||||
|   document.removeEventListener('pointerlockchange', handlePointerLockChange) | ||||
| }) | ||||
| function onDragPointerUp(e){if(!isDragging.value)return;isDragging.value=false;lastClientY=null;window.removeEventListener('pointermove',onDragPointerMove);window.removeEventListener('pointerup',onDragPointerUp);window.removeEventListener('pointercancel',onDragPointerUp);if(pointerLocked&&document.exitPointerLock)document.exitPointerLock();const v=computeDragVelocity();dragSamples=[];if(Math.abs(v)>=DRAG_MIN_V)startDragMomentum(v);e.preventDefault()} | ||||
|  | ||||
| const syncFromJogwheel = () => { | ||||
|   if (!jogwheelViewport.value || !jogwheelContent.value) return | ||||
| function handlePointerLockChange(){pointerLocked=document.pointerLockElement===jogwheelViewport.value;if(!pointerLocked&&isDragging.value)onDragPointerUp(new PointerEvent('pointerup'))} | ||||
|  | ||||
|   syncLock.value = 'main' | ||||
| onMounted(()=>{if(jogwheelViewport.value){jogwheelViewport.value.addEventListener('pointerdown',onDragPointerDown);jogwheelViewport.value.addEventListener('wheel',onWheel,{passive:false,capture:true});}document.addEventListener('pointerlockchange',handlePointerLockChange)}) | ||||
|  | ||||
|   const jogScrollable = Math.max( | ||||
|     0, | ||||
|     jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight, | ||||
|   ) | ||||
|   const mainScrollable = Math.max( | ||||
|     0, | ||||
|     props.totalVirtualWeeks * props.rowHeight - props.viewportHeight, | ||||
|   ) | ||||
| onBeforeUnmount(()=>{if(jogwheelViewport.value){jogwheelViewport.value.removeEventListener('pointerdown',onDragPointerDown);jogwheelViewport.value.removeEventListener('wheel',onWheel);}window.removeEventListener('pointermove',onDragPointerMove);window.removeEventListener('pointerup',onDragPointerUp);window.removeEventListener('pointercancel',onDragPointerUp);document.removeEventListener('pointerlockchange',handlePointerLockChange)}) | ||||
|  | ||||
|   if (jogScrollable > 0) { | ||||
|     const ratio = jogwheelViewport.value.scrollTop / jogScrollable | ||||
| function onWheel(e){if(e.ctrlKey)return;e.preventDefault();e.stopPropagation();cancelDragMomentum();const dy=e.deltaY;if(Math.abs(dy)<MIN_WHEEL_ABS)return;const dir=dy>0?1:-1;const base=animActive?animTo:props.scrollTop;animateTo(base+dir*MONTH_SCROLL())} | ||||
|  | ||||
|     // Emit scroll event to parent to update main viewport | ||||
|     emit('scroll-to', ratio * mainScrollable) | ||||
|   } | ||||
| // Keep API stable for parent components (previously exposed) | ||||
| function syncFromMain(){};defineExpose({syncFromMain}) | ||||
|  | ||||
|   setTimeout(() => { | ||||
|     if (syncLock.value === 'main') syncLock.value = null | ||||
|   }, 50) | ||||
| } | ||||
|  | ||||
| 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, | ||||
|   ) | ||||
|  | ||||
|   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) | ||||
|   }, | ||||
| ) | ||||
|  | ||||
| defineExpose({ | ||||
|   syncFromMain, | ||||
| }) | ||||
| // ---- Drag Momentum Helpers ---- | ||||
| function cancelDragMomentum(){if(!dragMomentumActive)return;dragMomentumActive=false;dragMomentumFrame&&cancelAnimationFrame(dragMomentumFrame);dragMomentumFrame=null} | ||||
| function computeDragVelocity(){if(dragSamples.length<2)return 0;const now=performance.now();const cutoff=now-80;while(dragSamples.length&&dragSamples[0].t<cutoff)dragSamples.shift();if(dragSamples.length<2)return 0;const first=dragSamples[0],last=dragSamples[dragSamples.length-1],dt=last.t-first.t;if(dt<=8)return 0;return (last.s-first.s)/dt} | ||||
| function startDragMomentum(v){cancelDragMomentum();dragMomentumVelocity=v;dragMomentumPos=props.scrollTop;if(!isFinite(v)||Math.abs(v)<DRAG_MIN_V)return;dragMomentumActive=true;let lastTs=performance.now();const stepM=()=>{if(!dragMomentumActive)return;const now=performance.now(),dt=now-lastTs;lastTs=now;if(dt<=0){dragMomentumFrame=requestAnimationFrame(stepM);return}dragMomentumVelocity*=Math.exp(-DRAG_FRICTION_PER_MS*dt);dragMomentumPos=clampScroll(dragMomentumPos+dragMomentumVelocity*dt);const maxScroll=Math.max(0,props.totalVirtualWeeks*props.rowHeight-props.viewportHeight);if((dragMomentumPos<=0&&dragMomentumVelocity<0)||(dragMomentumPos>=maxScroll&&dragMomentumVelocity>0))dragMomentumVelocity=0;emit('scroll-to',dragMomentumPos);if(Math.abs(dragMomentumVelocity)<DRAG_MIN_V*0.6){cancelDragMomentum();return}dragMomentumFrame=requestAnimationFrame(stepM)};dragMomentumFrame=requestAnimationFrame(stepM)} | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| @@ -184,19 +82,13 @@ defineExpose({ | ||||
|   inset-inline-end: 0; | ||||
|   bottom: 0; | ||||
|   width: var(--month-w); | ||||
|   overflow-y: auto; | ||||
|   overflow-x: hidden; | ||||
|   scrollbar-width: none; | ||||
|   /* Transparent interactive overlay */ | ||||
|   overflow: hidden; | ||||
|   z-index: 20; | ||||
|   cursor: ns-resize; | ||||
|   overscroll-behavior: contain; | ||||
|   touch-action: none; | ||||
| } | ||||
|  | ||||
| .jogwheel-viewport::-webkit-scrollbar { | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
| .jogwheel-content { | ||||
|   position: relative; | ||||
|   width: 100%; | ||||
| } | ||||
| .jogwheel-viewport::-webkit-scrollbar { display: none; } | ||||
| </style> | ||||
|   | ||||
| @@ -245,6 +245,7 @@ function onWheel(e) { | ||||
|   justify-content: center; | ||||
|   gap: 0.25rem; | ||||
|   background: none; | ||||
|   font-size: 1rem; | ||||
|   font-variant-numeric: tabular-nums; | ||||
|   touch-action: none; | ||||
| } | ||||
|   | ||||
| @@ -8,6 +8,9 @@ | ||||
|       aria-label="Search dates, holidays and events" | ||||
|       @keydown="handleSearchKeydown" | ||||
|     /> | ||||
|     <div v-if="shortcut" class="shortcut-hint"> | ||||
|       {{ shortcut }} | ||||
|     </div> | ||||
|     <ul | ||||
|       v-if="searchQuery.trim() && searchResults.length" | ||||
|       class="search-dropdown" | ||||
| @@ -26,14 +29,11 @@ | ||||
|         ><span class="date">{{ r.startDate }}</span> | ||||
|       </li> | ||||
|     </ul> | ||||
|     <div v-else-if="searchQuery.trim() && !searchResults.length" class="search-empty"> | ||||
|       No matches | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, watch, nextTick, computed, defineExpose, onUnmounted, onMounted } from 'vue' | ||||
| import { ref, watch, nextTick, computed, onUnmounted, onMounted } from 'vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
| import { | ||||
|   fromLocalString, | ||||
| @@ -60,6 +60,12 @@ const searchIndex = ref(0) | ||||
| const searchInputRef = ref(null) | ||||
| let previewTimer = null | ||||
|  | ||||
| // Note: Android is also Linux. HarmonyOS 5 doesn't include "Linux". | ||||
| const shortcut = /Android/.test(navigator.userAgent) ? '' | ||||
|   : /Mac/.test(navigator.userAgent) ? '⌘F' | ||||
|   : /Windows|Linux/.test(navigator.userAgent) ? 'Ctrl+F' | ||||
|   : '' | ||||
|  | ||||
| // Accent-insensitive lowercasing | ||||
| const norm = (s) => | ||||
|   s | ||||
| @@ -469,8 +475,8 @@ function parseGoToDateCandidate(input, refStr) { | ||||
|  | ||||
| <style scoped> | ||||
| .search-bar { | ||||
|   flex: 0 1 20rem; | ||||
|   margin-inline: auto; /* center with equal free-space on both sides */ | ||||
|   flex: 1; | ||||
|   min-width: 0; | ||||
|   position: relative; | ||||
| } | ||||
| .search-bar input { | ||||
| @@ -478,9 +484,9 @@ function parseGoToDateCandidate(input, refStr) { | ||||
|   padding: 0.32rem 0.5rem; | ||||
|   padding-inline-start: 2.05rem; /* increased space for icon */ | ||||
|   border-radius: 0.45rem; | ||||
|   border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent); | ||||
|   border: .1rem solid color-mix(in srgb, var(--muted) 35%, transparent); | ||||
|   background: color-mix(in srgb, var(--panel) 88%, transparent); | ||||
|   font: inherit; | ||||
|   font-size: 1rem; | ||||
|   line-height: 1.1; | ||||
|   color: var(--ink); | ||||
|   outline: none; | ||||
| @@ -509,6 +515,27 @@ function parseGoToDateCandidate(input, refStr) { | ||||
| .search-bar input::-webkit-search-cancel-button { | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| .shortcut-hint { | ||||
|   position: absolute; | ||||
|   top: 50%; | ||||
|   inset-inline-end: 0.5rem; | ||||
|   transform: translateY(-50%); | ||||
|   pointer-events: none; | ||||
|   font-size: 0.75rem; | ||||
|   opacity: 0.6; | ||||
|   color: var(--muted); | ||||
|   font-family: ui-monospace, SF Mono, Consolas, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace; | ||||
|   background: color-mix(in srgb, var(--panel) 85%, transparent); | ||||
|   padding: 0.15rem 0.3rem; | ||||
|   border-radius: 0.25rem; | ||||
|   border: .1rem solid color-mix(in srgb, var(--muted) 25%, transparent); | ||||
| } | ||||
|  | ||||
| .search-bar input:focus + .shortcut-hint, | ||||
| .search-bar input:not(:placeholder-shown) + .shortcut-hint { | ||||
|   display: none; | ||||
| } | ||||
| .search-dropdown { | ||||
|   position: absolute; | ||||
|   top: calc(100% + 0.25rem); | ||||
| @@ -520,8 +547,7 @@ function parseGoToDateCandidate(input, refStr) { | ||||
|   padding: 0.2rem; | ||||
|   background: color-mix(in srgb, var(--panel) 92%, transparent); | ||||
|   backdrop-filter: blur(0.6em); | ||||
|   -webkit-backdrop-filter: blur(0.6em); | ||||
|   border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent); | ||||
|   border: .1rem solid color-mix(in srgb, var(--muted) 35%, transparent); | ||||
|   border-radius: 0.55rem; | ||||
|   max-height: 16rem; | ||||
|   overflow: auto; | ||||
| @@ -562,8 +588,7 @@ function parseGoToDateCandidate(input, refStr) { | ||||
|   padding: 0.45rem 0.6rem; | ||||
|   background: color-mix(in srgb, var(--panel) 92%, transparent); | ||||
|   backdrop-filter: blur(0.6em); | ||||
|   -webkit-backdrop-filter: blur(0.6em); | ||||
|   border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent); | ||||
|   border: .1rem solid color-mix(in srgb, var(--muted) 35%, transparent); | ||||
|   border-radius: 0.55rem; | ||||
|   box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.3); | ||||
|   font-size: 0.7rem; | ||||
|   | ||||
| @@ -253,11 +253,12 @@ defineExpose({ open }) | ||||
|   border-inline-start: 2px solid var(--border-color); | ||||
| } | ||||
| select { | ||||
|   border: 1px solid var(--muted); | ||||
|   border: .1rem solid var(--muted); | ||||
|   background: var(--panel-alt, transparent); | ||||
|   color: var(--ink); | ||||
|   padding: 0.4rem 0.5rem; | ||||
|   border-radius: 0.4rem; | ||||
|   font-size: 1rem; | ||||
| } | ||||
|  | ||||
| .holiday-row { | ||||
| @@ -273,7 +274,7 @@ select { | ||||
|  | ||||
| .state-select { | ||||
|   flex: 0 0 auto; | ||||
|   min-width: 120px; | ||||
|   min-width: 4rem; | ||||
| } | ||||
|  | ||||
| .footer-row { | ||||
| @@ -291,12 +292,13 @@ select { | ||||
|   gap: 0.5rem; | ||||
| } | ||||
| .ec-btn { | ||||
|   border: 1px solid var(--muted); | ||||
|   border: .1rem solid var(--muted); | ||||
|   background: transparent; | ||||
|   color: var(--ink); | ||||
|   padding: 0.5rem 0.8rem; | ||||
|   border-radius: 0.4rem; | ||||
|   cursor: pointer; | ||||
|   font-size: 1rem; | ||||
| } | ||||
| .ec-btn.close-btn { | ||||
|   background: var(--panel-alt); | ||||
|   | ||||
| @@ -287,20 +287,20 @@ export function createVirtualWeekManager({ | ||||
|   function goToToday() { | ||||
|     const todayDate = new Date(calendarStore.now) | ||||
|     const targetWeekIndex = getWeekIndex(todayDate) | ||||
|     scrollToWeekCentered(targetWeekIndex, 'go-to-today', true) | ||||
|     scrollToWeek(targetWeekIndex, 'go-to-today', true) | ||||
|   } | ||||
|  | ||||
|   function scrollToWeekCentered(weekIndex, reason = 'center-scroll', smooth = true) { | ||||
|   function scrollToWeek(weekIndex, reason = 'scroll', smooth = true) { | ||||
|     if (weekIndex == null || !isFinite(weekIndex)) return | ||||
|     const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) | ||||
|     const baseTop = (weekIndex - minVirtualWeek.value) * rowHeight.value | ||||
|     // Center: subtract half viewport minus half row height | ||||
|     let newScrollTop = baseTop - (viewportHeight.value / 2 - rowHeight.value / 2) | ||||
|     newScrollTop = Math.max(0, Math.min(newScrollTop, maxScroll)) | ||||
|     // Scroll so that the top of the viewport aligns with the top of the previous week, | ||||
|     // making the target week the second visible week row | ||||
|     const newScrollTop = (weekIndex - 1 - minVirtualWeek.value) * rowHeight.value | ||||
|     const clampedScrollTop = Math.max(0, Math.min(newScrollTop, maxScroll)) | ||||
|     if (smooth && viewport.value && typeof viewport.value.scrollTo === 'function') { | ||||
|       viewport.value.scrollTo({ top: newScrollTop, behavior: 'smooth' }) | ||||
|       viewport.value.scrollTo({ top: clampedScrollTop, behavior: 'smooth' }) | ||||
|     } else if (setScrollTopFn) { | ||||
|       setScrollTopFn(newScrollTop, reason) | ||||
|       setScrollTopFn(clampedScrollTop, reason) | ||||
|       scheduleWindowUpdate(reason) | ||||
|     } | ||||
|   } | ||||
| @@ -322,7 +322,7 @@ export function createVirtualWeekManager({ | ||||
|     getWeekIndex, | ||||
|     getFirstDayForVirtualWeek, | ||||
|     goToToday, | ||||
|     scrollToWeekCentered, | ||||
|     scrollToWeek, | ||||
|     handleHeaderYearChange, | ||||
|     attachScroll, | ||||
|   } | ||||
|   | ||||
| @@ -23,7 +23,6 @@ export const useCalendarStore = defineStore('calendar', { | ||||
|     _holidayConfigSignature: null, | ||||
|     _holidaysInitialized: false, | ||||
|     config: { | ||||
|       select_days: 14, | ||||
|       first_day: 1, | ||||
|       holidays: { | ||||
|         enabled: true, | ||||
|   | ||||
| @@ -2,14 +2,14 @@ | ||||
| import * as dateFns from 'date-fns' | ||||
| import { fromZonedTime, toZonedTime } from 'date-fns-tz' | ||||
|  | ||||
| const DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' | ||||
| export const DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' | ||||
|  | ||||
| // Re-exported iso helpers (keep the same exported names used elsewhere) | ||||
| const getISOWeek = dateFns.getISOWeek | ||||
| const getISOWeekYear = dateFns.getISOWeekYear | ||||
| export const getISOWeek = dateFns.getISOWeek | ||||
| export const getISOWeekYear = dateFns.getISOWeekYear | ||||
|  | ||||
| // Constants | ||||
| const monthAbbr = [ | ||||
| export const monthAbbr = [ | ||||
|   'jan', | ||||
|   'feb', | ||||
|   'mar', | ||||
| @@ -24,15 +24,15 @@ const monthAbbr = [ | ||||
|   'dec', | ||||
| ] | ||||
| // We get scrolling issues if the virtual view is bigger than that | ||||
| const MIN_YEAR = 1582 | ||||
| const MAX_YEAR = 3000 | ||||
| export const MIN_YEAR = 1582 | ||||
| export const MAX_YEAR = 3000 | ||||
|  | ||||
| // Core helpers ------------------------------------------------------------ | ||||
| /** | ||||
|  * Construct a date at local midnight in the specified IANA timezone. | ||||
|  * Returns a native Date whose wall-clock components in that zone are (Y, M, D 00:00:00). | ||||
|  */ | ||||
| function makeTZDate(year, monthIndex, day, timeZone = DEFAULT_TZ) { | ||||
| export function makeTZDate(year, monthIndex, day, timeZone = DEFAULT_TZ) { | ||||
|   const iso = `${String(year).padStart(4, '0')}-${String(monthIndex + 1).padStart(2, '0')}-${String( | ||||
|     day, | ||||
|   ).padStart(2, '0')}` | ||||
| @@ -43,40 +43,40 @@ function makeTZDate(year, monthIndex, day, timeZone = DEFAULT_TZ) { | ||||
| /** | ||||
|  * Alias constructor for timezone-specific calendar date (semantic sugar over makeTZDate). | ||||
|  */ | ||||
| const TZDate = (year, monthIndex, day, timeZone = DEFAULT_TZ) => | ||||
| export const TZDate = (year, monthIndex, day, timeZone = DEFAULT_TZ) => | ||||
|   makeTZDate(year, monthIndex, day, timeZone) | ||||
|  | ||||
| /** | ||||
|  * Construct a UTC-based date/time (wrapper for Date.UTC for consistency). | ||||
|  */ | ||||
| const UTCDate = (year, monthIndex, day, hour = 0, minute = 0, second = 0, ms = 0) => | ||||
| export const UTCDate = (year, monthIndex, day, hour = 0, minute = 0, second = 0, ms = 0) => | ||||
|   new Date(Date.UTC(year, monthIndex, day, hour, minute, second, ms)) | ||||
|  | ||||
| function toLocalString(date = new Date(), timeZone = DEFAULT_TZ) { | ||||
| export function toLocalString(date = new Date(), timeZone = DEFAULT_TZ) { | ||||
|   return dateFns.format(toZonedTime(date, timeZone), 'yyyy-MM-dd') | ||||
| } | ||||
|  | ||||
| function fromLocalString(dateString, timeZone = DEFAULT_TZ) { | ||||
| export function fromLocalString(dateString, timeZone = DEFAULT_TZ) { | ||||
|   if (!dateString) return makeTZDate(1970, 0, 1, timeZone) | ||||
|   const parsed = dateFns.parseISO(dateString) | ||||
|   const utcDate = fromZonedTime(`${dateString}T00:00:00`, timeZone) | ||||
|   return toZonedTime(utcDate, timeZone) || parsed | ||||
| } | ||||
|  | ||||
| function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) { | ||||
| export function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) { | ||||
|   const d = toZonedTime(date, timeZone) | ||||
|   const dow = (dateFns.getDay(d) + 6) % 7 // Monday=0 | ||||
|   return dateFns.addDays(dateFns.startOfDay(d), -dow) | ||||
| } | ||||
|  | ||||
| const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7 | ||||
| export const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7 | ||||
|  | ||||
| // (Recurrence utilities moved to events.js) | ||||
|  | ||||
| // Utility formatting & localization --------------------------------------- | ||||
| const pad = (n) => String(n).padStart(2, '0') | ||||
| export const pad = (n) => String(n).padStart(2, '0') | ||||
|  | ||||
| function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) { | ||||
| export function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) { | ||||
|   const a = fromLocalString(aStr, timeZone) | ||||
|   const b = fromLocalString(bStr, timeZone) | ||||
|   return ( | ||||
| @@ -84,12 +84,12 @@ function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) { | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function addDaysStr(str, n, timeZone = DEFAULT_TZ) { | ||||
| export function addDaysStr(str, n, timeZone = DEFAULT_TZ) { | ||||
|   return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone) | ||||
| } | ||||
|  | ||||
| // Weekday name helpers now return Sunday-first ordering (index 0 = Sunday ... 6 = Saturday) | ||||
| function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) { | ||||
| export function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) { | ||||
|   const sunday = makeTZDate(2025, 0, 5, timeZone) // a Sunday | ||||
|   return Array.from({ length: 7 }, (_, i) => | ||||
|     new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format( | ||||
| @@ -99,7 +99,7 @@ function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) { | ||||
| } | ||||
|  | ||||
| // Long (wide) localized weekday names, Sunday-first ordering | ||||
| function getLocalizedWeekdayNamesLong(timeZone = DEFAULT_TZ) { | ||||
| export function getLocalizedWeekdayNamesLong(timeZone = DEFAULT_TZ) { | ||||
|   const sunday = makeTZDate(2025, 0, 5, timeZone) | ||||
|   return Array.from({ length: 7 }, (_, i) => | ||||
|     new Intl.DateTimeFormat(undefined, { weekday: 'long', timeZone }).format( | ||||
| @@ -108,26 +108,26 @@ function getLocalizedWeekdayNamesLong(timeZone = DEFAULT_TZ) { | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function getLocaleFirstDay() { | ||||
| export function getLocaleFirstDay() { | ||||
|   const day = new Intl.Locale(navigator.language).weekInfo?.firstDay ?? 1 | ||||
|   return day % 7 | ||||
| } | ||||
|  | ||||
| function getLocaleWeekendDays() { | ||||
| export function getLocaleWeekendDays() { | ||||
|   const wk = new Set(new Intl.Locale(navigator.language).weekInfo?.weekend ?? [6, 7]) | ||||
|   return Array.from({ length: 7 }, (_, i) => wk.has(1 + ((i + 6) % 7))) | ||||
| } | ||||
|  | ||||
| function reorderByFirstDay(days, firstDay) { | ||||
| export function reorderByFirstDay(days, firstDay) { | ||||
|   return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7]) | ||||
| } | ||||
|  | ||||
| function getLocalizedMonthName(idx, short = false, timeZone = DEFAULT_TZ) { | ||||
| export function getLocalizedMonthName(idx, short = false, timeZone = DEFAULT_TZ) { | ||||
|   const d = makeTZDate(2025, idx, 1, timeZone) | ||||
|   return new Intl.DateTimeFormat(undefined, { month: short ? 'short' : 'long', timeZone }).format(d) | ||||
| } | ||||
|  | ||||
| function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) { | ||||
| export function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) { | ||||
|   const a = toLocalString(startDate, timeZone) | ||||
|   const b = toLocalString(endDate, timeZone) | ||||
|   if (a === b) return a | ||||
| @@ -138,7 +138,7 @@ function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) { | ||||
|   return `${a}/${b}` | ||||
| } | ||||
|  | ||||
| function lunarPhaseSymbol(date) { | ||||
| export function lunarPhaseSymbol(date) { | ||||
|   // Reference new moon (J2000 era) used for approximate phase calculations | ||||
|   const ref = UTCDate(2000, 0, 6, 18, 14, 0) | ||||
|   const obs = new Date(date) | ||||
| @@ -165,14 +165,14 @@ function lunarPhaseSymbol(date) { | ||||
| /** | ||||
|  * Format date as short localized string (e.g., "Jan 15") | ||||
|  */ | ||||
| function formatDateShort(date) { | ||||
| export function formatDateShort(date) { | ||||
|   return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }).replace(/, /, ' ') | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Format date as long localized string with optional year (e.g., "Mon Jan 15" or "Mon Jan 15, 2025") | ||||
|  */ | ||||
| function formatDateLong(date, includeYear = false) { | ||||
| export function formatDateLong(date, includeYear = false) { | ||||
|   const opts = { | ||||
|     weekday: 'short', | ||||
|     month: 'short', | ||||
| @@ -185,45 +185,9 @@ function formatDateLong(date, includeYear = false) { | ||||
| /** | ||||
|  * Format date as today string (e.g., "Monday\nJanuary 15") | ||||
|  */ | ||||
| function formatTodayString(date) { | ||||
| export function formatTodayString(date, weekday = "long", month = "long") { | ||||
|   const formatted = date | ||||
|     .toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) | ||||
|     .toLocaleDateString(undefined, { weekday, month, day: 'numeric' }) | ||||
|     .replace(/,? /, '\n') | ||||
|   return formatted.charAt(0).toUpperCase() + formatted.slice(1) | ||||
| } | ||||
|  | ||||
| export { | ||||
|   // constants | ||||
|   monthAbbr, | ||||
|   MIN_YEAR, | ||||
|   MAX_YEAR, | ||||
|   DEFAULT_TZ, | ||||
|   // core tz helpers | ||||
|   makeTZDate, | ||||
|   toLocalString, | ||||
|   fromLocalString, | ||||
|   // recurrence | ||||
|   getMondayOfISOWeek, | ||||
|   mondayIndex, | ||||
|   // formatting & localization | ||||
|   pad, | ||||
|   daysInclusive, | ||||
|   addDaysStr, | ||||
|   getLocalizedWeekdayNames, | ||||
|   getLocalizedWeekdayNamesLong, | ||||
|   getLocaleFirstDay, | ||||
|   getLocaleWeekendDays, | ||||
|   reorderByFirstDay, | ||||
|   getLocalizedMonthName, | ||||
|   formatDateRange, | ||||
|   formatDateShort, | ||||
|   formatDateLong, | ||||
|   formatTodayString, | ||||
|   lunarPhaseSymbol, | ||||
|   // iso helpers re-export | ||||
|   getISOWeek, | ||||
|   getISOWeekYear, | ||||
|   // constructors | ||||
|   TZDate, | ||||
|   UTCDate, | ||||
| } | ||||
|   | ||||
| @@ -15,4 +15,18 @@ export default defineConfig({ | ||||
|       '@': fileURLToPath(new URL('./src', import.meta.url)) | ||||
|     }, | ||||
|   }, | ||||
|   build: { | ||||
|     chunkSizeWarningLimit: 1500, | ||||
|     rollupOptions: { | ||||
|       output: { | ||||
|         manualChunks: (id) => { | ||||
|           if (id.includes('node_modules/date-fns') ||  | ||||
|               id.includes('node_modules/date-fns-tz') ||  | ||||
|               id.includes('node_modules/date-holidays')) { | ||||
|             return 'vendor-date-libs'; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| }) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user