From ab5370c831f32cf644ebc7caf15a3d94e9329ced Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:15:12 +0700 Subject: [PATCH] calendar: enforce two-phase zoom-in morph before view switch --- frontend/app.vue | 89 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 69 insertions(+), 20 deletions(-) diff --git a/frontend/app.vue b/frontend/app.vue index b06092a..32aa78a 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -2281,6 +2281,7 @@ type CalendarZoomGhost = { }; const calendarContentWrapRef = ref(null); +const calendarZoomOverlayRef = ref(null); const calendarHoveredMonthIndex = ref(null); const calendarHoveredWeekStartKey = ref(""); const calendarHoveredDayKey = ref(""); @@ -2400,13 +2401,29 @@ function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | n const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect(); if (!wrapRect) return null; const rect = element.getBoundingClientRect(); - const left = Math.max(0, rect.left - wrapRect.left); - const top = Math.max(0, rect.top - wrapRect.top); - const width = Math.max(24, Math.min(rect.width, wrapRect.width - left)); - const height = Math.max(24, Math.min(rect.height, wrapRect.height - top)); + const left = Math.max(0, Math.min(rect.left - wrapRect.left, wrapRect.width)); + const top = Math.max(0, Math.min(rect.top - wrapRect.top, wrapRect.height)); + const right = Math.max(0, Math.min(rect.right - wrapRect.left, wrapRect.width)); + const bottom = Math.max(0, Math.min(rect.bottom - wrapRect.top, wrapRect.height)); + const visibleWidth = right - left; + const visibleHeight = bottom - top; + if (visibleWidth < 2 || visibleHeight < 2) return null; + const width = Math.min(Math.max(24, visibleWidth), wrapRect.width - left); + const height = Math.min(Math.max(24, visibleHeight), wrapRect.height - top); return { left, top, width, height }; } +function fallbackZoomOriginRect(viewportRect: CalendarRect): CalendarRect { + const width = Math.max(96, Math.round(viewportRect.width * 0.28)); + const height = Math.max(64, Math.round(viewportRect.height * 0.24)); + return { + left: Math.max(0, Math.round((viewportRect.width - width) / 2)), + top: Math.max(0, Math.round((viewportRect.height - height) / 2)), + width: Math.min(width, viewportRect.width), + height: Math.min(height, viewportRect.height), + }; +} + function weekRowStartForDate(key: string) { const date = new Date(`${key}T00:00:00`); date.setDate(date.getDate() - date.getDay()); @@ -2490,30 +2507,61 @@ function primeCalendarRect(rect: CalendarRect) { } function morphCalendarRect(toRect: CalendarRect) { - requestAnimationFrame(() => { - calendarZoomOverlay.value = { - active: true, - left: toRect.left, - top: toRect.top, - width: toRect.width, - height: toRect.height, - }; + calendarZoomOverlay.value = { + active: true, + left: toRect.left, + top: toRect.top, + width: toRect.width, + height: toRect.height, + }; +} + +function nextAnimationFrame() { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); }); } -function waitCalendarZoom() { +async function flushCalendarZoomStartFrame() { + await nextTick(); + await nextAnimationFrame(); + calendarZoomOverlayRef.value?.getBoundingClientRect(); + await nextAnimationFrame(); +} + +function waitCalendarZoomTransition() { + const overlay = calendarZoomOverlayRef.value; + if (!overlay) { + return new Promise((resolve) => { + setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS); + }); + } return new Promise((resolve) => { - setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS); + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + overlay.removeEventListener("transitionend", onTransitionEnd); + clearTimeout(fallbackTimer); + resolve(); + }; + const onTransitionEnd = (event: TransitionEvent) => { + if (event.target !== overlay) return; + if (!["left", "top", "width", "height"].includes(event.propertyName)) return; + finish(); + }; + const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 140); + overlay.addEventListener("transitionend", onTransitionEnd); }); } async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: CalendarZoomGhost, apply: () => void) { - const fromRect = getElementRectInCalendar(sourceElement); const viewportRect = getCalendarViewportRect(); - if (!fromRect || !viewportRect) { + if (!viewportRect) { apply(); return; } + const fromRect = getElementRectInCalendar(sourceElement) ?? fallbackZoomOriginRect(viewportRect); clearCalendarZoomPrime(); calendarZoomBusy.value = true; @@ -2521,9 +2569,9 @@ async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: C primeCalendarRect(fromRect); calendarZoomGhost.value = ghost; calendarSceneMasked.value = true; - await nextTick(); + await flushCalendarZoomStartFrame(); morphCalendarRect(viewportRect); - await waitCalendarZoom(); + await waitCalendarZoomTransition(); apply(); await nextTick(); } finally { @@ -2546,7 +2594,7 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT primeCalendarRect(viewportRect); calendarZoomGhost.value = zoomGhostForCurrentView(); calendarSceneMasked.value = true; - await nextTick(); + await flushCalendarZoomStartFrame(); apply(); await nextTick(); const targetRect = getElementRectInCalendar(resolveTarget()); @@ -2558,7 +2606,7 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT return; } morphCalendarRect(targetRect); - await waitCalendarZoom(); + await waitCalendarZoomTransition(); } finally { clearCalendarZoomOverlay(); calendarSceneMasked.value = false; @@ -4964,6 +5012,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")