Header refactor, styling, collapsible header controls.
This commit is contained in:
parent
8e91d360be
commit
100b420003
@ -1,73 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="wrap">
|
|
||||||
<!-- AppHeader component reference removed (file missing); add inline header with Settings button -->
|
|
||||||
<div class="inline-header">
|
|
||||||
<h1 class="app-title">Calendar</h1>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button type="button" class="settings-btn" @click="openSettings">Settings</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="calendar-container" ref="containerEl">
|
|
||||||
<CalendarGrid />
|
|
||||||
<Jogwheel />
|
|
||||||
</div>
|
|
||||||
<EventDialog />
|
|
||||||
<SettingsDialog ref="settingsDialog" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
|
||||||
import CalendarGrid from './CalendarGrid.vue'
|
|
||||||
import Jogwheel from './Jogwheel.vue'
|
|
||||||
import EventDialog from './EventDialog.vue'
|
|
||||||
import SettingsDialog from './SettingsDialog.vue'
|
|
||||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
|
||||||
|
|
||||||
const calendarStore = useCalendarStore()
|
|
||||||
const containerEl = ref(null)
|
|
||||||
const settingsDialog = ref(null)
|
|
||||||
|
|
||||||
function openSettings() {
|
|
||||||
settingsDialog.value?.open()
|
|
||||||
}
|
|
||||||
|
|
||||||
let intervalId
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
calendarStore.setToday()
|
|
||||||
intervalId = setInterval(() => {
|
|
||||||
calendarStore.setToday()
|
|
||||||
}, 1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
clearInterval(intervalId)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.inline-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
}
|
|
||||||
.app-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.settings-btn {
|
|
||||||
border: 1px solid var(--muted);
|
|
||||||
background: var(--panel-alt, transparent);
|
|
||||||
color: var(--ink);
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.settings-btn:hover {
|
|
||||||
background: var(--muted);
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -81,37 +81,34 @@ const props = defineProps({
|
|||||||
.cell.selected h1 {
|
.cell.selected h1 {
|
||||||
color: var(--strong);
|
color: var(--strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lunar-phase {
|
.lunar-phase {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.1em;
|
top: 0.5em;
|
||||||
right: 0.1em;
|
right: 0.2em;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell.holiday {
|
.cell.holiday {
|
||||||
/* Remove solid background & border color overrides; use gradient overlay instead */
|
background-image: linear-gradient(
|
||||||
position: relative;
|
135deg,
|
||||||
|
var(--holiday-grad-start, rgba(255, 255, 255, 0.5)) 0%,
|
||||||
|
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
.cell.holiday::before {
|
@media (prefers-color-scheme: dark) {
|
||||||
content: '';
|
.cell.holiday {
|
||||||
position: absolute;
|
background-image: linear-gradient(
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
135deg,
|
||||||
var(--holiday-grad-start, rgba(255, 255, 255, 0.1)) 0%,
|
var(--holiday-grad-start, rgba(255, 255, 255, 0.1)) 0%,
|
||||||
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
|
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
|
||||||
);
|
);
|
||||||
pointer-events: none;
|
}
|
||||||
mix-blend-mode: normal; /* can switch to 'overlay' or 'screen' if thematic */
|
|
||||||
}
|
}
|
||||||
.cell.holiday h1 {
|
.cell.holiday h1 {
|
||||||
/* Slight emphasis without forcing a specific hue */
|
/* Slight emphasis without forcing a specific hue */
|
||||||
color: var(--holiday);
|
color: var(--holiday);
|
||||||
text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4);
|
text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.holiday-info {
|
.holiday-info {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0.1em;
|
bottom: 0.1em;
|
||||||
|
@ -1,176 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="calendar-header">
|
|
||||||
<div class="year-label" @wheel.prevent="handleWheel">{{ calendarStore.viewYear }}</div>
|
|
||||||
<div v-for="day in weekdayNames" :key="day" class="dow" :class="{ weekend: isWeekend(day) }">
|
|
||||||
{{ day }}
|
|
||||||
</div>
|
|
||||||
<div class="overlay-header-spacer"></div>
|
|
||||||
</div>
|
|
||||||
<div class="calendar-viewport" ref="viewportEl" @scroll="handleScroll">
|
|
||||||
<div class="calendar-content" :style="{ height: `${totalVirtualWeeks * rowHeight}px` }">
|
|
||||||
<WeekRow
|
|
||||||
v.for="week in visibleWeeks"
|
|
||||||
:key="week.virtualWeek"
|
|
||||||
:week="week"
|
|
||||||
:style="{ top: `${(week.virtualWeek - minVirtualWeek) * rowHeight}px` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
|
||||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
|
||||||
import {
|
|
||||||
getLocalizedWeekdayNames,
|
|
||||||
getLocaleWeekendDays,
|
|
||||||
getLocaleFirstDay,
|
|
||||||
getISOWeek,
|
|
||||||
getISOWeekYear,
|
|
||||||
fromLocalString,
|
|
||||||
toLocalString,
|
|
||||||
mondayIndex,
|
|
||||||
DEFAULT_TZ,
|
|
||||||
MIN_YEAR,
|
|
||||||
MAX_YEAR,
|
|
||||||
} from '@/utils/date'
|
|
||||||
import { addDays } from 'date-fns'
|
|
||||||
import WeekRow from './WeekRow.vue'
|
|
||||||
|
|
||||||
const calendarStore = useCalendarStore()
|
|
||||||
const viewportEl = ref(null)
|
|
||||||
const rowHeight = ref(64) // Default value, will be computed
|
|
||||||
const totalVirtualWeeks = ref(0)
|
|
||||||
const minVirtualWeek = ref(0)
|
|
||||||
const visibleWeeks = ref([])
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
weekend: getLocaleWeekendDays(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anchor Monday (or locale first day) reference date
|
|
||||||
const baseDate = new Date(1970, 0, 4 + getLocaleFirstDay())
|
|
||||||
const WEEK_MS = 7 * 86400000
|
|
||||||
|
|
||||||
const weekdayNames = getLocalizedWeekdayNames()
|
|
||||||
|
|
||||||
const isWeekend = (day) => {
|
|
||||||
const dayIndex = weekdayNames.indexOf(day)
|
|
||||||
return config.weekend[(dayIndex + 1) % 7]
|
|
||||||
}
|
|
||||||
|
|
||||||
const getWeekIndex = (date) => {
|
|
||||||
const monday = addDays(date, -mondayIndex(date))
|
|
||||||
return Math.floor((monday.getTime() - baseDate.getTime()) / WEEK_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMondayForVirtualWeek = (virtualWeek) => addDays(baseDate, virtualWeek * 7)
|
|
||||||
|
|
||||||
const computeRowHeight = () => {
|
|
||||||
const el = document.createElement('div')
|
|
||||||
el.style.position = 'absolute'
|
|
||||||
el.style.visibility = 'hidden'
|
|
||||||
el.style.height = 'var(--cell-h)'
|
|
||||||
document.body.appendChild(el)
|
|
||||||
const h = el.getBoundingClientRect().height || 64
|
|
||||||
el.remove()
|
|
||||||
return Math.round(h)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateVisibleWeeks = () => {
|
|
||||||
if (!viewportEl.value) return
|
|
||||||
|
|
||||||
const scrollTop = viewportEl.value.scrollTop
|
|
||||||
const viewportH = viewportEl.value.clientHeight
|
|
||||||
|
|
||||||
const topDisplayIndex = Math.floor(scrollTop / rowHeight.value)
|
|
||||||
const topVW = topDisplayIndex + minVirtualWeek.value
|
|
||||||
const monday = getMondayForVirtualWeek(topVW)
|
|
||||||
const year = getISOWeekYear(monday)
|
|
||||||
if (calendarStore.viewYear !== year) {
|
|
||||||
calendarStore.setViewYear(year)
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = 10
|
|
||||||
const startIdx = Math.floor((scrollTop - buffer * rowHeight.value) / rowHeight.value)
|
|
||||||
const endIdx = Math.ceil((scrollTop + viewportH + buffer * rowHeight.value) / rowHeight.value)
|
|
||||||
|
|
||||||
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
|
|
||||||
const endVW = Math.min(
|
|
||||||
totalVirtualWeeks.value + minVirtualWeek.value - 1,
|
|
||||||
endIdx + minVirtualWeek.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
const newVisibleWeeks = []
|
|
||||||
for (let vw = startVW; vw <= endVW; vw++) {
|
|
||||||
newVisibleWeeks.push({ virtualWeek: vw, monday: getMondayForVirtualWeek(vw) })
|
|
||||||
}
|
|
||||||
visibleWeeks.value = newVisibleWeeks
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
requestAnimationFrame(updateVisibleWeeks)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleWheel = (e) => {
|
|
||||||
const currentYear = calendarStore.viewYear
|
|
||||||
const delta = Math.round(e.deltaY * (1 / 3))
|
|
||||||
if (!delta) return
|
|
||||||
const newYear = Math.max(MIN_YEAR, Math.min(MAX_YEAR, currentYear + delta))
|
|
||||||
if (newYear === currentYear) return
|
|
||||||
|
|
||||||
const topDisplayIndex = Math.floor(viewportEl.value.scrollTop / rowHeight.value)
|
|
||||||
const currentWeekIndex = topDisplayIndex + minVirtualWeek.value
|
|
||||||
|
|
||||||
navigateToYear(newYear, currentWeekIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToYear = (targetYear, weekIndex) => {
|
|
||||||
const monday = getMondayForVirtualWeek(weekIndex)
|
|
||||||
const week = getISOWeek(monday)
|
|
||||||
const jan4 = new Date(targetYear, 0, 4)
|
|
||||||
const jan4Monday = addDays(jan4, -mondayIndex(jan4))
|
|
||||||
const targetMonday = addDays(jan4Monday, (week - 1) * 7)
|
|
||||||
scrollToTarget(targetMonday)
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollToTarget = (target) => {
|
|
||||||
let targetWeekIndex
|
|
||||||
if (target instanceof Date) {
|
|
||||||
targetWeekIndex = getWeekIndex(target)
|
|
||||||
} else {
|
|
||||||
targetWeekIndex = target
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
|
||||||
viewportEl.value.scrollTop = targetScrollTop
|
|
||||||
updateVisibleWeeks()
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToTodayHandler = () => {
|
|
||||||
const today = new Date()
|
|
||||||
const top = addDays(today, -21)
|
|
||||||
scrollToTarget(top)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
rowHeight.value = computeRowHeight()
|
|
||||||
|
|
||||||
const minYearDate = new Date(MIN_YEAR, 0, 1)
|
|
||||||
const maxYearLastDay = new Date(MAX_YEAR, 11, 31)
|
|
||||||
const lastWeekMonday = addDays(maxYearLastDay, -mondayIndex(maxYearLastDay))
|
|
||||||
|
|
||||||
minVirtualWeek.value = getWeekIndex(minYearDate)
|
|
||||||
const maxVirtualWeek = getWeekIndex(lastWeekMonday)
|
|
||||||
totalVirtualWeeks.value = maxVirtualWeek - minVirtualWeek.value + 1
|
|
||||||
|
|
||||||
const initialDate = fromLocalString(calendarStore.today, DEFAULT_TZ)
|
|
||||||
scrollToTarget(initialDate)
|
|
||||||
|
|
||||||
document.addEventListener('goToToday', goToTodayHandler)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('goToToday', goToTodayHandler)
|
|
||||||
})
|
|
||||||
</script>
|
|
@ -144,7 +144,6 @@ const weekdayNames = computed(() => {
|
|||||||
.dow {
|
.dow {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.5rem;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.dow.weekend {
|
.dow.weekend {
|
||||||
|
@ -3,6 +3,7 @@ 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 Jogwheel from '@/components/Jogwheel.vue'
|
import Jogwheel from '@/components/Jogwheel.vue'
|
||||||
import SettingsDialog from '@/components/SettingsDialog.vue'
|
import SettingsDialog from '@/components/SettingsDialog.vue'
|
||||||
import {
|
import {
|
||||||
@ -13,7 +14,6 @@ import {
|
|||||||
daysInclusive,
|
daysInclusive,
|
||||||
addDaysStr,
|
addDaysStr,
|
||||||
formatDateRange,
|
formatDateRange,
|
||||||
formatTodayString,
|
|
||||||
getOccurrenceIndex,
|
getOccurrenceIndex,
|
||||||
getVirtualOccurrenceEndDate,
|
getVirtualOccurrenceEndDate,
|
||||||
getISOWeek,
|
getISOWeek,
|
||||||
@ -113,11 +113,6 @@ const selectedDateRange = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const todayString = computed(() => {
|
|
||||||
const d = new Date(calendarStore.now)
|
|
||||||
return formatTodayString(d)
|
|
||||||
})
|
|
||||||
|
|
||||||
// PERFORMANCE: Maintain a manual cache of computed weeks instead of relying on
|
// PERFORMANCE: Maintain a manual cache of computed weeks instead of relying on
|
||||||
// deep reactive tracking of every event & day object. We rebuild lazily when
|
// deep reactive tracking of every event & day object. We rebuild lazily when
|
||||||
// (a) scrolling changes the needed range or (b) eventsMutation counter bumps.
|
// (a) scrolling changes the needed range or (b) eventsMutation counter bumps.
|
||||||
@ -704,44 +699,12 @@ window.addEventListener('resize', () => {
|
|||||||
<template>
|
<template>
|
||||||
<!-- hidden probe for measuring var(--cell-h) -->
|
<!-- hidden probe for measuring var(--cell-h) -->
|
||||||
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
||||||
<!-- existing template root starts below -->
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<header>
|
<HeaderControls
|
||||||
<h1>Calendar</h1>
|
@go-to-today="goToToday"
|
||||||
<div class="header-controls">
|
@open-settings="openSettings"
|
||||||
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
/>
|
||||||
<!-- Reference historyTick to ensure reactivity of canUndo/canRedo -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="hist-btn"
|
|
||||||
:disabled="!calendarStore.historyCanUndo"
|
|
||||||
@click="calendarStore.$history?.undo()"
|
|
||||||
title="Undo (Ctrl+Z)"
|
|
||||||
aria-label="Undo"
|
|
||||||
>
|
|
||||||
↶
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="hist-btn"
|
|
||||||
:disabled="!calendarStore.historyCanRedo"
|
|
||||||
@click="calendarStore.$history?.redo()"
|
|
||||||
title="Redo (Ctrl+Shift+Z)"
|
|
||||||
aria-label="Redo"
|
|
||||||
>
|
|
||||||
↷
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="settings-btn"
|
|
||||||
@click="openSettings"
|
|
||||||
aria-label="Open settings"
|
|
||||||
title="Settings"
|
|
||||||
>
|
|
||||||
⚙
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<CalendarHeader
|
<CalendarHeader
|
||||||
:scroll-top="scrollTop"
|
:scroll-top="scrollTop"
|
||||||
:row-height="rowHeight"
|
:row-height="rowHeight"
|
||||||
@ -811,81 +774,6 @@ header h1 {
|
|||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.header-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.6rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
margin-right: 0.6rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.hist-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
outline: none;
|
|
||||||
width: 1.9rem;
|
|
||||||
height: 1.9rem;
|
|
||||||
}
|
|
||||||
.hist-btn:disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.hist-btn:not(:disabled):hover,
|
|
||||||
.hist-btn:not(:disabled):focus-visible {
|
|
||||||
color: var(--strong);
|
|
||||||
}
|
|
||||||
.hist-btn:active:not(:disabled) {
|
|
||||||
transform: scale(0.88);
|
|
||||||
}
|
|
||||||
.settings-btn:hover {
|
|
||||||
color: var(--strong);
|
|
||||||
}
|
|
||||||
.settings-btn:focus-visible {
|
|
||||||
/* Keep visual accessibility without background */
|
|
||||||
outline: 2px solid var(--selected);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
.settings-btn:active {
|
|
||||||
transform: scale(0.88);
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-date {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: var(--today-btn-bg);
|
|
||||||
color: var(--today-btn-text);
|
|
||||||
border-radius: 4px;
|
|
||||||
white-space: pre-line;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-date:hover {
|
|
||||||
background: var(--today-btn-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-container {
|
.calendar-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -940,5 +828,10 @@ header h1 {
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-height-probe { position: absolute; visibility: hidden; height: var(--cell-h); pointer-events: none; }
|
.row-height-probe {
|
||||||
|
position: absolute;
|
||||||
|
visibility: hidden;
|
||||||
|
height: var(--cell-h);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
225
src/components/HeaderControls.vue
Normal file
225
src/components/HeaderControls.vue
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Header controls with transition -->
|
||||||
|
<Transition name="header-controls" appear>
|
||||||
|
<div v-if="isVisible" class="header-controls">
|
||||||
|
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
||||||
|
<!-- Reference historyTick to ensure reactivity of canUndo/canRedo -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hist-btn"
|
||||||
|
:disabled="!calendarStore.historyCanUndo"
|
||||||
|
@click="calendarStore.$history?.undo()"
|
||||||
|
title="Undo (Ctrl+Z)"
|
||||||
|
aria-label="Undo"
|
||||||
|
>
|
||||||
|
↶
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hist-btn"
|
||||||
|
:disabled="!calendarStore.historyCanRedo"
|
||||||
|
@click="calendarStore.$history?.redo()"
|
||||||
|
title="Redo (Ctrl+Shift+Z)"
|
||||||
|
aria-label="Redo"
|
||||||
|
>
|
||||||
|
↷
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="settings-btn"
|
||||||
|
@click="$emit('open-settings')"
|
||||||
|
aria-label="Open settings"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
⚙
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle-btn"
|
||||||
|
@click="toggleVisibility"
|
||||||
|
:aria-label="isVisible ? 'Hide controls' : 'Show controls'"
|
||||||
|
:title="isVisible ? 'Hide controls' : 'Show controls'"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
|
import { formatTodayString } from '@/utils/date'
|
||||||
|
import { addDays } from 'date-fns'
|
||||||
|
|
||||||
|
const calendarStore = useCalendarStore()
|
||||||
|
|
||||||
|
const todayString = computed(() => {
|
||||||
|
const d = new Date(calendarStore.now)
|
||||||
|
return formatTodayString(d)
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['open-settings', 'go-to-today'])
|
||||||
|
|
||||||
|
function goToToday() {
|
||||||
|
// Emit the event so the parent can handle the viewport scrolling logic
|
||||||
|
// since this component doesn't have access to viewport refs
|
||||||
|
emit('go-to-today')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen size detection and visibility toggle
|
||||||
|
const isVisible = ref(false)
|
||||||
|
|
||||||
|
function checkScreenSize() {
|
||||||
|
const isSmallScreen = window.innerHeight < 600
|
||||||
|
// Default to open on large screens, closed on small screens
|
||||||
|
isVisible.value = !isSmallScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVisibility() {
|
||||||
|
isVisible.value = !isVisible.value
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkScreenSize()
|
||||||
|
window.addEventListener('resize', checkScreenSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', checkScreenSize)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 2.5em;
|
||||||
|
gap: 0.6rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
outline: none;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn:hover {
|
||||||
|
color: var(--strong);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition animations */
|
||||||
|
.header-controls-enter-active,
|
||||||
|
.header-controls-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls-enter-from,
|
||||||
|
.header-controls-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls-enter-to,
|
||||||
|
.header-controls-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 100px;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hist-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
outline: none;
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hist-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hist-btn:not(:disabled):hover,
|
||||||
|
.hist-btn:not(:disabled):focus-visible {
|
||||||
|
color: var(--strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hist-btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn:hover {
|
||||||
|
color: var(--strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-date {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--today-btn-bg);
|
||||||
|
color: var(--today-btn-text);
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre-line;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-date:hover {
|
||||||
|
background: var(--today-btn-hover-bg);
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,6 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll">
|
<div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll">
|
||||||
<div class="jogwheel-content" ref="jogwheelContent" :style="{ height: jogwheelHeight + 'px' }"></div>
|
<div
|
||||||
|
class="jogwheel-content"
|
||||||
|
ref="jogwheelContent"
|
||||||
|
:style="{ height: jogwheelHeight + 'px' }"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -11,7 +15,7 @@ const props = defineProps({
|
|||||||
totalVirtualWeeks: { type: Number, required: true },
|
totalVirtualWeeks: { type: Number, required: true },
|
||||||
rowHeight: { type: Number, required: true },
|
rowHeight: { type: Number, required: true },
|
||||||
viewportHeight: { type: Number, required: true },
|
viewportHeight: { type: Number, required: true },
|
||||||
scrollTop: { type: Number, required: true }
|
scrollTop: { type: Number, required: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['scroll-to'])
|
const emit = defineEmits(['scroll-to'])
|
||||||
@ -42,10 +46,16 @@ function onDragMouseDown(e) {
|
|||||||
mainStartScroll = props.scrollTop
|
mainStartScroll = props.scrollTop
|
||||||
accumDelta = 0
|
accumDelta = 0
|
||||||
// Precompute scale between jogwheel scrollable range and main scrollable range
|
// Precompute scale between jogwheel scrollable range and main scrollable range
|
||||||
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
const mainScrollable = Math.max(
|
||||||
|
0,
|
||||||
|
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
||||||
|
)
|
||||||
let jogScrollable = 0
|
let jogScrollable = 0
|
||||||
if (jogwheelViewport.value && jogwheelContent.value) {
|
if (jogwheelViewport.value && jogwheelContent.value) {
|
||||||
jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
jogScrollable = Math.max(
|
||||||
|
0,
|
||||||
|
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
dragScale = jogScrollable > 0 ? mainScrollable / jogScrollable : 1
|
dragScale = jogScrollable > 0 ? mainScrollable / jogScrollable : 1
|
||||||
if (!isFinite(dragScale) || dragScale <= 0) dragScale = 1
|
if (!isFinite(dragScale) || dragScale <= 0) dragScale = 1
|
||||||
@ -108,8 +118,14 @@ const syncFromJogwheel = () => {
|
|||||||
|
|
||||||
syncLock.value = 'main'
|
syncLock.value = 'main'
|
||||||
|
|
||||||
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
const jogScrollable = Math.max(
|
||||||
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
0,
|
||||||
|
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
||||||
|
)
|
||||||
|
const mainScrollable = Math.max(
|
||||||
|
0,
|
||||||
|
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
||||||
|
)
|
||||||
|
|
||||||
if (jogScrollable > 0) {
|
if (jogScrollable > 0) {
|
||||||
const ratio = jogwheelViewport.value.scrollTop / jogScrollable
|
const ratio = jogwheelViewport.value.scrollTop / jogScrollable
|
||||||
@ -129,8 +145,14 @@ const syncFromMain = (mainScrollTop) => {
|
|||||||
|
|
||||||
syncLock.value = 'jogwheel'
|
syncLock.value = 'jogwheel'
|
||||||
|
|
||||||
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
const mainScrollable = Math.max(
|
||||||
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
0,
|
||||||
|
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
||||||
|
)
|
||||||
|
const jogScrollable = Math.max(
|
||||||
|
0,
|
||||||
|
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
||||||
|
)
|
||||||
|
|
||||||
if (mainScrollable > 0) {
|
if (mainScrollable > 0) {
|
||||||
const ratio = mainScrollTop / mainScrollable
|
const ratio = mainScrollTop / mainScrollable
|
||||||
@ -143,12 +165,15 @@ const syncFromMain = (mainScrollTop) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Watch for main calendar scroll changes
|
// Watch for main calendar scroll changes
|
||||||
watch(() => props.scrollTop, (newScrollTop) => {
|
watch(
|
||||||
|
() => props.scrollTop,
|
||||||
|
(newScrollTop) => {
|
||||||
syncFromMain(newScrollTop)
|
syncFromMain(newScrollTop)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
syncFromMain
|
syncFromMain,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user