From db49c4a8306b9a3819dfafe6486fa0dc0b1a5823 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:33:24 +0700 Subject: [PATCH] fix(calendar): make nested block zoom smooth in both directions --- .../components/workspace/CrmWorkspaceApp.vue | 235 +++++++++--------- .../workspace/calendar/CrmCalendarPanel.vue | 6 +- 2 files changed, 120 insertions(+), 121 deletions(-) diff --git a/frontend/app/components/workspace/CrmWorkspaceApp.vue b/frontend/app/components/workspace/CrmWorkspaceApp.vue index c2a638a..667454b 100644 --- a/frontend/app/components/workspace/CrmWorkspaceApp.vue +++ b/frontend/app/components/workspace/CrmWorkspaceApp.vue @@ -2565,143 +2565,119 @@ function nextAnimationFrame() { }); } -function waitCalendarCameraTransition() { - const scene = calendarSceneRef.value; - if (!scene) { - return new Promise((resolve) => { - setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS); - }); - } +function waitForTransformTransition(element: HTMLElement) { return new Promise((resolve) => { let settled = false; const finish = () => { if (settled) return; settled = true; - scene.removeEventListener("transitionend", onTransitionEnd); + element.removeEventListener("transitionend", onTransitionEnd); clearTimeout(fallbackTimer); resolve(); }; const onTransitionEnd = (event: TransitionEvent) => { - if (event.target !== scene) return; + if (event.target !== element) return; if (event.propertyName !== "transform") return; finish(); }; const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 160); - scene.addEventListener("transitionend", onTransitionEnd); + element.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, +function fadeOutCalendarSiblings(sourceElement: HTMLElement) { + const scene = calendarSceneRef.value; + if (!scene) return () => {}; + const targets = Array.from(scene.querySelectorAll(".calendar-hover-targetable")); + const siblings = targets.filter((element) => { + if (element === sourceElement) return false; + if (sourceElement.contains(element)) return false; + if (element.contains(sourceElement)) return false; + return true; + }); + const snapshots = siblings.map((element) => ({ + element, + opacity: element.style.opacity, + pointerEvents: element.style.pointerEvents, + transition: element.style.transition, + })); + for (const { element } of snapshots) { + element.style.transition = "opacity 180ms ease"; + element.style.opacity = "0"; + element.style.pointerEvents = "none"; + } + return () => { + for (const snapshot of snapshots) { + snapshot.element.style.opacity = snapshot.opacity; + snapshot.element.style.pointerEvents = snapshot.pointerEvents; + snapshot.element.style.transition = snapshot.transition; + } }; } -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, - }; +function isRenderableRect(rect: DOMRect | null) { + return Boolean(rect && rect.width >= 2 && rect.height >= 2); } -async function animateCalendarZoomIn( +async function animateCalendarFlipTransition( sourceElement: HTMLElement | null, apply: () => void, + resolveTarget: () => HTMLElement | null, ) { clearCalendarZoomPrime(); calendarZoomBusy.value = true; + let restoreSiblings = () => {}; + let animatedElement: HTMLElement | null = null; + let snapshot: { + transform: string; + transition: string; + transformOrigin: string; + willChange: string; + zIndex: string; + } | null = null; try { - 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(); + const sourceRect = sourceElement?.getBoundingClientRect() ?? null; apply(); await nextTick(); - calendarCameraState.value = { - active: true, - left: 0, - top: 0, - scale: 1, - durationMs: 0, - }; - await nextAnimationFrame(); - } finally { - await resetCalendarCamera(); - calendarZoomBusy.value = false; - } -} + const targetElement = resolveTarget(); + const targetRect = targetElement?.getBoundingClientRect() ?? null; + if (!targetElement || !isRenderableRect(sourceRect) || !isRenderableRect(targetRect)) return; -async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HTMLElement | null) { - clearCalendarZoomPrime(); - calendarZoomBusy.value = true; - try { - apply(); - await nextTick(); - const targetRect = getElementRectInScene(resolveTarget()) ?? fallbackZoomOriginRectInScene(); - const cameraStart = targetRect ? cameraTransformForRect(targetRect) : null; - if (!cameraStart) return; - calendarCameraState.value = { - active: true, - left: cameraStart.left, - top: cameraStart.top, - scale: cameraStart.scale, - durationMs: 0, + restoreSiblings = fadeOutCalendarSiblings(targetElement); + animatedElement = targetElement; + snapshot = { + transform: targetElement.style.transform, + transition: targetElement.style.transition, + transformOrigin: targetElement.style.transformOrigin, + willChange: targetElement.style.willChange, + zIndex: targetElement.style.zIndex, }; - await nextTick(); + + const dx = sourceRect.left - targetRect.left; + const dy = sourceRect.top - targetRect.top; + const sx = Math.max(0.01, sourceRect.width / targetRect.width); + const sy = Math.max(0.01, sourceRect.height / targetRect.height); + + targetElement.style.transformOrigin = "top left"; + targetElement.style.willChange = "transform"; + targetElement.style.zIndex = "24"; + targetElement.style.transition = "none"; + targetElement.style.transform = `translate3d(${dx}px, ${dy}px, 0px) scale(${sx}, ${sy})`; + targetElement.getBoundingClientRect(); await nextAnimationFrame(); - calendarSceneRef.value?.getBoundingClientRect(); - calendarCameraState.value = { - active: true, - left: 0, - top: 0, - scale: 1, - durationMs: CALENDAR_ZOOM_DURATION_MS, - }; - await waitCalendarCameraTransition(); + + targetElement.style.transition = `transform ${CALENDAR_ZOOM_DURATION_MS}ms cubic-bezier(0.16, 0.86, 0.18, 1)`; + targetElement.style.transform = "translate3d(0px, 0px, 0px) scale(1, 1)"; + await waitForTransformTransition(targetElement); } finally { - await resetCalendarCamera(); + if (animatedElement && snapshot) { + animatedElement.style.transform = snapshot.transform; + animatedElement.style.transition = snapshot.transition; + animatedElement.style.transformOrigin = snapshot.transformOrigin; + animatedElement.style.willChange = snapshot.willChange; + animatedElement.style.zIndex = snapshot.zIndex; + } + restoreSiblings(); calendarZoomBusy.value = false; } } @@ -2749,9 +2725,13 @@ async function zoomInCalendar(event?: Event) { queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`) ?? queryCalendarElement("[data-calendar-month-index]"); if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return; - await animateCalendarZoomIn(sourceElement, () => { - openYearMonth(monthIndex); - }); + await animateCalendarFlipTransition( + sourceElement, + () => { + openYearMonth(monthIndex); + }, + () => queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), + ); return; } @@ -2764,9 +2744,14 @@ async function zoomInCalendar(event?: Event) { queryCalendarElement("[data-calendar-week-start-key]") ?? queryCalendarElement("[data-calendar-day-key]"); if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return; - await animateCalendarZoomIn(sourceElement, () => { - openWeekView(anchorDayKey); - }); + const monthIndex = new Date(`${anchorDayKey}T00:00:00`).getMonth(); + await animateCalendarFlipTransition( + sourceElement, + () => { + openWeekView(anchorDayKey); + }, + () => queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), + ); return; } @@ -2774,16 +2759,25 @@ async function zoomInCalendar(event?: Event) { const dayAnchor = resolveDayAnchor(wheelEvent); const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`) ?? queryCalendarElement("[data-calendar-day-key]"); if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return; - await animateCalendarZoomIn(sourceElement, () => { - openDayView(dayAnchor); - }); + const monthIndex = new Date(`${dayAnchor}T00:00:00`).getMonth(); + await animateCalendarFlipTransition( + sourceElement, + () => { + openDayView(dayAnchor); + }, + () => queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), + ); } } async function zoomToMonth(monthIndex: number) { - await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => { - openYearMonth(monthIndex); - }); + await animateCalendarFlipTransition( + queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), + () => { + openYearMonth(monthIndex); + }, + () => queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), + ); } async function zoomOutCalendar() { @@ -2792,7 +2786,8 @@ async function zoomOutCalendar() { if (calendarView.value === "day") { const targetDayKey = selectedDateKey.value; - await animateCalendarZoomOut( + await animateCalendarFlipTransition( + queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`), () => { calendarView.value = "week"; }, @@ -2803,7 +2798,8 @@ async function zoomOutCalendar() { if (calendarView.value === "week") { const targetRowKey = weekRowStartForDate(selectedDateKey.value); - await animateCalendarZoomOut( + await animateCalendarFlipTransition( + queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`), () => { calendarView.value = "month"; }, @@ -2816,7 +2812,8 @@ async function zoomOutCalendar() { if (calendarView.value === "month" || calendarView.value === "agenda") { const targetMonthIndex = calendarCursor.value.getMonth(); - await animateCalendarZoomOut( + await animateCalendarFlipTransition( + queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`), () => { calendarView.value = "year"; }, diff --git a/frontend/app/components/workspace/calendar/CrmCalendarPanel.vue b/frontend/app/components/workspace/calendar/CrmCalendarPanel.vue index 0746084..f88bcb2 100644 --- a/frontend/app/components/workspace/calendar/CrmCalendarPanel.vue +++ b/frontend/app/components/workspace/calendar/CrmCalendarPanel.vue @@ -169,14 +169,16 @@ defineProps<{ :style="calendarSceneTransformStyle" @mouseleave="onCalendarSceneMouseLeave" > -
+