feat(calendar): add hierarchical zoom drill-down transitions

This commit is contained in:
Ruslan Bakiev
2026-02-22 14:53:40 +07:00
parent 2fd97f6593
commit 7040200abe

View File

@@ -2074,7 +2074,7 @@ onBeforeUnmount(() => {
} }
}); });
const calendarView = ref<CalendarView>("month"); const calendarView = ref<CalendarView>("year");
const calendarCursor = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1)); const calendarCursor = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
const selectedDateKey = ref(dayKey(new Date())); const selectedDateKey = ref(dayKey(new Date()));
@@ -2115,118 +2115,112 @@ const calendarViewOptions: { value: CalendarView; label: string }[] = [
{ value: "agenda", label: "Agenda" }, { value: "agenda", label: "Agenda" },
]; ];
const calendarZoomLevel = computed<number>({ type CalendarTransitionDirection = "in" | "out" | "side";
get() {
if (calendarView.value === "year") return 1; const calendarTransitionDirection = ref<CalendarTransitionDirection>("side");
if (calendarView.value === "month" || calendarView.value === "agenda") return 2; const calendarHoveredMonthIndex = ref<number | null>(null);
if (calendarView.value === "week") return 3; const calendarHoveredWeekStartKey = ref("");
return 4; const calendarHoveredDayKey = ref("");
}, let calendarWheelLockUntil = 0;
set(next) {
const level = Math.max(1, Math.min(4, Number(next) || 2)); const calendarSceneKey = computed(() => `${calendarView.value}-${calendarRouteToken(calendarView.value)}`);
if (level === 1) { const calendarTransitionName = computed(() => {
calendarView.value = "year"; if (calendarTransitionDirection.value === "in") return "calendar-zoom-in";
return; if (calendarTransitionDirection.value === "out") return "calendar-zoom-out";
} return "calendar-zoom-side";
if (level === 2) {
calendarView.value = "month";
return;
}
if (level === 3) {
calendarView.value = "week";
return;
}
calendarView.value = "day";
},
}); });
const calendarZoomLabel = computed(() => { const canCalendarZoomIn = computed(() => calendarView.value !== "day");
const canCalendarZoomOut = computed(() => calendarView.value !== "year");
const calendarZoomDepthLabel = computed(() => {
if (calendarView.value === "day") return "Day"; if (calendarView.value === "day") return "Day";
if (calendarView.value === "week") return "Week"; if (calendarView.value === "week") return "Week";
if (calendarView.value === "month" || calendarView.value === "agenda") return "Month"; if (calendarView.value === "month" || calendarView.value === "agenda") return "Month";
return "Year"; return "Months";
}); });
const calendarPanMode = ref(false); function setCalendarTransition(direction: CalendarTransitionDirection) {
const calendarCanvasRef = ref<HTMLElement | null>(null); calendarTransitionDirection.value = direction;
const calendarCanvasScale = ref(1);
const calendarCanvasOffsetX = ref(0);
const calendarCanvasOffsetY = ref(0);
const calendarCanvasDragging = ref(false);
let calendarCanvasPointerId: number | null = null;
let calendarCanvasDragStartX = 0;
let calendarCanvasDragStartY = 0;
let calendarCanvasStartOffsetX = 0;
let calendarCanvasStartOffsetY = 0;
const calendarCanvasStyle = computed(() => ({
transform: `translate(${calendarCanvasOffsetX.value}px, ${calendarCanvasOffsetY.value}px) scale(${calendarCanvasScale.value})`,
}));
function clampCalendarCanvasScale(next: number) {
return Math.max(0.72, Math.min(2.35, Number(next) || 1));
} }
function resetCalendarCanvas() { function resolveMonthAnchor(event?: WheelEvent) {
calendarCanvasScale.value = 1; const target = event?.target as HTMLElement | null;
calendarCanvasOffsetX.value = 0; const monthAttr = target?.closest<HTMLElement>("[data-calendar-month-index]")?.dataset.calendarMonthIndex;
calendarCanvasOffsetY.value = 0; if (monthAttr) {
calendarCanvasDragging.value = false; const parsed = Number(monthAttr);
calendarCanvasPointerId = null; if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 11) return parsed;
}
if (calendarHoveredMonthIndex.value !== null) return calendarHoveredMonthIndex.value;
return calendarCursor.value.getMonth();
} }
function toggleCalendarPanMode() { function resolveWeekAnchor(event?: WheelEvent) {
calendarPanMode.value = !calendarPanMode.value; const target = event?.target as HTMLElement | null;
if (!calendarPanMode.value) { const weekKey = target?.closest<HTMLElement>("[data-calendar-week-start-key]")?.dataset.calendarWeekStartKey;
calendarCanvasDragging.value = false; if (weekKey) return weekKey;
calendarCanvasPointerId = null; if (calendarHoveredWeekStartKey.value) return calendarHoveredWeekStartKey.value;
if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value;
return selectedDateKey.value;
}
function resolveDayAnchor(event?: WheelEvent) {
const target = event?.target as HTMLElement | null;
const dayKeyAttr = target?.closest<HTMLElement>("[data-calendar-day-key]")?.dataset.calendarDayKey;
if (dayKeyAttr) return dayKeyAttr;
if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value;
return selectedDateKey.value;
}
function zoomInCalendar(event?: Event) {
const wheelEvent = event instanceof WheelEvent ? event : undefined;
if (calendarView.value === "year") {
openYearMonth(resolveMonthAnchor(wheelEvent), "in");
return;
}
if (calendarView.value === "month" || calendarView.value === "agenda") {
openWeekView(resolveWeekAnchor(wheelEvent), "in");
return;
}
if (calendarView.value === "week") {
openDayView(resolveDayAnchor(wheelEvent), "in");
} }
} }
function onCalendarCanvasWheel(event: WheelEvent) { function zoomOutCalendar() {
const host = calendarCanvasRef.value; focusedCalendarEventId.value = "";
if (!host) return;
const rect = host.getBoundingClientRect(); if (calendarView.value === "day") {
const pivotX = event.clientX - rect.left; setCalendarTransition("out");
const pivotY = event.clientY - rect.top; calendarView.value = "week";
const prevScale = calendarCanvasScale.value; return;
const ratio = event.deltaY < 0 ? 1.08 : 0.92;
const nextScale = clampCalendarCanvasScale(prevScale * ratio);
if (Math.abs(nextScale - prevScale) < 0.0001) return;
const contentX = (pivotX - calendarCanvasOffsetX.value) / prevScale;
const contentY = (pivotY - calendarCanvasOffsetY.value) / prevScale;
calendarCanvasScale.value = nextScale;
calendarCanvasOffsetX.value = pivotX - contentX * nextScale;
calendarCanvasOffsetY.value = pivotY - contentY * nextScale;
} }
function onCalendarCanvasPointerDown(event: PointerEvent) { if (calendarView.value === "week") {
if (!calendarPanMode.value || event.button !== 0) return; setCalendarTransition("out");
const target = event.currentTarget as HTMLElement | null; calendarView.value = "month";
if (!target) return; return;
calendarCanvasDragging.value = true;
calendarCanvasPointerId = event.pointerId;
calendarCanvasDragStartX = event.clientX;
calendarCanvasDragStartY = event.clientY;
calendarCanvasStartOffsetX = calendarCanvasOffsetX.value;
calendarCanvasStartOffsetY = calendarCanvasOffsetY.value;
target.setPointerCapture(event.pointerId);
} }
function onCalendarCanvasPointerMove(event: PointerEvent) { if (calendarView.value === "month" || calendarView.value === "agenda") {
if (!calendarPanMode.value || !calendarCanvasDragging.value || calendarCanvasPointerId !== event.pointerId) return; setCalendarTransition("out");
calendarCanvasOffsetX.value = calendarCanvasStartOffsetX + (event.clientX - calendarCanvasDragStartX); calendarView.value = "year";
calendarCanvasOffsetY.value = calendarCanvasStartOffsetY + (event.clientY - calendarCanvasDragStartY); }
} }
function stopCalendarCanvasDrag(event?: PointerEvent) { function onCalendarHierarchyWheel(event: WheelEvent) {
if (!calendarCanvasDragging.value) return; const now = Date.now();
if (event && calendarCanvasPointerId !== event.pointerId) return; if (now < calendarWheelLockUntil) return;
calendarCanvasDragging.value = false; if (Math.abs(event.deltaY) < 5) return;
calendarCanvasPointerId = null; calendarWheelLockUntil = now + 240;
if (event.deltaY < 0) {
zoomInCalendar(event);
return;
}
zoomOutCalendar();
} }
const monthCells = computed(() => { const monthCells = computed(() => {
@@ -2344,6 +2338,7 @@ const selectedDayEvents = computed(() => getEventsByDate(selectedDateKey.value))
function shiftCalendar(step: number) { function shiftCalendar(step: number) {
focusedCalendarEventId.value = ""; focusedCalendarEventId.value = "";
setCalendarTransition("side");
if (calendarView.value === "year") { if (calendarView.value === "year") {
const next = new Date(calendarCursor.value); const next = new Date(calendarCursor.value);
next.setFullYear(next.getFullYear() + step); next.setFullYear(next.getFullYear() + step);
@@ -2371,6 +2366,7 @@ function shiftCalendar(step: number) {
function setToday() { function setToday() {
focusedCalendarEventId.value = ""; focusedCalendarEventId.value = "";
setCalendarTransition("side");
const now = new Date(); const now = new Date();
selectedDateKey.value = dayKey(now); selectedDateKey.value = dayKey(now);
calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1); calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1);
@@ -2383,21 +2379,24 @@ function pickDate(key: string) {
calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1); calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1);
} }
function openDayView(key: string) { function openDayView(key: string, direction: CalendarTransitionDirection = "in") {
pickDate(key); pickDate(key);
setCalendarTransition(direction);
calendarView.value = "day"; calendarView.value = "day";
} }
function openWeekView(key: string) { function openWeekView(key: string, direction: CalendarTransitionDirection = "in") {
pickDate(key); pickDate(key);
setCalendarTransition(direction);
calendarView.value = "week"; calendarView.value = "week";
} }
function openYearMonth(monthIndex: number) { function openYearMonth(monthIndex: number, direction: CalendarTransitionDirection = "in") {
focusedCalendarEventId.value = ""; focusedCalendarEventId.value = "";
const year = calendarCursor.value.getFullYear(); const year = calendarCursor.value.getFullYear();
calendarCursor.value = new Date(year, monthIndex, 1); calendarCursor.value = new Date(year, monthIndex, 1);
selectedDateKey.value = dayKey(new Date(year, monthIndex, 1)); selectedDateKey.value = dayKey(new Date(year, monthIndex, 1));
setCalendarTransition(direction);
calendarView.value = "month"; calendarView.value = "month";
} }
@@ -4083,30 +4082,12 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</div> </div>
<div class="justify-self-end flex items-center gap-2"> <div class="justify-self-end flex items-center gap-2">
<button <div class="hidden items-center gap-2 rounded-lg border border-base-300 bg-base-100 px-2 py-1 sm:flex">
class="btn btn-xs" <span class="text-[10px] font-semibold uppercase tracking-wide text-base-content/60">Wheel to zoom</span>
:class="calendarPanMode ? 'btn-primary' : 'btn-ghost'" <span class="badge badge-ghost badge-xs min-w-[3.2rem] justify-center">{{ calendarZoomDepthLabel }}</span>
:title="calendarPanMode ? 'Disable pan mode' : 'Enable pan mode'"
@click.stop="toggleCalendarPanMode"
>
Pan
</button>
<button class="btn btn-xs btn-ghost" title="Reset canvas" @click.stop="resetCalendarCanvas">Reset</button>
<span class="text-[10px] uppercase tracking-wide text-base-content/60">Zoom</span>
<div class="flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 px-2 py-1">
<input
v-model.number="calendarZoomLevel"
type="range"
min="1"
max="4"
step="1"
class="range range-xs w-24 calendar-zoom-range"
aria-label="Calendar zoom level"
>
<span class="min-w-[3.2rem] text-right text-[10px] font-semibold uppercase tracking-wide text-base-content/70">
{{ calendarZoomLabel }}
</span>
</div> </div>
<button class="btn btn-xs btn-ghost" :disabled="!canCalendarZoomOut" title="Zoom out" @click.stop="zoomOutCalendar"></button>
<button class="btn btn-xs btn-ghost" :disabled="!canCalendarZoomIn" title="Zoom in" @click.stop="zoomInCalendar">+</button>
</div> </div>
</div> </div>
@@ -4141,18 +4122,13 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</button> </button>
<div <div
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"
:class="calendarPanMode ? (calendarCanvasDragging ? 'calendar-pan-active' : 'calendar-pan-ready') : ''" @wheel.prevent="onCalendarHierarchyWheel"
> >
<Transition :name="calendarTransitionName" mode="out-in">
<div <div
ref="calendarCanvasRef" :key="calendarSceneKey"
class="calendar-canvas" class="calendar-scene"
:style="calendarCanvasStyle" @mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''"
@wheel.prevent="onCalendarCanvasWheel"
@pointerdown="onCalendarCanvasPointerDown"
@pointermove="onCalendarCanvasPointerMove"
@pointerup="stopCalendarCanvasDrag"
@pointercancel="stopCalendarCanvasDrag"
@pointerleave="stopCalendarCanvasDrag"
> >
<div v-if="calendarView === 'month'" class="space-y-1"> <div v-if="calendarView === 'month'" class="space-y-1">
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60"> <div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
@@ -4170,6 +4146,8 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
v-for="row in monthRows" v-for="row in monthRows"
:key="row.key" :key="row.key"
class="group relative" class="group relative"
:data-calendar-week-start-key="row.startKey"
@mouseenter="calendarHoveredWeekStartKey = row.startKey"
> >
<button <button
type="button" type="button"
@@ -4192,6 +4170,8 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '', selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
monthCellHasFocusedEvent(cell.events) ? 'border-success/60 bg-success/10' : '', monthCellHasFocusedEvent(cell.events) ? 'border-success/60 bg-success/10' : '',
]" ]"
:data-calendar-day-key="cell.key"
@mouseenter="calendarHoveredDayKey = cell.key"
@click="pickDate(cell.key)" @click="pickDate(cell.key)"
> >
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p> <p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
@@ -4226,8 +4206,10 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<article <article
v-for="day in weekDays" v-for="day in weekDays"
:key="day.key" :key="day.key"
class="group relative flex min-h-[18rem] flex-col rounded-xl border border-base-300 bg-base-100 p-2.5" class="group relative flex min-h-[18rem] flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in"
:class="selectedDateKey === day.key ? 'border-primary bg-primary/5' : ''" :class="selectedDateKey === day.key ? 'border-primary bg-primary/5' : ''"
:data-calendar-day-key="day.key"
@mouseenter="calendarHoveredDayKey = day.key"
@click="pickDate(day.key)" @click="pickDate(day.key)"
> >
<div class="mb-2 flex items-start justify-between gap-2"> <div class="mb-2 flex items-start justify-between gap-2">
@@ -4280,7 +4262,9 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<button <button
v-for="item in yearMonths" v-for="item in yearMonths"
:key="`year-month-${item.monthIndex}`" :key="`year-month-${item.monthIndex}`"
class="group relative rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5" class="group relative rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in"
:data-calendar-month-index="item.monthIndex"
@mouseenter="calendarHoveredMonthIndex = item.monthIndex"
@click="openYearMonth(item.monthIndex)" @click="openYearMonth(item.monthIndex)"
> >
<button <button
@@ -4321,6 +4305,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</button> </button>
</div> </div>
</div> </div>
</Transition>
</div> </div>
</div> </div>
</section> </section>
@@ -5394,21 +5379,14 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
.calendar-content-scroll { .calendar-content-scroll {
height: 100%; height: 100%;
overscroll-behavior: contain;
} }
.calendar-canvas { .calendar-scene {
min-height: 100%;
min-width: 100%; min-width: 100%;
transform-origin: 0 0; transform-origin: center center;
transition: transform 120ms ease-out; will-change: transform, opacity;
will-change: transform;
}
.calendar-pan-ready {
cursor: grab;
}
.calendar-pan-active {
cursor: grabbing;
} }
.calendar-week-grid { .calendar-week-grid {
@@ -5514,9 +5492,43 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
transform: translate(0, -50%); transform: translate(0, -50%);
} }
.calendar-zoom-range { .calendar-zoom-in-enter-active,
--range-shdw: color-mix(in oklab, var(--color-primary) 72%, white 8%); .calendar-zoom-in-leave-active,
--range-bg: color-mix(in oklab, var(--color-base-300) 85%, white 10%); .calendar-zoom-out-enter-active,
.calendar-zoom-out-leave-active,
.calendar-zoom-side-enter-active,
.calendar-zoom-side-leave-active {
transition: transform 230ms cubic-bezier(0.2, 0.86, 0.2, 1), opacity 230ms ease;
}
.calendar-zoom-in-enter-from {
opacity: 0;
transform: scale(0.92) translateY(8px);
}
.calendar-zoom-in-leave-to {
opacity: 0;
transform: scale(1.07) translateY(-8px);
}
.calendar-zoom-out-enter-from {
opacity: 0;
transform: scale(1.07) translateY(-8px);
}
.calendar-zoom-out-leave-to {
opacity: 0;
transform: scale(0.92) translateY(8px);
}
.calendar-zoom-side-enter-from {
opacity: 0;
transform: translateY(8px);
}
.calendar-zoom-side-leave-to {
opacity: 0;
transform: translateY(-8px);
} }
@media (max-width: 960px) { @media (max-width: 960px) {