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"> <script setup lang="ts">
import gsap from "gsap";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect } from "vue"; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect } from "vue";
import CrmAuthLoading from "~~/app/components/workspace/auth/CrmAuthLoading.vue"; import CrmAuthLoading from "~~/app/components/workspace/auth/CrmAuthLoading.vue";
import CrmCalendarPanel from "~~/app/components/workspace/calendar/CrmCalendarPanel.vue"; import CrmCalendarPanel from "~~/app/components/workspace/calendar/CrmCalendarPanel.vue";
@@ -159,14 +158,22 @@ const {
calendarHoveredMonthIndex, calendarHoveredMonthIndex,
calendarHoveredWeekStartKey, calendarHoveredWeekStartKey,
calendarHoveredDayKey, calendarHoveredDayKey,
calendarFlyRectRef,
calendarFlyVisible,
calendarFlyLabelRef,
calendarFlyLabelVisible,
calendarToolbarLabelRef,
calendarZoomBusy, calendarZoomBusy,
calendarZoomPrimeToken,
normalizedCalendarView, normalizedCalendarView,
calendarZoomLevelIndex, calendarZoomLevelIndex,
calendarSceneTransformStyle,
calendarZoomOrder, calendarZoomOrder,
setCalendarContentWrapRef, setCalendarContentWrapRef,
setCalendarContentScrollRef, setCalendarContentScrollRef,
setCalendarSceneRef, setCalendarSceneRef,
setCalendarFlyRectRef,
setCalendarFlyLabelRef,
setCalendarToolbarLabelRef,
setCalendarHoveredMonthIndex, setCalendarHoveredMonthIndex,
setCalendarHoveredWeekStartKey, setCalendarHoveredWeekStartKey,
setCalendarHoveredDayKey, setCalendarHoveredDayKey,
@@ -210,13 +217,6 @@ const {
refetchCalendar, refetchCalendar,
} = calendar; } = 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 // 5. Deals
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1401,6 +1401,9 @@ onBeforeUnmount(() => {
:normalized-calendar-view="normalizedCalendarView" :normalized-calendar-view="normalizedCalendarView"
:calendar-fly-visible="calendarFlyVisible" :calendar-fly-visible="calendarFlyVisible"
:set-calendar-fly-rect-ref="setCalendarFlyRectRef" :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" :on-calendar-scene-mouse-leave="onCalendarSceneMouseLeave"
:calendar-view="calendarView" :calendar-view="calendarView"
:year-months="yearMonths" :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 { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, type ComputedRef } from "vue";
import { useQuery, useMutation } from "@vue/apollo-composable"; import { useQuery, useMutation } from "@vue/apollo-composable";
import { import {
@@ -6,7 +7,6 @@ import {
ArchiveCalendarEventMutationDocument, ArchiveCalendarEventMutationDocument,
} from "~~/graphql/generated"; } from "~~/graphql/generated";
type CalendarHierarchyView = "year" | "month" | "week" | "day"; type CalendarHierarchyView = "year" | "month" | "week" | "day";
type CalendarRect = { left: number; top: number; width: number; height: number };
export type CalendarView = "day" | "week" | "month" | "year" | "agenda"; export type CalendarView = "day" | "week" | "month" | "year" | "agenda";
@@ -232,6 +232,7 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
calendarViewportResizeObserver.disconnect(); calendarViewportResizeObserver.disconnect();
calendarViewportResizeObserver = null; calendarViewportResizeObserver = null;
} }
calendarKillTweens();
clearCalendarZoomPrime(); clearCalendarZoomPrime();
}); });
@@ -288,11 +289,21 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
const calendarContentWrapRef = ref<HTMLElement | null>(null); const calendarContentWrapRef = ref<HTMLElement | null>(null);
const calendarContentScrollRef = ref<HTMLElement | null>(null); const calendarContentScrollRef = ref<HTMLElement | null>(null);
const calendarSceneRef = 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 calendarViewportHeight = ref(0);
const calendarHoveredMonthIndex = ref<number | null>(null); const calendarHoveredMonthIndex = ref<number | null>(null);
const calendarHoveredWeekStartKey = ref(""); const calendarHoveredWeekStartKey = ref("");
const calendarHoveredDayKey = ref(""); const calendarHoveredDayKey = ref("");
let calendarViewportResizeObserver: ResizeObserver | null = null; 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) { function setCalendarContentWrapRef(element: HTMLElement | null) {
calendarContentWrapRef.value = element; calendarContentWrapRef.value = element;
@@ -317,6 +328,18 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
calendarSceneRef.value = element; 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) { function setCalendarHoveredMonthIndex(value: number | null) {
calendarHoveredMonthIndex.value = value; calendarHoveredMonthIndex.value = value;
} }
@@ -336,21 +359,31 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
clearCalendarZoomPrime(); clearCalendarZoomPrime();
} }
const calendarZoomBusy = ref(false); function calendarTweenTo(target: gsap.TweenTarget, vars: gsap.TweenVars): Promise<void> {
const calendarCameraState = ref({ return new Promise((resolve) => {
active: false, const t = gsap.to(target, {
left: 0, ...vars,
top: 0, onComplete: () => {
scale: 1, calendarActiveTweens = calendarActiveTweens.filter((tw) => tw !== t);
durationMs: 0, resolve();
},
}); });
calendarActiveTweens.push(t);
});
}
function calendarKillTweens() {
for (const t of calendarActiveTweens) t.kill();
calendarActiveTweens = [];
}
const calendarZoomBusy = ref(false);
const calendarZoomPrimeToken = ref(""); const calendarZoomPrimeToken = ref("");
const calendarZoomPrimeScale = ref(1); const calendarZoomPrimeScale = ref(1);
const calendarZoomPrimeTicks = ref(0); const calendarZoomPrimeTicks = ref(0);
let calendarWheelLockUntil = 0; let calendarWheelLockUntil = 0;
let calendarZoomPrimeTimer: ReturnType<typeof setTimeout> | null = null; let calendarZoomPrimeTimer: ReturnType<typeof setTimeout> | null = null;
let calendarZoomPrimeLastAt = 0; let calendarZoomPrimeLastAt = 0;
const CALENDAR_ZOOM_DURATION_MS = 2400;
const CALENDAR_ZOOM_PRIME_STEPS = 2; const CALENDAR_ZOOM_PRIME_STEPS = 2;
const CALENDAR_ZOOM_PRIME_MAX_SCALE = 1.05; const CALENDAR_ZOOM_PRIME_MAX_SCALE = 1.05;
const CALENDAR_ZOOM_PRIME_RESET_MS = 900; const CALENDAR_ZOOM_PRIME_RESET_MS = 900;
@@ -360,18 +393,6 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
calendarView.value === "agenda" ? "month" : calendarView.value, calendarView.value === "agenda" ? "month" : calendarView.value,
); );
const calendarZoomLevelIndex = computed(() => Math.max(0, calendarZoomOrder.indexOf(normalizedCalendarView.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() { function clearCalendarZoomPrime() {
if (calendarZoomPrimeTimer) { if (calendarZoomPrimeTimer) {
@@ -432,194 +453,226 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
return calendarContentWrapRef.value?.querySelector<HTMLElement>(selector) ?? null; 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) { function weekRowStartForDate(key: string) {
const date = new Date(`${key}T00:00:00`); const date = new Date(`${key}T00:00:00`);
date.setDate(date.getDate() - date.getDay()); date.setDate(date.getDate() - date.getDay());
return dayKey(date); 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() { function nextAnimationFrame() {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve()); requestAnimationFrame(() => resolve());
}); });
} }
function waitForTransformTransition(element: HTMLElement) { // ---------------------------------------------------------------------------
return new Promise<void>((resolve) => { // GSAP animation helpers
let settled = false; // ---------------------------------------------------------------------------
const finish = () => { function cloneElementStyleToFlyRect(source: HTMLElement, flyEl: HTMLElement) {
if (settled) return; const s = getComputedStyle(source);
settled = true; flyEl.style.borderColor = s.borderColor;
element.removeEventListener("transitionend", onTransitionEnd); flyEl.style.borderWidth = s.borderWidth;
clearTimeout(fallbackTimer); flyEl.style.borderStyle = s.borderStyle;
resolve(); flyEl.style.backgroundColor = s.backgroundColor;
}; flyEl.style.borderRadius = s.borderRadius;
const onTransitionEnd = (event: TransitionEvent) => { flyEl.style.boxShadow = s.boxShadow;
if (event.target !== element) return;
if (event.propertyName !== "transform") return;
finish();
};
const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 160);
element.addEventListener("transitionend", onTransitionEnd);
});
} }
function fadeOutCalendarSiblings(sourceElement: HTMLElement) { function resetFlyRectStyle(flyEl: HTMLElement) {
const scene = calendarSceneRef.value; flyEl.style.borderColor = "";
if (!scene) return () => {}; flyEl.style.borderWidth = "";
const targets = Array.from(scene.querySelectorAll<HTMLElement>(".calendar-hover-targetable")); flyEl.style.borderStyle = "";
const siblings = targets.filter((element) => { flyEl.style.backgroundColor = "";
if (element === sourceElement) return false; flyEl.style.borderRadius = "";
if (sourceElement.contains(element)) return false; flyEl.style.boxShadow = "";
if (element.contains(sourceElement)) return false; flyEl.innerHTML = "";
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;
}
};
} }
function isRenderableRect(rect: DOMRect | null) { function extractSourceLabel(sourceElement: HTMLElement, viewBefore: string): string {
return Boolean(rect && rect.width >= 2 && rect.height >= 2); if (viewBefore === "year") {
const p = sourceElement.querySelector("p");
return p?.textContent?.trim() ?? "";
}
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 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( async function animateCalendarFlipTransition(
sourceElement: HTMLElement | null, _sourceElement: HTMLElement | null,
apply: () => void, apply: () => void,
resolveTarget: () => HTMLElement | null, resolveTarget: () => HTMLElement | null,
) { ) {
clearCalendarZoomPrime(); clearCalendarZoomPrime();
calendarZoomBusy.value = true; calendarZoomBusy.value = true;
let restoreSiblings = () => {}; calendarKillTweens();
let animatedElement: HTMLElement | null = null;
let snapshot: { const flyEl = calendarFlyRectRef.value;
transform: string; const wrapEl = calendarContentWrapRef.value;
transition: string; const sceneEl = calendarSceneRef.value;
transformOrigin: string; const flyLabelEl = calendarFlyLabelRef.value;
willChange: string; const toolbarLabelEl = calendarToolbarLabelRef.value;
zIndex: string;
} | null = null; if (!flyEl || !wrapEl) {
apply();
calendarZoomBusy.value = false;
return;
}
try { 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(); apply();
await nextTick(); 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(); await nextAnimationFrame();
targetElement.style.transition = `transform ${CALENDAR_ZOOM_DURATION_MS}ms cubic-bezier(0.16, 0.86, 0.18, 1)`; // 5. Find target element
targetElement.style.transform = "translate3d(0px, 0px, 0px) scale(1, 1)"; const targetElement = resolveTarget();
await waitForTransformTransition(targetElement); const targetRect = targetElement?.getBoundingClientRect() ?? null;
} finally { const viewAfter = calendarView.value;
if (animatedElement && snapshot) {
animatedElement.style.transform = snapshot.transform; if (targetElement && targetRect && targetRect.width >= 2 && targetRect.height >= 2) {
animatedElement.style.transition = snapshot.transition; cloneElementStyleToFlyRect(targetElement, flyEl);
animatedElement.style.transformOrigin = snapshot.transformOrigin; targetElement.style.opacity = "0";
animatedElement.style.willChange = snapshot.willChange;
animatedElement.style.zIndex = snapshot.zIndex; 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,
});
} }
restoreSiblings(); }
// 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 = "";
}
// 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; calendarZoomBusy.value = false;
} }
} }
@@ -630,60 +683,135 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
) { ) {
clearCalendarZoomPrime(); clearCalendarZoomPrime();
calendarZoomBusy.value = true; calendarZoomBusy.value = true;
let restoreSiblings = () => {}; calendarKillTweens();
let snapshot: {
transform: string; const flyEl = calendarFlyRectRef.value;
transition: string; const wrapEl = calendarContentWrapRef.value;
transformOrigin: string; const scrollEl = calendarContentScrollRef.value;
willChange: string; const sceneEl = calendarSceneRef.value;
zIndex: string; const flyLabelEl = calendarFlyLabelRef.value;
} | null = null; const toolbarLabelEl = calendarToolbarLabelRef.value;
if (!sourceElement || !flyEl || !wrapEl || !scrollEl) {
apply();
calendarZoomBusy.value = false;
return;
}
try { try {
const viewportRect = calendarContentScrollRef.value?.getBoundingClientRect() ?? null; const wrapRect = wrapEl.getBoundingClientRect();
const sourceRect = sourceElement?.getBoundingClientRect() ?? null; const sourceRect = sourceElement.getBoundingClientRect();
if (!sourceElement || !isRenderableRect(viewportRect) || !isRenderableRect(sourceRect)) { if (sourceRect.width < 2 || sourceRect.height < 2) {
apply(); apply();
return; return;
} }
restoreSiblings = fadeOutCalendarSiblings(sourceElement); // 1. Find source title element and extract label
snapshot = { const viewBefore = calendarView.value;
transform: sourceElement.style.transform, const labelText = extractSourceLabel(sourceElement, viewBefore);
transition: sourceElement.style.transition, const sourceTitleEl = findSourceTitleElement(sourceElement, viewBefore);
transformOrigin: sourceElement.style.transformOrigin,
willChange: sourceElement.style.willChange,
zIndex: sourceElement.style.zIndex,
};
const dx = viewportRect!.left - sourceRect!.left; // 2. Fade out siblings (cards + external titles + week numbers)
const dy = viewportRect!.top - sourceRect!.top; const allFadable = Array.from(
const sx = Math.max(0.01, viewportRect!.width / sourceRect!.width); sceneEl?.querySelectorAll<HTMLElement>(".calendar-hover-targetable, .calendar-card-title, .calendar-week-number") ?? [],
const sy = Math.max(0.01, viewportRect!.height / sourceRect!.height); );
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" });
// 3. Fade out source children
const sourceChildren = Array.from(sourceElement.children) as HTMLElement[];
await calendarTweenTo(sourceChildren, { opacity: 0, duration: 0.12, ease: "power2.in" });
// 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;
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(); await nextAnimationFrame();
const endFontSize = parseFloat(toolbarStyle.fontSize);
const toolbarTextRange = document.createRange();
toolbarTextRange.selectNodeContents(toolbarLabelEl);
const toolbarTextRect = toolbarTextRange.getBoundingClientRect();
sourceElement.style.transition = `transform ${CALENDAR_ZOOM_DURATION_MS}ms cubic-bezier(0.16, 0.86, 0.18, 1)`; flyLabelPromise = calendarTweenTo(flyLabelEl, {
sourceElement.style.transform = `translate3d(${dx}px, ${dy}px, 0px) scale(${sx}, ${sy})`; left: toolbarTextRect.left - sectionRect.left,
await waitForTransformTransition(sourceElement); 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(); apply();
await nextTick(); await nextTick();
await nextAnimationFrame();
} finally { // 10. Hide fly-rect, fade in
if (sourceElement && snapshot) { calendarFlyVisible.value = false;
sourceElement.style.transform = snapshot.transform; resetFlyRectStyle(flyEl);
sourceElement.style.transition = snapshot.transition; if (sceneEl) {
sourceElement.style.transformOrigin = snapshot.transformOrigin; gsap.set(sceneEl, { opacity: 0 });
sourceElement.style.willChange = snapshot.willChange; await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
sourceElement.style.zIndex = snapshot.zIndex;
} }
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; calendarZoomBusy.value = false;
} }
} }
@@ -879,13 +1007,15 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
}); });
const monthRows = computed(() => { 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) { for (let index = 0; index < monthCells.value.length; index += 7) {
const cells = monthCells.value.slice(index, index + 7); const cells = monthCells.value.slice(index, index + 7);
if (!cells.length) continue; if (!cells.length) continue;
const startKey = cells[0]?.key ?? selectedDateKey.value;
rows.push({ rows.push({
key: `${cells[0]?.key ?? index}-week-row`, key: `${cells[0]?.key ?? index}-week-row`,
startKey: cells[0]?.key ?? selectedDateKey.value, startKey,
weekNumber: isoWeekNumber(startKey),
cells, cells,
}); });
} }
@@ -1217,40 +1347,42 @@ export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
calendarContentWrapRef, calendarContentWrapRef,
calendarContentScrollRef, calendarContentScrollRef,
calendarSceneRef, calendarSceneRef,
calendarFlyRectRef,
calendarFlyVisible,
calendarFlyLabelRef,
calendarFlyLabelVisible,
calendarToolbarLabelRef,
calendarViewportHeight, calendarViewportHeight,
calendarHoveredMonthIndex, calendarHoveredMonthIndex,
calendarHoveredWeekStartKey, calendarHoveredWeekStartKey,
calendarHoveredDayKey, calendarHoveredDayKey,
calendarZoomBusy, calendarZoomBusy,
calendarCameraState,
calendarZoomPrimeToken, calendarZoomPrimeToken,
calendarZoomPrimeScale, calendarZoomPrimeScale,
calendarZoomPrimeTicks, calendarZoomPrimeTicks,
normalizedCalendarView, normalizedCalendarView,
calendarZoomLevelIndex, calendarZoomLevelIndex,
calendarSceneTransformStyle,
calendarZoomOrder, calendarZoomOrder,
// Zoom / camera setters // Zoom / camera setters
setCalendarContentWrapRef, setCalendarContentWrapRef,
setCalendarContentScrollRef, setCalendarContentScrollRef,
setCalendarSceneRef, setCalendarSceneRef,
setCalendarFlyRectRef,
setCalendarFlyLabelRef,
setCalendarToolbarLabelRef,
setCalendarHoveredMonthIndex, setCalendarHoveredMonthIndex,
setCalendarHoveredWeekStartKey, setCalendarHoveredWeekStartKey,
setCalendarHoveredDayKey, setCalendarHoveredDayKey,
onCalendarSceneMouseLeave, onCalendarSceneMouseLeave,
clearCalendarZoomPrime, clearCalendarZoomPrime,
calendarKillTweens,
calendarPrimeMonthToken, calendarPrimeMonthToken,
calendarPrimeWeekToken, calendarPrimeWeekToken,
calendarPrimeDayToken, calendarPrimeDayToken,
calendarPrimeStyle, calendarPrimeStyle,
maybePrimeWheelZoom, maybePrimeWheelZoom,
queryCalendarElement, queryCalendarElement,
getCalendarViewportRect,
getCalendarCameraViewportRect,
getElementRectInCalendar,
getElementRectInScene,
fallbackZoomOriginRectInScene,
weekRowStartForDate, weekRowStartForDate,
// Zoom animations // Zoom animations