calendar: unify zoom-in pipeline with ghost card text
This commit is contained in:
178
frontend/app.vue
178
frontend/app.vue
@@ -32,7 +32,7 @@ type CalendarView = "day" | "week" | "month" | "year" | "agenda";
|
||||
type SortMode = "name" | "lastContact";
|
||||
type PeopleLeftMode = "contacts" | "calendar";
|
||||
type PeopleSortMode = "name" | "lastContact" | "company" | "country";
|
||||
type DocumentSortMode = "updatedAt" | "title" | "owner" | "type";
|
||||
type DocumentSortMode = "updatedAt" | "title" | "owner";
|
||||
|
||||
type FeedCard = {
|
||||
id: string;
|
||||
@@ -2274,6 +2274,10 @@ const calendarViewOptions: { value: CalendarView; label: string }[] = [
|
||||
|
||||
type CalendarHierarchyView = "year" | "month" | "week" | "day";
|
||||
type CalendarRect = { left: number; top: number; width: number; height: number };
|
||||
type CalendarZoomGhost = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
const calendarContentWrapRef = ref<HTMLElement | null>(null);
|
||||
const calendarHoveredMonthIndex = ref<number | null>(null);
|
||||
@@ -2286,13 +2290,13 @@ const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
const calendarZoomGhost = ref<CalendarZoomGhost | null>(null);
|
||||
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;
|
||||
let calendarZoomPrimeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let calendarZoomPrimeLastAt = 0;
|
||||
const CALENDAR_ZOOM_DURATION_MS = 2400;
|
||||
@@ -2313,10 +2317,11 @@ const calendarZoomOverlayStyle = computed(() => ({
|
||||
}));
|
||||
|
||||
function clearCalendarZoomOverlay() {
|
||||
if (calendarZoomOverlayTimer) {
|
||||
clearTimeout(calendarZoomOverlayTimer);
|
||||
calendarZoomOverlayTimer = null;
|
||||
}
|
||||
calendarZoomOverlay.value = {
|
||||
...calendarZoomOverlay.value,
|
||||
active: false,
|
||||
};
|
||||
calendarZoomGhost.value = null;
|
||||
}
|
||||
|
||||
function clearCalendarZoomPrime() {
|
||||
@@ -2407,6 +2412,71 @@ function weekRowStartForDate(key: string) {
|
||||
return dayKey(date);
|
||||
}
|
||||
|
||||
function zoomGhostForMonth(monthIndex: number): CalendarZoomGhost {
|
||||
const item = yearMonths.value.find((entry) => entry.monthIndex === monthIndex);
|
||||
if (!item) {
|
||||
return {
|
||||
title: new Intl.DateTimeFormat("en-US", { month: "long" }).format(new Date(calendarCursor.value.getFullYear(), monthIndex, 1)),
|
||||
subtitle: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: item.label,
|
||||
subtitle: `${item.count} events`,
|
||||
};
|
||||
}
|
||||
|
||||
function zoomGhostForWeek(startKey: string): CalendarZoomGhost {
|
||||
const start = new Date(`${startKey}T00:00:00`);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
const row = monthRows.value.find((item) => item.startKey === startKey);
|
||||
const count = row ? row.cells.reduce((sum, cell) => sum + cell.events.length, 0) : 0;
|
||||
return {
|
||||
title: `${formatDay(`${dayKey(start)}T00:00:00`)} - ${formatDay(`${dayKey(end)}T00:00:00`)}`,
|
||||
subtitle: `${count} events`,
|
||||
};
|
||||
}
|
||||
|
||||
function zoomGhostForDay(dayKeyValue: string): CalendarZoomGhost {
|
||||
const day = weekDays.value.find((entry) => entry.key === dayKeyValue);
|
||||
if (!day) {
|
||||
return {
|
||||
title: formatDay(`${dayKeyValue}T00:00:00`),
|
||||
subtitle: `${getEventsByDate(dayKeyValue).length} events`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: `${day.label} ${day.day}`,
|
||||
subtitle: `${day.events.length} events`,
|
||||
};
|
||||
}
|
||||
|
||||
function zoomGhostForCurrentView(): CalendarZoomGhost {
|
||||
if (calendarView.value === "day") {
|
||||
return {
|
||||
title: formatDay(`${selectedDateKey.value}T00:00:00`),
|
||||
subtitle: `${selectedDayEvents.value.length} events`,
|
||||
};
|
||||
}
|
||||
if (calendarView.value === "week") {
|
||||
return {
|
||||
title: calendarPeriodLabel.value,
|
||||
subtitle: `${weekDays.value.reduce((sum, day) => sum + day.events.length, 0)} events`,
|
||||
};
|
||||
}
|
||||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||||
return {
|
||||
title: monthLabel.value,
|
||||
subtitle: `${monthCells.value.reduce((sum, cell) => sum + cell.events.length, 0)} events`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: String(calendarCursor.value.getFullYear()),
|
||||
subtitle: `${sortedEvents.value.filter((event) => new Date(event.start).getFullYear() === calendarCursor.value.getFullYear()).length} events`,
|
||||
};
|
||||
}
|
||||
|
||||
function primeCalendarRect(rect: CalendarRect) {
|
||||
clearCalendarZoomOverlay();
|
||||
calendarZoomOverlay.value = {
|
||||
@@ -2428,12 +2498,6 @@ function morphCalendarRect(toRect: CalendarRect) {
|
||||
height: toRect.height,
|
||||
};
|
||||
});
|
||||
calendarZoomOverlayTimer = setTimeout(() => {
|
||||
calendarZoomOverlay.value = {
|
||||
...calendarZoomOverlay.value,
|
||||
active: false,
|
||||
};
|
||||
}, CALENDAR_ZOOM_DURATION_MS + 40);
|
||||
}
|
||||
|
||||
function waitCalendarZoom() {
|
||||
@@ -2442,13 +2506,7 @@ function waitCalendarZoom() {
|
||||
});
|
||||
}
|
||||
|
||||
function waitMs(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function animateCalendarZoomIn(sourceElement: HTMLElement | null, apply: () => void) {
|
||||
async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: CalendarZoomGhost, apply: () => void) {
|
||||
const fromRect = getElementRectInCalendar(sourceElement);
|
||||
const viewportRect = getCalendarViewportRect();
|
||||
if (!fromRect || !viewportRect) {
|
||||
@@ -2460,14 +2518,15 @@ async function animateCalendarZoomIn(sourceElement: HTMLElement | null, apply: (
|
||||
calendarZoomBusy.value = true;
|
||||
try {
|
||||
primeCalendarRect(fromRect);
|
||||
morphCalendarRect(viewportRect);
|
||||
const switchLag = 260;
|
||||
await waitMs(Math.max(0, CALENDAR_ZOOM_DURATION_MS - switchLag));
|
||||
calendarZoomGhost.value = ghost;
|
||||
calendarSceneMasked.value = true;
|
||||
await nextTick();
|
||||
morphCalendarRect(viewportRect);
|
||||
await waitCalendarZoom();
|
||||
apply();
|
||||
await nextTick();
|
||||
await waitMs(switchLag);
|
||||
} finally {
|
||||
clearCalendarZoomOverlay();
|
||||
calendarSceneMasked.value = false;
|
||||
calendarZoomBusy.value = false;
|
||||
}
|
||||
@@ -2484,6 +2543,7 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
|
||||
calendarZoomBusy.value = true;
|
||||
try {
|
||||
primeCalendarRect(viewportRect);
|
||||
calendarZoomGhost.value = zoomGhostForCurrentView();
|
||||
calendarSceneMasked.value = true;
|
||||
await nextTick();
|
||||
apply();
|
||||
@@ -2499,6 +2559,7 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
|
||||
morphCalendarRect(targetRect);
|
||||
await waitCalendarZoom();
|
||||
} finally {
|
||||
clearCalendarZoomOverlay();
|
||||
calendarSceneMasked.value = false;
|
||||
calendarZoomBusy.value = false;
|
||||
}
|
||||
@@ -2538,7 +2599,7 @@ async function zoomInCalendar(event?: Event) {
|
||||
const monthIndex = resolveMonthAnchor(wheelEvent);
|
||||
const sourceElement = queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`);
|
||||
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return;
|
||||
await animateCalendarZoomIn(sourceElement, () => {
|
||||
await animateCalendarZoomIn(sourceElement, zoomGhostForMonth(monthIndex), () => {
|
||||
openYearMonth(monthIndex);
|
||||
});
|
||||
return;
|
||||
@@ -2553,6 +2614,7 @@ async function zoomInCalendar(event?: Event) {
|
||||
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return;
|
||||
await animateCalendarZoomIn(
|
||||
sourceElement,
|
||||
zoomGhostForWeek(rowStartKey),
|
||||
() => {
|
||||
openWeekView(anchorDayKey);
|
||||
},
|
||||
@@ -2564,16 +2626,20 @@ async function zoomInCalendar(event?: Event) {
|
||||
const dayAnchor = resolveDayAnchor(wheelEvent);
|
||||
const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`);
|
||||
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return;
|
||||
await animateCalendarZoomIn(sourceElement, () => {
|
||||
await animateCalendarZoomIn(sourceElement, zoomGhostForDay(dayAnchor), () => {
|
||||
openDayView(dayAnchor);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function zoomToMonth(monthIndex: number) {
|
||||
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => {
|
||||
openYearMonth(monthIndex);
|
||||
});
|
||||
await animateCalendarZoomIn(
|
||||
queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`),
|
||||
zoomGhostForMonth(monthIndex),
|
||||
() => {
|
||||
openYearMonth(monthIndex);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function zoomOutCalendar() {
|
||||
@@ -2950,7 +3016,6 @@ const documentSortOptions: Array<{ value: DocumentSortMode; label: string }> = [
|
||||
{ value: "updatedAt", label: "Updated" },
|
||||
{ value: "title", label: "Title" },
|
||||
{ value: "owner", label: "Owner" },
|
||||
{ value: "type", label: "Type" },
|
||||
];
|
||||
|
||||
const filteredDocuments = computed(() => {
|
||||
@@ -2965,7 +3030,6 @@ const filteredDocuments = computed(() => {
|
||||
.sort((a, b) => {
|
||||
if (documentSortMode.value === "title") return a.title.localeCompare(b.title);
|
||||
if (documentSortMode.value === "owner") return a.owner.localeCompare(b.owner);
|
||||
if (documentSortMode.value === "type") return a.type.localeCompare(b.type);
|
||||
return b.updatedAt.localeCompare(a.updatedAt);
|
||||
});
|
||||
|
||||
@@ -3153,10 +3217,8 @@ const commEventForm = ref({
|
||||
durationMinutes: 30,
|
||||
});
|
||||
const commDocumentForm = ref<{
|
||||
type: WorkspaceDocument["type"];
|
||||
title: string;
|
||||
}>({
|
||||
type: "Template",
|
||||
title: "",
|
||||
});
|
||||
const eventCloseOpen = ref<Record<string, boolean>>({});
|
||||
@@ -3184,7 +3246,7 @@ watch(selectedCommThreadId, () => {
|
||||
commComposerMode.value = "message";
|
||||
commQuickMenuOpen.value = false;
|
||||
commEventError.value = "";
|
||||
commDocumentForm.value = { type: "Template", title: "" };
|
||||
commDocumentForm.value = { title: "" };
|
||||
eventCloseOpen.value = {};
|
||||
eventCloseDraft.value = {};
|
||||
eventCloseSaving.value = {};
|
||||
@@ -3953,7 +4015,6 @@ function setDefaultCommEventForm(mode: "planned" | "logged") {
|
||||
|
||||
function setDefaultCommDocumentForm() {
|
||||
commDocumentForm.value = {
|
||||
type: "Template",
|
||||
title: "",
|
||||
};
|
||||
}
|
||||
@@ -4089,7 +4150,6 @@ async function createCommDocument() {
|
||||
const res = await gqlFetch<{ createWorkspaceDocument: WorkspaceDocument }>(createWorkspaceDocumentMutation, {
|
||||
input: {
|
||||
title,
|
||||
type: commDocumentForm.value.type,
|
||||
owner: authDisplayName.value,
|
||||
scope,
|
||||
summary,
|
||||
@@ -4876,7 +4936,12 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
v-if="calendarZoomOverlay.active"
|
||||
class="calendar-zoom-overlay"
|
||||
:style="calendarZoomOverlayStyle"
|
||||
/>
|
||||
>
|
||||
<div v-if="calendarZoomGhost" class="calendar-zoom-overlay-content">
|
||||
<p class="calendar-zoom-overlay-title">{{ calendarZoomGhost.title }}</p>
|
||||
<p v-if="calendarZoomGhost.subtitle" class="calendar-zoom-overlay-subtitle">{{ calendarZoomGhost.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -5626,16 +5691,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
:disabled="commEventSaving"
|
||||
placeholder="Document title (optional)"
|
||||
>
|
||||
<select
|
||||
v-model="commDocumentForm.type"
|
||||
class="select select-bordered select-xs h-7 min-h-7"
|
||||
:disabled="commEventSaving"
|
||||
>
|
||||
<option value="Regulation">Regulation</option>
|
||||
<option value="Playbook">Playbook</option>
|
||||
<option value="Policy">Policy</option>
|
||||
<option value="Template">Template</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="commEventError && commComposerMode !== 'message'" class="comm-event-error text-xs text-error">
|
||||
@@ -5764,7 +5819,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ doc.title }}</p>
|
||||
<span class="shrink-0 text-[10px] text-base-content/55">{{ doc.type }}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 line-clamp-2 text-[11px] text-base-content/70">{{ doc.summary }}</p>
|
||||
<div class="mt-1 flex items-center justify-between gap-2">
|
||||
@@ -5909,7 +5963,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ doc.title }}</p>
|
||||
<span class="shrink-0 text-[10px] text-base-content/55">{{ doc.type }}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ formatDocumentScope(doc.scope) }}</p>
|
||||
<p class="mt-0.5 line-clamp-2 text-[11px] text-base-content/70">{{ doc.summary }}</p>
|
||||
@@ -5926,7 +5979,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
<div class="border-b border-base-300 pb-2">
|
||||
<p class="font-medium">{{ selectedDocument.title }}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{{ selectedDocument.type }} · {{ formatDocumentScope(selectedDocument.scope) }} · {{ selectedDocument.owner }}
|
||||
{{ formatDocumentScope(selectedDocument.scope) }} · {{ selectedDocument.owner }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-base-content/80">{{ selectedDocument.summary }}</p>
|
||||
</div>
|
||||
@@ -6227,6 +6280,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
border: 3px solid color-mix(in oklab, var(--color-primary) 64%, transparent);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
left 2400ms cubic-bezier(0.16, 0.86, 0.18, 1),
|
||||
@@ -6235,6 +6289,32 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
height 2400ms cubic-bezier(0.16, 0.86, 0.18, 1);
|
||||
}
|
||||
|
||||
.calendar-zoom-overlay-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
color: color-mix(in oklab, var(--color-base-content) 86%, transparent);
|
||||
}
|
||||
|
||||
.calendar-zoom-overlay-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.calendar-zoom-overlay-subtitle {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
opacity: 0.74;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.calendar-content-wrap {
|
||||
padding-left: 32px;
|
||||
|
||||
Reference in New Issue
Block a user