Compare commits

...

2 Commits

Author SHA1 Message Date
Ruslan Bakiev
ab5370c831 calendar: enforce two-phase zoom-in morph before view switch 2026-02-23 09:15:12 +07:00
Ruslan Bakiev
eb298e786e calendar: prevent zoom-in instant switch when anchor is offscreen 2026-02-23 09:11:55 +07:00

View File

@@ -2281,6 +2281,7 @@ type CalendarZoomGhost = {
};
const calendarContentWrapRef = ref<HTMLElement | null>(null);
const calendarZoomOverlayRef = ref<HTMLElement | null>(null);
const calendarHoveredMonthIndex = ref<number | null>(null);
const calendarHoveredWeekStartKey = ref("");
const calendarHoveredDayKey = ref("");
@@ -2400,13 +2401,29 @@ function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | n
const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
if (!wrapRect) return null;
const rect = element.getBoundingClientRect();
const left = Math.max(0, rect.left - wrapRect.left);
const top = Math.max(0, rect.top - wrapRect.top);
const width = Math.max(24, Math.min(rect.width, wrapRect.width - left));
const height = Math.max(24, Math.min(rect.height, wrapRect.height - top));
const left = Math.max(0, Math.min(rect.left - wrapRect.left, wrapRect.width));
const top = Math.max(0, Math.min(rect.top - wrapRect.top, wrapRect.height));
const right = Math.max(0, Math.min(rect.right - wrapRect.left, wrapRect.width));
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 };
}
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) {
const date = new Date(`${key}T00:00:00`);
date.setDate(date.getDate() - date.getDay());
@@ -2490,30 +2507,61 @@ function primeCalendarRect(rect: CalendarRect) {
}
function morphCalendarRect(toRect: CalendarRect) {
requestAnimationFrame(() => {
calendarZoomOverlay.value = {
active: true,
left: toRect.left,
top: toRect.top,
width: toRect.width,
height: toRect.height,
};
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());
});
}
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) => {
setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS);
});
}
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS);
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) {
const fromRect = getElementRectInCalendar(sourceElement);
const viewportRect = getCalendarViewportRect();
if (!fromRect || !viewportRect) {
if (!viewportRect) {
apply();
return;
}
const fromRect = getElementRectInCalendar(sourceElement) ?? fallbackZoomOriginRect(viewportRect);
clearCalendarZoomPrime();
calendarZoomBusy.value = true;
@@ -2521,9 +2569,9 @@ async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: C
primeCalendarRect(fromRect);
calendarZoomGhost.value = ghost;
calendarSceneMasked.value = true;
await nextTick();
await flushCalendarZoomStartFrame();
morphCalendarRect(viewportRect);
await waitCalendarZoom();
await waitCalendarZoomTransition();
apply();
await nextTick();
} finally {
@@ -2546,7 +2594,7 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
primeCalendarRect(viewportRect);
calendarZoomGhost.value = zoomGhostForCurrentView();
calendarSceneMasked.value = true;
await nextTick();
await flushCalendarZoomStartFrame();
apply();
await nextTick();
const targetRect = getElementRectInCalendar(resolveTarget());
@@ -2558,7 +2606,7 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
return;
}
morphCalendarRect(targetRect);
await waitCalendarZoom();
await waitCalendarZoomTransition();
} finally {
clearCalendarZoomOverlay();
calendarSceneMasked.value = false;
@@ -2577,13 +2625,20 @@ function resolveMonthAnchor(event?: WheelEvent) {
return calendarCursor.value.getMonth();
}
function fallbackMonthGridAnchorKey() {
if (monthCells.value.some((cell) => cell.key === selectedDateKey.value)) return selectedDateKey.value;
const middle = dayKey(new Date(calendarCursor.value.getFullYear(), calendarCursor.value.getMonth(), 15));
if (monthCells.value.some((cell) => cell.key === middle)) return middle;
return monthCells.value.find((cell) => cell.inMonth)?.key ?? monthCells.value[0]?.key ?? selectedDateKey.value;
}
function resolveWeekAnchor(event?: WheelEvent) {
const target = event?.target as HTMLElement | null;
const weekKey = target?.closest<HTMLElement>("[data-calendar-week-start-key]")?.dataset.calendarWeekStartKey;
if (weekKey) return weekKey;
if (calendarHoveredWeekStartKey.value) return calendarHoveredWeekStartKey.value;
if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value;
return selectedDateKey.value;
return fallbackMonthGridAnchorKey();
}
function resolveDayAnchor(event?: WheelEvent) {
@@ -2591,14 +2646,16 @@ function resolveDayAnchor(event?: WheelEvent) {
const dayKeyAttr = target?.closest<HTMLElement>("[data-calendar-day-key]")?.dataset.calendarDayKey;
if (dayKeyAttr) return dayKeyAttr;
if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value;
return selectedDateKey.value;
return weekDays.value[0]?.key ?? selectedDateKey.value;
}
async function zoomInCalendar(event?: Event) {
const wheelEvent = event instanceof WheelEvent ? event : undefined;
if (calendarView.value === "year") {
const monthIndex = resolveMonthAnchor(wheelEvent);
const sourceElement = queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`);
const sourceElement =
queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`) ??
queryCalendarElement("[data-calendar-month-index]");
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return;
await animateCalendarZoomIn(sourceElement, zoomGhostForMonth(monthIndex), () => {
openYearMonth(monthIndex);
@@ -2611,7 +2668,9 @@ async function zoomInCalendar(event?: Event) {
const rowStartKey = weekRowStartForDate(anchorDayKey);
const sourceElement =
queryCalendarElement(`[data-calendar-week-start-key="${rowStartKey}"]`) ??
queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`);
queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`) ??
queryCalendarElement("[data-calendar-week-start-key]") ??
queryCalendarElement("[data-calendar-day-key]");
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return;
await animateCalendarZoomIn(
sourceElement,
@@ -2625,7 +2684,7 @@ async function zoomInCalendar(event?: Event) {
if (calendarView.value === "week") {
const dayAnchor = resolveDayAnchor(wheelEvent);
const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`);
const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`) ?? queryCalendarElement("[data-calendar-day-key]");
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return;
await animateCalendarZoomIn(sourceElement, zoomGhostForDay(dayAnchor), () => {
openDayView(dayAnchor);
@@ -4953,6 +5012,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</div>
<div
v-if="calendarZoomOverlay.active"
ref="calendarZoomOverlayRef"
class="calendar-zoom-overlay"
:style="calendarZoomOverlayStyle"
>