calendar: enforce two-phase zoom-in morph before view switch

This commit is contained in:
Ruslan Bakiev
2026-02-23 09:15:12 +07:00
parent eb298e786e
commit ab5370c831

View File

@@ -2281,6 +2281,7 @@ type CalendarZoomGhost = {
}; };
const calendarContentWrapRef = ref<HTMLElement | null>(null); const calendarContentWrapRef = ref<HTMLElement | null>(null);
const calendarZoomOverlayRef = ref<HTMLElement | null>(null);
const calendarHoveredMonthIndex = ref<number | null>(null); const calendarHoveredMonthIndex = ref<number | null>(null);
const calendarHoveredWeekStartKey = ref(""); const calendarHoveredWeekStartKey = ref("");
const calendarHoveredDayKey = ref(""); const calendarHoveredDayKey = ref("");
@@ -2400,13 +2401,29 @@ function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | n
const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect(); const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
if (!wrapRect) return null; if (!wrapRect) return null;
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const left = Math.max(0, rect.left - wrapRect.left); const left = Math.max(0, Math.min(rect.left - wrapRect.left, wrapRect.width));
const top = Math.max(0, rect.top - wrapRect.top); const top = Math.max(0, Math.min(rect.top - wrapRect.top, wrapRect.height));
const width = Math.max(24, Math.min(rect.width, wrapRect.width - left)); const right = Math.max(0, Math.min(rect.right - wrapRect.left, wrapRect.width));
const height = Math.max(24, Math.min(rect.height, wrapRect.height - top)); 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 }; return { left, top, width, height };
} }
function fallbackZoomOriginRect(viewportRect: CalendarRect): CalendarRect {
const width = Math.max(96, Math.round(viewportRect.width * 0.28));
const height = Math.max(64, Math.round(viewportRect.height * 0.24));
return {
left: Math.max(0, Math.round((viewportRect.width - width) / 2)),
top: Math.max(0, Math.round((viewportRect.height - height) / 2)),
width: Math.min(width, viewportRect.width),
height: Math.min(height, viewportRect.height),
};
}
function weekRowStartForDate(key: string) { function weekRowStartForDate(key: string) {
const date = new Date(`${key}T00:00:00`); const date = new Date(`${key}T00:00:00`);
date.setDate(date.getDate() - date.getDay()); date.setDate(date.getDate() - date.getDay());
@@ -2490,7 +2507,6 @@ function primeCalendarRect(rect: CalendarRect) {
} }
function morphCalendarRect(toRect: CalendarRect) { function morphCalendarRect(toRect: CalendarRect) {
requestAnimationFrame(() => {
calendarZoomOverlay.value = { calendarZoomOverlay.value = {
active: true, active: true,
left: toRect.left, left: toRect.left,
@@ -2498,22 +2514,54 @@ function morphCalendarRect(toRect: CalendarRect) {
width: toRect.width, width: toRect.width,
height: toRect.height, height: toRect.height,
}; };
}
function nextAnimationFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
}); });
} }
function waitCalendarZoom() { async function flushCalendarZoomStartFrame() {
await nextTick();
await nextAnimationFrame();
calendarZoomOverlayRef.value?.getBoundingClientRect();
await nextAnimationFrame();
}
function waitCalendarZoomTransition() {
const overlay = calendarZoomOverlayRef.value;
if (!overlay) {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS); setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS);
}); });
} }
return new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
overlay.removeEventListener("transitionend", onTransitionEnd);
clearTimeout(fallbackTimer);
resolve();
};
const onTransitionEnd = (event: TransitionEvent) => {
if (event.target !== overlay) return;
if (!["left", "top", "width", "height"].includes(event.propertyName)) return;
finish();
};
const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 140);
overlay.addEventListener("transitionend", onTransitionEnd);
});
}
async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: CalendarZoomGhost, apply: () => void) { async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: CalendarZoomGhost, apply: () => void) {
const fromRect = getElementRectInCalendar(sourceElement);
const viewportRect = getCalendarViewportRect(); const viewportRect = getCalendarViewportRect();
if (!fromRect || !viewportRect) { if (!viewportRect) {
apply(); apply();
return; return;
} }
const fromRect = getElementRectInCalendar(sourceElement) ?? fallbackZoomOriginRect(viewportRect);
clearCalendarZoomPrime(); clearCalendarZoomPrime();
calendarZoomBusy.value = true; calendarZoomBusy.value = true;
@@ -2521,9 +2569,9 @@ async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: C
primeCalendarRect(fromRect); primeCalendarRect(fromRect);
calendarZoomGhost.value = ghost; calendarZoomGhost.value = ghost;
calendarSceneMasked.value = true; calendarSceneMasked.value = true;
await nextTick(); await flushCalendarZoomStartFrame();
morphCalendarRect(viewportRect); morphCalendarRect(viewportRect);
await waitCalendarZoom(); await waitCalendarZoomTransition();
apply(); apply();
await nextTick(); await nextTick();
} finally { } finally {
@@ -2546,7 +2594,7 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
primeCalendarRect(viewportRect); primeCalendarRect(viewportRect);
calendarZoomGhost.value = zoomGhostForCurrentView(); calendarZoomGhost.value = zoomGhostForCurrentView();
calendarSceneMasked.value = true; calendarSceneMasked.value = true;
await nextTick(); await flushCalendarZoomStartFrame();
apply(); apply();
await nextTick(); await nextTick();
const targetRect = getElementRectInCalendar(resolveTarget()); const targetRect = getElementRectInCalendar(resolveTarget());
@@ -2558,7 +2606,7 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
return; return;
} }
morphCalendarRect(targetRect); morphCalendarRect(targetRect);
await waitCalendarZoom(); await waitCalendarZoomTransition();
} finally { } finally {
clearCalendarZoomOverlay(); clearCalendarZoomOverlay();
calendarSceneMasked.value = false; calendarSceneMasked.value = false;
@@ -4964,6 +5012,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</div> </div>
<div <div
v-if="calendarZoomOverlay.active" v-if="calendarZoomOverlay.active"
ref="calendarZoomOverlayRef"
class="calendar-zoom-overlay" class="calendar-zoom-overlay"
:style="calendarZoomOverlayStyle" :style="calendarZoomOverlayStyle"
> >