Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d461a42ae5 | ||
|
|
ade17b80b1 | ||
|
|
a0b140d54b | ||
|
|
365d9e1be2 | ||
|
|
e210babe29 | ||
|
|
31c5551535 | ||
|
|
9b2354fd91 | ||
|
|
43aa8db650 | ||
|
|
debeececaf | ||
|
|
258d0ba02c | ||
|
|
c134d8875c | ||
|
|
dca3e21843 | ||
|
|
d11c551636 | ||
|
|
eaa55c94fd | ||
|
|
0d4094826d | ||
|
|
983826b5a6 | ||
|
|
3a902a9dfa | ||
|
|
0dfccb7b34 | ||
|
|
f20a54da57 | ||
|
|
b3b19832b4 | ||
|
|
151566ba22 | ||
|
|
7816ccd196 |
@@ -37,8 +37,6 @@ onMounted(() => {
|
|||||||
document.addEventListener('keydown', handleGlobalKey, { passive: false })
|
document.addEventListener('keydown', handleGlobalKey, { passive: false })
|
||||||
// Set document language via shared util
|
// Set document language via shared util
|
||||||
if (lang) document.documentElement.setAttribute('lang', lang)
|
if (lang) document.documentElement.setAttribute('lang', lang)
|
||||||
// Initialize title
|
|
||||||
document.title = formatTodayString(new Date(calendarStore.now))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -49,7 +47,7 @@ onBeforeUnmount(() => {
|
|||||||
watch(
|
watch(
|
||||||
() => calendarStore.now,
|
() => calendarStore.now,
|
||||||
(val) => {
|
(val) => {
|
||||||
document.title = formatTodayString(new Date(val))
|
document.title = formatTodayString(new Date(val), "short", "short")
|
||||||
},
|
},
|
||||||
{ immediate: false },
|
{ immediate: false },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* Color tokens */
|
/* Light mode & common */
|
||||||
:root {
|
:root {
|
||||||
--panel: #ffffff;
|
--panel: #ffffff;
|
||||||
--panel-alt: #f6f8fa;
|
--panel-alt: #f6f8fa;
|
||||||
@@ -8,19 +8,17 @@
|
|||||||
--strong: #000;
|
--strong: #000;
|
||||||
--muted: #6a6f76;
|
--muted: #6a6f76;
|
||||||
--muted-alt: #9aa2ad;
|
--muted-alt: #9aa2ad;
|
||||||
--accent: #2563eb; /* blue */
|
--accent: #2563eb;
|
||||||
--accent-soft: #dbeafe;
|
--accent-soft: #dbeafe;
|
||||||
--accent-hover: #1d4ed8;
|
--accent-hover: #1d4ed8;
|
||||||
--danger: #dc2626;
|
--danger: #dc2626;
|
||||||
--danger-hover: #b91c1c;
|
--danger-hover: #b91c1c;
|
||||||
--weekend: #888;
|
--weekend: #555;
|
||||||
--firstday: #000;
|
--firstday: #000;
|
||||||
--select: #aaf;
|
--select: #aaf;
|
||||||
--shadow: #fff;
|
--shadow: #fff;
|
||||||
--label-bg: #fafbfe;
|
--label-bg: #fafbfe;
|
||||||
--label-bg-rgb: 250, 251, 254;
|
--label-bg-rgb: 250, 251, 254;
|
||||||
|
|
||||||
/* Holiday colors */
|
|
||||||
--holiday: #da0;
|
--holiday: #da0;
|
||||||
--holiday-label: var(--strong);
|
--holiday-label: var(--strong);
|
||||||
|
|
||||||
@@ -35,73 +33,11 @@
|
|||||||
/* Vue component color mappings */
|
/* Vue component color mappings */
|
||||||
--bg: var(--panel);
|
--bg: var(--panel);
|
||||||
--border-color: #ddd;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--panel: #121417;
|
--panel: #121417;
|
||||||
@@ -138,67 +74,61 @@
|
|||||||
/* Holiday colors (dark mode) */
|
/* Holiday colors (dark mode) */
|
||||||
--holiday: #ffc107;
|
--holiday: #ffc107;
|
||||||
--holiday-label: #fff8e1;
|
--holiday-label: #fff8e1;
|
||||||
|
|
||||||
|
--weekend: #aaa;
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dec {
|
/* Month tints (light) */
|
||||||
background: hsl(220 50% 8%);
|
.dec { background: hsl(220 50% 77%) }
|
||||||
}
|
.jan { background: hsl(220 50% 60%) }
|
||||||
.jan {
|
.feb { background: hsl(220 50% 77%) }
|
||||||
background: hsl(220 50% 6%);
|
.mar { background: hsl(130 40% 85%) }
|
||||||
}
|
.apr { background: hsl(130 65% 75%) }
|
||||||
.feb {
|
.may { background: hsl(130 80% 65%) }
|
||||||
background: hsl(220 50% 8%);
|
.jun { background: hsl(50 85% 70%) }
|
||||||
}
|
.jul { background: hsl(50 85% 85%) }
|
||||||
.mar {
|
.aug { background: hsl(50 85% 70%) }
|
||||||
background: hsl(125 60% 6%);
|
.sep { background: hsl(22 100% 75%) }
|
||||||
}
|
.oct { background: hsl(22 40% 65%) }
|
||||||
.apr {
|
.nov { background: hsl(22 15% 55%) }
|
||||||
background: hsl(125 60% 8%);
|
|
||||||
}
|
@media (prefers-color-scheme: dark) {
|
||||||
.may {
|
.dec { background: hsl(220 50% 10%) }
|
||||||
background: hsl(125 60% 6%);
|
.jan { background: hsl(220 50% 4%) }
|
||||||
}
|
.feb { background: hsl(220 50% 10%) }
|
||||||
.jun {
|
.mar { background: hsl(130 60% 3%) }
|
||||||
background: hsl(45 85% 8%);
|
.apr { background: hsl(130 60% 6%) }
|
||||||
}
|
.may { background: hsl(130 60% 10%) }
|
||||||
.jul {
|
.jun { background: hsl(50 85% 8%) }
|
||||||
background: hsl(45 85% 6%);
|
.jul { background: hsl(50 85% 12%) }
|
||||||
}
|
.aug { background: hsl(50 85% 8%) }
|
||||||
.aug {
|
.sep { background: hsl(22 100% 10%) }
|
||||||
background: hsl(45 85% 8%);
|
.oct { background: hsl(22 90% 6%) }
|
||||||
}
|
.nov { background: hsl(22 80% 3%) }
|
||||||
.sep {
|
|
||||||
background: hsl(18 78% 6%);
|
|
||||||
}
|
|
||||||
.oct {
|
|
||||||
background: hsl(18 78% 8%);
|
|
||||||
}
|
|
||||||
.nov {
|
|
||||||
background: hsl(18 78% 6%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-color-0 {
|
/* Light mode — gray shades and colors */
|
||||||
background: hsl(0, 0%, 50%);
|
.event-color-0 { background: hsla(0, 0%, 85%, var(--event-alpha)); } /* lightest grey */
|
||||||
} /* lightest grey */
|
.event-color-1 { background: hsla(0, 0%, 75%, var(--event-alpha)); } /* light grey */
|
||||||
.event-color-1 {
|
.event-color-2 { background: hsla(0, 0%, 65%, var(--event-alpha)); } /* medium grey */
|
||||||
background: hsl(0, 0%, 40%);
|
.event-color-3 { background: hsla(0, 0%, 55%, var(--event-alpha)); } /* dark grey */
|
||||||
} /* light grey */
|
.event-color-4 { background: hsla(0, 100%, 70%, var(--event-alpha)); } /* red */
|
||||||
.event-color-2 {
|
.event-color-5 { background: hsla(90, 100%, 50%, var(--event-alpha)); } /* green - darker for perceptional purposes */
|
||||||
background: hsl(0, 0%, 30%);
|
.event-color-6 { background: hsla(220, 100%, 70%, var(--event-alpha)); } /* blue */
|
||||||
} /* medium grey */
|
.event-color-7 { background: hsla(280, 100%, 70%, var(--event-alpha)); } /* purple */
|
||||||
.event-color-3 {
|
|
||||||
background: hsl(0, 0%, 20%);
|
/* Dark-mode event colors are grouped right after the light-mode equivalents */
|
||||||
} /* dark grey */
|
@media (prefers-color-scheme: dark) {
|
||||||
.event-color-4 {
|
.event-color-0 { background: hsla(0, 0%, 50%, var(--event-alpha)); } /* lightest grey */
|
||||||
background: hsl(0, 70%, 40%);
|
.event-color-1 { background: hsla(0, 0%, 40%, var(--event-alpha)); } /* light grey */
|
||||||
} /* red */
|
.event-color-2 { background: hsla(0, 0%, 30%, var(--event-alpha)); } /* medium grey */
|
||||||
.event-color-5 {
|
.event-color-3 { background: hsla(0, 0%, 20%, var(--event-alpha)); } /* dark grey */
|
||||||
background: hsl(90, 70%, 30%);
|
.event-color-4 { background: hsla(0, 80%, 40%, var(--event-alpha)); } /* red */
|
||||||
} /* green - darker for perceptional purposes */
|
.event-color-5 { background: hsla(90, 80%, 30%, var(--event-alpha)); } /* green - darker for perceptional purposes */
|
||||||
.event-color-6 {
|
.event-color-6 { background: hsla(220, 80%, 40%, var(--event-alpha)); } /* blue */
|
||||||
background: hsl(230, 70%, 40%);
|
.event-color-7 { background: hsla(280, 80%, 40%, var(--event-alpha)); } /* purple */
|
||||||
} /* blue */
|
|
||||||
.event-color-7 {
|
|
||||||
background: hsl(280, 70%, 40%);
|
|
||||||
} /* purple */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -82,17 +82,6 @@ header {
|
|||||||
#calendar-content {
|
#calendar-content {
|
||||||
position: relative;
|
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 */
|
/* Label cells */
|
||||||
.year-label,
|
.year-label,
|
||||||
.week-label {
|
.week-label {
|
||||||
@@ -109,8 +98,8 @@ header {
|
|||||||
}
|
}
|
||||||
/* 7-day grid inside each week row */
|
/* 7-day grid inside each week row */
|
||||||
.week-row > .days-grid {
|
.week-row > .days-grid {
|
||||||
grid-column: 2 / span 7;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-column: 2 / span 7;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
grid-auto-rows: 1fr;
|
grid-auto-rows: 1fr;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -1,26 +1,72 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { fromLocalString } from '@/utils/date'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
day: Object,
|
day: Object,
|
||||||
dragging: { type: Boolean, default: false },
|
dragging: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Reactive viewport width detection
|
||||||
|
const isNarrowView = ref(false)
|
||||||
|
const isVeryNarrowView = ref(false)
|
||||||
|
const isSmallView = ref(false)
|
||||||
|
|
||||||
|
function checkViewportWidth() {
|
||||||
|
const width = window.innerWidth
|
||||||
|
isSmallView.value = width < 800
|
||||||
|
isNarrowView.value = width < 600
|
||||||
|
isVeryNarrowView.value = width < 400
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkViewportWidth()
|
||||||
|
window.addEventListener('resize', checkViewportWidth)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', checkViewportWidth)
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
const date = fromLocalString(props.day.date)
|
||||||
|
|
||||||
|
let options = { day: 'numeric', month: 'short' }
|
||||||
|
|
||||||
|
if (isVeryNarrowView.value) {
|
||||||
|
// Very narrow: show only day number
|
||||||
|
options = { day: 'numeric' }
|
||||||
|
} else if (isNarrowView.value) {
|
||||||
|
// Narrow: show day and month, no weekday
|
||||||
|
options = { day: 'numeric', month: 'short' }
|
||||||
|
} else {
|
||||||
|
// Wide: show weekday, day, and month
|
||||||
|
options = { weekday: 'short', day: 'numeric', month: 'short' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatted = date.toLocaleDateString(undefined, options)
|
||||||
|
|
||||||
|
// Below 700px, replace first space with newline to force weekday on separate line
|
||||||
|
if (isSmallView.value && !isNarrowView.value && !isVeryNarrowView.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="cell"
|
class="cell"
|
||||||
:style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'"
|
:style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'"
|
||||||
:class="[
|
:class="[props.day.monthClass, { today: props.day.isToday, weekend: props.day.isWeekend, firstday: props.day.isFirstDay, selected: props.day.isSelected, holiday: props.day.isHoliday }]"
|
||||||
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"
|
:data-date="props.day.date"
|
||||||
>
|
>
|
||||||
|
<span class="compact-date">{{ formattedDate }}</span>
|
||||||
<h1 class="day-number">{{ props.day.displayText }}</h1>
|
<h1 class="day-number">{{ props.day.displayText }}</h1>
|
||||||
<span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span>
|
<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" :title="props.day.holiday.name">
|
||||||
@@ -32,102 +78,137 @@ const props = defineProps({
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.cell {
|
.cell {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-inline-end: 1px solid var(--border-color);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
/* 3 columns: day number, flexible space, lunar phase */
|
grid-template-columns: 1fr;
|
||||||
grid-template-columns: min-content 1fr min-content;
|
grid-template-rows: 1fr auto;
|
||||||
/* 3 rows: header, flexible filler, holiday label */
|
|
||||||
grid-template-rows: auto 1fr auto;
|
|
||||||
/* Named grid areas (only ones actually used) */
|
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'day-number . lunar-phase'
|
'day-number'
|
||||||
'day-number . lunar-phase'
|
'holiday-info';
|
||||||
'holiday-info holiday-info holiday-info';
|
|
||||||
/* Explicit areas mainly for clarity */
|
|
||||||
grid-auto-flow: row;
|
|
||||||
padding: 0.25em;
|
padding: 0.25em;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: var(--row-h);
|
height: var(--row-h);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
align-items: start;
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
}
|
}
|
||||||
.cell h1.day-number {
|
.cell h1.day-number {
|
||||||
margin: 0;
|
position: absolute;
|
||||||
padding: 0;
|
font-size: 5vmin;
|
||||||
min-width: 1.5em;
|
font-weight: 800;
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
transition: background-color 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
grid-area: day-number;
|
}
|
||||||
|
.cell.firstday h1.day-number {
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
.cell.weekend h1.day-number {
|
.cell.weekend h1.day-number {
|
||||||
color: var(--weekend);
|
color: var(--weekend);
|
||||||
}
|
}
|
||||||
.cell.firstday h1.day-number {
|
.cell.firstday h1.day-number {
|
||||||
color: var(--firstday);
|
color: var(--firstday);
|
||||||
text-shadow: 0 0 0.1em var(--strong);
|
|
||||||
}
|
}
|
||||||
.cell.today h1.day-number {
|
.cell.today::before {
|
||||||
border-radius: 2em;
|
content: '';
|
||||||
background: var(--today);
|
position: absolute;
|
||||||
border: 0.2em solid var(--today);
|
top: 50%;
|
||||||
margin: -0.2em;
|
left: 50%;
|
||||||
color: var(--strong);
|
transform: translate(-50%, -50%);
|
||||||
font-weight: bold;
|
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 {
|
.cell.selected h1.day-number {
|
||||||
color: var(--strong);
|
opacity: 0.3;
|
||||||
|
filter: brightness(1.2);
|
||||||
}
|
}
|
||||||
.cell.holiday {
|
.cell {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
135deg,
|
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%
|
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.cell.holiday {
|
.cell {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
135deg,
|
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%
|
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 {
|
.lunar-phase {
|
||||||
grid-area: lunar-phase;
|
grid-area: lunar-phase;
|
||||||
align-self: start;
|
position: absolute;
|
||||||
justify-self: end;
|
inset-block-start: 0.5em;
|
||||||
margin-top: 0.5em;
|
inset-inline-end: 0.2em;
|
||||||
margin-inline-end: 0.2em;
|
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-date {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.25em;
|
||||||
|
left: 0.25em;
|
||||||
|
inset-inline-end: 1rem; /* Space for lunar phase */
|
||||||
|
font-weight: 400;
|
||||||
|
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 {
|
.holiday-info {
|
||||||
grid-area: holiday-info;
|
grid-area: holiday-info;
|
||||||
align-self: end;
|
align-self: end;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
max-width: 100%;
|
||||||
white-space: nowrap;
|
color: var(--holiday);
|
||||||
color: var(--holiday-label);
|
font-size: 1em;
|
||||||
font-size: clamp(1.2vw, 0.6em, 1em);
|
font-weight: 400;
|
||||||
line-height: 1;
|
line-height: 1.0;
|
||||||
padding-inline: 0.15em;
|
padding-inline: 0.15em;
|
||||||
padding-block-end: 0.05em;
|
padding-block: 0;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup>
|
<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 { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
import CalendarHeader from '@/components/CalendarHeader.vue'
|
import CalendarHeader from '@/components/CalendarHeader.vue'
|
||||||
import CalendarWeek from '@/components/CalendarWeek.vue'
|
import CalendarWeek from '@/components/CalendarWeek.vue'
|
||||||
import HeaderControls from '@/components/HeaderControls.vue'
|
import HeaderControls from '@/components/HeaderControls.vue'
|
||||||
|
import Jogwheel from '@/components/Jogwheel.vue'
|
||||||
import {
|
import {
|
||||||
createScrollManager,
|
createScrollManager,
|
||||||
createWeekColumnScrollManager,
|
createWeekColumnScrollManager,
|
||||||
@@ -25,7 +26,9 @@ function openCreateEventDialog(eventData) {
|
|||||||
// Capture baseline before dialog opens (new event creation flow)
|
// Capture baseline before dialog opens (new event creation flow)
|
||||||
try {
|
try {
|
||||||
calendarStore.$history?._baselineIfNeeded?.(true)
|
calendarStore.$history?._baselineIfNeeded?.(true)
|
||||||
} catch {}
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount }
|
const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount }
|
||||||
setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30)
|
setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30)
|
||||||
}
|
}
|
||||||
@@ -33,7 +36,9 @@ function openEditEventDialog(eventClickPayload) {
|
|||||||
// Capture baseline before editing existing event
|
// Capture baseline before editing existing event
|
||||||
try {
|
try {
|
||||||
calendarStore.$history?._baselineIfNeeded?.(true)
|
calendarStore.$history?._baselineIfNeeded?.(true)
|
||||||
} catch {}
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
eventDialogRef.value?.openEditDialog(eventClickPayload)
|
eventDialogRef.value?.openEditDialog(eventClickPayload)
|
||||||
}
|
}
|
||||||
const viewport = ref(null)
|
const viewport = ref(null)
|
||||||
@@ -41,6 +46,26 @@ const viewportHeight = ref(600)
|
|||||||
const rowHeight = ref(64)
|
const rowHeight = ref(64)
|
||||||
const rowProbe = ref(null)
|
const rowProbe = ref(null)
|
||||||
let rowProbeObserver = 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.5 * Math.abs(pos - _lastBlurPos)
|
||||||
|
}
|
||||||
|
_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 baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))
|
||||||
const selection = ref({ startDate: null, dayCount: 0 })
|
const selection = ref({ startDate: null, dayCount: 0 })
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
@@ -172,11 +197,24 @@ function measureFromProbe() {
|
|||||||
const {
|
const {
|
||||||
getWeekIndex,
|
getWeekIndex,
|
||||||
getFirstDayForVirtualWeek,
|
getFirstDayForVirtualWeek,
|
||||||
goToToday,
|
|
||||||
handleHeaderYearChange,
|
handleHeaderYearChange,
|
||||||
scrollToWeekCentered,
|
scrollToWeekCentered,
|
||||||
} = vwm
|
} = vwm
|
||||||
|
|
||||||
|
function showDay(input) {
|
||||||
|
const dateStr = input instanceof Date ? toLocalString(input, DEFAULT_TZ) : String(input)
|
||||||
|
const weekIndex = getWeekIndex(fromLocalString(dateStr, DEFAULT_TZ))
|
||||||
|
scrollToWeekCentered(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)
|
// Reference date for search: center of the current viewport (virtual week at vertical midpoint)
|
||||||
const centerVisibleWeek = computed(() => {
|
const centerVisibleWeek = computed(() => {
|
||||||
const midRow = (scrollTop.value + viewportHeight.value / 2) / rowHeight.value
|
const midRow = (scrollTop.value + viewportHeight.value / 2) / rowHeight.value
|
||||||
@@ -207,7 +245,7 @@ watch(
|
|||||||
calendarStore.config.holidays.state,
|
calendarStore.config.holidays.state,
|
||||||
calendarStore.config.holidays.region,
|
calendarStore.config.holidays.region,
|
||||||
],
|
],
|
||||||
(_newVals, _oldVals) => {
|
() => {
|
||||||
// If weeks already built, just refresh holiday info
|
// If weeks already built, just refresh holiday info
|
||||||
if (visibleWeeks.value.length) {
|
if (visibleWeeks.value.length) {
|
||||||
refreshHolidays('config-change')
|
refreshHolidays('config-change')
|
||||||
@@ -220,7 +258,6 @@ watch(
|
|||||||
|
|
||||||
function startDrag(dateStr) {
|
function startDrag(dateStr) {
|
||||||
dateStr = normalizeDate(dateStr)
|
dateStr = normalizeDate(dateStr)
|
||||||
if (calendarStore.config.select_days === 0) return
|
|
||||||
isDragging.value = true
|
isDragging.value = true
|
||||||
dragAnchor.value = dateStr
|
dragAnchor.value = dateStr
|
||||||
selection.value = { startDate: dateStr, dayCount: 1 }
|
selection.value = { startDate: dateStr, dayCount: 1 }
|
||||||
@@ -333,25 +370,15 @@ function getDateFromCoordinates(clientX, clientY) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function calculateSelection(anchorStr, otherStr) {
|
function calculateSelection(anchorStr, otherStr) {
|
||||||
const limit = calendarStore.config.select_days
|
|
||||||
const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
|
const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
|
||||||
const otherDate = fromLocalString(otherStr, DEFAULT_TZ)
|
const otherDate = fromLocalString(otherStr, DEFAULT_TZ)
|
||||||
const forward = otherDate >= anchorDate
|
const forward = otherDate >= anchorDate
|
||||||
const span = daysInclusive(anchorStr, otherStr)
|
const span = daysInclusive(anchorStr, otherStr)
|
||||||
|
|
||||||
if (span <= limit) {
|
|
||||||
const startDate = forward ? anchorStr : otherStr
|
const startDate = forward ? anchorStr : otherStr
|
||||||
return { startDate, dayCount: span }
|
return { startDate, dayCount: span }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forward) {
|
|
||||||
return { startDate: anchorStr, dayCount: limit }
|
|
||||||
} else {
|
|
||||||
const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ)
|
|
||||||
return { startDate, dayCount: limit }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
computeRowHeight()
|
computeRowHeight()
|
||||||
calendarStore.updateCurrentDate()
|
calendarStore.updateCurrentDate()
|
||||||
@@ -382,6 +409,10 @@ onMounted(() => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Start motion blur loop
|
||||||
|
_lastBlurPos = scrollTop.value || 0
|
||||||
|
_blurFrame = requestAnimationFrame(_updateMotionBlur)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -393,9 +424,12 @@ onBeforeUnmount(() => {
|
|||||||
try {
|
try {
|
||||||
rowProbeObserver.unobserve(rowProbe.value)
|
rowProbeObserver.unobserve(rowProbe.value)
|
||||||
rowProbeObserver.disconnect()
|
rowProbeObserver.disconnect()
|
||||||
} catch (e) {}
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
||||||
|
if (_blurFrame) cancelAnimationFrame(_blurFrame)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDayMouseDown = (d) => {
|
const handleDayMouseDown = (d) => {
|
||||||
@@ -425,24 +459,15 @@ const handleEventClick = (payload) => {
|
|||||||
openEditEventDialog(payload)
|
openEditEventDialog(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToEventStart(startDate, smooth = true) {
|
function handleHeaderSearchPreview(r) { if (r) showDay(r.startDate) }
|
||||||
try {
|
function handleHeaderSearchActivate(r) {
|
||||||
const dateObj = fromLocalString(startDate, DEFAULT_TZ)
|
if (!r) return
|
||||||
const weekIndex = getWeekIndex(dateObj)
|
showDay(r.startDate)
|
||||||
scrollToWeekCentered(weekIndex, 'search-jump', smooth)
|
if (!r._goto && !r._holiday) {
|
||||||
} catch {}
|
const ev = calendarStore.getEventById(r.id)
|
||||||
}
|
|
||||||
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 })
|
if (ev) openEditEventDialog({ id: ev.id, event: ev })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
||||||
// We explicitly avoid locale detection; rely solely on characters present.
|
// We explicitly avoid locale detection; rely solely on characters present.
|
||||||
@@ -497,10 +522,19 @@ window.addEventListener('resize', () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="calendar-view-root" :dir="rtl && 'rtl'">
|
<div class="calendar-view-root" :dir="rtl && 'rtl'">
|
||||||
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
<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">
|
<div class="wrap">
|
||||||
<HeaderControls
|
<HeaderControls
|
||||||
:reference-date="centerVisibleDateStr"
|
:reference-date="centerVisibleDateStr"
|
||||||
@go-to-today="goToToday"
|
@go-to-today="() => showDay(calendarStore.today)"
|
||||||
@search-preview="handleHeaderSearchPreview"
|
@search-preview="handleHeaderSearchPreview"
|
||||||
@search-activate="handleHeaderSearchActivate"
|
@search-activate="handleHeaderSearchActivate"
|
||||||
/>
|
/>
|
||||||
@@ -511,14 +545,19 @@ window.addEventListener('resize', () => {
|
|||||||
@year-change="handleHeaderYearChange"
|
@year-change="handleHeaderYearChange"
|
||||||
/>
|
/>
|
||||||
<div class="calendar-container">
|
<div class="calendar-container">
|
||||||
<div class="calendar-viewport" ref="viewport">
|
<div class="calendar-viewport" ref="viewport" :style="viewportBlurStyle">
|
||||||
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
||||||
|
<div
|
||||||
|
class="weeks-wrapper"
|
||||||
|
:style="{
|
||||||
|
transform: `translateY(${visibleWeeks.length ? visibleWeeks[0].top : 0}px)`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
<CalendarWeek
|
<CalendarWeek
|
||||||
v-for="week in visibleWeeks"
|
v-for="week in visibleWeeks"
|
||||||
:key="week.virtualWeek"
|
:key="week.virtualWeek"
|
||||||
:week="week"
|
:week="week"
|
||||||
:dragging="isDragging"
|
:dragging="isDragging"
|
||||||
:style="{ top: week.top + 'px' }"
|
|
||||||
@day-mousedown="handleDayMouseDown"
|
@day-mousedown="handleDayMouseDown"
|
||||||
@day-mouseenter="handleDayMouseEnter"
|
@day-mouseenter="handleDayMouseEnter"
|
||||||
@day-mouseup="handleDayMouseUp"
|
@day-mouseup="handleDayMouseUp"
|
||||||
@@ -526,17 +565,22 @@ window.addEventListener('resize', () => {
|
|||||||
@event-click="handleEventClick"
|
@event-click="handleEventClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="month-column-area" :style="{ height: contentHeight + 'px' }">
|
<div class="month-column-area" :style="{ height: contentHeight + 'px' }">
|
||||||
<div class="month-labels-container" :style="{ height: '100%' }">
|
<div class="month-labels-container" :style="{ height: '100%' }">
|
||||||
<template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'">
|
<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
|
<div
|
||||||
v-if="monthWeek && monthWeek.monthLabel"
|
v-if="monthWeek && monthWeek.monthLabel"
|
||||||
class="month-label"
|
class="month-label"
|
||||||
:class="monthWeek.monthLabel?.monthClass"
|
:class="monthWeek.monthLabel?.monthClass"
|
||||||
:style="{
|
:style="{ gridRow: `${i + 1} / span ${monthWeek.monthLabel?.weeksSpan || 1}` }"
|
||||||
height: `calc(var(--row-h) * ${monthWeek.monthLabel?.weeksSpan || 1})`,
|
|
||||||
top: (monthWeek.top || 0) + 'px',
|
|
||||||
}"
|
|
||||||
@pointerdown="handleMonthScrollPointerDown"
|
@pointerdown="handleMonthScrollPointerDown"
|
||||||
@touchstart.prevent="handleMonthScrollTouchStart"
|
@touchstart.prevent="handleMonthScrollTouchStart"
|
||||||
@wheel="handleMonthScrollWheel"
|
@wheel="handleMonthScrollWheel"
|
||||||
@@ -550,6 +594,15 @@ window.addEventListener('resize', () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 }" />
|
<EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -604,6 +657,13 @@ header h1 {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.weeks-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto auto 0;
|
||||||
|
width: 100%;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
.month-column-area {
|
.month-column-area {
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
@@ -615,18 +675,24 @@ header h1 {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.month-label {
|
.month-labels-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline-start: 0;
|
inset: 0 auto auto 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2));
|
will-change: transform;
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-label {
|
||||||
|
width: 100%;
|
||||||
|
opacity: 0.8;
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--muted);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 15;
|
z-index: 5;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|||||||
@@ -33,20 +33,10 @@ const handleDayTouchStart = (dateStr) => {
|
|||||||
const handleEventClick = (payload) => {
|
const handleEventClick = (payload) => {
|
||||||
emit('event-click', 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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="week-label">W{{ props.week.weekNumber }}</div>
|
||||||
<div class="days-grid">
|
<div class="days-grid">
|
||||||
<CalendarDay
|
<CalendarDay
|
||||||
@@ -68,7 +58,6 @@ function shouldRotateMonth(label) {
|
|||||||
.week-row {
|
.week-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--week-w) repeat(7, 1fr);
|
grid-template-columns: var(--week-w) repeat(7, 1fr);
|
||||||
position: absolute;
|
|
||||||
height: var(--row-h);
|
height: var(--row-h);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
<div
|
<div
|
||||||
v-for="seg in eventSegments"
|
v-for="seg in eventSegments"
|
||||||
:key="'seg-' + seg.startIdx + '-' + seg.endIdx"
|
:key="'seg-' + seg.startIdx + '-' + seg.endIdx"
|
||||||
:class="['segment-grid', { compress: isSegmentCompressed(seg) }]"
|
class="segment-grid"
|
||||||
:style="segmentStyle(seg)"
|
:style="{
|
||||||
|
...segmentStyle(seg),
|
||||||
|
'--segment-row-height': getSegmentRowHeight(seg),
|
||||||
|
height: getSegmentTotalHeight(seg)
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="span in seg.events"
|
v-for="span in seg.events"
|
||||||
@@ -179,8 +183,14 @@ function segmentKey(seg) {
|
|||||||
return seg.startIdx + '-' + seg.endIdx
|
return seg.startIdx + '-' + seg.endIdx
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSegmentCompressed(seg) {
|
function getSegmentRowHeight(seg) {
|
||||||
return !!segmentCompression.value[segmentKey(seg)]
|
const data = segmentCompression.value[segmentKey(seg)]
|
||||||
|
return data && typeof data.rowHeight === 'number' ? `${data.rowHeight}px` : '1.5em'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSegmentTotalHeight(seg) {
|
||||||
|
const data = segmentCompression.value[segmentKey(seg)]
|
||||||
|
return data && typeof data.totalHeight === 'number' ? `${data.totalHeight}px` : 'auto'
|
||||||
}
|
}
|
||||||
|
|
||||||
function recomputeCompression() {
|
function recomputeCompression() {
|
||||||
@@ -190,13 +200,36 @@ function recomputeCompression() {
|
|||||||
if (!available) return
|
if (!available) return
|
||||||
const cs = getComputedStyle(el)
|
const cs = getComputedStyle(el)
|
||||||
const fontSize = parseFloat(cs.fontSize) || 16
|
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 marginTop = 0 // already applied outside height
|
||||||
const usable = Math.max(0, available - marginTop)
|
const usable = Math.max(0, available - marginTop)
|
||||||
const nextMap = {}
|
const nextMap = {}
|
||||||
|
|
||||||
for (const seg of eventSegments.value) {
|
for (const seg of eventSegments.value) {
|
||||||
const desired = (seg.rowsCount || 1) * baseRowPx
|
const rowCount = seg.rowsCount || 1
|
||||||
nextMap[segmentKey(seg)] = desired > usable
|
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
|
segmentCompression.value = nextMap
|
||||||
}
|
}
|
||||||
@@ -537,36 +570,40 @@ function applyRangeDuringDrag(st, startDate, endDate) {
|
|||||||
}
|
}
|
||||||
.segment-grid {
|
.segment-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
|
||||||
align-content: start;
|
align-content: start;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
grid-auto-columns: 1fr;
|
grid-auto-columns: 1fr;
|
||||||
grid-auto-rows: 1.5em;
|
grid-auto-rows: var(--segment-row-height);
|
||||||
}
|
|
||||||
.segment-grid.compress {
|
|
||||||
grid-auto-rows: 1fr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-span {
|
.event-span {
|
||||||
padding: 0.1em 0.3em;
|
padding: 0;
|
||||||
border-radius: 1em;
|
border-radius: 1rem;
|
||||||
font-size: clamp(0.45em, 1.8vh, 0.75em);
|
/* Font-size so that ascender+descender exactly fills the row height:
|
||||||
font-weight: 600;
|
given total = asc+desc at 1em (hardcoded 1.15), font-size = rowHeight / total */
|
||||||
|
font-size: calc(var(--segment-row-height, 1.5em) / 1.15);
|
||||||
|
font-weight: 500;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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;
|
line-height: 1;
|
||||||
display: flex;
|
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;
|
position: relative;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
z-index: 1;
|
z-index: 10;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
/* Ensure touch pointer events aren't turned into a scroll gesture; needed for reliable drag on mobile */
|
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
backdrop-filter: blur(.05rem);
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-span.cont-prev {
|
.event-span.cont-prev {
|
||||||
@@ -579,17 +616,21 @@ function applyRangeDuringDrag(st, startDate, endDate) {
|
|||||||
border-bottom-right-radius: 0;
|
border-bottom-right-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inner title wrapper ensures proper ellipsis within flex/grid constraints */
|
|
||||||
.event-title {
|
.event-title {
|
||||||
display: block;
|
display: block;
|
||||||
flex: 1 1 0%;
|
flex: 0 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 100%;
|
||||||
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Resize handles */
|
/* Resize handles */
|
||||||
@@ -597,7 +638,7 @@ function applyRangeDuringDrag(st, startDate, endDate) {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 6px;
|
width: 1rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
|
|||||||
@@ -2,12 +2,27 @@
|
|||||||
<div class="header-controls-wrapper">
|
<div class="header-controls-wrapper">
|
||||||
<Transition name="header-controls" appear>
|
<Transition name="header-controls" appear>
|
||||||
<div v-if="isVisible" class="header-controls">
|
<div v-if="isVisible" class="header-controls">
|
||||||
|
<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
|
<EventSearch
|
||||||
ref="eventSearchRef"
|
ref="eventSearchRef"
|
||||||
:reference-date="referenceDate"
|
:reference-date="referenceDate"
|
||||||
@activate="handleSearchActivate"
|
@activate="handleSearchActivate"
|
||||||
@preview="(r) => emit('search-preview', r)"
|
@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>
|
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -62,11 +77,40 @@ import SettingsDialog from '@/components/SettingsDialog.vue'
|
|||||||
|
|
||||||
const calendarStore = useCalendarStore()
|
const calendarStore = useCalendarStore()
|
||||||
|
|
||||||
|
// Today label: derive from local ticking clock so it flips right at midnight
|
||||||
const todayString = computed(() => {
|
const todayString = computed(() => {
|
||||||
const d = new Date(calendarStore.now)
|
const d = new Date(localNowMs?.value ?? Date.now())
|
||||||
return formatTodayString(d)
|
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 emit = defineEmits(['go-to-today', 'search-activate', 'search-preview'])
|
||||||
const { referenceDate = null } = defineProps({ referenceDate: { type: String, default: null } })
|
const { referenceDate = null } = defineProps({ referenceDate: { type: String, default: null } })
|
||||||
|
|
||||||
@@ -98,7 +142,9 @@ function openSettings() {
|
|||||||
// Capture baseline before opening settings
|
// Capture baseline before opening settings
|
||||||
try {
|
try {
|
||||||
calendarStore.$history?._baselineIfNeeded?.(true)
|
calendarStore.$history?._baselineIfNeeded?.(true)
|
||||||
} catch {}
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
settingsDialog.value?.open()
|
settingsDialog.value?.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,16 +202,38 @@ onBeforeUnmount(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.4rem 0.5rem 0 0.5rem;
|
|
||||||
}
|
}
|
||||||
.header-controls {
|
.header-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-inline-end: 2rem;
|
padding-inline-end: 2rem;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.header-controls { gap: 0.1rem; }
|
||||||
|
}
|
||||||
|
/* Group search + spacer so outer gap doesn't create unwanted space */
|
||||||
|
.search-with-spacer {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.search-with-spacer > .search-bar {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 6rem; /* allow spacer to give up space first */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre-search-spacer {
|
||||||
|
flex: 0 1000 var(--week-w);
|
||||||
|
width: var(--week-w);
|
||||||
|
min-width: .5rem;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: flex-basis 0.35s ease, width 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -268,6 +336,24 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
text-align: center;
|
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.6em;
|
||||||
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
<template>
|
<template><div class="jogwheel-viewport" ref="jogwheelViewport" /></template>
|
||||||
<div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll">
|
|
||||||
<div
|
|
||||||
class="jogwheel-content"
|
|
||||||
ref="jogwheelContent"
|
|
||||||
:style="{ height: jogwheelHeight + 'px' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
totalVirtualWeeks: { type: Number, required: true },
|
totalVirtualWeeks: { type: Number, required: true },
|
||||||
@@ -21,160 +13,66 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['scroll-to'])
|
const emit = defineEmits(['scroll-to'])
|
||||||
|
|
||||||
const jogwheelViewport = ref(null)
|
const jogwheelViewport = ref(null)
|
||||||
const jogwheelContent = ref(null)
|
|
||||||
const syncLock = ref(null)
|
|
||||||
// Drag state (no momentum, 1:1 mapping)
|
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
let mainStartScroll = 0
|
let mainStartScroll = 0
|
||||||
let dragScale = 1 // mainScrollPixels per mouse pixel
|
|
||||||
let accumDelta = 0
|
let accumDelta = 0
|
||||||
let pointerLocked = false
|
let pointerLocked = false
|
||||||
|
let lastClientY = null
|
||||||
|
|
||||||
// Jogwheel content height is 1/10th of main calendar
|
const SPEED_DRAG = 4
|
||||||
const jogwheelHeight = computed(() => {
|
|
||||||
return (props.totalVirtualWeeks * props.rowHeight) / 10
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleJogwheelScroll = () => {
|
const WEEKS_PER_MONTH = 30.4375 / 7
|
||||||
if (syncLock.value === 'jogwheel') return
|
const MONTH_SCROLL = () => props.rowHeight * WEEKS_PER_MONTH
|
||||||
syncFromJogwheel()
|
const ANIM_DURATION = 420 // ms
|
||||||
}
|
let animActive = false
|
||||||
|
let animFrom = 0
|
||||||
|
let animTo = 0
|
||||||
|
let animStart = 0
|
||||||
|
let animFrame = null
|
||||||
|
|
||||||
function onDragMouseDown(e) {
|
// Drag momentum (independent from month-step animation)
|
||||||
if (e.button !== 0) return
|
let dragMomentumActive = false
|
||||||
isDragging.value = true
|
let dragMomentumFrame = null
|
||||||
mainStartScroll = props.scrollTop
|
let dragMomentumVelocity = 0
|
||||||
accumDelta = 0
|
let dragMomentumPos = 0
|
||||||
// Precompute scale between jogwheel scrollable range and main scrollable range
|
const DRAG_FRICTION_PER_MS = 0.0018
|
||||||
const mainScrollable = Math.max(
|
const DRAG_MIN_V = 0.03
|
||||||
0,
|
let dragSamples = [] // { t, s } sampled scroll positions during drag
|
||||||
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
|
||||||
)
|
|
||||||
let jogScrollable = 0
|
|
||||||
if (jogwheelViewport.value && jogwheelContent.value) {
|
|
||||||
jogScrollable = Math.max(
|
|
||||||
0,
|
|
||||||
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
dragScale = jogScrollable > 0 ? mainScrollable / jogScrollable : 1
|
|
||||||
if (!isFinite(dragScale) || dragScale <= 0) dragScale = 1
|
|
||||||
// Attempt pointer lock for relative movement
|
|
||||||
if (jogwheelViewport.value && jogwheelViewport.value.requestPointerLock) {
|
|
||||||
jogwheelViewport.value.requestPointerLock()
|
|
||||||
}
|
|
||||||
window.addEventListener('mousemove', onDragMouseMove, { passive: false })
|
|
||||||
window.addEventListener('mouseup', onDragMouseUp, { passive: false })
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragMouseMove(e) {
|
const MIN_WHEEL_ABS = 2
|
||||||
if (!isDragging.value) return
|
function easeOutCubic(t){return 1-Math.pow(1-t,3)}
|
||||||
const dy = pointerLocked ? e.movementY : e.clientY // movementY only valid in pointer lock
|
|
||||||
accumDelta += dy
|
function clampScroll(x) {
|
||||||
let desired = mainStartScroll - accumDelta * dragScale
|
|
||||||
if (desired < 0) desired = 0
|
|
||||||
const maxScroll = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
const maxScroll = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
||||||
if (desired > maxScroll) desired = maxScroll
|
if (x < 0) return 0
|
||||||
emit('scroll-to', desired)
|
if (x > maxScroll) return maxScroll
|
||||||
e.preventDefault()
|
return x
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragMouseUp(e) {
|
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)}
|
||||||
if (!isDragging.value) return
|
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)}
|
||||||
isDragging.value = false
|
|
||||||
window.removeEventListener('mousemove', onDragMouseMove)
|
|
||||||
window.removeEventListener('mouseup', onDragMouseUp)
|
|
||||||
if (pointerLocked && document.exitPointerLock) document.exitPointerLock()
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerLockChange() {
|
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()}
|
||||||
pointerLocked = document.pointerLockElement === jogwheelViewport.value
|
|
||||||
if (!pointerLocked && isDragging.value) {
|
|
||||||
// Pointer lock lost (Esc) -> end drag gracefully
|
|
||||||
onDragMouseUp(new MouseEvent('mouseup'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
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()}
|
||||||
if (jogwheelViewport.value) {
|
|
||||||
jogwheelViewport.value.addEventListener('mousedown', onDragMouseDown)
|
|
||||||
}
|
|
||||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
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()}
|
||||||
if (jogwheelViewport.value) {
|
|
||||||
jogwheelViewport.value.removeEventListener('mousedown', onDragMouseDown)
|
|
||||||
}
|
|
||||||
window.removeEventListener('mousemove', onDragMouseMove)
|
|
||||||
window.removeEventListener('mouseup', onDragMouseUp)
|
|
||||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
||||||
})
|
|
||||||
|
|
||||||
const syncFromJogwheel = () => {
|
function handlePointerLockChange(){pointerLocked=document.pointerLockElement===jogwheelViewport.value;if(!pointerLocked&&isDragging.value)onDragPointerUp(new PointerEvent('pointerup'))}
|
||||||
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
|
||||||
|
|
||||||
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(
|
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)})
|
||||||
0,
|
|
||||||
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
|
||||||
)
|
|
||||||
const mainScrollable = Math.max(
|
|
||||||
0,
|
|
||||||
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (jogScrollable > 0) {
|
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())}
|
||||||
const ratio = jogwheelViewport.value.scrollTop / jogScrollable
|
|
||||||
|
|
||||||
// Emit scroll event to parent to update main viewport
|
// Keep API stable for parent components (previously exposed)
|
||||||
emit('scroll-to', ratio * mainScrollable)
|
function syncFromMain(){};defineExpose({syncFromMain})
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
// ---- Drag Momentum Helpers ----
|
||||||
if (syncLock.value === 'main') syncLock.value = null
|
function cancelDragMomentum(){if(!dragMomentumActive)return;dragMomentumActive=false;dragMomentumFrame&&cancelAnimationFrame(dragMomentumFrame);dragMomentumFrame=null}
|
||||||
}, 50)
|
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)}
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -184,19 +82,13 @@ defineExpose({
|
|||||||
inset-inline-end: 0;
|
inset-inline-end: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: var(--month-w);
|
width: var(--month-w);
|
||||||
overflow-y: auto;
|
/* Transparent interactive overlay */
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
scrollbar-width: none;
|
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jogwheel-viewport::-webkit-scrollbar {
|
.jogwheel-viewport::-webkit-scrollbar { display: none; }
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jogwheel-content {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
aria-label="Search dates, holidays and events"
|
aria-label="Search dates, holidays and events"
|
||||||
@keydown="handleSearchKeydown"
|
@keydown="handleSearchKeydown"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="shortcut" class="shortcut-hint">
|
||||||
|
{{ shortcut }}
|
||||||
|
</div>
|
||||||
<ul
|
<ul
|
||||||
v-if="searchQuery.trim() && searchResults.length"
|
v-if="searchQuery.trim() && searchResults.length"
|
||||||
class="search-dropdown"
|
class="search-dropdown"
|
||||||
@@ -60,6 +63,12 @@ const searchIndex = ref(0)
|
|||||||
const searchInputRef = ref(null)
|
const searchInputRef = ref(null)
|
||||||
let previewTimer = null
|
let previewTimer = null
|
||||||
|
|
||||||
|
// Note: Android is also Linux. HarmonyOS 5 doesn't include "Linux".
|
||||||
|
const shortcut = /Android/.test(navigator.userAgent) ? ''
|
||||||
|
: /Mac/.test(navigator.userAgent) ? '⌘F'
|
||||||
|
: /Windows|Linux/.test(navigator.userAgent) ? 'Ctrl+F'
|
||||||
|
: ''
|
||||||
|
|
||||||
// Accent-insensitive lowercasing
|
// Accent-insensitive lowercasing
|
||||||
const norm = (s) =>
|
const norm = (s) =>
|
||||||
s
|
s
|
||||||
@@ -469,8 +478,8 @@ function parseGoToDateCandidate(input, refStr) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.search-bar {
|
.search-bar {
|
||||||
flex: 0 1 20rem;
|
flex: 1;
|
||||||
margin-inline: auto; /* center with equal free-space on both sides */
|
min-width: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.search-bar input {
|
.search-bar input {
|
||||||
@@ -509,6 +518,27 @@ function parseGoToDateCandidate(input, refStr) {
|
|||||||
.search-bar input::-webkit-search-cancel-button {
|
.search-bar input::-webkit-search-cancel-button {
|
||||||
cursor: pointer;
|
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: 1px 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 {
|
.search-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 0.25rem);
|
top: calc(100% + 0.25rem);
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
_holidayConfigSignature: null,
|
_holidayConfigSignature: null,
|
||||||
_holidaysInitialized: false,
|
_holidaysInitialized: false,
|
||||||
config: {
|
config: {
|
||||||
select_days: 14,
|
|
||||||
first_day: 1,
|
first_day: 1,
|
||||||
holidays: {
|
holidays: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -185,9 +185,9 @@ function formatDateLong(date, includeYear = false) {
|
|||||||
/**
|
/**
|
||||||
* Format date as today string (e.g., "Monday\nJanuary 15")
|
* Format date as today string (e.g., "Monday\nJanuary 15")
|
||||||
*/
|
*/
|
||||||
function formatTodayString(date) {
|
function formatTodayString(date, weekday = "long", month = "long") {
|
||||||
const formatted = date
|
const formatted = date
|
||||||
.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
|
.toLocaleDateString(undefined, { weekday, month, day: 'numeric' })
|
||||||
.replace(/,? /, '\n')
|
.replace(/,? /, '\n')
|
||||||
return formatted.charAt(0).toUpperCase() + formatted.slice(1)
|
return formatted.charAt(0).toUpperCase() + formatted.slice(1)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user