fix(calendar): remove overlay swap and keep in-place zoom flow

This commit is contained in:
Ruslan Bakiev
2026-02-23 12:50:11 +07:00
parent d3b751db65
commit 894210cd42
2 changed files with 109 additions and 360 deletions

View File

@@ -2309,7 +2309,6 @@ onBeforeUnmount(() => {
clearInterval(lifecycleClock);
lifecycleClock = null;
}
clearCalendarZoomOverlay();
clearCalendarZoomPrime();
});
@@ -2319,7 +2318,7 @@ const selectedDateKey = ref(dayKey(new Date()));
const sortedEvents = computed(() => [...calendarEvents.value].sort((a, b) => a.start.localeCompare(b.start)));
const focusedCalendarEvent = computed(() => {
const id = focusedCalendarEventId.value.trim();
const id = (focusedCalendarEventId.value ?? "").trim();
if (!id) return null;
return sortedEvents.value.find((event) => event.id === id) ?? null;
});
@@ -2356,15 +2355,10 @@ const calendarViewOptions: { value: CalendarView; label: string }[] = [
type CalendarHierarchyView = "year" | "month" | "week" | "day";
type CalendarRect = { left: number; top: number; width: number; height: number };
type CalendarZoomGhost = {
title: string;
subtitle?: string;
};
const calendarContentWrapRef = ref<HTMLElement | null>(null);
const calendarContentScrollRef = ref<HTMLElement | null>(null);
const calendarSceneRef = ref<HTMLElement | null>(null);
const calendarZoomOverlayRef = ref<HTMLElement | null>(null);
const calendarHoveredMonthIndex = ref<number | null>(null);
const calendarHoveredWeekStartKey = ref("");
const calendarHoveredDayKey = ref("");
@@ -2381,10 +2375,6 @@ function setCalendarSceneRef(element: HTMLElement | null) {
calendarSceneRef.value = element;
}
function setCalendarZoomOverlayRef(element: HTMLElement | null) {
calendarZoomOverlayRef.value = element;
}
function setCalendarHoveredMonthIndex(value: number | null) {
calendarHoveredMonthIndex.value = value;
}
@@ -2403,14 +2393,6 @@ function onCalendarSceneMouseLeave() {
calendarHoveredDayKey.value = "";
clearCalendarZoomPrime();
}
const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({
active: false,
left: 0,
top: 0,
width: 0,
height: 0,
});
const calendarZoomGhost = ref<CalendarZoomGhost | null>(null);
const calendarZoomBusy = ref(false);
const calendarCameraState = ref({
active: false,
@@ -2426,8 +2408,6 @@ let calendarWheelLockUntil = 0;
let calendarZoomPrimeTimer: ReturnType<typeof setTimeout> | null = null;
let calendarZoomPrimeLastAt = 0;
const CALENDAR_ZOOM_DURATION_MS = 2400;
const CALENDAR_ZOOM_FOCUS_MS = 1400;
const CALENDAR_ZOOM_REVEAL_MS = Math.max(500, CALENDAR_ZOOM_DURATION_MS - CALENDAR_ZOOM_FOCUS_MS);
const CALENDAR_ZOOM_PRIME_STEPS = 2;
const CALENDAR_ZOOM_PRIME_MAX_SCALE = 1.05;
const CALENDAR_ZOOM_PRIME_RESET_MS = 900;
@@ -2437,12 +2417,6 @@ const normalizedCalendarView = computed<CalendarHierarchyView>(() =>
calendarView.value === "agenda" ? "month" : calendarView.value,
);
const calendarZoomLevelIndex = computed(() => Math.max(0, calendarZoomOrder.indexOf(normalizedCalendarView.value)));
const calendarZoomOverlayStyle = computed(() => ({
left: `${calendarZoomOverlay.value.left}px`,
top: `${calendarZoomOverlay.value.top}px`,
width: `${calendarZoomOverlay.value.width}px`,
height: `${calendarZoomOverlay.value.height}px`,
}));
const calendarSceneTransformStyle = computed(() => {
if (!calendarCameraState.value.active) return undefined;
return {
@@ -2456,14 +2430,6 @@ const calendarSceneTransformStyle = computed(() => {
};
});
function clearCalendarZoomOverlay() {
calendarZoomOverlay.value = {
...calendarZoomOverlay.value,
active: false,
};
calendarZoomGhost.value = null;
}
function clearCalendarZoomPrime() {
if (calendarZoomPrimeTimer) {
clearTimeout(calendarZoomPrimeTimer);
@@ -2592,92 +2558,6 @@ function weekRowStartForDate(key: string) {
return dayKey(date);
}
function zoomGhostForMonth(monthIndex: number): CalendarZoomGhost {
const item = yearMonths.value.find((entry) => entry.monthIndex === monthIndex);
if (!item) {
return {
title: new Intl.DateTimeFormat("en-US", { month: "long" }).format(new Date(calendarCursor.value.getFullYear(), monthIndex, 1)),
subtitle: "",
};
}
return {
title: item.label,
subtitle: `${item.count} events`,
};
}
function zoomGhostForWeek(startKey: string): CalendarZoomGhost {
const start = new Date(`${startKey}T00:00:00`);
const end = new Date(start);
end.setDate(start.getDate() + 6);
const row = monthRows.value.find((item) => item.startKey === startKey);
const count = row ? row.cells.reduce((sum, cell) => sum + cell.events.length, 0) : 0;
return {
title: `${formatDay(`${dayKey(start)}T00:00:00`)} - ${formatDay(`${dayKey(end)}T00:00:00`)}`,
subtitle: `${count} events`,
};
}
function zoomGhostForDay(dayKeyValue: string): CalendarZoomGhost {
const day = weekDays.value.find((entry) => entry.key === dayKeyValue);
if (!day) {
return {
title: formatDay(`${dayKeyValue}T00:00:00`),
subtitle: `${getEventsByDate(dayKeyValue).length} events`,
};
}
return {
title: `${day.label} ${day.day}`,
subtitle: `${day.events.length} events`,
};
}
function zoomGhostForCurrentView(): CalendarZoomGhost {
if (calendarView.value === "day") {
return {
title: formatDay(`${selectedDateKey.value}T00:00:00`),
subtitle: `${selectedDayEvents.value.length} events`,
};
}
if (calendarView.value === "week") {
return {
title: calendarPeriodLabel.value,
subtitle: `${weekDays.value.reduce((sum, day) => sum + day.events.length, 0)} events`,
};
}
if (calendarView.value === "month" || calendarView.value === "agenda") {
return {
title: monthLabel.value,
subtitle: `${monthCells.value.reduce((sum, cell) => sum + cell.events.length, 0)} events`,
};
}
return {
title: String(calendarCursor.value.getFullYear()),
subtitle: `${sortedEvents.value.filter((event) => new Date(event.start).getFullYear() === calendarCursor.value.getFullYear()).length} events`,
};
}
function primeCalendarRect(rect: CalendarRect) {
clearCalendarZoomOverlay();
calendarZoomOverlay.value = {
active: true,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
}
function morphCalendarRect(toRect: CalendarRect) {
calendarZoomOverlay.value = {
active: true,
left: toRect.left,
top: toRect.top,
width: toRect.width,
height: toRect.height,
};
}
function nextAnimationFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
@@ -2745,50 +2625,13 @@ async function resetCalendarCamera() {
};
}
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) => {
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,
resolveRevealTarget?: () => HTMLElement | null,
) {
clearCalendarZoomPrime();
calendarZoomBusy.value = true;
clearCalendarZoomOverlay();
try {
calendarZoomGhost.value = ghost;
const fromRect = getElementRectInScene(sourceElement) ?? fallbackZoomOriginRectInScene();
const cameraTarget = fromRect ? cameraTransformForRect(fromRect) : null;
if (!cameraTarget) {
@@ -2810,36 +2653,21 @@ async function animateCalendarZoomIn(
left: cameraTarget.left,
top: cameraTarget.top,
scale: cameraTarget.scale,
durationMs: CALENDAR_ZOOM_FOCUS_MS,
durationMs: CALENDAR_ZOOM_DURATION_MS,
};
await waitCalendarCameraTransition();
apply();
await nextTick();
const revealTargetElement = resolveRevealTarget ? resolveRevealTarget() : sourceElement;
const revealTargetRect = getElementRectInScene(revealTargetElement) ?? fallbackZoomOriginRectInScene();
const revealTarget = revealTargetRect ? cameraTransformForRect(revealTargetRect) : null;
if (revealTarget) {
calendarCameraState.value = {
active: true,
left: revealTarget.left,
top: revealTarget.top,
scale: revealTarget.scale,
durationMs: 0,
};
await nextTick();
await nextAnimationFrame();
}
calendarCameraState.value = {
active: true,
left: 0,
top: 0,
scale: 1,
durationMs: CALENDAR_ZOOM_REVEAL_MS,
durationMs: 0,
};
await waitCalendarCameraTransition();
await nextAnimationFrame();
} finally {
await resetCalendarCamera();
calendarZoomGhost.value = null;
calendarZoomBusy.value = false;
}
}
@@ -2847,9 +2675,7 @@ async function animateCalendarZoomIn(
async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HTMLElement | null) {
clearCalendarZoomPrime();
calendarZoomBusy.value = true;
clearCalendarZoomOverlay();
try {
calendarZoomGhost.value = zoomGhostForCurrentView();
apply();
await nextTick();
const targetRect = getElementRectInScene(resolveTarget()) ?? fallbackZoomOriginRectInScene();
@@ -2875,7 +2701,6 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
await waitCalendarCameraTransition();
} finally {
await resetCalendarCamera();
calendarZoomGhost.value = null;
calendarZoomBusy.value = false;
}
}
@@ -2923,9 +2748,9 @@ async function zoomInCalendar(event?: Event) {
queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`) ??
queryCalendarElement("[data-calendar-month-index]");
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return;
await animateCalendarZoomIn(sourceElement, zoomGhostForMonth(monthIndex), () => {
await animateCalendarZoomIn(sourceElement, () => {
openYearMonth(monthIndex);
}, () => queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`));
});
return;
}
@@ -2938,16 +2763,9 @@ async function zoomInCalendar(event?: Event) {
queryCalendarElement("[data-calendar-week-start-key]") ??
queryCalendarElement("[data-calendar-day-key]");
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return;
await animateCalendarZoomIn(
sourceElement,
zoomGhostForWeek(rowStartKey),
() => {
openWeekView(anchorDayKey);
},
() =>
queryCalendarElement(`[data-calendar-week-start-key="${rowStartKey}"]`) ??
queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`),
);
await animateCalendarZoomIn(sourceElement, () => {
openWeekView(anchorDayKey);
});
return;
}
@@ -2955,21 +2773,16 @@ async function zoomInCalendar(event?: Event) {
const dayAnchor = resolveDayAnchor(wheelEvent);
const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`) ?? queryCalendarElement("[data-calendar-day-key]");
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return;
await animateCalendarZoomIn(sourceElement, zoomGhostForDay(dayAnchor), () => {
await animateCalendarZoomIn(sourceElement, () => {
openDayView(dayAnchor);
}, () => queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`));
});
}
}
async function zoomToMonth(monthIndex: number) {
await animateCalendarZoomIn(
queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`),
zoomGhostForMonth(monthIndex),
() => {
openYearMonth(monthIndex);
},
() => queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`),
);
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => {
openYearMonth(monthIndex);
});
}
async function zoomOutCalendar() {
@@ -4950,10 +4763,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
:week-days="weekDays"
:calendar-prime-day-token="calendarPrimeDayToken"
:selected-day-events="selectedDayEvents"
:calendar-zoom-overlay="calendarZoomOverlay"
:set-calendar-zoom-overlay-ref="setCalendarZoomOverlayRef"
:calendar-zoom-overlay-style="calendarZoomOverlayStyle"
:calendar-zoom-ghost="calendarZoomGhost"
/>