frontend: add pan+wheel zoom canvas interaction for calendar
This commit is contained in:
123
frontend/app.vue
123
frontend/app.vue
@@ -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>
|
||||
@@ -4217,6 +4322,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user