From e5ad3809e0a5016ac4d0e72c181f3ab9bf48e7bd Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:03:16 +0700 Subject: [PATCH] feat(calendar): flying label animation from card title to toolbar on zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/workspace/CrmWorkspaceApp.vue | 248 ++++++++++++++---- .../workspace/calendar/CrmCalendarPanel.vue | 30 ++- 2 files changed, 212 insertions(+), 66 deletions(-) diff --git a/frontend/app/components/workspace/CrmWorkspaceApp.vue b/frontend/app/components/workspace/CrmWorkspaceApp.vue index ff537d7..24b7154 100644 --- a/frontend/app/components/workspace/CrmWorkspaceApp.vue +++ b/frontend/app/components/workspace/CrmWorkspaceApp.vue @@ -2468,6 +2468,18 @@ function setCalendarFlyRectRef(element: HTMLDivElement | null) { calendarFlyRectRef.value = element; } +const calendarFlyLabelRef = ref(null); +const calendarFlyLabelVisible = ref(false); +const calendarToolbarLabelRef = ref(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 { 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 = ` -
+function findSourceTitleElement(sourceElement: HTMLElement, viewBefore: string): HTMLElement | null { + if (viewBefore === "year") { + return sourceElement.parentElement?.querySelector(".calendar-card-title") ?? null; + } + if (viewBefore === "month" || viewBefore === "agenda") { + return sourceElement.querySelector(".calendar-week-number") ?? null; + } + if (viewBefore === "week") { + return sourceElement.parentElement?.querySelector(".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 `
-
- `; - return `

${labelText}

${skeleton}
`; -} - -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); +
`; } 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 | 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(".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(".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 | 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" diff --git a/frontend/app/components/workspace/calendar/CrmCalendarPanel.vue b/frontend/app/components/workspace/calendar/CrmCalendarPanel.vue index 97a7e84..63f8828 100644 --- a/frontend/app/components/workspace/calendar/CrmCalendarPanel.vue +++ b/frontend/app/components/workspace/calendar/CrmCalendarPanel.vue @@ -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; }>(); @@ -103,7 +106,7 @@ defineProps<{ -
+
{{ calendarPeriodLabel }}
@@ -141,6 +144,13 @@ defineProps<{

{{ focusedCalendarEvent.note || "No note" }}

+ +
+