Compare commits
40 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 | ||
|
|
dee8ce5079 | ||
|
|
abc7aba20f |
@@ -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,
|
||||
@@ -599,15 +598,15 @@ const recurrenceSummary = computed(() => {
|
||||
<div class="line compact">
|
||||
<Numeric
|
||||
v-model="displayInterval"
|
||||
:prefix-values="[{ value: 1, display: 'Every' }]"
|
||||
:prefix-values="[{ value: 1, display: 'All' }]"
|
||||
:min="2"
|
||||
number-prefix="Every "
|
||||
aria-label="Interval"
|
||||
/>
|
||||
<select v-model="displayFrequency" class="freq-select">
|
||||
<option value="weeks">{{ displayInterval === 1 ? 'week' : 'weeks' }}</option>
|
||||
<option value="months">{{ displayInterval === 1 ? 'month' : 'months' }}</option>
|
||||
<option value="years">{{ displayInterval === 1 ? 'year' : 'years' }}</option>
|
||||
<option value="weeks">{{ 'weeks' }}</option>
|
||||
<option value="months">{{ 'months' }}</option>
|
||||
<option value="years">{{ 'years' }}</option>
|
||||
</select>
|
||||
<Numeric
|
||||
class="occ-stepper"
|
||||
@@ -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,22 +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;
|
||||
}
|
||||
.header-controls :deep(.search-bar) {
|
||||
flex: 1 1 clamp(14rem, 40vw, 30rem);
|
||||
max-width: clamp(18rem, 40vw, 30rem);
|
||||
min-width: 12rem;
|
||||
margin-inline-end: auto;
|
||||
|
||||
@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;
|
||||
@@ -182,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;
|
||||
@@ -210,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);
|
||||
}
|
||||
|
||||
@@ -271,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;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="Date or event..."
|
||||
aria-label="Search date and events"
|
||||
placeholder="Date or Event..."
|
||||
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"
|
||||
@@ -22,18 +25,15 @@
|
||||
role="option"
|
||||
@mousedown.prevent="selectResult(i)"
|
||||
>
|
||||
<span class="title">{{ r.title }}</span>
|
||||
<span class="date">{{ r.startDate }}</span>
|
||||
<span class="title">{{ r.title }}</span
|
||||
><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 } from 'vue'
|
||||
import { ref, watch, nextTick, computed, onUnmounted, onMounted } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import {
|
||||
fromLocalString,
|
||||
@@ -44,10 +44,11 @@ import {
|
||||
getMondayOfISOWeek,
|
||||
formatTodayString,
|
||||
makeTZDate,
|
||||
getISOWeek,
|
||||
} from '@/utils/date'
|
||||
import { addDays } from 'date-fns'
|
||||
import { getDate as getRecurDate, getNearestOccurrence } from '@/utils/events'
|
||||
import * as dateFns from 'date-fns'
|
||||
import { getDate as getNearestOccurrence } from '@/utils/events'
|
||||
import { getHolidaysForYear } from '@/utils/holidays'
|
||||
|
||||
const emit = defineEmits(['activate', 'preview'])
|
||||
const props = defineProps({ referenceDate: { type: String, default: null } })
|
||||
@@ -57,57 +58,128 @@ const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const searchIndex = ref(0)
|
||||
const searchInputRef = ref(null)
|
||||
let previewTimer = null
|
||||
|
||||
function buildSearchResults() {
|
||||
// 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
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toLowerCase()
|
||||
let lastQuery = ''
|
||||
let frozenRefStr = null // reference date frozen at last query change
|
||||
const YEAR_SCAN_OFFSETS = [-4, -3, -2, -1, 0, 1, 2, 3, 4]
|
||||
function buildSearchResults(queryChanged = false) {
|
||||
const raw = searchQuery.value.trim()
|
||||
const q = raw.toLowerCase()
|
||||
if (!q) {
|
||||
if (!raw) {
|
||||
searchResults.value = []
|
||||
searchIndex.value = 0
|
||||
lastQuery = raw
|
||||
return
|
||||
}
|
||||
const listAll = raw === '*'
|
||||
const search = norm(raw)
|
||||
const out = []
|
||||
// Reference date: prefer viewport anchor (date-only) else 'now'. Normalize to midnight local.
|
||||
let refStr = props.referenceDate || calendarStore.today || calendarStore.now
|
||||
// If it's full ISO (with time), slice date portion.
|
||||
if (refStr.includes('T')) refStr = refStr.slice(0, 10)
|
||||
let refStrLive = props.referenceDate || calendarStore.today || calendarStore.now
|
||||
if (refStrLive.includes('T')) refStrLive = refStrLive.slice(0, 10)
|
||||
if (queryChanged || !frozenRefStr) frozenRefStr = refStrLive
|
||||
const refStr = frozenRefStr
|
||||
const nowDate = fromLocalString(refStr, DEFAULT_TZ)
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
const title = (ev.title || '').trim()
|
||||
if (!title) continue
|
||||
if (!(listAll || title.toLowerCase().includes(q))) continue
|
||||
const title = '⚜️ ' + (ev.title || '').trim()
|
||||
if (!(listAll || norm(title).includes(search))) continue
|
||||
let displayStart = ev.startDate
|
||||
if (ev.recur) {
|
||||
const nearest = getNearestOccurrence(ev, refStr, DEFAULT_TZ)
|
||||
if (nearest && nearest.dateStr) displayStart = nearest.dateStr
|
||||
if (nearest?.dateStr) displayStart = nearest.dateStr
|
||||
}
|
||||
out.push({ id: ev.id, title, startDate: displayStart })
|
||||
}
|
||||
out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0))
|
||||
// Inject Go To Date option if query matches a date pattern (first item)
|
||||
const gotoDateStr = parseGoToDateCandidate(raw)
|
||||
if (calendarStore.config?.holidays?.enabled) {
|
||||
try {
|
||||
calendarStore._ensureHolidaysInitialized?.()
|
||||
const refYear = nowDate.getFullYear()
|
||||
const yearWindow = YEAR_SCAN_OFFSETS.map((o) => refYear + o)
|
||||
const bestByName = Object.create(null)
|
||||
for (const yr of yearWindow) {
|
||||
for (const h of getHolidaysForYear(yr) || []) {
|
||||
const name = (h.name || '').trim().split(/\s*\/\s*/)[0]
|
||||
if (!name) continue
|
||||
if (!listAll && !norm(name).includes(search)) continue
|
||||
let dateObj
|
||||
try {
|
||||
dateObj = new Date(h.date)
|
||||
} catch {
|
||||
dateObj = null
|
||||
}
|
||||
if (!dateObj || isNaN(dateObj)) continue
|
||||
const diff = Math.abs(dateObj - nowDate)
|
||||
const key = name.toLowerCase()
|
||||
const prev = bestByName[key]
|
||||
if (!prev || diff < prev.diff) bestByName[key] = { name, dateObj, diff }
|
||||
}
|
||||
}
|
||||
for (const key in bestByName) {
|
||||
const { name, dateObj } = bestByName[key]
|
||||
const dateStr = toLocalString(dateObj, DEFAULT_TZ)
|
||||
out.push({
|
||||
id: '__holiday__' + dateStr + ':' + key,
|
||||
title: `✨ ${name}`,
|
||||
startDate: dateStr,
|
||||
_holiday: true,
|
||||
_dupeKey: '__holiday__' + dateStr + ':' + key,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
if (process.env.NODE_ENV !== 'production') console.debug('[Search] holiday search skipped', e)
|
||||
}
|
||||
}
|
||||
if (queryChanged) {
|
||||
out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0))
|
||||
} else if (searchResults.value.length) {
|
||||
const order = new Map(searchResults.value.map((r, i) => [r.id, i]))
|
||||
out.sort((a, b) => {
|
||||
const ai = order.has(a.id) ? order.get(a.id) : 1e9
|
||||
const bi = order.has(b.id) ? order.get(b.id) : 1e9
|
||||
if (ai !== bi) return ai - bi
|
||||
return a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0
|
||||
})
|
||||
}
|
||||
const gotoDateStr = parseGoToDateCandidate(raw, refStr)
|
||||
if (gotoDateStr) {
|
||||
const dateObj = fromLocalString(gotoDateStr, DEFAULT_TZ)
|
||||
const label = formatTodayString(dateObj).replace(/\n+/g, ' ')
|
||||
out.unshift({ id: '__goto__' + gotoDateStr, title: label, startDate: gotoDateStr, _goto: true })
|
||||
out.unshift({
|
||||
id: '__goto__' + gotoDateStr,
|
||||
title: '📅 ' + formatTodayString(dateObj),
|
||||
startDate: gotoDateStr,
|
||||
_goto: true,
|
||||
})
|
||||
}
|
||||
searchResults.value = out
|
||||
if (searchIndex.value >= out.length) searchIndex.value = 0
|
||||
lastQuery = raw
|
||||
}
|
||||
|
||||
watch(searchQuery, buildSearchResults)
|
||||
watch(searchQuery, (nv, ov) => {
|
||||
buildSearchResults(nv.trim() !== lastQuery)
|
||||
})
|
||||
watch(
|
||||
() => calendarStore.events,
|
||||
() => {
|
||||
if (searchQuery.value.trim()) buildSearchResults()
|
||||
if (searchQuery.value.trim()) buildSearchResults(false)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
watch(
|
||||
() => props.referenceDate,
|
||||
() => {
|
||||
if (searchQuery.value.trim()) buildSearchResults()
|
||||
if (searchQuery.value.trim()) buildSearchResults(false)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -124,19 +196,38 @@ function navigate(delta) {
|
||||
const n = searchResults.value.length
|
||||
if (!n) return
|
||||
searchIndex.value = (searchIndex.value + delta + n) % n
|
||||
// Ensure active item is visible
|
||||
const r = searchResults.value[searchIndex.value]
|
||||
if (r) emit('preview', r)
|
||||
if (r) {
|
||||
const el = document.getElementById('sr-' + r.id)
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
if (previewTimer) clearTimeout(previewTimer)
|
||||
if (r)
|
||||
previewTimer = setTimeout(() => {
|
||||
if (r === searchResults.value[searchIndex.value]) emit('preview', r)
|
||||
}, 200)
|
||||
}
|
||||
function selectResult(idx) {
|
||||
searchIndex.value = idx
|
||||
const r = searchResults.value[searchIndex.value]
|
||||
if (r) {
|
||||
if (previewTimer) {
|
||||
clearTimeout(previewTimer)
|
||||
previewTimer = null
|
||||
}
|
||||
emit('activate', r)
|
||||
// Clear query after activation (auto-close handled by parent visibility)
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
function handleSearchKeydown(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
focusSearch(true)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
navigate(1)
|
||||
@@ -163,122 +254,220 @@ const activeResultId = computed(() => {
|
||||
})
|
||||
|
||||
defineExpose({ focusSearch })
|
||||
onUnmounted(() => {
|
||||
if (previewTimer) clearTimeout(previewTimer)
|
||||
})
|
||||
// global Ctrl/Cmd+F -> search
|
||||
let globalFindHandler = null
|
||||
onMounted(() => {
|
||||
globalFindHandler = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
focusSearch(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', globalFindHandler, { capture: true })
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (globalFindHandler) {
|
||||
window.removeEventListener('keydown', globalFindHandler, { capture: true })
|
||||
globalFindHandler = null
|
||||
}
|
||||
})
|
||||
|
||||
function parseGoToDateCandidate(input) {
|
||||
function parseGoToDateCandidate(input, refStr) {
|
||||
const s = input.trim()
|
||||
if (!s) return null
|
||||
const today = new Date()
|
||||
const currentYear = today.getFullYear()
|
||||
const base = refStr ? fromLocalString(refStr, DEFAULT_TZ) : new Date(),
|
||||
baseYear = base.getFullYear()
|
||||
// now/today -> system date
|
||||
if (/^(now|today)$/i.test(s)) {
|
||||
const sys = new Date()
|
||||
return toLocalString(
|
||||
makeTZDate(sys.getFullYear(), sys.getMonth(), sys.getDate(), DEFAULT_TZ),
|
||||
DEFAULT_TZ,
|
||||
)
|
||||
}
|
||||
const localized = Array.from({ length: 12 }, (_, i) => getLocalizedMonthName(i))
|
||||
function monthFromToken(tok) {
|
||||
const monthFromToken = (tok) => {
|
||||
if (!tok) return null
|
||||
const t = tok.toLowerCase()
|
||||
if (/^\d{1,2}$/.test(t)) {
|
||||
const n = +t
|
||||
const tNorm = norm(tok.trim())
|
||||
if (/^\d{1,2}$/.test(tok)) {
|
||||
const n = +tok
|
||||
return n >= 1 && n <= 12 ? n : null
|
||||
}
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const ab = monthAbbr[i]
|
||||
if (t === ab || t === ab.slice(0, 3)) return i + 1
|
||||
if (norm(monthAbbr[i]) === tNorm || norm(monthAbbr[i].slice(0, 3)) === tNorm) return i + 1
|
||||
}
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const full = localized[i].toLowerCase()
|
||||
if (t === full || full.startsWith(t)) return i + 1
|
||||
const full = norm(localized[i])
|
||||
if (full === tNorm || full.startsWith(tNorm)) return i + 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
// ISO full date or year-month (defaults day=1)
|
||||
let mIsoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (mIsoFull) {
|
||||
const y = +mIsoFull[1],
|
||||
m = +mIsoFull[2],
|
||||
d = +mIsoFull[3]
|
||||
const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ)
|
||||
return toLocalString(dt, DEFAULT_TZ)
|
||||
// month token -> 15th of nearest year
|
||||
const soleMonth = s.match(/^(\p{L}+)[.,;:]?$/u)
|
||||
if (soleMonth) {
|
||||
const rawMonthTok = soleMonth[1]
|
||||
const m = monthFromToken(rawMonthTok)
|
||||
if (m) {
|
||||
let bestYear = baseYear
|
||||
let best = Infinity
|
||||
for (const cand of YEAR_SCAN_OFFSETS.map((o) => baseYear + o)) {
|
||||
const mid = new Date(cand, m - 1, 15)
|
||||
const diff = Math.abs(mid - base)
|
||||
if (diff < best) {
|
||||
best = diff
|
||||
bestYear = cand
|
||||
}
|
||||
}
|
||||
return toLocalString(makeTZDate(bestYear, m - 1, 15, DEFAULT_TZ), DEFAULT_TZ)
|
||||
}
|
||||
}
|
||||
let mIsoMonth = s.match(/^(\d{4})[-/](\d{2})$/)
|
||||
if (mIsoMonth) {
|
||||
const y = +mIsoMonth[1],
|
||||
m = +mIsoMonth[2]
|
||||
const dt = makeTZDate(y, m - 1, 1, DEFAULT_TZ)
|
||||
return toLocalString(dt, DEFAULT_TZ)
|
||||
const isoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (isoFull) {
|
||||
const y = +isoFull[1],
|
||||
mm = +isoFull[2],
|
||||
d = +isoFull[3]
|
||||
return toLocalString(makeTZDate(y, mm - 1, d, DEFAULT_TZ), DEFAULT_TZ)
|
||||
}
|
||||
// ISO week
|
||||
const mWeek = s.match(/^(\d{4})-W(\d{1,2})$/i)
|
||||
if (mWeek) {
|
||||
const wy = +mWeek[1],
|
||||
w = +mWeek[2]
|
||||
// wNN -> Monday of nearest ISO week
|
||||
const weekOnly = s.match(/^w(\d{1,2})[.,;:]?$/i)
|
||||
if (weekOnly) {
|
||||
const wk = +weekOnly[1]
|
||||
if (wk >= 1 && wk <= 53) {
|
||||
const has53Weeks = (year) => getISOWeek(makeTZDate(year, 11, 28, DEFAULT_TZ)) === 53
|
||||
let bestYear = baseYear,
|
||||
bestDiff = Infinity,
|
||||
bestDate = null
|
||||
for (const off of YEAR_SCAN_OFFSETS) {
|
||||
const cand = baseYear + off
|
||||
if (wk === 53 && !has53Weeks(cand)) continue
|
||||
const jan4 = makeTZDate(cand, 0, 4, DEFAULT_TZ)
|
||||
const target = addDays(jan4, (wk - 1) * 7)
|
||||
const monday = getMondayOfISOWeek(target)
|
||||
const diff = Math.abs(monday - base)
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff
|
||||
bestYear = cand
|
||||
bestDate = monday
|
||||
}
|
||||
}
|
||||
if (bestDate) return toLocalString(bestDate, DEFAULT_TZ)
|
||||
}
|
||||
}
|
||||
const isoMonth = s.match(/^(\d{4})[-/](\d{2})$/)
|
||||
if (isoMonth) {
|
||||
const y = +isoMonth[1],
|
||||
mm = +isoMonth[2]
|
||||
return toLocalString(makeTZDate(y, mm - 1, 15, DEFAULT_TZ), DEFAULT_TZ)
|
||||
}
|
||||
// year+week variants
|
||||
let isoWeek = s.match(/^(\d{4})[-/]?w(\d{1,2})$/i)
|
||||
if (!isoWeek) isoWeek = s.match(/^w(\d{1,2})[-/]?(\d{4})$/i)
|
||||
if (!isoWeek) isoWeek = s.match(/^(\d{4})\s+w(\d{1,2})$/i)
|
||||
if (!isoWeek) isoWeek = s.match(/^w(\d{1,2})\s+(\d{4})$/i)
|
||||
if (isoWeek) {
|
||||
const wy = +isoWeek[1]
|
||||
const w = +isoWeek[2]
|
||||
if (w >= 1 && w <= 53) {
|
||||
const jan4 = new Date(Date.UTC(wy, 0, 4))
|
||||
if (w === 53 && getISOWeek(makeTZDate(wy, 11, 28, DEFAULT_TZ)) !== 53) return null
|
||||
const jan4 = makeTZDate(wy, 0, 4, DEFAULT_TZ)
|
||||
const target = addDays(jan4, (w - 1) * 7)
|
||||
const monday = getMondayOfISOWeek(target)
|
||||
return toLocalString(monday, DEFAULT_TZ)
|
||||
return toLocalString(getMondayOfISOWeek(target), DEFAULT_TZ)
|
||||
}
|
||||
return null
|
||||
}
|
||||
// Dotted: day.month[.year] or day.month. (trailing dot) or day.month.year.
|
||||
let d = null,
|
||||
m = null,
|
||||
y = null
|
||||
let mDot = s.match(/^(\d{1,2})\.([A-Za-z]+|\d{1,2})(?:\.(\d{4}))?\.?$/)
|
||||
if (mDot) {
|
||||
d = +mDot[1]
|
||||
m = monthFromToken(mDot[2])
|
||||
y = mDot[3] ? +mDot[3] : currentYear
|
||||
} else {
|
||||
// Slash month/day(/year) (month accepts names); year optional -> current year
|
||||
let mUSFull = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/)
|
||||
if (mUSFull) {
|
||||
m = monthFromToken(mUSFull[1])
|
||||
d = +mUSFull[2]
|
||||
y = +mUSFull[3]
|
||||
y = null,
|
||||
yearExplicit = false
|
||||
const dot = s.match(/^(\d{1,2})\.([\p{L}]+|\d{1,2})(?:\.(\d{4}))?\.?$/u)
|
||||
if (dot) {
|
||||
d = +dot[1]
|
||||
m = monthFromToken(dot[2])
|
||||
if (dot[3]) {
|
||||
y = +dot[3]
|
||||
yearExplicit = true
|
||||
}
|
||||
}
|
||||
if (m == null) {
|
||||
const usFull = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/u)
|
||||
if (usFull) {
|
||||
m = monthFromToken(usFull[1])
|
||||
d = +usFull[2]
|
||||
y = +usFull[3]
|
||||
yearExplicit = true
|
||||
} else {
|
||||
let mUSShort = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})$/)
|
||||
if (mUSShort) {
|
||||
m = monthFromToken(mUSShort[1])
|
||||
d = +mUSShort[2]
|
||||
y = currentYear
|
||||
const usShort = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})$/u)
|
||||
if (usShort) {
|
||||
m = monthFromToken(usShort[1])
|
||||
d = +usShort[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
// Free-form with spaces: tokens containing month names and numbers
|
||||
if (!y && !m && !d) {
|
||||
if (m == null) {
|
||||
const tokens = s.split(/[ ,]+/).filter(Boolean)
|
||||
if (tokens.length >= 2 && tokens.length <= 3) {
|
||||
// Prefer a token with letters as month over numeric month
|
||||
let monthIdx = tokens.findIndex((t) => /[a-zA-Z]/.test(t) && monthFromToken(t) != null)
|
||||
let monthIdx = tokens.findIndex((t) => /\p{L}/u.test(t) && monthFromToken(t) != null)
|
||||
if (monthIdx === -1) monthIdx = tokens.findIndex((t) => monthFromToken(t) != null)
|
||||
if (monthIdx !== -1) {
|
||||
const monthTok = tokens[monthIdx]
|
||||
const monthTokIsNum = /^\d{1,2}$/.test(monthTok)
|
||||
const hasNonMonthLetter = tokens.some(
|
||||
(t, i) => i !== monthIdx && /\p{L}/u.test(t) && monthFromToken(t) == null,
|
||||
)
|
||||
const otherNumeric = tokens.some((t, i) => i !== monthIdx && /^\d{1,2}$/.test(t))
|
||||
if (monthTokIsNum && hasNonMonthLetter && !otherNumeric) {
|
||||
monthIdx = -1
|
||||
}
|
||||
}
|
||||
if (monthIdx !== -1) {
|
||||
m = monthFromToken(tokens[monthIdx])
|
||||
const others = tokens.filter((_t, i) => i !== monthIdx)
|
||||
const others = tokens.filter((_, i) => i !== monthIdx)
|
||||
let dayExplicit = false
|
||||
for (const rawTok of others) {
|
||||
const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '') // trim punctuation
|
||||
const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '')
|
||||
if (!tok) continue
|
||||
if (/^\d+$/.test(tok)) {
|
||||
const num = +tok
|
||||
if (num > 100) {
|
||||
y = num
|
||||
yearExplicit = true
|
||||
} else if (!d) {
|
||||
d = num
|
||||
dayExplicit = true
|
||||
}
|
||||
} else if (!y && /^\d{4}[.,;:]?$/.test(tok)) {
|
||||
// salvage year with trailing punctuation
|
||||
const num = parseInt(tok, 10)
|
||||
if (num > 1000) y = num
|
||||
if (num > 1000) {
|
||||
y = num
|
||||
yearExplicit = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!y) y = currentYear
|
||||
// Only default day=1 if user didn't provide any day-ish numeric token
|
||||
if (!d && !dayExplicit) d = 1
|
||||
if (!d && !dayExplicit) d = 15
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m != null && d != null && !yearExplicit) {
|
||||
let bestYear = baseYear,
|
||||
bestDiff = Infinity
|
||||
for (const cand of YEAR_SCAN_OFFSETS.map((o) => baseYear + o)) {
|
||||
const dt = new Date(cand, m - 1, d)
|
||||
if (dt.getMonth() !== m - 1) continue
|
||||
const diff = Math.abs(dt - base)
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff
|
||||
bestYear = cand
|
||||
}
|
||||
}
|
||||
y = bestYear
|
||||
}
|
||||
if (y != null && m != null && d != null) {
|
||||
if (y < 1000 || y > 9999 || m < 1 || m > 12 || d < 1 || d > 31) return null
|
||||
const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ)
|
||||
return toLocalString(dt, DEFAULT_TZ)
|
||||
return toLocalString(makeTZDate(y, m - 1, d, DEFAULT_TZ), DEFAULT_TZ)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -286,20 +475,18 @@ function parseGoToDateCandidate(input) {
|
||||
|
||||
<style scoped>
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
min-width: 14rem;
|
||||
flex: 1 1 clamp(14rem, 40vw, 30rem);
|
||||
max-width: clamp(18rem, 40vw, 30rem);
|
||||
min-width: 12rem;
|
||||
}
|
||||
.search-bar input {
|
||||
width: 100%;
|
||||
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: 0.8rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.1;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
@@ -308,6 +495,18 @@ function parseGoToDateCandidate(input) {
|
||||
box-shadow 0.15s ease,
|
||||
background 0.2s;
|
||||
}
|
||||
.search-bar::before {
|
||||
content: '🔍';
|
||||
position: absolute;
|
||||
inset-inline-start: 0.55rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.85rem;
|
||||
pointer-events: none;
|
||||
opacity: 0.75;
|
||||
line-height: 1;
|
||||
filter: saturate(0.8);
|
||||
}
|
||||
.search-bar input:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--accent, #4b7) 70%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #4b7) 45%, transparent);
|
||||
@@ -316,6 +515,27 @@ function parseGoToDateCandidate(input) {
|
||||
.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);
|
||||
@@ -327,8 +547,7 @@ function parseGoToDateCandidate(input) {
|
||||
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;
|
||||
@@ -369,8 +588,7 @@ function parseGoToDateCandidate(input) {
|
||||
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