fix(calendar): restore GSAP fly-rect + fly-label animation in useCalendar composable

The refactoring in a4d8d81 moved calendar logic into useCalendar.ts but
used the old CSS-transform animation code instead of the GSAP-based
flying rect + flying label implementation. This restores:

- GSAP-based animateCalendarZoomIntoSource and animateCalendarFlipTransition
- Flying label that animates from card title → toolbar on zoom-in and back
- Clone-and-swap pattern with skeleton content in fly-rect (no text)
- Fly-rect/fly-label refs and setters now live in the composable
- isoWeekNumber() and weekNumber field on monthRows
- Sibling card titles and week numbers faded during zoom
- Removed old CSS-transform camera state and calendarSceneTransformStyle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-02-24 15:50:35 +07:00
parent 3775d881f9
commit c229bdee23
2 changed files with 373 additions and 238 deletions

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import gsap from "gsap";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect } from "vue";
import CrmAuthLoading from "~~/app/components/workspace/auth/CrmAuthLoading.vue";
import CrmCalendarPanel from "~~/app/components/workspace/calendar/CrmCalendarPanel.vue";
@@ -159,14 +158,22 @@ const {
calendarHoveredMonthIndex,
calendarHoveredWeekStartKey,
calendarHoveredDayKey,
calendarFlyRectRef,
calendarFlyVisible,
calendarFlyLabelRef,
calendarFlyLabelVisible,
calendarToolbarLabelRef,
calendarZoomBusy,
calendarZoomPrimeToken,
normalizedCalendarView,
calendarZoomLevelIndex,
calendarSceneTransformStyle,
calendarZoomOrder,
setCalendarContentWrapRef,
setCalendarContentScrollRef,
setCalendarSceneRef,
setCalendarFlyRectRef,
setCalendarFlyLabelRef,
setCalendarToolbarLabelRef,
setCalendarHoveredMonthIndex,
setCalendarHoveredWeekStartKey,
setCalendarHoveredDayKey,
@@ -210,13 +217,6 @@ const {
refetchCalendar,
} = calendar;
// Fly rect for zoom animation (kept in orchestrator as template ref)
const calendarFlyRectRef = ref<HTMLDivElement | null>(null);
const calendarFlyVisible = ref(false);
function setCalendarFlyRectRef(element: HTMLDivElement | null) {
calendarFlyRectRef.value = element;
}
// ---------------------------------------------------------------------------
// 5. Deals
// ---------------------------------------------------------------------------
@@ -1401,6 +1401,9 @@ onBeforeUnmount(() => {
:normalized-calendar-view="normalizedCalendarView"
:calendar-fly-visible="calendarFlyVisible"
:set-calendar-fly-rect-ref="setCalendarFlyRectRef"
:calendar-fly-label-visible="calendarFlyLabelVisible"
:set-calendar-fly-label-ref="setCalendarFlyLabelRef"
:set-calendar-toolbar-label-ref="setCalendarToolbarLabelRef"
:on-calendar-scene-mouse-leave="onCalendarSceneMouseLeave"
:calendar-view="calendarView"
:year-months="yearMonths"

View File

@@ -1,3 +1,4 @@
import gsap from "gsap";
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, type ComputedRef } from "vue";
import { useQuery, useMutation } from "@vue/apollo-composable";
import {
@@ -6,7 +7,6 @@ import {
ArchiveCalendarEventMutationDocument,
} from "~~/graphql/generated";
type CalendarHierarchyView = "year" | "month" | "week" | "day";
type CalendarRect = { left: number; top: number; width: number; height: number };
export type CalendarView = "day" | "week" | "month" | "year" | "agenda";
@@ -232,6 +232,7 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
calendarViewportResizeObserver.disconnect();
calendarViewportResizeObserver = null;
}
calendarKillTweens();
clearCalendarZoomPrime();
});
@@ -288,11 +289,21 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
const calendarContentWrapRef = ref<HTMLElement | null>(null);
const calendarContentScrollRef = ref<HTMLElement | null>(null);
const calendarSceneRef = ref<HTMLElement | null>(null);
const calendarFlyRectRef = ref<HTMLDivElement | null>(null);
const calendarFlyVisible = ref(false);
const calendarFlyLabelRef = ref<HTMLDivElement | null>(null);
const calendarFlyLabelVisible = ref(false);
const calendarToolbarLabelRef = ref<HTMLDivElement | null>(null);
const calendarViewportHeight = ref(0);
const calendarHoveredMonthIndex = ref<number | null>(null);
const calendarHoveredWeekStartKey = ref("");
const calendarHoveredDayKey = ref("");
let calendarViewportResizeObserver: ResizeObserver | null = null;
let calendarActiveTweens: gsap.core.Tween[] = [];
const CALENDAR_FLY_DURATION = 0.65;
const CALENDAR_FADE_DURATION = 0.18;
const CALENDAR_EASE = "power3.inOut";
function setCalendarContentWrapRef(element: HTMLElement | null) {
calendarContentWrapRef.value = element;
@@ -317,6 +328,18 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
calendarSceneRef.value = element;
}
function setCalendarFlyRectRef(element: HTMLDivElement | null) {
calendarFlyRectRef.value = element;
}
function setCalendarFlyLabelRef(element: HTMLDivElement | null) {
calendarFlyLabelRef.value = element;
}
function setCalendarToolbarLabelRef(element: HTMLDivElement | null) {
calendarToolbarLabelRef.value = element;
}
function setCalendarHoveredMonthIndex(value: number | null) {
calendarHoveredMonthIndex.value = value;
}
@@ -336,21 +359,31 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
clearCalendarZoomPrime();
}
function calendarTweenTo(target: gsap.TweenTarget, vars: gsap.TweenVars): Promise<void> {
return new Promise((resolve) => {
const t = gsap.to(target, {
...vars,
onComplete: () => {
calendarActiveTweens = calendarActiveTweens.filter((tw) => tw !== t);
resolve();
},
});
calendarActiveTweens.push(t);
});
}
function calendarKillTweens() {
for (const t of calendarActiveTweens) t.kill();
calendarActiveTweens = [];
}
const calendarZoomBusy = 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);
let calendarWheelLockUntil = 0;
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;
@@ -360,18 +393,6 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
calendarView.value === "agenda" ? "month" : calendarView.value,
);
const calendarZoomLevelIndex = computed(() => Math.max(0, calendarZoomOrder.indexOf(normalizedCalendarView.value)));
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 clearCalendarZoomPrime() {
if (calendarZoomPrimeTimer) {
@@ -432,194 +453,226 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
return calendarContentWrapRef.value?.querySelector<HTMLElement>(selector) ?? null;
}
function getCalendarViewportRect(): CalendarRect | null {
const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
if (!wrapRect) return null;
return {
left: 0,
top: 0,
width: Math.max(24, wrapRect.width),
height: Math.max(24, wrapRect.height),
};
}
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();
if (!wrapRect) return null;
const rect = element.getBoundingClientRect();
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 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: scroll.scrollLeft + Math.max(0, (viewport.width - width) / 2),
top: scroll.scrollTop + Math.max(0, (viewport.height - height) / 2),
width,
height,
};
}
function weekRowStartForDate(key: string) {
const date = new Date(`${key}T00:00:00`);
date.setDate(date.getDate() - date.getDay());
return dayKey(date);
}
function isoWeekNumber(dateString: string): number {
const d = new Date(`${dateString}T00:00:00`);
const t = new Date(d.getTime());
t.setDate(t.getDate() + 3 - ((t.getDay() + 6) % 7));
const y = new Date(t.getFullYear(), 0, 4);
return 1 + Math.round(((t.getTime() - y.getTime()) / 86400000 - 3 + ((y.getDay() + 6) % 7)) / 7);
}
function nextAnimationFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
function waitForTransformTransition(element: HTMLElement) {
return new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
element.removeEventListener("transitionend", onTransitionEnd);
clearTimeout(fallbackTimer);
resolve();
};
const onTransitionEnd = (event: TransitionEvent) => {
if (event.target !== element) return;
if (event.propertyName !== "transform") return;
finish();
};
const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 160);
element.addEventListener("transitionend", onTransitionEnd);
});
// ---------------------------------------------------------------------------
// GSAP animation helpers
// ---------------------------------------------------------------------------
function cloneElementStyleToFlyRect(source: HTMLElement, flyEl: HTMLElement) {
const s = getComputedStyle(source);
flyEl.style.borderColor = s.borderColor;
flyEl.style.borderWidth = s.borderWidth;
flyEl.style.borderStyle = s.borderStyle;
flyEl.style.backgroundColor = s.backgroundColor;
flyEl.style.borderRadius = s.borderRadius;
flyEl.style.boxShadow = s.boxShadow;
}
function fadeOutCalendarSiblings(sourceElement: HTMLElement) {
const scene = calendarSceneRef.value;
if (!scene) return () => {};
const targets = Array.from(scene.querySelectorAll<HTMLElement>(".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";
function resetFlyRectStyle(flyEl: HTMLElement) {
flyEl.style.borderColor = "";
flyEl.style.borderWidth = "";
flyEl.style.borderStyle = "";
flyEl.style.backgroundColor = "";
flyEl.style.borderRadius = "";
flyEl.style.boxShadow = "";
flyEl.innerHTML = "";
}
function extractSourceLabel(sourceElement: HTMLElement, viewBefore: string): string {
if (viewBefore === "year") {
const p = sourceElement.querySelector("p");
return p?.textContent?.trim() ?? "";
}
return () => {
for (const snapshot of snapshots) {
snapshot.element.style.opacity = snapshot.opacity;
snapshot.element.style.pointerEvents = snapshot.pointerEvents;
snapshot.element.style.transition = snapshot.transition;
}
};
if (viewBefore === "month" || viewBefore === "agenda") {
const wn = sourceElement.querySelector(".calendar-week-number");
return wn ? `Week ${wn.textContent?.trim()}` : "";
}
if (viewBefore === "week") {
const p = sourceElement.querySelector("p");
return p?.textContent?.trim() ?? "";
}
return "";
}
function isRenderableRect(rect: DOMRect | null) {
return Boolean(rect && rect.width >= 2 && rect.height >= 2);
function findSourceTitleElement(sourceElement: HTMLElement, viewBefore: string): HTMLElement | null {
if (viewBefore === "year") {
return sourceElement.parentElement?.querySelector<HTMLElement>(".calendar-card-title") ?? null;
}
if (viewBefore === "month" || viewBefore === "agenda") {
return sourceElement.querySelector<HTMLElement>(".calendar-week-number") ?? null;
}
if (viewBefore === "week") {
return sourceElement.parentElement?.querySelector<HTMLElement>(".calendar-card-title") ?? null;
}
return null;
}
function resetFlyLabelStyle(el: HTMLElement) {
el.textContent = "";
el.style.fontWeight = "";
el.style.color = "";
el.style.fontSize = "";
}
function buildFlyRectSkeletonContent(): string {
return `<div class="calendar-fly-content"><div class="calendar-fly-skeleton">
<div class="calendar-fly-skeleton-line" style="width:70%"></div>
<div class="calendar-fly-skeleton-line" style="width:45%"></div>
<div class="calendar-fly-skeleton-line" style="width:60%"></div>
</div></div>`;
}
// ---------------------------------------------------------------------------
// GSAP zoom animations
// ---------------------------------------------------------------------------
async function animateCalendarFlipTransition(
sourceElement: HTMLElement | null,
_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;
calendarKillTweens();
const flyEl = calendarFlyRectRef.value;
const wrapEl = calendarContentWrapRef.value;
const sceneEl = calendarSceneRef.value;
const flyLabelEl = calendarFlyLabelRef.value;
const toolbarLabelEl = calendarToolbarLabelRef.value;
if (!flyEl || !wrapEl) {
apply();
calendarZoomBusy.value = false;
return;
}
try {
const sourceRect = sourceElement?.getBoundingClientRect() ?? null;
const wrapRect = wrapEl.getBoundingClientRect();
const flyLabelText = calendarPeriodLabel.value;
// 1. Fade out current scene
if (sceneEl) {
await calendarTweenTo(sceneEl, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
}
// 2. Position fly rect at full viewport
gsap.set(flyEl, { left: 0, top: 0, width: wrapRect.width, height: wrapRect.height, opacity: 1 });
flyEl.style.borderRadius = "0.75rem";
flyEl.style.borderWidth = "1px";
flyEl.style.borderStyle = "solid";
flyEl.style.borderColor = "color-mix(in oklab, var(--color-base-300) 100%, transparent)";
flyEl.style.backgroundColor = "color-mix(in oklab, var(--color-base-100) 100%, transparent)";
flyEl.style.boxShadow = "";
flyEl.innerHTML = buildFlyRectSkeletonContent();
calendarFlyVisible.value = true;
// 3. Position flying label at toolbar
let flyLabelReady = false;
if (flyLabelEl && toolbarLabelEl && flyLabelText) {
const sectionRect = (flyLabelEl.offsetParent as HTMLElement | null)?.getBoundingClientRect();
if (sectionRect) {
const toolbarStyle = getComputedStyle(toolbarLabelEl);
const toolbarTextRange = document.createRange();
toolbarTextRange.selectNodeContents(toolbarLabelEl);
const toolbarTextRect = toolbarTextRange.getBoundingClientRect();
flyLabelEl.textContent = flyLabelText;
flyLabelEl.style.fontWeight = toolbarStyle.fontWeight;
flyLabelEl.style.color = toolbarStyle.color;
gsap.set(flyLabelEl, {
left: toolbarTextRect.left - sectionRect.left,
top: toolbarTextRect.top - sectionRect.top,
fontSize: parseFloat(toolbarStyle.fontSize),
opacity: 1,
});
toolbarLabelEl.style.opacity = "0";
calendarFlyLabelVisible.value = true;
flyLabelReady = true;
}
}
// 4. Switch to parent view
apply();
await nextTick();
const targetElement = resolveTarget();
const targetRect = targetElement?.getBoundingClientRect() ?? null;
if (!targetElement || !isRenderableRect(sourceRect) || !isRenderableRect(targetRect)) return;
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,
};
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();
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 {
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;
// 5. Find target element
const targetElement = resolveTarget();
const targetRect = targetElement?.getBoundingClientRect() ?? null;
const viewAfter = calendarView.value;
if (targetElement && targetRect && targetRect.width >= 2 && targetRect.height >= 2) {
cloneElementStyleToFlyRect(targetElement, flyEl);
targetElement.style.opacity = "0";
const tgtLeft = targetRect.left - wrapRect.left;
const tgtTop = targetRect.top - wrapRect.top;
const targetTitleEl = findSourceTitleElement(targetElement, viewAfter);
let flyLabelPromise: Promise<void> | null = null;
if (flyLabelReady && flyLabelEl && targetTitleEl) {
const sectionRect = (flyLabelEl.offsetParent as HTMLElement | null)?.getBoundingClientRect();
if (sectionRect) {
const titleRect = targetTitleEl.getBoundingClientRect();
const titleStyle = getComputedStyle(targetTitleEl);
targetTitleEl.style.opacity = "0";
flyLabelPromise = calendarTweenTo(flyLabelEl, {
left: titleRect.left - sectionRect.left,
top: titleRect.top - sectionRect.top,
fontSize: parseFloat(titleStyle.fontSize),
color: titleStyle.color,
duration: CALENDAR_FLY_DURATION,
ease: CALENDAR_EASE,
});
}
}
// 6. Animate fly rect → target (concurrent with label)
const flyRectPromise = calendarTweenTo(flyEl, {
left: tgtLeft, top: tgtTop,
width: targetRect.width, height: targetRect.height,
duration: CALENDAR_FLY_DURATION, ease: CALENDAR_EASE,
});
await Promise.all([flyRectPromise, flyLabelPromise].filter(Boolean));
targetElement.style.opacity = "";
if (targetTitleEl) targetTitleEl.style.opacity = "";
}
restoreSiblings();
// 7. Cleanup
calendarFlyLabelVisible.value = false;
if (flyLabelEl) resetFlyLabelStyle(flyLabelEl);
if (toolbarLabelEl) toolbarLabelEl.style.opacity = "";
calendarFlyVisible.value = false;
resetFlyRectStyle(flyEl);
if (sceneEl) {
gsap.set(sceneEl, { opacity: 0 });
await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
}
} finally {
calendarFlyVisible.value = false;
calendarFlyLabelVisible.value = false;
resetFlyRectStyle(flyEl);
if (flyLabelEl) resetFlyLabelStyle(flyLabelEl);
if (toolbarLabelEl) toolbarLabelEl.style.opacity = "";
calendarZoomBusy.value = false;
}
}
@@ -630,60 +683,135 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
) {
clearCalendarZoomPrime();
calendarZoomBusy.value = true;
let restoreSiblings = () => {};
let snapshot: {
transform: string;
transition: string;
transformOrigin: string;
willChange: string;
zIndex: string;
} | null = null;
calendarKillTweens();
const flyEl = calendarFlyRectRef.value;
const wrapEl = calendarContentWrapRef.value;
const scrollEl = calendarContentScrollRef.value;
const sceneEl = calendarSceneRef.value;
const flyLabelEl = calendarFlyLabelRef.value;
const toolbarLabelEl = calendarToolbarLabelRef.value;
if (!sourceElement || !flyEl || !wrapEl || !scrollEl) {
apply();
calendarZoomBusy.value = false;
return;
}
try {
const viewportRect = calendarContentScrollRef.value?.getBoundingClientRect() ?? null;
const sourceRect = sourceElement?.getBoundingClientRect() ?? null;
if (!sourceElement || !isRenderableRect(viewportRect) || !isRenderableRect(sourceRect)) {
const wrapRect = wrapEl.getBoundingClientRect();
const sourceRect = sourceElement.getBoundingClientRect();
if (sourceRect.width < 2 || sourceRect.height < 2) {
apply();
return;
}
restoreSiblings = fadeOutCalendarSiblings(sourceElement);
snapshot = {
transform: sourceElement.style.transform,
transition: sourceElement.style.transition,
transformOrigin: sourceElement.style.transformOrigin,
willChange: sourceElement.style.willChange,
zIndex: sourceElement.style.zIndex,
};
// 1. Find source title element and extract label
const viewBefore = calendarView.value;
const labelText = extractSourceLabel(sourceElement, viewBefore);
const sourceTitleEl = findSourceTitleElement(sourceElement, viewBefore);
const dx = viewportRect!.left - sourceRect!.left;
const dy = viewportRect!.top - sourceRect!.top;
const sx = Math.max(0.01, viewportRect!.width / sourceRect!.width);
const sy = Math.max(0.01, viewportRect!.height / sourceRect!.height);
// 2. Fade out siblings (cards + external titles + week numbers)
const allFadable = Array.from(
sceneEl?.querySelectorAll<HTMLElement>(".calendar-hover-targetable, .calendar-card-title, .calendar-week-number") ?? [],
);
const siblings = allFadable.filter(
(el) => el !== sourceElement && el !== sourceTitleEl
&& !sourceElement.contains(el) && !el.contains(sourceElement),
);
await calendarTweenTo(siblings, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
sourceElement.style.transformOrigin = "top left";
sourceElement.style.willChange = "transform";
sourceElement.style.zIndex = "24";
sourceElement.style.transition = "none";
sourceElement.style.transform = "translate3d(0px, 0px, 0px) scale(1, 1)";
sourceElement.getBoundingClientRect();
await nextAnimationFrame();
// 3. Fade out source children
const sourceChildren = Array.from(sourceElement.children) as HTMLElement[];
await calendarTweenTo(sourceChildren, { opacity: 0, duration: 0.12, ease: "power2.in" });
sourceElement.style.transition = `transform ${CALENDAR_ZOOM_DURATION_MS}ms cubic-bezier(0.16, 0.86, 0.18, 1)`;
sourceElement.style.transform = `translate3d(${dx}px, ${dy}px, 0px) scale(${sx}, ${sy})`;
await waitForTransformTransition(sourceElement);
// 4. Clone source style to fly-rect, inject skeleton
cloneElementStyleToFlyRect(sourceElement, flyEl);
flyEl.innerHTML = buildFlyRectSkeletonContent();
const srcLeft = sourceRect.left - wrapRect.left;
const srcTop = sourceRect.top - wrapRect.top;
gsap.set(flyEl, { left: srcLeft, top: srcTop, width: sourceRect.width, height: sourceRect.height, opacity: 1 });
// 5. Swap: hide source, show fly-rect
sourceElement.style.opacity = "0";
calendarFlyVisible.value = true;
// 6. Setup flying label: source title → toolbar
let flyLabelPromise: Promise<void> | null = null;
if (flyLabelEl && toolbarLabelEl && sourceTitleEl && labelText) {
const sectionRect = (flyLabelEl.offsetParent as HTMLElement | null)?.getBoundingClientRect();
if (sectionRect) {
const srcTitleRect = sourceTitleEl.getBoundingClientRect();
const srcTitleStyle = getComputedStyle(sourceTitleEl);
const toolbarStyle = getComputedStyle(toolbarLabelEl);
flyLabelEl.textContent = labelText;
flyLabelEl.style.fontWeight = srcTitleStyle.fontWeight;
flyLabelEl.style.color = srcTitleStyle.color;
gsap.set(flyLabelEl, {
left: srcTitleRect.left - sectionRect.left,
top: srcTitleRect.top - sectionRect.top,
fontSize: parseFloat(srcTitleStyle.fontSize),
opacity: 1,
});
sourceTitleEl.style.opacity = "0";
toolbarLabelEl.style.opacity = "0";
calendarFlyLabelVisible.value = true;
await nextAnimationFrame();
const endFontSize = parseFloat(toolbarStyle.fontSize);
const toolbarTextRange = document.createRange();
toolbarTextRange.selectNodeContents(toolbarLabelEl);
const toolbarTextRect = toolbarTextRange.getBoundingClientRect();
flyLabelPromise = calendarTweenTo(flyLabelEl, {
left: toolbarTextRect.left - sectionRect.left,
top: toolbarTextRect.top - sectionRect.top,
fontSize: endFontSize,
color: toolbarStyle.color,
duration: CALENDAR_FLY_DURATION,
ease: CALENDAR_EASE,
});
}
}
// 7. Animate fly-rect to viewport (concurrent with label)
const flyRectPromise = calendarTweenTo(flyEl, {
left: 0, top: 0, width: wrapRect.width, height: wrapRect.height,
duration: CALENDAR_FLY_DURATION, ease: CALENDAR_EASE,
});
await Promise.all([flyRectPromise, flyLabelPromise].filter(Boolean));
// 8. Cleanup flying label
calendarFlyLabelVisible.value = false;
if (flyLabelEl) resetFlyLabelStyle(flyLabelEl);
if (toolbarLabelEl) toolbarLabelEl.style.opacity = "";
if (sourceTitleEl) sourceTitleEl.style.opacity = "";
// 9. Switch view
apply();
await nextTick();
await nextAnimationFrame();
} finally {
if (sourceElement && snapshot) {
sourceElement.style.transform = snapshot.transform;
sourceElement.style.transition = snapshot.transition;
sourceElement.style.transformOrigin = snapshot.transformOrigin;
sourceElement.style.willChange = snapshot.willChange;
sourceElement.style.zIndex = snapshot.zIndex;
// 10. Hide fly-rect, fade in
calendarFlyVisible.value = false;
resetFlyRectStyle(flyEl);
if (sceneEl) {
gsap.set(sceneEl, { opacity: 0 });
await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
}
restoreSiblings();
// 11. Restore
sourceElement.style.opacity = "";
for (const child of sourceChildren) child.style.opacity = "";
for (const el of siblings) el.style.opacity = "";
} finally {
calendarFlyVisible.value = false;
calendarFlyLabelVisible.value = false;
resetFlyRectStyle(flyEl);
if (flyLabelEl) resetFlyLabelStyle(flyLabelEl);
if (toolbarLabelEl) toolbarLabelEl.style.opacity = "";
calendarZoomBusy.value = false;
}
}
@@ -879,13 +1007,15 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
});
const monthRows = computed(() => {
const rows: Array<{ key: string; startKey: string; cells: typeof monthCells.value }> = [];
const rows: Array<{ key: string; startKey: string; weekNumber: number; cells: typeof monthCells.value }> = [];
for (let index = 0; index < monthCells.value.length; index += 7) {
const cells = monthCells.value.slice(index, index + 7);
if (!cells.length) continue;
const startKey = cells[0]?.key ?? selectedDateKey.value;
rows.push({
key: `${cells[0]?.key ?? index}-week-row`,
startKey: cells[0]?.key ?? selectedDateKey.value,
startKey,
weekNumber: isoWeekNumber(startKey),
cells,
});
}
@@ -1217,40 +1347,42 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
calendarContentWrapRef,
calendarContentScrollRef,
calendarSceneRef,
calendarFlyRectRef,
calendarFlyVisible,
calendarFlyLabelRef,
calendarFlyLabelVisible,
calendarToolbarLabelRef,
calendarViewportHeight,
calendarHoveredMonthIndex,
calendarHoveredWeekStartKey,
calendarHoveredDayKey,
calendarZoomBusy,
calendarCameraState,
calendarZoomPrimeToken,
calendarZoomPrimeScale,
calendarZoomPrimeTicks,
normalizedCalendarView,
calendarZoomLevelIndex,
calendarSceneTransformStyle,
calendarZoomOrder,
// Zoom / camera setters
setCalendarContentWrapRef,
setCalendarContentScrollRef,
setCalendarSceneRef,
setCalendarFlyRectRef,
setCalendarFlyLabelRef,
setCalendarToolbarLabelRef,
setCalendarHoveredMonthIndex,
setCalendarHoveredWeekStartKey,
setCalendarHoveredDayKey,
onCalendarSceneMouseLeave,
clearCalendarZoomPrime,
calendarKillTweens,
calendarPrimeMonthToken,
calendarPrimeWeekToken,
calendarPrimeDayToken,
calendarPrimeStyle,
maybePrimeWheelZoom,
queryCalendarElement,
getCalendarViewportRect,
getCalendarCameraViewportRect,
getElementRectInCalendar,
getElementRectInScene,
fallbackZoomOriginRectInScene,
weekRowStartForDate,
// Zoom animations