calendar: switch zoom-in to DOM camera panzoom flow
This commit is contained in:
173
frontend/app.vue
173
frontend/app.vue
@@ -2281,6 +2281,8 @@ type CalendarZoomGhost = {
|
||||
};
|
||||
|
||||
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("");
|
||||
@@ -2295,6 +2297,13 @@ const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({
|
||||
const calendarZoomGhost = ref<CalendarZoomGhost | null>(null);
|
||||
const calendarZoomBusy = ref(false);
|
||||
const calendarSceneMasked = ref(false);
|
||||
const calendarCameraState = ref({
|
||||
active: false,
|
||||
left: 0,
|
||||
top: 0,
|
||||
scale: 1,
|
||||
durationMs: 0,
|
||||
});
|
||||
const calendarZoomPrimeToken = ref("");
|
||||
const calendarZoomPrimeScale = ref(1);
|
||||
const calendarZoomPrimeTicks = ref(0);
|
||||
@@ -2317,6 +2326,18 @@ const calendarZoomOverlayStyle = computed(() => ({
|
||||
width: `${calendarZoomOverlay.value.width}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() {
|
||||
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 {
|
||||
if (!element) return null;
|
||||
const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
|
||||
@@ -2413,14 +2443,29 @@ function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | n
|
||||
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));
|
||||
function getElementRectInScene(element: HTMLElement | null): CalendarRect | null {
|
||||
if (!element) return null;
|
||||
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 {
|
||||
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),
|
||||
left: scroll.scrollLeft + Math.max(0, (viewport.width - width) / 2),
|
||||
top: scroll.scrollTop + Math.max(0, (viewport.height - height) / 2),
|
||||
width,
|
||||
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() {
|
||||
await nextTick();
|
||||
await nextAnimationFrame();
|
||||
@@ -2556,27 +2662,41 @@ function waitCalendarZoomTransition() {
|
||||
}
|
||||
|
||||
async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: CalendarZoomGhost, apply: () => void) {
|
||||
const viewportRect = getCalendarViewportRect();
|
||||
if (!viewportRect) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
const fromRect = getElementRectInCalendar(sourceElement) ?? fallbackZoomOriginRect(viewportRect);
|
||||
|
||||
clearCalendarZoomPrime();
|
||||
calendarZoomBusy.value = true;
|
||||
clearCalendarZoomOverlay();
|
||||
calendarSceneMasked.value = false;
|
||||
try {
|
||||
primeCalendarRect(fromRect);
|
||||
calendarZoomGhost.value = ghost;
|
||||
calendarSceneMasked.value = true;
|
||||
await flushCalendarZoomStartFrame();
|
||||
morphCalendarRect(viewportRect);
|
||||
await waitCalendarZoomTransition();
|
||||
const fromRect = getElementRectInScene(sourceElement) ?? fallbackZoomOriginRectInScene();
|
||||
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();
|
||||
await nextTick();
|
||||
} finally {
|
||||
clearCalendarZoomOverlay();
|
||||
calendarSceneMasked.value = false;
|
||||
await resetCalendarCamera();
|
||||
calendarZoomGhost.value = null;
|
||||
calendarZoomBusy.value = false;
|
||||
}
|
||||
}
|
||||
@@ -4852,15 +4972,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
<span>→</span>
|
||||
</button>
|
||||
<div
|
||||
ref="calendarContentScrollRef"
|
||||
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
|
||||
@wheel.prevent="onCalendarHierarchyWheel"
|
||||
>
|
||||
<div
|
||||
ref="calendarSceneRef"
|
||||
:class="[
|
||||
'calendar-scene',
|
||||
normalizedCalendarView === 'day' ? 'cursor-zoom-out' : 'cursor-zoom-in',
|
||||
calendarSceneMasked ? 'calendar-scene-hidden' : '',
|
||||
]"
|
||||
'calendar-scene',
|
||||
normalizedCalendarView === 'day' ? 'cursor-zoom-out' : 'cursor-zoom-in',
|
||||
calendarSceneMasked ? 'calendar-scene-hidden' : '',
|
||||
]"
|
||||
:style="calendarSceneTransformStyle"
|
||||
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''; clearCalendarZoomPrime()"
|
||||
>
|
||||
<div v-if="calendarView === 'month'" class="space-y-1">
|
||||
|
||||
Reference in New Issue
Block a user