Major new version #2
@ -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 {
|
||||
color: var(--strong);
|
||||
}
|
||||
|
||||
.lunar-phase {
|
||||
position: absolute;
|
||||
top: 0.1em;
|
||||
right: 0.1em;
|
||||
top: 0.5em;
|
||||
right: 0.2em;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cell.holiday {
|
||||
/* Remove solid background & border color overrides; use gradient overlay instead */
|
||||
position: relative;
|
||||
}
|
||||
.cell.holiday::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
var(--holiday-grad-start, rgba(255, 255, 255, 0.1)) 0%,
|
||||
var(--holiday-grad-start, rgba(255, 255, 255, 0.5)) 0%,
|
||||
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 */
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cell.holiday {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
var(--holiday-grad-start, rgba(255, 255, 255, 0.1)) 0%,
|
||||
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
|
||||
);
|
||||
}
|
||||
}
|
||||
.cell.holiday h1 {
|
||||
/* Slight emphasis without forcing a specific hue */
|
||||
color: var(--holiday);
|
||||
text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.holiday-info {
|
||||
position: absolute;
|
||||
bottom: 0.1em;
|
||||
|
@ -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 {
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.dow.weekend {
|
||||
|
@ -3,6 +3,7 @@ import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import CalendarHeader from '@/components/CalendarHeader.vue'
|
||||
import CalendarWeek from '@/components/CalendarWeek.vue'
|
||||
import HeaderControls from '@/components/HeaderControls.vue'
|
||||
import Jogwheel from '@/components/Jogwheel.vue'
|
||||
import SettingsDialog from '@/components/SettingsDialog.vue'
|
||||
import {
|
||||
@ -13,7 +14,6 @@ import {
|
||||
daysInclusive,
|
||||
addDaysStr,
|
||||
formatDateRange,
|
||||
formatTodayString,
|
||||
getOccurrenceIndex,
|
||||
getVirtualOccurrenceEndDate,
|
||||
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
|
||||
// deep reactive tracking of every event & day object. We rebuild lazily when
|
||||
// (a) scrolling changes the needed range or (b) eventsMutation counter bumps.
|
||||
@ -578,8 +573,8 @@ onMounted(() => {
|
||||
viewportHeight.value = viewport.value.clientHeight
|
||||
viewport.value.scrollTop = initialScrollTop.value
|
||||
viewport.value.addEventListener('scroll', onScroll)
|
||||
// Capture mousedown in viewport to allow dragging via week label column
|
||||
viewport.value.addEventListener('mousedown', handleWeekColMouseDown, true)
|
||||
// Capture mousedown in viewport to allow dragging via week label column
|
||||
viewport.value.addEventListener('mousedown', handleWeekColMouseDown, true)
|
||||
}
|
||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
||||
|
||||
@ -704,44 +699,12 @@ window.addEventListener('resize', () => {
|
||||
<template>
|
||||
<!-- hidden probe for measuring var(--cell-h) -->
|
||||
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
||||
<!-- existing template root starts below -->
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>Calendar</h1>
|
||||
<div 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="openSettings"
|
||||
aria-label="Open settings"
|
||||
title="Settings"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<HeaderControls
|
||||
@go-to-today="goToToday"
|
||||
@open-settings="openSettings"
|
||||
/>
|
||||
|
||||
<CalendarHeader
|
||||
:scroll-top="scrollTop"
|
||||
:row-height="rowHeight"
|
||||
@ -811,81 +774,6 @@ header h1 {
|
||||
font-size: 1.6rem;
|
||||
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 {
|
||||
flex: 1;
|
||||
@ -940,5 +828,10 @@ header h1 {
|
||||
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>
|
||||
|
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>
|
||||
<div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll">
|
||||
<div class="jogwheel-content" ref="jogwheelContent" :style="{ height: jogwheelHeight + 'px' }"></div>
|
||||
<div
|
||||
class="jogwheel-content"
|
||||
ref="jogwheelContent"
|
||||
:style="{ height: jogwheelHeight + 'px' }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -11,7 +15,7 @@ const props = defineProps({
|
||||
totalVirtualWeeks: { type: Number, required: true },
|
||||
rowHeight: { type: Number, required: true },
|
||||
viewportHeight: { type: Number, required: true },
|
||||
scrollTop: { type: Number, required: true }
|
||||
scrollTop: { type: Number, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['scroll-to'])
|
||||
@ -42,10 +46,16 @@ function onDragMouseDown(e) {
|
||||
mainStartScroll = props.scrollTop
|
||||
accumDelta = 0
|
||||
// Precompute scale between jogwheel scrollable range and main scrollable range
|
||||
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
||||
const mainScrollable = Math.max(
|
||||
0,
|
||||
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
||||
)
|
||||
let jogScrollable = 0
|
||||
if (jogwheelViewport.value && jogwheelContent.value) {
|
||||
jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
||||
jogScrollable = Math.max(
|
||||
0,
|
||||
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
||||
)
|
||||
}
|
||||
dragScale = jogScrollable > 0 ? mainScrollable / jogScrollable : 1
|
||||
if (!isFinite(dragScale) || dragScale <= 0) dragScale = 1
|
||||
@ -105,19 +115,25 @@ onBeforeUnmount(() => {
|
||||
|
||||
const syncFromJogwheel = () => {
|
||||
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
||||
|
||||
|
||||
syncLock.value = 'main'
|
||||
|
||||
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
||||
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
||||
|
||||
|
||||
const jogScrollable = Math.max(
|
||||
0,
|
||||
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
||||
)
|
||||
const mainScrollable = Math.max(
|
||||
0,
|
||||
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
||||
)
|
||||
|
||||
if (jogScrollable > 0) {
|
||||
const ratio = jogwheelViewport.value.scrollTop / jogScrollable
|
||||
|
||||
|
||||
// Emit scroll event to parent to update main viewport
|
||||
emit('scroll-to', ratio * mainScrollable)
|
||||
}
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
if (syncLock.value === 'main') syncLock.value = null
|
||||
}, 50)
|
||||
@ -126,29 +142,38 @@ const syncFromJogwheel = () => {
|
||||
const syncFromMain = (mainScrollTop) => {
|
||||
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
||||
if (syncLock.value === 'main') return
|
||||
|
||||
|
||||
syncLock.value = 'jogwheel'
|
||||
|
||||
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
||||
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
||||
|
||||
|
||||
const mainScrollable = Math.max(
|
||||
0,
|
||||
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
|
||||
)
|
||||
const jogScrollable = Math.max(
|
||||
0,
|
||||
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
|
||||
)
|
||||
|
||||
if (mainScrollable > 0) {
|
||||
const ratio = mainScrollTop / mainScrollable
|
||||
jogwheelViewport.value.scrollTop = ratio * jogScrollable
|
||||
}
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
if (syncLock.value === 'jogwheel') syncLock.value = null
|
||||
}, 50)
|
||||
}
|
||||
|
||||
// Watch for main calendar scroll changes
|
||||
watch(() => props.scrollTop, (newScrollTop) => {
|
||||
syncFromMain(newScrollTop)
|
||||
})
|
||||
watch(
|
||||
() => props.scrollTop,
|
||||
(newScrollTop) => {
|
||||
syncFromMain(newScrollTop)
|
||||
},
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
syncFromMain
|
||||
syncFromMain,
|
||||
})
|
||||
</script>
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user