feat(calendar): flying label animation from card title to toolbar on zoom
The label (month name, week number, day label) now animates from its position above the source card to the toolbar center on zoom-in, and flies back from toolbar to the target card title on zoom-out. The fly-rect rectangle no longer contains text — only skeleton placeholder lines. Sibling card titles and week numbers also fade during zoom. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2468,6 +2468,18 @@ function setCalendarFlyRectRef(element: HTMLDivElement | null) {
|
||||
calendarFlyRectRef.value = element;
|
||||
}
|
||||
|
||||
const calendarFlyLabelRef = ref<HTMLDivElement | null>(null);
|
||||
const calendarFlyLabelVisible = ref(false);
|
||||
const calendarToolbarLabelRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
function setCalendarFlyLabelRef(element: HTMLDivElement | null) {
|
||||
calendarFlyLabelRef.value = element;
|
||||
}
|
||||
|
||||
function setCalendarToolbarLabelRef(element: HTMLDivElement | null) {
|
||||
calendarToolbarLabelRef.value = element;
|
||||
}
|
||||
|
||||
function calendarTweenTo(target: gsap.TweenTarget, vars: gsap.TweenVars): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const t = gsap.to(target, {
|
||||
@@ -2722,29 +2734,32 @@ function extractSourceLabel(sourceElement: HTMLElement, viewBefore: string): str
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildFlyRectContent(labelText: string): string {
|
||||
const skeleton = `
|
||||
<div class="calendar-fly-skeleton">
|
||||
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>
|
||||
`;
|
||||
return `<div class="calendar-fly-content"><p class="calendar-fly-label">${labelText}</p>${skeleton}</div>`;
|
||||
}
|
||||
|
||||
function buildFlyRectContentForZoomOut(currentView: string): string {
|
||||
let labelText = "";
|
||||
if (currentView === "day") {
|
||||
const d = new Date(`${selectedDateKey.value}T00:00:00`);
|
||||
labelText = new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(d) + " " + d.getDate();
|
||||
} else if (currentView === "week") {
|
||||
const weekStart = weekRowStartForDate(selectedDateKey.value);
|
||||
labelText = `Week ${isoWeekNumber(weekStart)}`;
|
||||
} else if (currentView === "month" || currentView === "agenda") {
|
||||
labelText = new Intl.DateTimeFormat("en-US", { month: "long" }).format(calendarCursor.value);
|
||||
}
|
||||
return buildFlyRectContent(labelText);
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
async function animateCalendarFlipTransition(
|
||||
@@ -2759,6 +2774,8 @@ async function animateCalendarFlipTransition(
|
||||
const flyEl = calendarFlyRectRef.value;
|
||||
const wrapEl = calendarContentWrapRef.value;
|
||||
const sceneEl = calendarSceneRef.value;
|
||||
const flyLabelEl = calendarFlyLabelRef.value;
|
||||
const toolbarLabelEl = calendarToolbarLabelRef.value;
|
||||
|
||||
if (!flyEl || !wrapEl) {
|
||||
apply();
|
||||
@@ -2769,51 +2786,97 @@ async function animateCalendarFlipTransition(
|
||||
try {
|
||||
const wrapRect = wrapEl.getBoundingClientRect();
|
||||
|
||||
// Capture toolbar label text BEFORE view change
|
||||
const flyLabelText = calendarPeriodLabel.value;
|
||||
|
||||
// 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, styled like a card
|
||||
const pad = 0;
|
||||
gsap.set(flyEl, {
|
||||
left: pad,
|
||||
top: pad,
|
||||
width: wrapRect.width - pad * 2,
|
||||
height: wrapRect.height - pad * 2,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: wrapRect.width,
|
||||
height: wrapRect.height,
|
||||
opacity: 1,
|
||||
});
|
||||
// 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 = "";
|
||||
// Inject label + skeleton for zoom-out
|
||||
flyEl.innerHTML = buildFlyRectContentForZoomOut(calendarView.value);
|
||||
flyEl.innerHTML = buildFlyRectSkeletonContent();
|
||||
calendarFlyVisible.value = true;
|
||||
|
||||
// 3. Switch to parent view
|
||||
// 3. Position flying label at toolbar position
|
||||
let flyLabelReady = false;
|
||||
if (flyLabelEl && toolbarLabelEl && flyLabelText) {
|
||||
const sectionRect = (flyLabelEl.offsetParent as HTMLElement | null)?.getBoundingClientRect();
|
||||
if (sectionRect) {
|
||||
const toolbarStyle = getComputedStyle(toolbarLabelEl);
|
||||
// Measure exact text position (centered via text-align)
|
||||
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();
|
||||
await nextAnimationFrame();
|
||||
|
||||
// 4. Find target element in new view
|
||||
// 5. Find target element in new view
|
||||
const targetElement = resolveTarget();
|
||||
const targetRect = targetElement?.getBoundingClientRect() ?? null;
|
||||
const viewAfter = calendarView.value;
|
||||
|
||||
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 bounds
|
||||
await calendarTweenTo(flyEl, {
|
||||
// Find target's title element for fly-label destination
|
||||
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 bounds (concurrent with label flight)
|
||||
const flyRectPromise = calendarTweenTo(flyEl, {
|
||||
left: tgtLeft,
|
||||
top: tgtTop,
|
||||
width: targetRect.width,
|
||||
@@ -2822,11 +2885,19 @@ async function animateCalendarFlipTransition(
|
||||
ease: CALENDAR_EASE,
|
||||
});
|
||||
|
||||
// Restore target visibility
|
||||
await Promise.all([flyRectPromise, flyLabelPromise].filter(Boolean));
|
||||
|
||||
// Restore visibility
|
||||
targetElement.style.opacity = "";
|
||||
if (targetTitleEl) targetTitleEl.style.opacity = "";
|
||||
}
|
||||
|
||||
// 6. Hide fly rect, fade in content
|
||||
// 7. Cleanup flying label
|
||||
calendarFlyLabelVisible.value = false;
|
||||
if (flyLabelEl) resetFlyLabelStyle(flyLabelEl);
|
||||
if (toolbarLabelEl) toolbarLabelEl.style.opacity = "";
|
||||
|
||||
// 8. Hide fly rect, fade in content
|
||||
calendarFlyVisible.value = false;
|
||||
resetFlyRectStyle(flyEl);
|
||||
if (sceneEl) {
|
||||
@@ -2835,7 +2906,10 @@ async function animateCalendarFlipTransition(
|
||||
}
|
||||
} finally {
|
||||
calendarFlyVisible.value = false;
|
||||
calendarFlyLabelVisible.value = false;
|
||||
resetFlyRectStyle(flyEl);
|
||||
if (flyLabelEl) resetFlyLabelStyle(flyLabelEl);
|
||||
if (toolbarLabelEl) toolbarLabelEl.style.opacity = "";
|
||||
calendarZoomBusy.value = false;
|
||||
}
|
||||
}
|
||||
@@ -2852,6 +2926,8 @@ async function animateCalendarZoomIntoSource(
|
||||
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();
|
||||
@@ -2867,22 +2943,28 @@ async function animateCalendarZoomIntoSource(
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Extract label before fading children
|
||||
const labelText = extractSourceLabel(sourceElement, calendarView.value);
|
||||
// 1. Find source title element and extract label text
|
||||
const viewBefore = calendarView.value;
|
||||
const labelText = extractSourceLabel(sourceElement, viewBefore);
|
||||
const sourceTitleEl = findSourceTitleElement(sourceElement, viewBefore);
|
||||
|
||||
// 2. Fade out siblings
|
||||
const siblings = Array.from(
|
||||
sceneEl?.querySelectorAll<HTMLElement>(".calendar-hover-targetable") ?? [],
|
||||
).filter((el) => el !== sourceElement && !sourceElement.contains(el) && !el.contains(sourceElement));
|
||||
// 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" });
|
||||
|
||||
// 3. 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" });
|
||||
|
||||
// 4. Clone source visual style to fly-rect, inject label + skeleton
|
||||
// 4. Clone source visual style to fly-rect, inject skeleton (no label text)
|
||||
cloneElementStyleToFlyRect(sourceElement, flyEl);
|
||||
flyEl.innerHTML = buildFlyRectContent(labelText);
|
||||
flyEl.innerHTML = buildFlyRectSkeletonContent();
|
||||
const srcLeft = sourceRect.left - wrapRect.left;
|
||||
const srcTop = sourceRect.top - wrapRect.top;
|
||||
gsap.set(flyEl, {
|
||||
@@ -2893,26 +2975,76 @@ async function animateCalendarZoomIntoSource(
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
// 4. Swap: hide source, show fly-rect (seamless — identical visual)
|
||||
// 5. Swap: hide source, show fly-rect
|
||||
sourceElement.style.opacity = "0";
|
||||
calendarFlyVisible.value = true;
|
||||
|
||||
// 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,
|
||||
// 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);
|
||||
|
||||
// Position fly label at source title
|
||||
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,
|
||||
});
|
||||
|
||||
// Hide source title and toolbar label
|
||||
sourceTitleEl.style.opacity = "0";
|
||||
toolbarLabelEl.style.opacity = "0";
|
||||
calendarFlyLabelVisible.value = true;
|
||||
|
||||
// Compute end position: exact text position in toolbar (centered)
|
||||
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 expanding to full viewport (concurrent with label flight)
|
||||
const flyRectPromise = calendarTweenTo(flyEl, {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: wrapRect.width,
|
||||
height: wrapRect.height,
|
||||
duration: CALENDAR_FLY_DURATION,
|
||||
ease: CALENDAR_EASE,
|
||||
});
|
||||
|
||||
// 6. Switch view content
|
||||
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 content
|
||||
apply();
|
||||
await nextTick();
|
||||
|
||||
// 7. Hide fly-rect, fade in new content
|
||||
// 10. Hide fly-rect, fade in new content
|
||||
calendarFlyVisible.value = false;
|
||||
resetFlyRectStyle(flyEl);
|
||||
if (sceneEl) {
|
||||
@@ -2920,7 +3052,7 @@ async function animateCalendarZoomIntoSource(
|
||||
await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
|
||||
}
|
||||
|
||||
// 8. Restore source + siblings
|
||||
// 11. Restore source + siblings
|
||||
sourceElement.style.opacity = "";
|
||||
for (const child of sourceChildren) {
|
||||
child.style.opacity = "";
|
||||
@@ -2930,7 +3062,10 @@ async function animateCalendarZoomIntoSource(
|
||||
}
|
||||
} finally {
|
||||
calendarFlyVisible.value = false;
|
||||
calendarFlyLabelVisible.value = false;
|
||||
resetFlyRectStyle(flyEl);
|
||||
if (flyLabelEl) resetFlyLabelStyle(flyLabelEl);
|
||||
if (toolbarLabelEl) toolbarLabelEl.style.opacity = "";
|
||||
calendarZoomBusy.value = false;
|
||||
}
|
||||
}
|
||||
@@ -4972,6 +5107,9 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
: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"
|
||||
|
||||
@@ -82,6 +82,9 @@ defineProps<{
|
||||
selectedDayEvents: CalendarEvent[];
|
||||
calendarFlyVisible: boolean;
|
||||
setCalendarFlyRectRef: (element: HTMLDivElement | null) => void;
|
||||
calendarFlyLabelVisible: boolean;
|
||||
setCalendarFlyLabelRef: (element: HTMLDivElement | null) => void;
|
||||
setCalendarToolbarLabelRef: (element: HTMLDivElement | null) => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -103,7 +106,7 @@ defineProps<{
|
||||
<button class="btn btn-xs" @click="setToday">Today</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-sm font-medium">
|
||||
<div :ref="setCalendarToolbarLabelRef" class="text-center text-sm font-medium">
|
||||
{{ calendarPeriodLabel }}
|
||||
</div>
|
||||
|
||||
@@ -141,6 +144,13 @@ defineProps<{
|
||||
<p class="mt-1 text-xs text-base-content/80">{{ focusedCalendarEvent.note || "No note" }}</p>
|
||||
</article>
|
||||
|
||||
<!-- GSAP flying label (title transition overlay) -->
|
||||
<div
|
||||
v-show="calendarFlyLabelVisible"
|
||||
:ref="setCalendarFlyLabelRef"
|
||||
class="calendar-fly-label-el"
|
||||
/>
|
||||
|
||||
<div :ref="setCalendarContentWrapRef" class="calendar-content-wrap min-h-0 flex-1">
|
||||
<button
|
||||
class="calendar-side-nav calendar-side-nav-left"
|
||||
@@ -499,6 +509,14 @@ defineProps<{
|
||||
will-change: left, top, width, height;
|
||||
}
|
||||
|
||||
.calendar-fly-label-el {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
will-change: left, top, font-size;
|
||||
}
|
||||
|
||||
.calendar-zoom-inline {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -612,16 +630,6 @@ defineProps<{
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-fly-rect .calendar-fly-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-base-content);
|
||||
margin: 0 0 12px 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.calendar-fly-rect .calendar-fly-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user