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:
parent
d980ac6d8f
commit
d18c341352
10
CLAUDE.md
10
CLAUDE.md
@ -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).
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user