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 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user