fix(calendar): make nested block zoom smooth in both directions
This commit is contained in:
@@ -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";
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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' : '',
|
||||||
]"
|
]"
|
||||||
|
|||||||
Reference in New Issue
Block a user