calendar: switch zoom-in to DOM camera panzoom flow

This commit is contained in:
Ruslan Bakiev
2026-02-23 09:55:45 +07:00
parent 43b487ccec
commit 8ef266e09d

View File

@@ -2281,6 +2281,8 @@ type CalendarZoomGhost = {
}; };
const calendarContentWrapRef = ref<HTMLElement | null>(null); 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 calendarZoomOverlayRef = ref<HTMLElement | null>(null);
const calendarHoveredMonthIndex = ref<number | null>(null); const calendarHoveredMonthIndex = ref<number | null>(null);
const calendarHoveredWeekStartKey = ref(""); const calendarHoveredWeekStartKey = ref("");
@@ -2295,6 +2297,13 @@ const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({
const calendarZoomGhost = ref<CalendarZoomGhost | null>(null); const calendarZoomGhost = ref<CalendarZoomGhost | null>(null);
const calendarZoomBusy = ref(false); const calendarZoomBusy = ref(false);
const calendarSceneMasked = ref(false); const calendarSceneMasked = ref(false);
const calendarCameraState = ref({
active: false,
left: 0,
top: 0,
scale: 1,
durationMs: 0,
});
const calendarZoomPrimeToken = ref(""); const calendarZoomPrimeToken = ref("");
const calendarZoomPrimeScale = ref(1); const calendarZoomPrimeScale = ref(1);
const calendarZoomPrimeTicks = ref(0); const calendarZoomPrimeTicks = ref(0);
@@ -2317,6 +2326,18 @@ const calendarZoomOverlayStyle = computed(() => ({
width: `${calendarZoomOverlay.value.width}px`, width: `${calendarZoomOverlay.value.width}px`,
height: `${calendarZoomOverlay.value.height}px`, height: `${calendarZoomOverlay.value.height}px`,
})); }));
const calendarSceneTransformStyle = computed(() => {
if (!calendarCameraState.value.active) return undefined;
return {
transform: `translate(${calendarCameraState.value.left}px, ${calendarCameraState.value.top}px) scale(${calendarCameraState.value.scale})`,
transformOrigin: "0 0",
transition:
calendarCameraState.value.durationMs > 0
? `transform ${calendarCameraState.value.durationMs}ms cubic-bezier(0.16, 0.86, 0.18, 1)`
: "none",
willChange: "transform",
};
});
function clearCalendarZoomOverlay() { function clearCalendarZoomOverlay() {
calendarZoomOverlay.value = { calendarZoomOverlay.value = {
@@ -2396,6 +2417,15 @@ function getCalendarViewportRect(): CalendarRect | null {
}; };
} }
function getCalendarCameraViewportRect() {
const viewport = calendarContentScrollRef.value?.getBoundingClientRect();
if (!viewport) return null;
return {
width: Math.max(24, viewport.width),
height: Math.max(24, viewport.height),
};
}
function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | null { function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | null {
if (!element) return null; if (!element) return null;
const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect(); const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
@@ -2413,14 +2443,29 @@ function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | n
return { left, top, width, height }; return { left, top, width, height };
} }
function fallbackZoomOriginRect(viewportRect: CalendarRect): CalendarRect { function getElementRectInScene(element: HTMLElement | null): CalendarRect | null {
const width = Math.max(96, Math.round(viewportRect.width * 0.28)); if (!element) return null;
const height = Math.max(64, Math.round(viewportRect.height * 0.24)); const sceneRect = calendarSceneRef.value?.getBoundingClientRect();
if (!sceneRect) return null;
const rect = element.getBoundingClientRect();
const left = rect.left - sceneRect.left;
const top = rect.top - sceneRect.top;
const width = Math.max(24, rect.width);
const height = Math.max(24, rect.height);
return { left, top, width, height };
}
function fallbackZoomOriginRectInScene(): CalendarRect | null {
const viewport = getCalendarCameraViewportRect();
const scroll = calendarContentScrollRef.value;
if (!viewport || !scroll) return null;
const width = Math.max(96, Math.round(viewport.width * 0.28));
const height = Math.max(64, Math.round(viewport.height * 0.24));
return { return {
left: Math.max(0, Math.round((viewportRect.width - width) / 2)), left: scroll.scrollLeft + Math.max(0, (viewport.width - width) / 2),
top: Math.max(0, Math.round((viewportRect.height - height) / 2)), top: scroll.scrollTop + Math.max(0, (viewport.height - height) / 2),
width: Math.min(width, viewportRect.width), width,
height: Math.min(height, viewportRect.height), height,
}; };
} }
@@ -2522,6 +2567,67 @@ function nextAnimationFrame() {
}); });
} }
function waitCalendarCameraTransition() {
const scene = calendarSceneRef.value;
if (!scene) {
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;
scene.removeEventListener("transitionend", onTransitionEnd);
clearTimeout(fallbackTimer);
resolve();
};
const onTransitionEnd = (event: TransitionEvent) => {
if (event.target !== scene) return;
if (event.propertyName !== "transform") return;
finish();
};
const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 160);
scene.addEventListener("transitionend", onTransitionEnd);
});
}
function cameraTransformForRect(rect: CalendarRect) {
const viewport = getCalendarCameraViewportRect();
if (!viewport) return null;
const availableWidth = Math.max(24, viewport.width - 24);
const availableHeight = Math.max(24, viewport.height - 24);
const fitScale = Math.min(availableWidth / rect.width, availableHeight / rect.height);
const scale = Math.max(1, Math.min(8, fitScale));
const targetLeft = (viewport.width - rect.width * scale) / 2;
const targetTop = (viewport.height - rect.height * scale) / 2;
return {
left: Math.round(targetLeft - rect.left * scale),
top: Math.round(targetTop - rect.top * scale),
scale,
};
}
async function resetCalendarCamera() {
calendarCameraState.value = {
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 flushCalendarZoomStartFrame() { async function flushCalendarZoomStartFrame() {
await nextTick(); await nextTick();
await nextAnimationFrame(); await nextAnimationFrame();
@@ -2556,27 +2662,41 @@ function waitCalendarZoomTransition() {
} }
async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: CalendarZoomGhost, apply: () => void) { async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: CalendarZoomGhost, apply: () => void) {
const viewportRect = getCalendarViewportRect();
if (!viewportRect) {
apply();
return;
}
const fromRect = getElementRectInCalendar(sourceElement) ?? fallbackZoomOriginRect(viewportRect);
clearCalendarZoomPrime(); clearCalendarZoomPrime();
calendarZoomBusy.value = true; calendarZoomBusy.value = true;
clearCalendarZoomOverlay();
calendarSceneMasked.value = false;
try { try {
primeCalendarRect(fromRect);
calendarZoomGhost.value = ghost; calendarZoomGhost.value = ghost;
calendarSceneMasked.value = true; const fromRect = getElementRectInScene(sourceElement) ?? fallbackZoomOriginRectInScene();
await flushCalendarZoomStartFrame(); const cameraTarget = fromRect ? cameraTransformForRect(fromRect) : null;
morphCalendarRect(viewportRect); if (!cameraTarget) {
await waitCalendarZoomTransition(); 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();
} finally { } finally {
clearCalendarZoomOverlay(); await resetCalendarCamera();
calendarSceneMasked.value = false; calendarZoomGhost.value = null;
calendarZoomBusy.value = false; calendarZoomBusy.value = false;
} }
} }
@@ -4852,15 +4972,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<span></span> <span></span>
</button> </button>
<div <div
ref="calendarContentScrollRef"
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1" class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
@wheel.prevent="onCalendarHierarchyWheel" @wheel.prevent="onCalendarHierarchyWheel"
> >
<div <div
ref="calendarSceneRef"
:class="[ :class="[
'calendar-scene', 'calendar-scene',
normalizedCalendarView === 'day' ? 'cursor-zoom-out' : 'cursor-zoom-in', normalizedCalendarView === 'day' ? 'cursor-zoom-out' : 'cursor-zoom-in',
calendarSceneMasked ? 'calendar-scene-hidden' : '', calendarSceneMasked ? 'calendar-scene-hidden' : '',
]" ]"
:style="calendarSceneTransformStyle"
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''; clearCalendarZoomPrime()" @mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''; clearCalendarZoomPrime()"
> >
<div v-if="calendarView === 'month'" class="space-y-1"> <div v-if="calendarView === 'month'" class="space-y-1">