frontend: add pan+wheel zoom canvas interaction for calendar

This commit is contained in:
Ruslan Bakiev
2026-02-22 08:06:40 +07:00
parent 6ee12ed254
commit 40b5fa86f6

View File

@@ -2129,6 +2129,88 @@ const calendarZoomLabel = computed(() => {
return "Year";
});
const calendarPanMode = ref(false);
const calendarCanvasRef = ref<HTMLElement | null>(null);
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() {
calendarCanvasScale.value = 1;
calendarCanvasOffsetX.value = 0;
calendarCanvasOffsetY.value = 0;
calendarCanvasDragging.value = false;
calendarCanvasPointerId = null;
}
function toggleCalendarPanMode() {
calendarPanMode.value = !calendarPanMode.value;
if (!calendarPanMode.value) {
calendarCanvasDragging.value = false;
calendarCanvasPointerId = null;
}
}
function onCalendarCanvasWheel(event: WheelEvent) {
const host = calendarCanvasRef.value;
if (!host) return;
const rect = host.getBoundingClientRect();
const pivotX = event.clientX - rect.left;
const pivotY = event.clientY - rect.top;
const prevScale = calendarCanvasScale.value;
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 (!calendarPanMode.value || event.button !== 0) return;
const target = event.currentTarget as HTMLElement | null;
if (!target) 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 (!calendarPanMode.value || !calendarCanvasDragging.value || calendarCanvasPointerId !== event.pointerId) return;
calendarCanvasOffsetX.value = calendarCanvasStartOffsetX + (event.clientX - calendarCanvasDragStartX);
calendarCanvasOffsetY.value = calendarCanvasStartOffsetY + (event.clientY - calendarCanvasDragStartY);
}
function stopCalendarCanvasDrag(event?: PointerEvent) {
if (!calendarCanvasDragging.value) return;
if (event && calendarCanvasPointerId !== event.pointerId) return;
calendarCanvasDragging.value = false;
calendarCanvasPointerId = null;
}
const monthCells = computed(() => {
const year = calendarCursor.value.getFullYear();
const month = calendarCursor.value.getMonth();
@@ -4003,6 +4085,15 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</div>
<div class="justify-self-end flex items-center gap-2">
<button
class="btn btn-xs"
:class="calendarPanMode ? 'btn-primary' : 'btn-ghost'"
: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
@@ -4050,7 +4141,21 @@ 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">
<div
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
:class="calendarPanMode ? (calendarCanvasDragging ? 'calendar-pan-active' : 'calendar-pan-ready') : ''"
>
<div
ref="calendarCanvasRef"
class="calendar-canvas"
:style="calendarCanvasStyle"
@wheel.prevent="onCalendarCanvasWheel"
@pointerdown="onCalendarCanvasPointerDown"
@pointermove="onCalendarCanvasPointerMove"
@pointerup="stopCalendarCanvasDrag"
@pointercancel="stopCalendarCanvasDrag"
@pointerleave="stopCalendarCanvasDrag"
>
<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>
@@ -4219,6 +4324,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</div>
</div>
</div>
</div>
</section>
<section v-else-if="selectedTab === 'communications' && false" class="space-y-3">
@@ -5292,6 +5398,21 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
height: 100%;
}
.calendar-canvas {
min-width: 100%;
transform-origin: 0 0;
transition: transform 120ms ease-out;
will-change: transform;
}
.calendar-pan-ready {
cursor: grab;
}
.calendar-pan-active {
cursor: grabbing;
}
.calendar-week-grid {
display: grid;
grid-template-columns: repeat(7, minmax(165px, 1fr));