feat(gantt): add zoom controls and pan mode (WIP)

zoom in/out buttons and ctrl+scroll work. pan mode
toggle added (pointer vs grab) but vertical panning
still broken - needs different approach for navigating
the chart body independently of the sticky header.
This commit is contained in:
Nicholai Vogel 2026-01-24 13:53:12 -07:00
parent d980ac6d8f
commit d18c341352
4 changed files with 153 additions and 11 deletions

View File

@ -66,3 +66,13 @@ key bindings:
- `ASSETS` - static asset serving
- `IMAGES` - cloudflare image optimization
- `WORKER_SELF_REFERENCE` - self-reference for caching
known issues (WIP)
---
- gantt chart pan/zoom: zoom controls (+/-) and ctrl+scroll work. pan mode
toggle (pointer/grab) exists but vertical panning does not work correctly
yet - the scroll-based approach conflicts with how frappe-gantt sizes its
container. horizontal panning works. needs a different approach for
vertical navigation (possibly a custom viewport with transform-based
rendering for the body while keeping the header fixed separately).

View File

@ -1,6 +1,6 @@
"use client"
import { useRef, useEffect, useState } from "react"
import { useRef, useEffect, useState, useCallback } from "react"
import type { FrappeTask } from "@/lib/schedule/gantt-transform"
import "./gantt.css"
@ -9,24 +9,85 @@ type ViewMode = "Day" | "Week" | "Month"
interface GanttChartProps {
tasks: FrappeTask[]
viewMode: ViewMode
columnWidth?: number
panMode?: boolean
onDateChange?: (
task: FrappeTask,
start: Date,
end: Date
) => void
onProgressChange?: (task: FrappeTask, progress: number) => void
onZoom?: (direction: "in" | "out") => void
}
export function GanttChart({
tasks,
viewMode,
columnWidth,
panMode = false,
onDateChange,
onProgressChange,
onZoom,
}: GanttChartProps) {
const containerRef = useRef<HTMLDivElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ganttRef = useRef<any>(null)
const [loaded, setLoaded] = useState(false)
// pan state - scrolls the .gantt-container directly
const isPanning = useRef(false)
const panStartX = useRef(0)
const panStartY = useRef(0)
const panScrollLeft = useRef(0)
const panScrollTop = useRef(0)
const ganttContainerRef = useRef<HTMLElement | null>(null)
const handleMouseDown = useCallback((e: React.MouseEvent) => {
const canPan = e.button === 1 || (panMode && e.button === 0)
if (!canPan) return
e.preventDefault()
const gc = ganttContainerRef.current
if (!gc) return
isPanning.current = true
panStartX.current = e.clientX
panStartY.current = e.clientY
panScrollLeft.current = gc.scrollLeft
panScrollTop.current = gc.scrollTop
const wrapper = wrapperRef.current
if (wrapper) wrapper.style.cursor = "grabbing"
}, [panMode])
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isPanning.current) return
const gc = ganttContainerRef.current
if (!gc) return
gc.scrollLeft = panScrollLeft.current - (e.clientX - panStartX.current)
gc.scrollTop = panScrollTop.current - (e.clientY - panStartY.current)
}, [])
const handleMouseUp = useCallback(() => {
if (!isPanning.current) return
isPanning.current = false
const wrapper = wrapperRef.current
if (wrapper) wrapper.style.cursor = ""
}, [])
// ctrl+scroll zoom
useEffect(() => {
const wrapper = wrapperRef.current
if (!wrapper || !onZoom) return
const handleWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return
e.preventDefault()
onZoom(e.deltaY < 0 ? "in" : "out")
}
wrapper.addEventListener("wheel", handleWheel, { passive: false })
return () => wrapper.removeEventListener("wheel", handleWheel)
}, [onZoom])
useEffect(() => {
if (!containerRef.current || tasks.length === 0) return
@ -53,13 +114,14 @@ export function GanttChart({
ganttRef.current = new Gantt(containerRef.current, ganttTasks, {
view_mode: viewMode,
on_date_change: (task: any, start: Date, end: Date) => {
...(columnWidth ? { column_width: columnWidth } : {}),
on_date_change: (task: { id: string }, start: Date, end: Date) => {
if (onDateChange) {
const original = tasks.find((t) => t.id === task.id)
if (original) onDateChange(original, start, end)
}
},
on_progress_change: (task: any, progress: number) => {
on_progress_change: (task: { id: string }, progress: number) => {
if (onProgressChange) {
const original = tasks.find((t) => t.id === task.id)
if (original) onProgressChange(original, progress)
@ -67,13 +129,14 @@ export function GanttChart({
},
})
// remove overflow from inner gantt-container so popup isn't clipped
// the parent wrapper handles horizontal scrolling instead
// constrain gantt-container to wrapper height so content overflows
// this enables scroll-based panning while keeping the header sticky
const ganttContainer = containerRef.current.querySelector(
".gantt-container"
) as HTMLElement | null
if (ganttContainer) {
ganttContainer.style.overflow = "visible"
ganttContainer.style.height = "100%"
ganttContainerRef.current = ganttContainer
}
setLoaded(true)
@ -81,7 +144,7 @@ export function GanttChart({
initGantt()
return () => { cancelled = true }
}, [tasks, viewMode, onDateChange, onProgressChange])
}, [tasks, viewMode, columnWidth, onDateChange, onProgressChange])
useEffect(() => {
if (ganttRef.current && loaded) {
@ -98,8 +161,16 @@ export function GanttChart({
}
return (
<div className="gantt-wrapper relative overflow-x-auto">
<div ref={containerRef} />
<div
ref={wrapperRef}
className="gantt-wrapper relative overflow-hidden h-full"
style={{ cursor: panMode ? "grab" : undefined }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div ref={containerRef} className="h-full" />
</div>
)
}

View File

@ -1,3 +1,11 @@
/* hide scrollbars on gantt-container (panning handles navigation) */
.gantt-container {
scrollbar-width: none;
}
.gantt-container::-webkit-scrollbar {
display: none;
}
/* frappe-gantt base styles (vendored from frappe-gantt/dist/frappe-gantt.css) */
:root{--g-arrow-color: #1f2937;--g-bar-color: #fff;--g-bar-border: #fff;--g-tick-color-thick: #ededed;--g-tick-color: #f3f3f3;--g-actions-background: #f3f3f3;--g-border-color: #ebeff2;--g-text-muted: #7c7c7c;--g-text-light: #fff;--g-text-dark: #171717;--g-progress-color: #dbdbdb;--g-handle-color: #37352f;--g-weekend-label-color: #dcdce4;--g-expected-progress: #c4c4e9;--g-header-background: #fff;--g-row-color: #fdfdfd;--g-row-border-color: #c7c7c7;--g-today-highlight: #37352f;--g-popup-actions: #ebeff2;--g-weekend-highlight-color: #f7f7f7}
.gantt-container{line-height:14.5px;position:relative;isolation:isolate;overflow:auto;font-size:12px;height:var(--gv-grid-height);width:100%;border-radius:8px}

View File

@ -22,6 +22,10 @@ import {
IconChevronRight,
IconChevronDown,
IconUsers,
IconZoomIn,
IconZoomOut,
IconPointer,
IconHandGrab,
} from "@tabler/icons-react"
import { GanttChart } from "./gantt-chart"
import { TaskFormDialog } from "./task-form-dialog"
@ -65,6 +69,25 @@ export function ScheduleGanttView({
null
)
const [panMode, setPanMode] = useState(false)
const defaultWidths: Record<ViewMode, number> = {
Day: 38, Week: 140, Month: 120,
}
const [columnWidth, setColumnWidth] = useState(defaultWidths[viewMode])
const handleZoom = useCallback((direction: "in" | "out") => {
setColumnWidth((prev) => {
const next = direction === "in" ? prev * 1.3 : prev / 1.3
return Math.round(Math.min(300, Math.max(20, next)))
})
}, [])
const handleViewModeChange = (mode: ViewMode) => {
setViewMode(mode)
setColumnWidth(defaultWidths[mode])
}
const filteredTasks = showCriticalPath
? tasks.filter((t) => t.isCriticalPath)
: tasks
@ -136,7 +159,7 @@ export function ScheduleGanttView({
key={mode}
size="sm"
variant={viewMode === mode ? "default" : "outline"}
onClick={() => setViewMode(mode)}
onClick={() => handleViewModeChange(mode)}
>
{mode}
</Button>
@ -148,6 +171,33 @@ export function ScheduleGanttView({
>
Today
</Button>
<Button
variant={panMode ? "default" : "outline"}
size="icon"
className="size-7"
onClick={() => setPanMode((p) => !p)}
title={panMode ? "Pan mode (click to switch to pointer)" : "Pointer mode (click to switch to pan)"}
>
{panMode
? <IconHandGrab className="size-3.5" />
: <IconPointer className="size-3.5" />}
</Button>
<Button
variant="outline"
size="icon"
className="size-7"
onClick={() => handleZoom("out")}
>
<IconZoomOut className="size-3.5" />
</Button>
<Button
variant="outline"
size="icon"
className="size-7"
onClick={() => handleZoom("in")}
>
<IconZoomIn className="size-3.5" />
</Button>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
@ -299,11 +349,14 @@ export function ScheduleGanttView({
<ResizableHandle withHandle />
<ResizablePanel defaultSize={70} minSize={40}>
<div className="h-full overflow-auto p-2">
<div className="h-full overflow-hidden p-2">
<GanttChart
tasks={frappeTasks}
viewMode={viewMode}
columnWidth={columnWidth}
panMode={panMode}
onDateChange={handleDateChange}
onZoom={handleZoom}
/>
</div>
</ResizablePanel>