diff --git a/web/src/components/workflows/TaskInspector.tsx b/web/src/components/workflows/TaskInspector.tsx index 4478549..a00b8d4 100644 --- a/web/src/components/workflows/TaskInspector.tsx +++ b/web/src/components/workflows/TaskInspector.tsx @@ -16,6 +16,7 @@ import type { PaletteAction, TaskTransition, PublishDirective, + LineStyle, } from "@/types/workflow"; import { PRESET_WHEN, @@ -196,6 +197,9 @@ export default function TaskInspector({ if (whenExpr) newTransition.when = whenExpr; newTransition.label = PRESET_LABELS[preset]; newTransition.color = PRESET_COLORS[preset]; + if (preset === "failed") { + newTransition.line_style = "dashed"; + } } next.push(newTransition); update({ next }); @@ -508,28 +512,87 @@ export default function TaskInspector({ title={swatch.label} /> ))} -
- - updateTransition(ti, { color: e.target.value }) - } - className="w-5 h-5 rounded cursor-pointer border border-gray-300" - title="Custom color" - /> - {transition.color && ( + + updateTransition(ti, { color: e.target.value }) + } + className="w-5 h-5 rounded cursor-pointer border border-gray-300 ml-1" + title="Custom color" + /> +
+ + + {/* Line style */} +
+ +
+ {( + [ + "solid", + "dashed", + "dotted", + "dash-dot", + ] as LineStyle[] + ).map((style) => { + const effectiveStyle = + transition.line_style || "solid"; + const isActive = effectiveStyle === style; + const dashArrays: Record = { + solid: "", + dashed: "6,4", + dotted: "2,3", + "dash-dot": "8,4,2,4", + }; + const labels: Record = { + solid: "Solid", + dashed: "Dashed", + dotted: "Dotted", + "dash-dot": "Dash-dot", + }; + return ( - )} -
+ ); + })}
diff --git a/web/src/components/workflows/TaskNode.tsx b/web/src/components/workflows/TaskNode.tsx index 98afde8..f1a4483 100644 --- a/web/src/components/workflows/TaskNode.tsx +++ b/web/src/components/workflows/TaskNode.tsx @@ -6,6 +6,7 @@ import { PRESET_WHEN, classifyTransitionWhen, } from "@/types/workflow"; +import type { ScreenToCanvas } from "./WorkflowCanvas"; export type { TransitionPreset }; @@ -23,6 +24,8 @@ interface TaskNodeProps { onStartConnection: (taskId: string, preset: TransitionPreset) => void; connectingFrom: { taskId: string; preset: TransitionPreset } | null; onCompleteConnection: (targetTaskId: string) => void; + /** Convert screen (client) coordinates to canvas-space coordinates. */ + screenToCanvas: ScreenToCanvas; } /** Handle visual configuration for each transition preset */ @@ -165,6 +168,7 @@ function TaskNodeInner({ onStartConnection, connectingFrom, onCompleteConnection, + screenToCanvas, }: TaskNodeProps) { const nodeRef = useRef(null); const [isDragging, setIsDragging] = useState(false); @@ -184,17 +188,20 @@ function TaskNodeInner({ e.stopPropagation(); setIsDragging(true); setShowTooltip(false); + + // Convert the initial click to canvas-space so dragging stays + // accurate regardless of the current zoom / pan. + const canvasPos = screenToCanvas(e.clientX, e.clientY); dragOffset.current = { - x: e.clientX - task.position.x, - y: e.clientY - task.position.y, + x: canvasPos.x - task.position.x, + y: canvasPos.y - task.position.y, }; const handleMouseMove = (moveEvent: MouseEvent) => { - const newX = moveEvent.clientX - dragOffset.current.x; - const newY = moveEvent.clientY - dragOffset.current.y; + const cur = screenToCanvas(moveEvent.clientX, moveEvent.clientY); onPositionChange(task.id, { - x: Math.max(0, newX), - y: Math.max(0, newY), + x: Math.max(0, cur.x - dragOffset.current.x), + y: Math.max(0, cur.y - dragOffset.current.y), }); }; @@ -207,7 +214,13 @@ function TaskNodeInner({ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [task.id, task.position.x, task.position.y, onPositionChange], + [ + task.id, + task.position.x, + task.position.y, + onPositionChange, + screenToCanvas, + ], ); const handleClick = useCallback( diff --git a/web/src/components/workflows/WorkflowCanvas.tsx b/web/src/components/workflows/WorkflowCanvas.tsx index 016533b..56a487f 100644 --- a/web/src/components/workflows/WorkflowCanvas.tsx +++ b/web/src/components/workflows/WorkflowCanvas.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useMemo } from "react"; +import { useState, useCallback, useRef, useMemo, useEffect } from "react"; import TaskNode from "./TaskNode"; import type { TransitionPreset } from "./TaskNode"; import WorkflowEdges from "./WorkflowEdges"; @@ -15,7 +15,7 @@ import { findStartingTaskIds, PRESET_LABELS, } from "@/types/workflow"; -import { Plus } from "lucide-react"; +import { Plus, Maximize } from "lucide-react"; interface WorkflowCanvasProps { tasks: WorkflowTask[]; @@ -39,6 +39,34 @@ const PRESET_BANNER_COLORS: Record = { always: "text-gray-200 font-bold", }; +const MIN_ZOOM = 0.15; +const MAX_ZOOM = 3; +const ZOOM_SENSITIVITY = 0.0015; + +/** + * Build CSS background style for the infinite grid. + * Two layers: regular lines every 20 canvas-units, bold lines every 100 (5th). + */ +function gridBackground(pan: { x: number; y: number }, zoom: number) { + const small = 20 * zoom; + const large = 100 * zoom; + return { + backgroundImage: [ + `linear-gradient(to right, rgba(0,0,0,0.07) 1px, transparent 1px)`, + `linear-gradient(to bottom, rgba(0,0,0,0.07) 1px, transparent 1px)`, + `linear-gradient(to right, rgba(0,0,0,0.03) 1px, transparent 1px)`, + `linear-gradient(to bottom, rgba(0,0,0,0.03) 1px, transparent 1px)`, + ].join(","), + backgroundSize: `${large}px ${large}px, ${large}px ${large}px, ${small}px ${small}px, ${small}px ${small}px`, + backgroundPosition: `${pan.x}px ${pan.y}px, ${pan.x}px ${pan.y}px, ${pan.x}px ${pan.y}px, ${pan.x}px ${pan.y}px`, + }; +} + +export type ScreenToCanvas = ( + clientX: number, + clientY: number, +) => { x: number; y: number }; + export default function WorkflowCanvas({ tasks, selectedTaskId, @@ -50,6 +78,17 @@ export default function WorkflowCanvas({ onEdgeClick, }: WorkflowCanvasProps) { const canvasRef = useRef(null); + const innerRef = useRef(null); + + // ---- Camera state ---- + // We keep refs for high-frequency updates (panning/zooming) and sync to + // state on mouseup / wheel-end so React can re-render once. + const panRef = useRef({ x: 0, y: 0 }); + const zoomRef = useRef(1); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + + // ---- Connection state ---- const [connectingFrom, setConnectingFrom] = useState<{ taskId: string; preset: TransitionPreset; @@ -59,22 +98,59 @@ export default function WorkflowCanvas({ y: number; } | null>(null); + // ---- Panning state (right-click drag) ---- + const isPanning = useRef(false); + const panDragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 }); + const [panningCursor, setPanningCursor] = useState(false); + const [selectedEdge, setSelectedEdge] = useState( null, ); const allTaskNames = useMemo(() => tasks.map((t) => t.name), [tasks]); - const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]); - const startingTaskIds = useMemo(() => findStartingTaskIds(tasks), [tasks]); + // ---- Coordinate conversion ---- + /** Convert screen (client) coordinates to canvas-space coordinates. */ + const screenToCanvas: ScreenToCanvas = useCallback( + (clientX: number, clientY: number) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return { x: clientX, y: clientY }; + return { + x: (clientX - rect.left - panRef.current.x) / zoomRef.current, + y: (clientY - rect.top - panRef.current.y) / zoomRef.current, + }; + }, + [], + ); + + // ---- Flush camera refs to React state (triggers re-render) ---- + const commitCamera = useCallback(() => { + setPan({ ...panRef.current }); + setZoom(zoomRef.current); + }, []); + + /** Apply current ref values directly to the DOM for smooth animation. */ + const applyTransformToDOM = useCallback(() => { + if (innerRef.current) { + innerRef.current.style.transform = `translate(${panRef.current.x}px, ${panRef.current.y}px) scale(${zoomRef.current})`; + } + if (canvasRef.current) { + const bg = gridBackground(panRef.current, zoomRef.current); + canvasRef.current.style.backgroundSize = bg.backgroundSize; + canvasRef.current.style.backgroundPosition = bg.backgroundPosition; + } + }, []); + + // ---- Canvas click (deselect / cancel connection) ---- const handleCanvasClick = useCallback( (e: React.MouseEvent) => { - // Only deselect if clicking the canvas background + const target = e.target as HTMLElement; if ( - e.target === canvasRef.current || - (e.target as HTMLElement).dataset.canvasBg === "true" + target === canvasRef.current || + target === innerRef.current || + target.dataset.canvasBg === "true" ) { if (connectingFrom) { setConnectingFrom(null); @@ -89,30 +165,122 @@ export default function WorkflowCanvas({ [onSelectTask, onEdgeClick, connectingFrom], ); + // ---- Mouse move: panning + connection preview ---- const handleCanvasMouseMove = useCallback( (e: React.MouseEvent) => { - if (connectingFrom && canvasRef.current) { - const rect = canvasRef.current.getBoundingClientRect(); - const scrollLeft = canvasRef.current.scrollLeft; - const scrollTop = canvasRef.current.scrollTop; - setMousePosition({ - x: e.clientX - rect.left + scrollLeft, - y: e.clientY - rect.top + scrollTop, - }); + // Right-click panning (direct DOM, no React re-render) + if (isPanning.current) { + const dx = e.clientX - panDragStart.current.x; + const dy = e.clientY - panDragStart.current.y; + panRef.current = { + x: panDragStart.current.panX + dx, + y: panDragStart.current.panY + dy, + }; + applyTransformToDOM(); + return; + } + + // Connection preview line + if (connectingFrom) { + const pos = screenToCanvas(e.clientX, e.clientY); + setMousePosition(pos); } }, - [connectingFrom], + [connectingFrom, screenToCanvas, applyTransformToDOM], ); - const handleCanvasMouseUp = useCallback(() => { - // If we're connecting and mouseup happens on the canvas (not on a node), - // cancel the connection - if (connectingFrom) { - setConnectingFrom(null); - setMousePosition(null); + // ---- Mouse down: start panning on right-click ---- + const handleCanvasMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button === 2) { + e.preventDefault(); + isPanning.current = true; + panDragStart.current = { + x: e.clientX, + y: e.clientY, + panX: panRef.current.x, + panY: panRef.current.y, + }; + setPanningCursor(true); } - }, [connectingFrom]); + }, []); + // ---- Mouse up: stop panning / cancel connection ---- + const handleCanvasMouseUp = useCallback( + (e: React.MouseEvent) => { + if (e.button === 2 && isPanning.current) { + isPanning.current = false; + setPanningCursor(false); + commitCamera(); + return; + } + if (connectingFrom) { + setConnectingFrom(null); + setMousePosition(null); + } + }, + [connectingFrom, commitCamera], + ); + + // ---- Context menu suppression ---- + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + }, []); + + // ---- Scroll wheel: zoom centred on cursor ---- + // Must be a non-passive imperative listener so preventDefault() reliably + // stops the page from scrolling. React's onWheel is passive in some browsers. + const handleWheel = useCallback( + (e: WheelEvent) => { + e.preventDefault(); + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const mouseScreenX = e.clientX - rect.left; + const mouseScreenY = e.clientY - rect.top; + + const oldZoom = zoomRef.current; + const delta = -e.deltaY * ZOOM_SENSITIVITY; + const newZoom = Math.min( + MAX_ZOOM, + Math.max(MIN_ZOOM, oldZoom * (1 + delta)), + ); + + // Adjust pan so the point under the cursor stays fixed + const scale = newZoom / oldZoom; + panRef.current = { + x: mouseScreenX - (mouseScreenX - panRef.current.x) * scale, + y: mouseScreenY - (mouseScreenY - panRef.current.y) * scale, + }; + zoomRef.current = newZoom; + + applyTransformToDOM(); + commitCamera(); + }, + [applyTransformToDOM, commitCamera], + ); + + // Attach wheel listener imperatively with { passive: false } + useEffect(() => { + const el = canvasRef.current; + if (!el) return; + el.addEventListener("wheel", handleWheel, { passive: false }); + return () => el.removeEventListener("wheel", handleWheel); + }, [handleWheel]); + + // Safety: cancel panning if mouse leaves the window + useEffect(() => { + const handleGlobalMouseUp = () => { + if (isPanning.current) { + isPanning.current = false; + setPanningCursor(false); + commitCamera(); + } + }; + window.addEventListener("mouseup", handleGlobalMouseUp); + return () => window.removeEventListener("mouseup", handleGlobalMouseUp); + }, [commitCamera]); + + // ---- Node interactions ---- const handlePositionChange = useCallback( (taskId: string, position: { x: number; y: number }) => { onUpdateTask(taskId, { position }); @@ -129,7 +297,6 @@ export default function WorkflowCanvas({ [onEdgeClick], ); - /** Handle edge click: select the edge and propagate to parent */ const handleEdgeClick = useCallback( (info: EdgeHoverInfo | null) => { if (info) { @@ -146,14 +313,10 @@ export default function WorkflowCanvas({ [onEdgeClick], ); - /** Handle selecting a task (also clears edge selection) */ const handleSelectTask = useCallback( (taskId: string | null) => { onSelectTask(taskId); if (taskId !== null) { - // Keep selected edge if the task being selected is part of it - // (i.e. user clicked the source task of the edge via edge click) - // Otherwise clear it if (selectedEdge && selectedEdge.from !== taskId) { setSelectedEdge(null); onEdgeClick?.(null); @@ -163,7 +326,6 @@ export default function WorkflowCanvas({ [onSelectTask, onEdgeClick, selectedEdge], ); - /** Update waypoints for a specific edge */ const handleWaypointUpdate = useCallback( ( fromTaskId: string, @@ -192,7 +354,6 @@ export default function WorkflowCanvas({ [tasks, onUpdateTask], ); - /** Update label position for a specific edge */ const handleLabelPositionUpdate = useCallback( ( fromTaskId: string, @@ -224,7 +385,6 @@ export default function WorkflowCanvas({ const handleCompleteConnection = useCallback( (targetTaskId: string) => { if (!connectingFrom) return; - const targetTask = tasks.find((t) => t.id === targetTaskId); if (!targetTask) return; @@ -241,12 +401,9 @@ export default function WorkflowCanvas({ const handleAddEmptyTask = useCallback(() => { const name = generateUniqueTaskName(tasks); - // Position new tasks below existing ones let maxY = 0; for (const task of tasks) { - if (task.position.y > maxY) { - maxY = task.position.y; - } + if (task.position.y > maxY) maxY = task.position.y; } const newTask: WorkflowTask = { id: generateTaskId(), @@ -262,43 +419,127 @@ export default function WorkflowCanvas({ onSelectTask(newTask.id); }, [tasks, onAddTask, onSelectTask]); - // Calculate minimum canvas dimensions based on node positions - const canvasDimensions = useMemo(() => { - let maxX = 800; - let maxY = 600; + /** Reset pan/zoom to fit all tasks (or default viewport). */ + const handleFitView = useCallback(() => { + if (tasks.length === 0) { + panRef.current = { x: 0, y: 0 }; + zoomRef.current = 1; + } else { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const t of tasks) { + minX = Math.min(minX, t.position.x); + minY = Math.min(minY, t.position.y); + maxX = Math.max(maxX, t.position.x + 240); + maxY = Math.max(maxY, t.position.y + 140); + } + + const contentW = maxX - minX; + const contentH = maxY - minY; + const pad = 80; + const scaleX = (rect.width - pad * 2) / contentW; + const scaleY = (rect.height - pad * 2) / contentH; + const newZoom = Math.min( + Math.max(Math.min(scaleX, scaleY), MIN_ZOOM), + MAX_ZOOM, + ); + + panRef.current = { + x: (rect.width - contentW * newZoom) / 2 - minX * newZoom, + y: (rect.height - contentH * newZoom) / 2 - minY * newZoom, + }; + zoomRef.current = newZoom; + } + applyTransformToDOM(); + commitCamera(); + }, [tasks, applyTransformToDOM, commitCamera]); + + // ---- Inner div dimensions (large enough to contain all content) ---- + const innerSize = useMemo(() => { + let maxX = 4000; + let maxY = 4000; for (const task of tasks) { - maxX = Math.max(maxX, task.position.x + 340); - maxY = Math.max(maxY, task.position.y + 220); + maxX = Math.max(maxX, task.position.x + 500); + maxY = Math.max(maxY, task.position.y + 500); } return { width: maxX, height: maxY }; }, [tasks]); + // ---- Grid background (recomputed from React state for the render) ---- + const gridBg = useMemo(() => gridBackground(pan, zoom), [pan, zoom]); + + // Zoom percentage for display + const zoomPercent = Math.round(zoom * 100); + return (
- {/* Grid background */} + {/* Transformed canvas content */}
+ > + {/* Edge rendering layer */} + + + {/* Task nodes */} + {tasks.map((task) => ( + + ))} +
+ + {/* ---- UI chrome (not transformed) ---- */} {/* Connecting mode indicator */} {connectingFrom && ( -
+
Drag to a task to connect as{" "} @@ -309,44 +550,23 @@ export default function WorkflowCanvas({
)} - {/* Edge rendering layer */} - - - {/* Task nodes */} - {tasks.map((task) => ( - - ))} + {/* Zoom indicator + fit-view button */} +
+
+ {zoomPercent}% +
+ +
{/* Empty state / Add task button */} {tasks.length === 0 ? ( -
+
@@ -370,7 +590,7 @@ export default function WorkflowCanvas({ ) : (