From 8ef266e09d360ec0ba04cf1817d25fbd8459fe18 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:55:45 +0700 Subject: [PATCH] calendar: switch zoom-in to DOM camera panzoom flow --- frontend/app.vue | 173 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 148 insertions(+), 25 deletions(-) diff --git a/frontend/app.vue b/frontend/app.vue index 32aa78a..cf073a3 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -2281,6 +2281,8 @@ type CalendarZoomGhost = { }; const calendarContentWrapRef = ref(null); +const calendarContentScrollRef = ref(null); +const calendarSceneRef = ref(null); const calendarZoomOverlayRef = ref(null); const calendarHoveredMonthIndex = ref(null); const calendarHoveredWeekStartKey = ref(""); @@ -2295,6 +2297,13 @@ const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({ const calendarZoomGhost = ref(null); const calendarZoomBusy = ref(false); const calendarSceneMasked = ref(false); +const calendarCameraState = ref({ + active: false, + left: 0, + top: 0, + scale: 1, + durationMs: 0, +}); const calendarZoomPrimeToken = ref(""); const calendarZoomPrimeScale = ref(1); const calendarZoomPrimeTicks = ref(0); @@ -2317,6 +2326,18 @@ const calendarZoomOverlayStyle = computed(() => ({ width: `${calendarZoomOverlay.value.width}px`, height: `${calendarZoomOverlay.value.height}px`, })); +const calendarSceneTransformStyle = computed(() => { + if (!calendarCameraState.value.active) return undefined; + return { + transform: `translate(${calendarCameraState.value.left}px, ${calendarCameraState.value.top}px) scale(${calendarCameraState.value.scale})`, + transformOrigin: "0 0", + transition: + calendarCameraState.value.durationMs > 0 + ? `transform ${calendarCameraState.value.durationMs}ms cubic-bezier(0.16, 0.86, 0.18, 1)` + : "none", + willChange: "transform", + }; +}); function clearCalendarZoomOverlay() { calendarZoomOverlay.value = { @@ -2396,6 +2417,15 @@ function getCalendarViewportRect(): CalendarRect | null { }; } +function getCalendarCameraViewportRect() { + const viewport = calendarContentScrollRef.value?.getBoundingClientRect(); + if (!viewport) return null; + return { + width: Math.max(24, viewport.width), + height: Math.max(24, viewport.height), + }; +} + function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | null { if (!element) return null; const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect(); @@ -2413,14 +2443,29 @@ function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | n 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)); +function getElementRectInScene(element: HTMLElement | null): CalendarRect | null { + if (!element) return null; + const sceneRect = calendarSceneRef.value?.getBoundingClientRect(); + if (!sceneRect) return null; + const rect = element.getBoundingClientRect(); + const left = rect.left - sceneRect.left; + const top = rect.top - sceneRect.top; + const width = Math.max(24, rect.width); + const height = Math.max(24, rect.height); + return { left, top, width, height }; +} + +function fallbackZoomOriginRectInScene(): CalendarRect | null { + const viewport = getCalendarCameraViewportRect(); + const scroll = calendarContentScrollRef.value; + if (!viewport || !scroll) return null; + const width = Math.max(96, Math.round(viewport.width * 0.28)); + const height = Math.max(64, Math.round(viewport.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), + left: scroll.scrollLeft + Math.max(0, (viewport.width - width) / 2), + top: scroll.scrollTop + Math.max(0, (viewport.height - height) / 2), + width, + height, }; } @@ -2522,6 +2567,67 @@ function nextAnimationFrame() { }); } +function waitCalendarCameraTransition() { + const scene = calendarSceneRef.value; + if (!scene) { + return new Promise((resolve) => { + setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS); + }); + } + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + scene.removeEventListener("transitionend", onTransitionEnd); + clearTimeout(fallbackTimer); + resolve(); + }; + const onTransitionEnd = (event: TransitionEvent) => { + if (event.target !== scene) return; + if (event.propertyName !== "transform") return; + finish(); + }; + const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 160); + scene.addEventListener("transitionend", onTransitionEnd); + }); +} + +function cameraTransformForRect(rect: CalendarRect) { + const viewport = getCalendarCameraViewportRect(); + if (!viewport) return null; + const availableWidth = Math.max(24, viewport.width - 24); + const availableHeight = Math.max(24, viewport.height - 24); + const fitScale = Math.min(availableWidth / rect.width, availableHeight / rect.height); + const scale = Math.max(1, Math.min(8, fitScale)); + const targetLeft = (viewport.width - rect.width * scale) / 2; + const targetTop = (viewport.height - rect.height * scale) / 2; + return { + left: Math.round(targetLeft - rect.left * scale), + top: Math.round(targetTop - rect.top * scale), + scale, + }; +} + +async function resetCalendarCamera() { + calendarCameraState.value = { + active: true, + left: 0, + top: 0, + scale: 1, + durationMs: 0, + }; + await nextTick(); + await nextAnimationFrame(); + calendarCameraState.value = { + active: false, + left: 0, + top: 0, + scale: 1, + durationMs: 0, + }; +} + async function flushCalendarZoomStartFrame() { await nextTick(); await nextAnimationFrame(); @@ -2556,27 +2662,41 @@ function waitCalendarZoomTransition() { } async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: CalendarZoomGhost, apply: () => void) { - const viewportRect = getCalendarViewportRect(); - if (!viewportRect) { - apply(); - return; - } - const fromRect = getElementRectInCalendar(sourceElement) ?? fallbackZoomOriginRect(viewportRect); - clearCalendarZoomPrime(); calendarZoomBusy.value = true; + clearCalendarZoomOverlay(); + calendarSceneMasked.value = false; try { - primeCalendarRect(fromRect); calendarZoomGhost.value = ghost; - calendarSceneMasked.value = true; - await flushCalendarZoomStartFrame(); - morphCalendarRect(viewportRect); - await waitCalendarZoomTransition(); + const fromRect = getElementRectInScene(sourceElement) ?? fallbackZoomOriginRectInScene(); + const cameraTarget = fromRect ? cameraTransformForRect(fromRect) : null; + if (!cameraTarget) { + apply(); + return; + } + calendarCameraState.value = { + active: true, + left: 0, + top: 0, + scale: 1, + durationMs: 0, + }; + await nextTick(); + await nextAnimationFrame(); + calendarSceneRef.value?.getBoundingClientRect(); + calendarCameraState.value = { + active: true, + left: cameraTarget.left, + top: cameraTarget.top, + scale: cameraTarget.scale, + durationMs: CALENDAR_ZOOM_DURATION_MS, + }; + await waitCalendarCameraTransition(); apply(); await nextTick(); } finally { - clearCalendarZoomOverlay(); - calendarSceneMasked.value = false; + await resetCalendarCamera(); + calendarZoomGhost.value = null; calendarZoomBusy.value = false; } } @@ -4852,15 +4972,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")