From 6620f4526291f7ada2c9aef8b0d35b791de90a1e Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 17 Aug 2026 02:05:05 +1000 Subject: [PATCH] Keep wheel zoom inside the chart --- e2e/planner.spec.ts | 8 ++- src/components/gantt-chart.test.tsx | 79 +++++++++++++++++++++++++++++ src/components/gantt-chart.tsx | 56 ++++++++++++-------- 3 files changed, 121 insertions(+), 22 deletions(-) diff --git a/e2e/planner.spec.ts b/e2e/planner.spec.ts index 138d5b4..d3e84ca 100644 --- a/e2e/planner.spec.ts +++ b/e2e/planner.spec.ts @@ -7,7 +7,13 @@ test.beforeEach(async ({ page }) => { test("zooms and toggles TaskNotes completion", async ({ page }) => { await expect(page.locator(".zoom-control output")).toHaveText("Weeks"); - await page.getByRole("button", { name: "Zoom in" }).click(); + const timeline = page.locator(".gantt-scroll"); + const bounds = await timeline.boundingBox(); + if (!bounds) throw new Error("Timeline bounds are unavailable."); + await page.mouse.move(bounds.x + bounds.width * 0.7, bounds.y + 180); + await page.keyboard.down("Control"); + await page.mouse.wheel(0, -100); + await page.keyboard.up("Control"); await expect(page.locator(".zoom-control output")).toHaveText("Work weeks"); await page diff --git a/src/components/gantt-chart.test.tsx b/src/components/gantt-chart.test.tsx index 7b23bf5..56b5d9e 100644 --- a/src/components/gantt-chart.test.tsx +++ b/src/components/gantt-chart.test.tsx @@ -31,6 +31,85 @@ beforeEach(() => { }); describe("GanttChart interactions", () => { + it("uses Ctrl+wheel for chart zoom without allowing Chrome page zoom", () => { + const task = makeTask({ + id: "zoomable", + title: "Zoomable task", + scheduled: "2026-08-17", + due: "2026-08-18", + }); + const onZoom = vi.fn(); + const addEventListener = vi.spyOn( + HTMLElement.prototype, + "addEventListener", + ); + render( + undefined} + onZoom={onZoom} + />, + ); + + expect(addEventListener).toHaveBeenCalledWith( + "wheel", + expect.any(Function), + { + passive: false, + }, + ); + + const surface = document.querySelector(".gantt-scroll")!; + const event = new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + clientX: 600, + ctrlKey: true, + deltaY: -100, + }); + surface.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(onZoom).toHaveBeenCalledWith(1); + }); + + it("leaves ordinary chart scrolling to the browser", () => { + const task = makeTask({ id: "scrollable", title: "Scrollable task" }); + const onZoom = vi.fn(); + render( + undefined} + onZoom={onZoom} + />, + ); + + const surface = document.querySelector(".gantt-scroll")!; + const event = new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + deltaY: 100, + }); + surface.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + expect(onZoom).not.toHaveBeenCalled(); + }); + it("places an unscheduled task on the timeline by dragging", async () => { const task = makeTask({ id: "unscheduled", title: "Unscheduled" }); const onScheduleChange = vi.fn().mockResolvedValue(undefined); diff --git a/src/components/gantt-chart.tsx b/src/components/gantt-chart.tsx index 4b8d250..cf8d734 100644 --- a/src/components/gantt-chart.tsx +++ b/src/components/gantt-chart.tsx @@ -1,12 +1,18 @@ import { Check, GitBranch, Minus } from "lucide-react"; -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, MutableRefObject, PointerEvent as ReactPointerEvent, - WheelEvent as ReactWheelEvent, } from "react"; import { @@ -525,25 +531,33 @@ export function GanttChart({ }; } - function handleWheel(event: ReactWheelEvent) { - if (!event.ctrlKey && !event.metaKey) return; - event.preventDefault(); + const handleWheel = useCallback( + (event: WheelEvent) => { + if (!event.ctrlKey && !event.metaKey) return; + event.preventDefault(); + const surface = scroller.current; + if (!surface || event.deltaY === 0) return; + const taskColumn = taskColumnWidth(surface); + const bounds = surface.getBoundingClientRect(); + const viewportX = Math.max(taskColumn, event.clientX - bounds.left); + const timelineX = surface.scrollLeft + viewportX - taskColumn; + pendingZoomAnchor.current = { + minute: + planningMinute(scale.start)! + + (timelineX / scale.cellWidth) * DAY_MINUTES, + viewportX, + }; + onZoom(event.deltaY < 0 ? 1 : -1); + }, + [onZoom, scale.cellWidth, scale.start], + ); + + useEffect(() => { const surface = scroller.current; - if (!surface || event.deltaY === 0) return; - const bounds = surface.getBoundingClientRect(); - const viewportX = Math.max( - taskColumnWidth(surface), - event.clientX - bounds.left, - ); - const timelineX = surface.scrollLeft + viewportX - taskColumnWidth(surface); - pendingZoomAnchor.current = { - minute: - planningMinute(scale.start)! + - (timelineX / scale.cellWidth) * DAY_MINUTES, - viewportX, - }; - onZoom(event.deltaY < 0 ? 1 : -1); - } + if (!surface) return; + surface.addEventListener("wheel", handleWheel, { passive: false }); + return () => surface.removeEventListener("wheel", handleWheel); + }, [handleWheel, rows.length]); if (!rows.length) return ( @@ -558,7 +572,7 @@ export function GanttChart({ className={`gantt-frame${linkDrag ? " is-linking" : ""}`} aria-label="Task timeline" > -
+