calendar: add hover target and staged wheel zoom

This commit is contained in:
Ruslan Bakiev
2026-02-23 07:26:22 +07:00
parent c4ef4d4297
commit 222c90a239

View File

@@ -2073,6 +2073,7 @@ onBeforeUnmount(() => {
lifecycleClock = null;
}
clearCalendarZoomOverlay();
clearCalendarZoomPrime();
});
const calendarView = ref<CalendarView>("year");
@@ -2130,9 +2131,19 @@ const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({
width: 0,
height: 0,
});
const calendarZoomBusy = ref(false);
const calendarSceneMasked = ref(false);
const calendarZoomPrimeToken = ref("");
const calendarZoomPrimeScale = ref(1);
const calendarZoomPrimeTicks = ref(0);
let calendarWheelLockUntil = 0;
let calendarZoomOverlayTimer: ReturnType<typeof setTimeout> | null = null;
const CALENDAR_ZOOM_DURATION_MS = 180;
let calendarZoomPrimeTimer: ReturnType<typeof setTimeout> | null = null;
let calendarZoomPrimeLastAt = 0;
const CALENDAR_ZOOM_DURATION_MS = 2400;
const CALENDAR_ZOOM_PRIME_STEPS = 2;
const CALENDAR_ZOOM_PRIME_MAX_SCALE = 1.05;
const CALENDAR_ZOOM_PRIME_RESET_MS = 900;
const calendarZoomOrder: CalendarHierarchyView[] = ["year", "month", "week", "day"];
const normalizedCalendarView = computed<CalendarHierarchyView>(() =>
@@ -2153,6 +2164,61 @@ function clearCalendarZoomOverlay() {
}
}
function clearCalendarZoomPrime() {
if (calendarZoomPrimeTimer) {
clearTimeout(calendarZoomPrimeTimer);
calendarZoomPrimeTimer = null;
}
calendarZoomPrimeToken.value = "";
calendarZoomPrimeScale.value = 1;
calendarZoomPrimeTicks.value = 0;
calendarZoomPrimeLastAt = 0;
}
function calendarPrimeMonthToken(monthIndex: number) {
return `year-month-${monthIndex}`;
}
function calendarPrimeWeekToken(startKey: string) {
return `month-week-${startKey}`;
}
function calendarPrimeDayToken(key: string) {
return `week-day-${key}`;
}
function calendarPrimeStyle(token: string) {
if (calendarZoomPrimeToken.value !== token) return undefined;
return {
transform: `scale(${calendarZoomPrimeScale.value})`,
};
}
function maybePrimeWheelZoom(event: WheelEvent | undefined, token: string) {
if (!event || event.deltaY >= 0) return false;
const now = Date.now();
if (calendarZoomPrimeToken.value !== token || now - calendarZoomPrimeLastAt > CALENDAR_ZOOM_PRIME_RESET_MS) {
calendarZoomPrimeTicks.value = 0;
}
calendarZoomPrimeToken.value = token;
calendarZoomPrimeTicks.value += 1;
calendarZoomPrimeLastAt = now;
if (calendarZoomPrimeTicks.value <= CALENDAR_ZOOM_PRIME_STEPS) {
const ratio = calendarZoomPrimeTicks.value / CALENDAR_ZOOM_PRIME_STEPS;
calendarZoomPrimeScale.value = 1 + (CALENDAR_ZOOM_PRIME_MAX_SCALE - 1) * ratio;
if (calendarZoomPrimeTimer) clearTimeout(calendarZoomPrimeTimer);
calendarZoomPrimeTimer = setTimeout(() => {
clearCalendarZoomPrime();
}, CALENDAR_ZOOM_PRIME_RESET_MS);
return true;
}
clearCalendarZoomPrime();
return false;
}
function queryCalendarElement(selector: string) {
return calendarContentWrapRef.value?.querySelector<HTMLElement>(selector) ?? null;
}
@@ -2229,11 +2295,20 @@ async function animateCalendarZoomIn(sourceElement: HTMLElement | null, apply: (
return;
}
primeCalendarRect(fromRect);
apply();
await nextTick();
morphCalendarRect(viewportRect);
await waitCalendarZoom();
clearCalendarZoomPrime();
calendarZoomBusy.value = true;
try {
primeCalendarRect(fromRect);
calendarSceneMasked.value = true;
await nextTick();
apply();
await nextTick();
morphCalendarRect(viewportRect);
await waitCalendarZoom();
} finally {
calendarSceneMasked.value = false;
calendarZoomBusy.value = false;
}
}
async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HTMLElement | null) {
@@ -2243,19 +2318,28 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
return;
}
primeCalendarRect(viewportRect);
apply();
await nextTick();
const targetRect = getElementRectInCalendar(resolveTarget());
if (!targetRect) {
calendarZoomOverlay.value = {
...calendarZoomOverlay.value,
active: false,
};
return;
clearCalendarZoomPrime();
calendarZoomBusy.value = true;
try {
primeCalendarRect(viewportRect);
calendarSceneMasked.value = true;
await nextTick();
apply();
await nextTick();
const targetRect = getElementRectInCalendar(resolveTarget());
if (!targetRect) {
calendarZoomOverlay.value = {
...calendarZoomOverlay.value,
active: false,
};
return;
}
morphCalendarRect(targetRect);
await waitCalendarZoom();
} finally {
calendarSceneMasked.value = false;
calendarZoomBusy.value = false;
}
morphCalendarRect(targetRect);
await waitCalendarZoom();
}
function resolveMonthAnchor(event?: WheelEvent) {
@@ -2290,7 +2374,9 @@ async function zoomInCalendar(event?: Event) {
const wheelEvent = event instanceof WheelEvent ? event : undefined;
if (calendarView.value === "year") {
const monthIndex = resolveMonthAnchor(wheelEvent);
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => {
const sourceElement = queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`);
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return;
await animateCalendarZoomIn(sourceElement, () => {
openYearMonth(monthIndex);
});
return;
@@ -2299,9 +2385,12 @@ async function zoomInCalendar(event?: Event) {
if (calendarView.value === "month" || calendarView.value === "agenda") {
const anchorDayKey = resolveWeekAnchor(wheelEvent);
const rowStartKey = weekRowStartForDate(anchorDayKey);
await animateCalendarZoomIn(
const sourceElement =
queryCalendarElement(`[data-calendar-week-start-key="${rowStartKey}"]`) ??
queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`),
queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`);
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return;
await animateCalendarZoomIn(
sourceElement,
() => {
openWeekView(anchorDayKey);
},
@@ -2311,7 +2400,9 @@ async function zoomInCalendar(event?: Event) {
if (calendarView.value === "week") {
const dayAnchor = resolveDayAnchor(wheelEvent);
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`), () => {
const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`);
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return;
await animateCalendarZoomIn(sourceElement, () => {
openDayView(dayAnchor);
});
}
@@ -2325,6 +2416,7 @@ async function zoomToMonth(monthIndex: number) {
async function zoomOutCalendar() {
focusedCalendarEventId.value = "";
clearCalendarZoomPrime();
if (calendarView.value === "day") {
const targetDayKey = selectedDateKey.value;
@@ -2363,9 +2455,10 @@ async function zoomOutCalendar() {
function onCalendarHierarchyWheel(event: WheelEvent) {
const now = Date.now();
if (calendarZoomBusy.value) return;
if (now < calendarWheelLockUntil) return;
if (Math.abs(event.deltaY) < 5) return;
calendarWheelLockUntil = now + 240;
calendarWheelLockUntil = now + 140;
if (event.deltaY < 0) {
void zoomInCalendar(event);
@@ -4303,14 +4396,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
>
<span></span>
</button>
<div
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
@wheel.prevent="onCalendarHierarchyWheel"
>
<div
:class="normalizedCalendarView === 'day' ? 'calendar-scene cursor-zoom-out' : 'calendar-scene cursor-zoom-in'"
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''"
>
<div
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
@wheel.prevent="onCalendarHierarchyWheel"
>
<div
:class="[
'calendar-scene',
normalizedCalendarView === 'day' ? 'cursor-zoom-out' : 'cursor-zoom-in',
calendarSceneMasked ? 'calendar-scene-hidden' : '',
]"
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''; clearCalendarZoomPrime()"
>
<div v-if="calendarView === 'month'" class="space-y-1">
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
<span>Sun</span>
@@ -4323,13 +4420,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</div>
<div class="space-y-1">
<div
v-for="row in monthRows"
:key="row.key"
class="group relative"
:data-calendar-week-start-key="row.startKey"
@mouseenter="calendarHoveredWeekStartKey = row.startKey"
>
<div
v-for="row in monthRows"
:key="row.key"
class="group relative calendar-hover-targetable"
:class="[
calendarHoveredWeekStartKey === row.startKey ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeWeekToken(row.startKey) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeWeekToken(row.startKey))"
:data-calendar-week-start-key="row.startKey"
@mouseenter="calendarHoveredWeekStartKey = row.startKey"
>
<div class="grid grid-cols-7 gap-1">
<button
v-for="cell in row.cells"
@@ -4362,14 +4464,19 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<div v-else-if="calendarView === 'week'" class="calendar-week-scroll overflow-x-auto pb-1">
<div class="calendar-week-grid">
<article
v-for="day in weekDays"
:key="day.key"
class="group relative flex min-h-[18rem] flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in"
:class="selectedDateKey === day.key ? 'border-primary bg-primary/5' : ''"
:data-calendar-day-key="day.key"
@mouseenter="calendarHoveredDayKey = day.key"
@click="pickDate(day.key)"
<article
v-for="day in weekDays"
:key="day.key"
class="group relative flex min-h-[18rem] flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in calendar-hover-targetable"
:class="[
selectedDateKey === day.key ? 'border-primary bg-primary/5' : '',
calendarHoveredDayKey === day.key ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeDayToken(day.key) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeDayToken(day.key))"
:data-calendar-day-key="day.key"
@mouseenter="calendarHoveredDayKey = day.key"
@click="pickDate(day.key)"
>
<div class="mb-2 flex items-start justify-between gap-2">
<p class="text-sm font-semibold leading-tight">{{ day.label }} {{ day.day }}</p>
@@ -4407,13 +4514,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</div>
<div v-else-if="calendarView === 'year'" class="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
<button
v-for="item in yearMonths"
:key="`year-month-${item.monthIndex}`"
class="group relative rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in"
:data-calendar-month-index="item.monthIndex"
@mouseenter="calendarHoveredMonthIndex = item.monthIndex"
@click="zoomToMonth(item.monthIndex)"
<button
v-for="item in yearMonths"
:key="`year-month-${item.monthIndex}`"
class="group relative rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in calendar-hover-targetable"
:class="[
calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeMonthToken(item.monthIndex))"
:data-calendar-month-index="item.monthIndex"
@mouseenter="calendarHoveredMonthIndex = item.monthIndex"
@click="zoomToMonth(item.monthIndex)"
>
<p class="font-medium">{{ item.label }}</p>
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
@@ -5529,6 +5641,10 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
transform-origin: center center;
}
.calendar-scene-hidden {
visibility: hidden;
}
.calendar-scene.cursor-zoom-in,
.calendar-scene.cursor-zoom-in * {
cursor: zoom-in;
@@ -5577,6 +5693,21 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
right: 4px;
}
.calendar-hover-targetable {
transform-origin: center center;
transition: transform 320ms ease, box-shadow 320ms ease, outline-color 320ms ease;
}
.calendar-hover-target {
outline: 2px solid color-mix(in oklab, var(--color-primary) 66%, transparent);
outline-offset: 1px;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 32%, transparent) inset;
}
.calendar-zoom-prime-active {
z-index: 2;
}
.calendar-zoom-inline {
position: relative;
display: flex;
@@ -5660,15 +5791,15 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
.calendar-zoom-overlay {
position: absolute;
z-index: 6;
border: 2px solid color-mix(in oklab, var(--color-primary) 60%, transparent);
border: 3px solid color-mix(in oklab, var(--color-primary) 64%, transparent);
border-radius: 12px;
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
pointer-events: none;
transition:
left 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
top 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
width 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
height 180ms cubic-bezier(0.2, 0.85, 0.25, 1);
left 2400ms cubic-bezier(0.16, 0.86, 0.18, 1),
top 2400ms cubic-bezier(0.16, 0.86, 0.18, 1),
width 2400ms cubic-bezier(0.16, 0.86, 0.18, 1),
height 2400ms cubic-bezier(0.16, 0.86, 0.18, 1);
}
@media (max-width: 960px) {