From 222c90a239368d74bfc6ad55719e8eca246ef551 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Mon, 23 Feb 2026 07:26:22 +0700 Subject: [PATCH] calendar: add hover target and staged wheel zoom --- frontend/app.vue | 249 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 190 insertions(+), 59 deletions(-) diff --git a/frontend/app.vue b/frontend/app.vue index 775132e..c1b55b0 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -2073,6 +2073,7 @@ onBeforeUnmount(() => { lifecycleClock = null; } clearCalendarZoomOverlay(); + clearCalendarZoomPrime(); }); const calendarView = ref("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 | null = null; -const CALENDAR_ZOOM_DURATION_MS = 180; +let calendarZoomPrimeTimer: ReturnType | 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(() => @@ -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(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") > -
-
+
+
Sun @@ -4323,13 +4420,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
-
+