From 40b5fa86f66ef015b4e271fe552867be51be747d Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Sun, 22 Feb 2026 08:06:40 +0700 Subject: [PATCH] frontend: add pan+wheel zoom canvas interaction for calendar --- frontend/app.vue | 123 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/frontend/app.vue b/frontend/app.vue index 107ea39..48885ec 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -2129,6 +2129,88 @@ const calendarZoomLabel = computed(() => { return "Year"; }); +const calendarPanMode = ref(false); +const calendarCanvasRef = ref(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")
+ + Zoom
-
+
+
Sun @@ -4217,6 +4322,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")

{{ event.note }}

+
@@ -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));