calendar: add hover target and staged wheel zoom
This commit is contained in:
249
frontend/app.vue
249
frontend/app.vue
@@ -2073,6 +2073,7 @@ onBeforeUnmount(() => {
|
||||
lifecycleClock = null;
|
||||
}
|
||||
clearCalendarZoomOverlay();
|
||||
clearCalendarZoomPrime();
|
||||
});
|
||||
|
||||
const calendarView = ref<CalendarView>("year");
|
||||
@@ -2130,9 +2131,19 @@ const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
const calendarZoomBusy = ref(false);
|
||||
const calendarSceneMasked = ref(false);
|
||||
const calendarZoomPrimeToken = ref("");
|
||||
const calendarZoomPrimeScale = ref(1);
|
||||
const calendarZoomPrimeTicks = ref(0);
|
||||
let calendarWheelLockUntil = 0;
|
||||
let calendarZoomOverlayTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const CALENDAR_ZOOM_DURATION_MS = 180;
|
||||
let calendarZoomPrimeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let calendarZoomPrimeLastAt = 0;
|
||||
const CALENDAR_ZOOM_DURATION_MS = 2400;
|
||||
const CALENDAR_ZOOM_PRIME_STEPS = 2;
|
||||
const CALENDAR_ZOOM_PRIME_MAX_SCALE = 1.05;
|
||||
const CALENDAR_ZOOM_PRIME_RESET_MS = 900;
|
||||
const calendarZoomOrder: CalendarHierarchyView[] = ["year", "month", "week", "day"];
|
||||
|
||||
const normalizedCalendarView = computed<CalendarHierarchyView>(() =>
|
||||
@@ -2153,6 +2164,61 @@ function clearCalendarZoomOverlay() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearCalendarZoomPrime() {
|
||||
if (calendarZoomPrimeTimer) {
|
||||
clearTimeout(calendarZoomPrimeTimer);
|
||||
calendarZoomPrimeTimer = null;
|
||||
}
|
||||
calendarZoomPrimeToken.value = "";
|
||||
calendarZoomPrimeScale.value = 1;
|
||||
calendarZoomPrimeTicks.value = 0;
|
||||
calendarZoomPrimeLastAt = 0;
|
||||
}
|
||||
|
||||
function calendarPrimeMonthToken(monthIndex: number) {
|
||||
return `year-month-${monthIndex}`;
|
||||
}
|
||||
|
||||
function calendarPrimeWeekToken(startKey: string) {
|
||||
return `month-week-${startKey}`;
|
||||
}
|
||||
|
||||
function calendarPrimeDayToken(key: string) {
|
||||
return `week-day-${key}`;
|
||||
}
|
||||
|
||||
function calendarPrimeStyle(token: string) {
|
||||
if (calendarZoomPrimeToken.value !== token) return undefined;
|
||||
return {
|
||||
transform: `scale(${calendarZoomPrimeScale.value})`,
|
||||
};
|
||||
}
|
||||
|
||||
function maybePrimeWheelZoom(event: WheelEvent | undefined, token: string) {
|
||||
if (!event || event.deltaY >= 0) return false;
|
||||
const now = Date.now();
|
||||
if (calendarZoomPrimeToken.value !== token || now - calendarZoomPrimeLastAt > CALENDAR_ZOOM_PRIME_RESET_MS) {
|
||||
calendarZoomPrimeTicks.value = 0;
|
||||
}
|
||||
|
||||
calendarZoomPrimeToken.value = token;
|
||||
calendarZoomPrimeTicks.value += 1;
|
||||
calendarZoomPrimeLastAt = now;
|
||||
|
||||
if (calendarZoomPrimeTicks.value <= CALENDAR_ZOOM_PRIME_STEPS) {
|
||||
const ratio = calendarZoomPrimeTicks.value / CALENDAR_ZOOM_PRIME_STEPS;
|
||||
calendarZoomPrimeScale.value = 1 + (CALENDAR_ZOOM_PRIME_MAX_SCALE - 1) * ratio;
|
||||
if (calendarZoomPrimeTimer) clearTimeout(calendarZoomPrimeTimer);
|
||||
calendarZoomPrimeTimer = setTimeout(() => {
|
||||
clearCalendarZoomPrime();
|
||||
}, CALENDAR_ZOOM_PRIME_RESET_MS);
|
||||
return true;
|
||||
}
|
||||
|
||||
clearCalendarZoomPrime();
|
||||
return false;
|
||||
}
|
||||
|
||||
function queryCalendarElement(selector: string) {
|
||||
return calendarContentWrapRef.value?.querySelector<HTMLElement>(selector) ?? null;
|
||||
}
|
||||
@@ -2229,11 +2295,20 @@ async function animateCalendarZoomIn(sourceElement: HTMLElement | null, apply: (
|
||||
return;
|
||||
}
|
||||
|
||||
primeCalendarRect(fromRect);
|
||||
apply();
|
||||
await nextTick();
|
||||
morphCalendarRect(viewportRect);
|
||||
await waitCalendarZoom();
|
||||
clearCalendarZoomPrime();
|
||||
calendarZoomBusy.value = true;
|
||||
try {
|
||||
primeCalendarRect(fromRect);
|
||||
calendarSceneMasked.value = true;
|
||||
await nextTick();
|
||||
apply();
|
||||
await nextTick();
|
||||
morphCalendarRect(viewportRect);
|
||||
await waitCalendarZoom();
|
||||
} finally {
|
||||
calendarSceneMasked.value = false;
|
||||
calendarZoomBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HTMLElement | null) {
|
||||
@@ -2243,19 +2318,28 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
|
||||
return;
|
||||
}
|
||||
|
||||
primeCalendarRect(viewportRect);
|
||||
apply();
|
||||
await nextTick();
|
||||
const targetRect = getElementRectInCalendar(resolveTarget());
|
||||
if (!targetRect) {
|
||||
calendarZoomOverlay.value = {
|
||||
...calendarZoomOverlay.value,
|
||||
active: false,
|
||||
};
|
||||
return;
|
||||
clearCalendarZoomPrime();
|
||||
calendarZoomBusy.value = true;
|
||||
try {
|
||||
primeCalendarRect(viewportRect);
|
||||
calendarSceneMasked.value = true;
|
||||
await nextTick();
|
||||
apply();
|
||||
await nextTick();
|
||||
const targetRect = getElementRectInCalendar(resolveTarget());
|
||||
if (!targetRect) {
|
||||
calendarZoomOverlay.value = {
|
||||
...calendarZoomOverlay.value,
|
||||
active: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
morphCalendarRect(targetRect);
|
||||
await waitCalendarZoom();
|
||||
} finally {
|
||||
calendarSceneMasked.value = false;
|
||||
calendarZoomBusy.value = false;
|
||||
}
|
||||
morphCalendarRect(targetRect);
|
||||
await waitCalendarZoom();
|
||||
}
|
||||
|
||||
function resolveMonthAnchor(event?: WheelEvent) {
|
||||
@@ -2290,7 +2374,9 @@ async function zoomInCalendar(event?: Event) {
|
||||
const wheelEvent = event instanceof WheelEvent ? event : undefined;
|
||||
if (calendarView.value === "year") {
|
||||
const monthIndex = resolveMonthAnchor(wheelEvent);
|
||||
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => {
|
||||
const sourceElement = queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`);
|
||||
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return;
|
||||
await animateCalendarZoomIn(sourceElement, () => {
|
||||
openYearMonth(monthIndex);
|
||||
});
|
||||
return;
|
||||
@@ -2299,9 +2385,12 @@ async function zoomInCalendar(event?: Event) {
|
||||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||||
const anchorDayKey = resolveWeekAnchor(wheelEvent);
|
||||
const rowStartKey = weekRowStartForDate(anchorDayKey);
|
||||
await animateCalendarZoomIn(
|
||||
const sourceElement =
|
||||
queryCalendarElement(`[data-calendar-week-start-key="${rowStartKey}"]`) ??
|
||||
queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`),
|
||||
queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`);
|
||||
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return;
|
||||
await animateCalendarZoomIn(
|
||||
sourceElement,
|
||||
() => {
|
||||
openWeekView(anchorDayKey);
|
||||
},
|
||||
@@ -2311,7 +2400,9 @@ async function zoomInCalendar(event?: Event) {
|
||||
|
||||
if (calendarView.value === "week") {
|
||||
const dayAnchor = resolveDayAnchor(wheelEvent);
|
||||
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`), () => {
|
||||
const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`);
|
||||
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return;
|
||||
await animateCalendarZoomIn(sourceElement, () => {
|
||||
openDayView(dayAnchor);
|
||||
});
|
||||
}
|
||||
@@ -2325,6 +2416,7 @@ async function zoomToMonth(monthIndex: number) {
|
||||
|
||||
async function zoomOutCalendar() {
|
||||
focusedCalendarEventId.value = "";
|
||||
clearCalendarZoomPrime();
|
||||
|
||||
if (calendarView.value === "day") {
|
||||
const targetDayKey = selectedDateKey.value;
|
||||
@@ -2363,9 +2455,10 @@ async function zoomOutCalendar() {
|
||||
|
||||
function onCalendarHierarchyWheel(event: WheelEvent) {
|
||||
const now = Date.now();
|
||||
if (calendarZoomBusy.value) return;
|
||||
if (now < calendarWheelLockUntil) return;
|
||||
if (Math.abs(event.deltaY) < 5) return;
|
||||
calendarWheelLockUntil = now + 240;
|
||||
calendarWheelLockUntil = now + 140;
|
||||
|
||||
if (event.deltaY < 0) {
|
||||
void zoomInCalendar(event);
|
||||
@@ -4303,14 +4396,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
>
|
||||
<span>→</span>
|
||||
</button>
|
||||
<div
|
||||
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
|
||||
@wheel.prevent="onCalendarHierarchyWheel"
|
||||
>
|
||||
<div
|
||||
:class="normalizedCalendarView === 'day' ? 'calendar-scene cursor-zoom-out' : 'calendar-scene cursor-zoom-in'"
|
||||
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''"
|
||||
>
|
||||
<div
|
||||
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
|
||||
@wheel.prevent="onCalendarHierarchyWheel"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'calendar-scene',
|
||||
normalizedCalendarView === 'day' ? 'cursor-zoom-out' : 'cursor-zoom-in',
|
||||
calendarSceneMasked ? 'calendar-scene-hidden' : '',
|
||||
]"
|
||||
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''; clearCalendarZoomPrime()"
|
||||
>
|
||||
<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">
|
||||
<span>Sun</span>
|
||||
@@ -4323,13 +4420,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="row in monthRows"
|
||||
:key="row.key"
|
||||
class="group relative"
|
||||
:data-calendar-week-start-key="row.startKey"
|
||||
@mouseenter="calendarHoveredWeekStartKey = row.startKey"
|
||||
>
|
||||
<div
|
||||
v-for="row in monthRows"
|
||||
:key="row.key"
|
||||
class="group relative calendar-hover-targetable"
|
||||
:class="[
|
||||
calendarHoveredWeekStartKey === row.startKey ? 'calendar-hover-target' : '',
|
||||
calendarZoomPrimeToken === calendarPrimeWeekToken(row.startKey) ? 'calendar-zoom-prime-active' : '',
|
||||
]"
|
||||
:style="calendarPrimeStyle(calendarPrimeWeekToken(row.startKey))"
|
||||
:data-calendar-week-start-key="row.startKey"
|
||||
@mouseenter="calendarHoveredWeekStartKey = row.startKey"
|
||||
>
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<button
|
||||
v-for="cell in row.cells"
|
||||
@@ -4362,14 +4464,19 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
|
||||
<div v-else-if="calendarView === 'week'" class="calendar-week-scroll overflow-x-auto pb-1">
|
||||
<div class="calendar-week-grid">
|
||||
<article
|
||||
v-for="day in weekDays"
|
||||
:key="day.key"
|
||||
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' : ''"
|
||||
:data-calendar-day-key="day.key"
|
||||
@mouseenter="calendarHoveredDayKey = day.key"
|
||||
@click="pickDate(day.key)"
|
||||
<article
|
||||
v-for="day in weekDays"
|
||||
:key="day.key"
|
||||
class="group relative flex min-h-[18rem] flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in calendar-hover-targetable"
|
||||
:class="[
|
||||
selectedDateKey === day.key ? 'border-primary bg-primary/5' : '',
|
||||
calendarHoveredDayKey === day.key ? 'calendar-hover-target' : '',
|
||||
calendarZoomPrimeToken === calendarPrimeDayToken(day.key) ? 'calendar-zoom-prime-active' : '',
|
||||
]"
|
||||
:style="calendarPrimeStyle(calendarPrimeDayToken(day.key))"
|
||||
:data-calendar-day-key="day.key"
|
||||
@mouseenter="calendarHoveredDayKey = day.key"
|
||||
@click="pickDate(day.key)"
|
||||
>
|
||||
<div class="mb-2 flex items-start justify-between gap-2">
|
||||
<p class="text-sm font-semibold leading-tight">{{ day.label }} {{ day.day }}</p>
|
||||
@@ -4407,13 +4514,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
</div>
|
||||
|
||||
<div v-else-if="calendarView === 'year'" class="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<button
|
||||
v-for="item in yearMonths"
|
||||
: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 cursor-zoom-in"
|
||||
:data-calendar-month-index="item.monthIndex"
|
||||
@mouseenter="calendarHoveredMonthIndex = item.monthIndex"
|
||||
@click="zoomToMonth(item.monthIndex)"
|
||||
<button
|
||||
v-for="item in yearMonths"
|
||||
: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 cursor-zoom-in calendar-hover-targetable"
|
||||
:class="[
|
||||
calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '',
|
||||
calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '',
|
||||
]"
|
||||
:style="calendarPrimeStyle(calendarPrimeMonthToken(item.monthIndex))"
|
||||
:data-calendar-month-index="item.monthIndex"
|
||||
@mouseenter="calendarHoveredMonthIndex = item.monthIndex"
|
||||
@click="zoomToMonth(item.monthIndex)"
|
||||
>
|
||||
<p class="font-medium">{{ item.label }}</p>
|
||||
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
|
||||
@@ -5529,6 +5641,10 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.calendar-scene-hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.calendar-scene.cursor-zoom-in,
|
||||
.calendar-scene.cursor-zoom-in * {
|
||||
cursor: zoom-in;
|
||||
@@ -5577,6 +5693,21 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.calendar-hover-targetable {
|
||||
transform-origin: center center;
|
||||
transition: transform 320ms ease, box-shadow 320ms ease, outline-color 320ms ease;
|
||||
}
|
||||
|
||||
.calendar-hover-target {
|
||||
outline: 2px solid color-mix(in oklab, var(--color-primary) 66%, transparent);
|
||||
outline-offset: 1px;
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 32%, transparent) inset;
|
||||
}
|
||||
|
||||
.calendar-zoom-prime-active {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.calendar-zoom-inline {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -5660,15 +5791,15 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
.calendar-zoom-overlay {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
border: 2px solid color-mix(in oklab, var(--color-primary) 60%, transparent);
|
||||
border: 3px solid color-mix(in oklab, var(--color-primary) 64%, transparent);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
|
||||
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
left 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
|
||||
top 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
|
||||
width 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
|
||||
height 180ms cubic-bezier(0.2, 0.85, 0.25, 1);
|
||||
left 2400ms cubic-bezier(0.16, 0.86, 0.18, 1),
|
||||
top 2400ms cubic-bezier(0.16, 0.86, 0.18, 1),
|
||||
width 2400ms cubic-bezier(0.16, 0.86, 0.18, 1),
|
||||
height 2400ms cubic-bezier(0.16, 0.86, 0.18, 1);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
|
||||
Reference in New Issue
Block a user