fix(calendar): make nested block zoom smooth in both directions

This commit is contained in:
Ruslan Bakiev
2026-02-23 14:33:24 +07:00
parent 6ad53e64c5
commit db49c4a830
2 changed files with 120 additions and 121 deletions

View File

@@ -2565,143 +2565,119 @@ function nextAnimationFrame() {
}); });
} }
function waitCalendarCameraTransition() { function waitForTransformTransition(element: HTMLElement) {
const scene = calendarSceneRef.value;
if (!scene) {
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS);
});
}
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
let settled = false; let settled = false;
const finish = () => { const finish = () => {
if (settled) return; if (settled) return;
settled = true; settled = true;
scene.removeEventListener("transitionend", onTransitionEnd); element.removeEventListener("transitionend", onTransitionEnd);
clearTimeout(fallbackTimer); clearTimeout(fallbackTimer);
resolve(); resolve();
}; };
const onTransitionEnd = (event: TransitionEvent) => { const onTransitionEnd = (event: TransitionEvent) => {
if (event.target !== scene) return; if (event.target !== element) return;
if (event.propertyName !== "transform") return; if (event.propertyName !== "transform") return;
finish(); finish();
}; };
const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 160); const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 160);
scene.addEventListener("transitionend", onTransitionEnd); element.addEventListener("transitionend", onTransitionEnd);
}); });
} }
function cameraTransformForRect(rect: CalendarRect) { function fadeOutCalendarSiblings(sourceElement: HTMLElement) {
const viewport = getCalendarCameraViewportRect(); const scene = calendarSceneRef.value;
if (!viewport) return null; if (!scene) return () => {};
const availableWidth = Math.max(24, viewport.width - 24); const targets = Array.from(scene.querySelectorAll<HTMLElement>(".calendar-hover-targetable"));
const availableHeight = Math.max(24, viewport.height - 24); const siblings = targets.filter((element) => {
const fitScale = Math.min(availableWidth / rect.width, availableHeight / rect.height); if (element === sourceElement) return false;
const scale = Math.max(1, Math.min(8, fitScale)); if (sourceElement.contains(element)) return false;
const targetLeft = (viewport.width - rect.width * scale) / 2; if (element.contains(sourceElement)) return false;
const targetTop = (viewport.height - rect.height * scale) / 2; return true;
return { });
left: Math.round(targetLeft - rect.left * scale), const snapshots = siblings.map((element) => ({
top: Math.round(targetTop - rect.top * scale), element,
scale, 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;
}
}; };
} }
async function resetCalendarCamera() { function isRenderableRect(rect: DOMRect | null) {
calendarCameraState.value = { return Boolean(rect && rect.width >= 2 && rect.height >= 2);
active: true,
left: 0,
top: 0,
scale: 1,
durationMs: 0,
};
await nextTick();
await nextAnimationFrame();
calendarCameraState.value = {
active: false,
left: 0,
top: 0,
scale: 1,
durationMs: 0,
};
} }
async function animateCalendarZoomIn( async function animateCalendarFlipTransition(
sourceElement: HTMLElement | null, sourceElement: HTMLElement | null,
apply: () => void, apply: () => void,
resolveTarget: () => HTMLElement | null,
) { ) {
clearCalendarZoomPrime(); clearCalendarZoomPrime();
calendarZoomBusy.value = true; calendarZoomBusy.value = true;
let restoreSiblings = () => {};
let animatedElement: HTMLElement | null = null;
let snapshot: {
transform: string;
transition: string;
transformOrigin: string;
willChange: string;
zIndex: string;
} | null = null;
try { try {
const fromRect = getElementRectInScene(sourceElement) ?? fallbackZoomOriginRectInScene(); const sourceRect = sourceElement?.getBoundingClientRect() ?? null;
const cameraTarget = fromRect ? cameraTransformForRect(fromRect) : null;
if (!cameraTarget) {
apply();
return;
}
calendarCameraState.value = {
active: true,
left: 0,
top: 0,
scale: 1,
durationMs: 0,
};
await nextTick();
await nextAnimationFrame();
calendarSceneRef.value?.getBoundingClientRect();
calendarCameraState.value = {
active: true,
left: cameraTarget.left,
top: cameraTarget.top,
scale: cameraTarget.scale,
durationMs: CALENDAR_ZOOM_DURATION_MS,
};
await waitCalendarCameraTransition();
apply(); apply();
await nextTick(); await nextTick();
calendarCameraState.value = { const targetElement = resolveTarget();
active: true, const targetRect = targetElement?.getBoundingClientRect() ?? null;
left: 0, if (!targetElement || !isRenderableRect(sourceRect) || !isRenderableRect(targetRect)) return;
top: 0,
scale: 1,
durationMs: 0,
};
await nextAnimationFrame();
} finally {
await resetCalendarCamera();
calendarZoomBusy.value = false;
}
}
async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HTMLElement | null) { restoreSiblings = fadeOutCalendarSiblings(targetElement);
clearCalendarZoomPrime(); animatedElement = targetElement;
calendarZoomBusy.value = true; snapshot = {
try { transform: targetElement.style.transform,
apply(); transition: targetElement.style.transition,
await nextTick(); transformOrigin: targetElement.style.transformOrigin,
const targetRect = getElementRectInScene(resolveTarget()) ?? fallbackZoomOriginRectInScene(); willChange: targetElement.style.willChange,
const cameraStart = targetRect ? cameraTransformForRect(targetRect) : null; zIndex: targetElement.style.zIndex,
if (!cameraStart) return;
calendarCameraState.value = {
active: true,
left: cameraStart.left,
top: cameraStart.top,
scale: cameraStart.scale,
durationMs: 0,
}; };
await nextTick();
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(); await nextAnimationFrame();
calendarSceneRef.value?.getBoundingClientRect();
calendarCameraState.value = { targetElement.style.transition = `transform ${CALENDAR_ZOOM_DURATION_MS}ms cubic-bezier(0.16, 0.86, 0.18, 1)`;
active: true, targetElement.style.transform = "translate3d(0px, 0px, 0px) scale(1, 1)";
left: 0, await waitForTransformTransition(targetElement);
top: 0,
scale: 1,
durationMs: CALENDAR_ZOOM_DURATION_MS,
};
await waitCalendarCameraTransition();
} finally { } finally {
await resetCalendarCamera(); 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;
}
restoreSiblings();
calendarZoomBusy.value = false; calendarZoomBusy.value = false;
} }
} }
@@ -2749,9 +2725,13 @@ async function zoomInCalendar(event?: Event) {
queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`) ?? queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`) ??
queryCalendarElement("[data-calendar-month-index]"); queryCalendarElement("[data-calendar-month-index]");
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return; if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return;
await animateCalendarZoomIn(sourceElement, () => { await animateCalendarFlipTransition(
sourceElement,
() => {
openYearMonth(monthIndex); openYearMonth(monthIndex);
}); },
() => queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`),
);
return; return;
} }
@@ -2764,9 +2744,14 @@ async function zoomInCalendar(event?: Event) {
queryCalendarElement("[data-calendar-week-start-key]") ?? queryCalendarElement("[data-calendar-week-start-key]") ??
queryCalendarElement("[data-calendar-day-key]"); queryCalendarElement("[data-calendar-day-key]");
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return; if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return;
await animateCalendarZoomIn(sourceElement, () => { const monthIndex = new Date(`${anchorDayKey}T00:00:00`).getMonth();
await animateCalendarFlipTransition(
sourceElement,
() => {
openWeekView(anchorDayKey); openWeekView(anchorDayKey);
}); },
() => queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`),
);
return; return;
} }
@@ -2774,16 +2759,25 @@ async function zoomInCalendar(event?: Event) {
const dayAnchor = resolveDayAnchor(wheelEvent); const dayAnchor = resolveDayAnchor(wheelEvent);
const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`) ?? queryCalendarElement("[data-calendar-day-key]"); const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`) ?? queryCalendarElement("[data-calendar-day-key]");
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return; if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return;
await animateCalendarZoomIn(sourceElement, () => { const monthIndex = new Date(`${dayAnchor}T00:00:00`).getMonth();
await animateCalendarFlipTransition(
sourceElement,
() => {
openDayView(dayAnchor); openDayView(dayAnchor);
}); },
() => queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`),
);
} }
} }
async function zoomToMonth(monthIndex: number) { async function zoomToMonth(monthIndex: number) {
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => { await animateCalendarFlipTransition(
queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`),
() => {
openYearMonth(monthIndex); openYearMonth(monthIndex);
}); },
() => queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`),
);
} }
async function zoomOutCalendar() { async function zoomOutCalendar() {
@@ -2792,7 +2786,8 @@ async function zoomOutCalendar() {
if (calendarView.value === "day") { if (calendarView.value === "day") {
const targetDayKey = selectedDateKey.value; const targetDayKey = selectedDateKey.value;
await animateCalendarZoomOut( await animateCalendarFlipTransition(
queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`),
() => { () => {
calendarView.value = "week"; calendarView.value = "week";
}, },
@@ -2803,7 +2798,8 @@ async function zoomOutCalendar() {
if (calendarView.value === "week") { if (calendarView.value === "week") {
const targetRowKey = weekRowStartForDate(selectedDateKey.value); const targetRowKey = weekRowStartForDate(selectedDateKey.value);
await animateCalendarZoomOut( await animateCalendarFlipTransition(
queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`),
() => { () => {
calendarView.value = "month"; calendarView.value = "month";
}, },
@@ -2816,7 +2812,8 @@ async function zoomOutCalendar() {
if (calendarView.value === "month" || calendarView.value === "agenda") { if (calendarView.value === "month" || calendarView.value === "agenda") {
const targetMonthIndex = calendarCursor.value.getMonth(); const targetMonthIndex = calendarCursor.value.getMonth();
await animateCalendarZoomOut( await animateCalendarFlipTransition(
queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`),
() => { () => {
calendarView.value = "year"; calendarView.value = "year";
}, },

View File

@@ -169,14 +169,16 @@ defineProps<{
:style="calendarSceneTransformStyle" :style="calendarSceneTransformStyle"
@mouseleave="onCalendarSceneMouseLeave" @mouseleave="onCalendarSceneMouseLeave"
> >
<div class="grid gap-2" :class="calendarView === 'year' ? 'sm:grid-cols-2 xl:grid-cols-3' : 'grid-cols-1'"> <div class="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
<article <article
v-for="item in yearMonths" v-for="item in yearMonths"
:key="`year-month-${item.monthIndex}`" :key="`year-month-${item.monthIndex}`"
v-show="calendarView === 'year' || item.monthIndex === calendarCursorMonth" v-show="calendarView === 'year' || item.monthIndex === calendarCursorMonth"
class="group relative rounded-xl border border-base-300 p-3 text-left transition calendar-hover-targetable" class="group relative rounded-xl border border-base-300 p-3 text-left transition calendar-hover-targetable"
:class="[ :class="[
calendarView === 'year' ? 'hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in' : 'cursor-default min-h-[26rem] bg-base-100', calendarView === 'year'
? 'hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in'
: 'cursor-default min-h-[26rem] bg-base-100 sm:col-span-2 xl:col-span-3',
calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '', calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '', calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '',
]" ]"