feat(calendar): seamless zoom animation with clone-and-swap + full-area coverage

Zoom-in: fade siblings → fade source content → clone source style to fly-rect →
hide source → animate fly-rect to viewport → switch view → fade in new content.

Zoom-out: fade scene → show fly-rect at viewport → switch view → clone target
style → animate fly-rect to target → fade in scene.

Full-area: all views (month/week/day) now fill 100% of container height.
Month grid rows stretch equally, day cells fill row height, depth layers flex.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-02-24 11:56:42 +07:00
parent 227030b9ae
commit 77141978c5
2 changed files with 74 additions and 25 deletions

View File

@@ -2678,6 +2678,24 @@ function nextAnimationFrame() {
});
}
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 resetFlyRectStyle(flyEl: HTMLElement) {
flyEl.style.borderColor = "";
flyEl.style.borderWidth = "";
flyEl.style.borderStyle = "";
flyEl.style.backgroundColor = "";
flyEl.style.borderRadius = "";
flyEl.style.boxShadow = "";
}
async function animateCalendarFlipTransition(
_sourceElement: HTMLElement | null,
@@ -2701,55 +2719,71 @@ async function animateCalendarFlipTransition(
try {
const wrapRect = wrapEl.getBoundingClientRect();
// 1. Fade out current content
// 1. Fade out current scene 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;
// 2. Position fly rect at full viewport, styled like a card
const pad = 0;
gsap.set(flyEl, {
left: pad,
top: pad,
width: wrapRect.width - pad * 2,
height: wrapRect.height - pad * 2,
opacity: 1,
borderRadius: 14,
});
// Apply card-like styling: border + bg matching article cards
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 = "";
calendarFlyVisible.value = true;
// 3. Switch to parent view
apply();
await nextTick();
await nextAnimationFrame();
// 4. Find target element in new view
const targetElement = resolveTarget();
const targetRect = targetElement?.getBoundingClientRect() ?? null;
if (targetElement && targetRect && targetRect.width >= 2 && targetRect.height >= 2) {
// Clone target's visual style to fly rect for seamless landing
cloneElementStyleToFlyRect(targetElement, flyEl);
// Hide target so there's no double
targetElement.style.opacity = "0";
const tgtLeft = targetRect.left - wrapRect.left;
const tgtTop = targetRect.top - wrapRect.top;
// 5. Animate fly rect → target element
// 5. Animate fly rect → target element bounds
await calendarTweenTo(flyEl, {
left: tgtLeft,
top: tgtTop,
width: targetRect.width,
height: targetRect.height,
borderRadius: 12,
duration: CALENDAR_FLY_DURATION,
ease: CALENDAR_EASE,
});
// Restore target visibility
targetElement.style.opacity = "";
}
// 6. Hide fly rect, fade in content
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;
resetFlyRectStyle(flyEl);
calendarZoomBusy.value = false;
}
}
@@ -2787,7 +2821,12 @@ async function animateCalendarZoomIntoSource(
).filter((el) => el !== sourceElement && !sourceElement.contains(el) && !el.contains(sourceElement));
await calendarTweenTo(siblings, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
// 2. Position fly rect at source element
// 2. Fade out source element's inner content (keep border/bg visible)
const sourceChildren = Array.from(sourceElement.children) as HTMLElement[];
await calendarTweenTo(sourceChildren, { opacity: 0, duration: 0.12, ease: "power2.in" });
// 3. Clone source visual style to fly-rect, position at source bounds
cloneElementStyleToFlyRect(sourceElement, flyEl);
const srcLeft = sourceRect.left - wrapRect.left;
const srcTop = sourceRect.top - wrapRect.top;
gsap.set(flyEl, {
@@ -2796,39 +2835,46 @@ async function animateCalendarZoomIntoSource(
width: sourceRect.width,
height: sourceRect.height,
opacity: 1,
borderRadius: 12,
});
// 4. Swap: hide source, show fly-rect (seamless — identical visual)
sourceElement.style.opacity = "0";
calendarFlyVisible.value = true;
// 3. Animate fly rect full viewport
const pad = 4;
// 5. Animate fly-rect expanding to full viewport
const pad = 0;
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
// 6. Switch view content
apply();
await nextTick();
// 5. Hide fly rect, fade in new content
// 7. Hide fly-rect, fade in new content
calendarFlyVisible.value = false;
resetFlyRectStyle(flyEl);
if (sceneEl) {
gsap.set(sceneEl, { opacity: 0 });
await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
}
// 6. Restore sibling opacity
// 8. Restore source + siblings
sourceElement.style.opacity = "";
for (const child of sourceChildren) {
child.style.opacity = "";
}
for (const el of siblings) {
el.style.opacity = "";
}
} finally {
calendarFlyVisible.value = false;
resetFlyRectStyle(flyEl);
calendarZoomBusy.value = false;
}
}