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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user