feat(calendar): replace CSS-transform zoom with GSAP flying-rect animation and scope data to year
- Add CalendarDateRange input to GraphQL schema; server resolver now accepts from/to params - Frontend query sends year-scoped date range variables reactively - Rewrite zoom-in/zoom-out animations using GSAP flying-rect overlay (650ms vs 2400ms) - Add flying-rect element to CrmCalendarPanel with proper CSS - Remove old calendarSceneTransformStyle CSS-transition approach - Add calendarKillTweens cleanup in onBeforeUnmount Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import gsap from "gsap";
|
||||
import { nextTick, onBeforeUnmount, onMounted } from "vue";
|
||||
import CrmAuthLoading from "~~/app/components/workspace/auth/CrmAuthLoading.vue";
|
||||
import CrmCalendarPanel from "~~/app/components/workspace/calendar/CrmCalendarPanel.vue";
|
||||
@@ -596,9 +597,14 @@ const { result: contactInboxesResult, refetch: refetchContactInboxes } = useQuer
|
||||
{ enabled: apolloAuthReady },
|
||||
);
|
||||
|
||||
const calendarQueryYear = ref(new Date().getFullYear());
|
||||
const calendarQueryVars = computed(() => ({
|
||||
from: new Date(calendarQueryYear.value, 0, 1).toISOString(),
|
||||
to: new Date(calendarQueryYear.value + 1, 0, 1).toISOString(),
|
||||
}));
|
||||
const { result: calendarResult, refetch: refetchCalendar } = useQuery(
|
||||
CalendarQueryDocument,
|
||||
null,
|
||||
calendarQueryVars,
|
||||
{ enabled: apolloAuthReady },
|
||||
);
|
||||
|
||||
@@ -2395,6 +2401,7 @@ onBeforeUnmount(() => {
|
||||
calendarViewportResizeObserver = null;
|
||||
}
|
||||
clearCalendarZoomPrime();
|
||||
calendarKillTweens();
|
||||
});
|
||||
|
||||
const calendarView = ref<CalendarView>("year");
|
||||
@@ -2444,7 +2451,10 @@ type CalendarRect = { left: number; top: number; width: number; height: number }
|
||||
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 calendarViewportHeight = ref(0);
|
||||
let calendarActiveTweens: gsap.core.Tween[] = [];
|
||||
const calendarHoveredMonthIndex = ref<number | null>(null);
|
||||
const calendarHoveredWeekStartKey = ref("");
|
||||
const calendarHoveredDayKey = ref("");
|
||||
@@ -2454,6 +2464,28 @@ function setCalendarContentWrapRef(element: HTMLElement | null) {
|
||||
calendarContentWrapRef.value = element;
|
||||
}
|
||||
|
||||
function setCalendarFlyRectRef(element: HTMLDivElement | null) {
|
||||
calendarFlyRectRef.value = element;
|
||||
}
|
||||
|
||||
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 = [];
|
||||
}
|
||||
|
||||
function setCalendarContentScrollRef(element: HTMLElement | null) {
|
||||
if (calendarViewportResizeObserver) {
|
||||
calendarViewportResizeObserver.disconnect();
|
||||
@@ -2492,20 +2524,16 @@ function onCalendarSceneMouseLeave() {
|
||||
clearCalendarZoomPrime();
|
||||
}
|
||||
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_DURATION_MS = 650;
|
||||
const CALENDAR_FLY_DURATION = 0.65;
|
||||
const CALENDAR_FADE_DURATION = 0.18;
|
||||
const CALENDAR_EASE = "power3.inOut";
|
||||
const CALENDAR_ZOOM_PRIME_STEPS = 2;
|
||||
const CALENDAR_ZOOM_PRIME_MAX_SCALE = 1.05;
|
||||
const CALENDAR_ZOOM_PRIME_RESET_MS = 900;
|
||||
@@ -2515,18 +2543,6 @@ const normalizedCalendarView = computed<CalendarHierarchyView>(() =>
|
||||
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) {
|
||||
@@ -2662,119 +2678,78 @@ function nextAnimationFrame() {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
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) {
|
||||
return Boolean(rect && rect.width >= 2 && rect.height >= 2);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!flyEl || !wrapEl) {
|
||||
apply();
|
||||
calendarZoomBusy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceRect = sourceElement?.getBoundingClientRect() ?? null;
|
||||
const wrapRect = wrapEl.getBoundingClientRect();
|
||||
|
||||
// 1. Fade out current content
|
||||
if (sceneEl) {
|
||||
await calendarTweenTo(sceneEl, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
|
||||
}
|
||||
|
||||
// 2. Position fly rect at full viewport
|
||||
const pad = 4;
|
||||
gsap.set(flyEl, {
|
||||
left: pad,
|
||||
top: pad,
|
||||
width: wrapRect.width - pad * 2,
|
||||
height: wrapRect.height - pad * 2,
|
||||
opacity: 1,
|
||||
borderRadius: 14,
|
||||
});
|
||||
calendarFlyVisible.value = true;
|
||||
|
||||
// 3. Switch to parent view
|
||||
apply();
|
||||
await nextTick();
|
||||
|
||||
// 4. Find target element in new view
|
||||
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,
|
||||
};
|
||||
if (targetElement && targetRect && targetRect.width >= 2 && targetRect.height >= 2) {
|
||||
const tgtLeft = targetRect.left - wrapRect.left;
|
||||
const tgtTop = targetRect.top - wrapRect.top;
|
||||
|
||||
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. Animate fly rect → target element
|
||||
await calendarTweenTo(flyEl, {
|
||||
left: tgtLeft,
|
||||
top: tgtTop,
|
||||
width: targetRect.width,
|
||||
height: targetRect.height,
|
||||
borderRadius: 12,
|
||||
duration: CALENDAR_FLY_DURATION,
|
||||
ease: CALENDAR_EASE,
|
||||
});
|
||||
}
|
||||
restoreSiblings();
|
||||
|
||||
// 6. Hide fly rect, fade in content
|
||||
calendarFlyVisible.value = false;
|
||||
if (sceneEl) {
|
||||
gsap.set(sceneEl, { opacity: 0 });
|
||||
await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
|
||||
}
|
||||
} finally {
|
||||
calendarFlyVisible.value = false;
|
||||
calendarZoomBusy.value = false;
|
||||
}
|
||||
}
|
||||
@@ -2785,60 +2760,75 @@ async function animateCalendarZoomIntoSource(
|
||||
) {
|
||||
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;
|
||||
|
||||
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. Fade out siblings
|
||||
const siblings = Array.from(
|
||||
sceneEl?.querySelectorAll<HTMLElement>(".calendar-hover-targetable") ?? [],
|
||||
).filter((el) => el !== sourceElement && !sourceElement.contains(el) && !el.contains(sourceElement));
|
||||
await calendarTweenTo(siblings, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
|
||||
|
||||
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. Position fly rect at source element
|
||||
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,
|
||||
borderRadius: 12,
|
||||
});
|
||||
calendarFlyVisible.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();
|
||||
|
||||
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);
|
||||
// 3. Animate fly rect → full viewport
|
||||
const pad = 4;
|
||||
await calendarTweenTo(flyEl, {
|
||||
left: pad,
|
||||
top: pad,
|
||||
width: wrapRect.width - pad * 2,
|
||||
height: wrapRect.height - pad * 2,
|
||||
borderRadius: 14,
|
||||
duration: CALENDAR_FLY_DURATION,
|
||||
ease: CALENDAR_EASE,
|
||||
});
|
||||
|
||||
// 4. Switch content
|
||||
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;
|
||||
|
||||
// 5. Hide fly rect, fade in new content
|
||||
calendarFlyVisible.value = false;
|
||||
if (sceneEl) {
|
||||
gsap.set(sceneEl, { opacity: 0 });
|
||||
await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
|
||||
}
|
||||
restoreSiblings();
|
||||
|
||||
// 6. Restore sibling opacity
|
||||
for (const el of siblings) {
|
||||
el.style.opacity = "";
|
||||
}
|
||||
} finally {
|
||||
calendarFlyVisible.value = false;
|
||||
calendarZoomBusy.value = false;
|
||||
}
|
||||
}
|
||||
@@ -4876,7 +4866,8 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
:set-calendar-scene-ref="setCalendarSceneRef"
|
||||
:calendar-viewport-height="calendarViewportHeight"
|
||||
:normalized-calendar-view="normalizedCalendarView"
|
||||
:calendar-scene-transform-style="calendarSceneTransformStyle"
|
||||
:calendar-fly-visible="calendarFlyVisible"
|
||||
:set-calendar-fly-rect-ref="setCalendarFlyRectRef"
|
||||
:on-calendar-scene-mouse-leave="onCalendarSceneMouseLeave"
|
||||
:calendar-view="calendarView"
|
||||
:year-months="yearMonths"
|
||||
|
||||
Reference in New Issue
Block a user