more workflow editor polish

This commit is contained in:
2026-02-24 12:30:33 -06:00
parent 7d942f5dca
commit 80c8eaaf22
5 changed files with 517 additions and 178 deletions

View File

@@ -16,6 +16,7 @@ import type {
PaletteAction, PaletteAction,
TaskTransition, TaskTransition,
PublishDirective, PublishDirective,
LineStyle,
} from "@/types/workflow"; } from "@/types/workflow";
import { import {
PRESET_WHEN, PRESET_WHEN,
@@ -196,6 +197,9 @@ export default function TaskInspector({
if (whenExpr) newTransition.when = whenExpr; if (whenExpr) newTransition.when = whenExpr;
newTransition.label = PRESET_LABELS[preset]; newTransition.label = PRESET_LABELS[preset];
newTransition.color = PRESET_COLORS[preset]; newTransition.color = PRESET_COLORS[preset];
if (preset === "failed") {
newTransition.line_style = "dashed";
}
} }
next.push(newTransition); next.push(newTransition);
update({ next }); update({ next });
@@ -508,29 +512,88 @@ export default function TaskInspector({
title={swatch.label} title={swatch.label}
/> />
))} ))}
<div className="flex items-center gap-1 ml-1">
<input <input
type="color" type="color"
value={transition.color || "#6b7280"} value={transition.color || "#6b7280"}
onChange={(e) => onChange={(e) =>
updateTransition(ti, { color: e.target.value }) updateTransition(ti, { color: e.target.value })
} }
className="w-5 h-5 rounded cursor-pointer border border-gray-300" className="w-5 h-5 rounded cursor-pointer border border-gray-300 ml-1"
title="Custom color" title="Custom color"
/> />
{transition.color && (
<button
onClick={() =>
updateTransition(ti, { color: undefined })
}
className="text-[9px] text-gray-400 hover:text-gray-600"
title="Reset to default"
>
reset
</button>
)}
</div> </div>
</div> </div>
{/* Line style */}
<div>
<label className="block text-[10px] font-medium text-gray-500 mb-0.5">
Line Style
</label>
<div className="flex items-center gap-1">
{(
[
"solid",
"dashed",
"dotted",
"dash-dot",
] as LineStyle[]
).map((style) => {
const effectiveStyle =
transition.line_style || "solid";
const isActive = effectiveStyle === style;
const dashArrays: Record<LineStyle, string> = {
solid: "",
dashed: "6,4",
dotted: "2,3",
"dash-dot": "8,4,2,4",
};
const labels: Record<LineStyle, string> = {
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
"dash-dot": "Dash-dot",
};
return (
<button
key={style}
onClick={() =>
updateTransition(ti, {
line_style:
style === "solid" ? undefined : style,
})
}
className={`flex items-center justify-center h-6 px-1.5 rounded border transition-all ${
isActive
? "border-gray-800 bg-gray-100"
: "border-gray-200 hover:border-gray-400"
}`}
title={labels[style]}
>
<svg
width="28"
height="2"
className="overflow-visible"
>
<line
x1="0"
y1="1"
x2="28"
y2="1"
stroke={
transition.color ||
PRESET_COLORS[
classifyTransitionWhen(transition.when)
] ||
"#6b7280"
}
strokeWidth="2"
strokeDasharray={dashArrays[style]}
/>
</svg>
</button>
);
})}
</div>
</div> </div>
{/* When condition */} {/* When condition */}

View File

@@ -6,6 +6,7 @@ import {
PRESET_WHEN, PRESET_WHEN,
classifyTransitionWhen, classifyTransitionWhen,
} from "@/types/workflow"; } from "@/types/workflow";
import type { ScreenToCanvas } from "./WorkflowCanvas";
export type { TransitionPreset }; export type { TransitionPreset };
@@ -23,6 +24,8 @@ interface TaskNodeProps {
onStartConnection: (taskId: string, preset: TransitionPreset) => void; onStartConnection: (taskId: string, preset: TransitionPreset) => void;
connectingFrom: { taskId: string; preset: TransitionPreset } | null; connectingFrom: { taskId: string; preset: TransitionPreset } | null;
onCompleteConnection: (targetTaskId: string) => void; onCompleteConnection: (targetTaskId: string) => void;
/** Convert screen (client) coordinates to canvas-space coordinates. */
screenToCanvas: ScreenToCanvas;
} }
/** Handle visual configuration for each transition preset */ /** Handle visual configuration for each transition preset */
@@ -165,6 +168,7 @@ function TaskNodeInner({
onStartConnection, onStartConnection,
connectingFrom, connectingFrom,
onCompleteConnection, onCompleteConnection,
screenToCanvas,
}: TaskNodeProps) { }: TaskNodeProps) {
const nodeRef = useRef<HTMLDivElement>(null); const nodeRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -184,17 +188,20 @@ function TaskNodeInner({
e.stopPropagation(); e.stopPropagation();
setIsDragging(true); setIsDragging(true);
setShowTooltip(false); 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 = { dragOffset.current = {
x: e.clientX - task.position.x, x: canvasPos.x - task.position.x,
y: e.clientY - task.position.y, y: canvasPos.y - task.position.y,
}; };
const handleMouseMove = (moveEvent: MouseEvent) => { const handleMouseMove = (moveEvent: MouseEvent) => {
const newX = moveEvent.clientX - dragOffset.current.x; const cur = screenToCanvas(moveEvent.clientX, moveEvent.clientY);
const newY = moveEvent.clientY - dragOffset.current.y;
onPositionChange(task.id, { onPositionChange(task.id, {
x: Math.max(0, newX), x: Math.max(0, cur.x - dragOffset.current.x),
y: Math.max(0, newY), y: Math.max(0, cur.y - dragOffset.current.y),
}); });
}; };
@@ -207,7 +214,13 @@ function TaskNodeInner({
document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp); 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( const handleClick = useCallback(

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useMemo } from "react"; import { useState, useCallback, useRef, useMemo, useEffect } from "react";
import TaskNode from "./TaskNode"; import TaskNode from "./TaskNode";
import type { TransitionPreset } from "./TaskNode"; import type { TransitionPreset } from "./TaskNode";
import WorkflowEdges from "./WorkflowEdges"; import WorkflowEdges from "./WorkflowEdges";
@@ -15,7 +15,7 @@ import {
findStartingTaskIds, findStartingTaskIds,
PRESET_LABELS, PRESET_LABELS,
} from "@/types/workflow"; } from "@/types/workflow";
import { Plus } from "lucide-react"; import { Plus, Maximize } from "lucide-react";
interface WorkflowCanvasProps { interface WorkflowCanvasProps {
tasks: WorkflowTask[]; tasks: WorkflowTask[];
@@ -39,6 +39,34 @@ const PRESET_BANNER_COLORS: Record<TransitionPreset, string> = {
always: "text-gray-200 font-bold", 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({ export default function WorkflowCanvas({
tasks, tasks,
selectedTaskId, selectedTaskId,
@@ -50,6 +78,17 @@ export default function WorkflowCanvas({
onEdgeClick, onEdgeClick,
}: WorkflowCanvasProps) { }: WorkflowCanvasProps) {
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(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<{ const [connectingFrom, setConnectingFrom] = useState<{
taskId: string; taskId: string;
preset: TransitionPreset; preset: TransitionPreset;
@@ -59,22 +98,59 @@ export default function WorkflowCanvas({
y: number; y: number;
} | null>(null); } | 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<SelectedEdgeInfo | null>( const [selectedEdge, setSelectedEdge] = useState<SelectedEdgeInfo | null>(
null, null,
); );
const allTaskNames = useMemo(() => tasks.map((t) => t.name), [tasks]); const allTaskNames = useMemo(() => tasks.map((t) => t.name), [tasks]);
const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]); const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]);
const startingTaskIds = useMemo(() => findStartingTaskIds(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( const handleCanvasClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
// Only deselect if clicking the canvas background const target = e.target as HTMLElement;
if ( if (
e.target === canvasRef.current || target === canvasRef.current ||
(e.target as HTMLElement).dataset.canvasBg === "true" target === innerRef.current ||
target.dataset.canvasBg === "true"
) { ) {
if (connectingFrom) { if (connectingFrom) {
setConnectingFrom(null); setConnectingFrom(null);
@@ -89,30 +165,122 @@ export default function WorkflowCanvas({
[onSelectTask, onEdgeClick, connectingFrom], [onSelectTask, onEdgeClick, connectingFrom],
); );
// ---- Mouse move: panning + connection preview ----
const handleCanvasMouseMove = useCallback( const handleCanvasMouseMove = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (connectingFrom && canvasRef.current) { // Right-click panning (direct DOM, no React re-render)
const rect = canvasRef.current.getBoundingClientRect(); if (isPanning.current) {
const scrollLeft = canvasRef.current.scrollLeft; const dx = e.clientX - panDragStart.current.x;
const scrollTop = canvasRef.current.scrollTop; const dy = e.clientY - panDragStart.current.y;
setMousePosition({ panRef.current = {
x: e.clientX - rect.left + scrollLeft, x: panDragStart.current.panX + dx,
y: e.clientY - rect.top + scrollTop, 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(() => { // ---- Mouse down: start panning on right-click ----
// If we're connecting and mouseup happens on the canvas (not on a node), const handleCanvasMouseDown = useCallback((e: React.MouseEvent) => {
// cancel the connection 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);
}
}, []);
// ---- 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) { if (connectingFrom) {
setConnectingFrom(null); setConnectingFrom(null);
setMousePosition(null); setMousePosition(null);
} }
}, [connectingFrom]); },
[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( const handlePositionChange = useCallback(
(taskId: string, position: { x: number; y: number }) => { (taskId: string, position: { x: number; y: number }) => {
onUpdateTask(taskId, { position }); onUpdateTask(taskId, { position });
@@ -129,7 +297,6 @@ export default function WorkflowCanvas({
[onEdgeClick], [onEdgeClick],
); );
/** Handle edge click: select the edge and propagate to parent */
const handleEdgeClick = useCallback( const handleEdgeClick = useCallback(
(info: EdgeHoverInfo | null) => { (info: EdgeHoverInfo | null) => {
if (info) { if (info) {
@@ -146,14 +313,10 @@ export default function WorkflowCanvas({
[onEdgeClick], [onEdgeClick],
); );
/** Handle selecting a task (also clears edge selection) */
const handleSelectTask = useCallback( const handleSelectTask = useCallback(
(taskId: string | null) => { (taskId: string | null) => {
onSelectTask(taskId); onSelectTask(taskId);
if (taskId !== null) { 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) { if (selectedEdge && selectedEdge.from !== taskId) {
setSelectedEdge(null); setSelectedEdge(null);
onEdgeClick?.(null); onEdgeClick?.(null);
@@ -163,7 +326,6 @@ export default function WorkflowCanvas({
[onSelectTask, onEdgeClick, selectedEdge], [onSelectTask, onEdgeClick, selectedEdge],
); );
/** Update waypoints for a specific edge */
const handleWaypointUpdate = useCallback( const handleWaypointUpdate = useCallback(
( (
fromTaskId: string, fromTaskId: string,
@@ -192,7 +354,6 @@ export default function WorkflowCanvas({
[tasks, onUpdateTask], [tasks, onUpdateTask],
); );
/** Update label position for a specific edge */
const handleLabelPositionUpdate = useCallback( const handleLabelPositionUpdate = useCallback(
( (
fromTaskId: string, fromTaskId: string,
@@ -224,7 +385,6 @@ export default function WorkflowCanvas({
const handleCompleteConnection = useCallback( const handleCompleteConnection = useCallback(
(targetTaskId: string) => { (targetTaskId: string) => {
if (!connectingFrom) return; if (!connectingFrom) return;
const targetTask = tasks.find((t) => t.id === targetTaskId); const targetTask = tasks.find((t) => t.id === targetTaskId);
if (!targetTask) return; if (!targetTask) return;
@@ -241,12 +401,9 @@ export default function WorkflowCanvas({
const handleAddEmptyTask = useCallback(() => { const handleAddEmptyTask = useCallback(() => {
const name = generateUniqueTaskName(tasks); const name = generateUniqueTaskName(tasks);
// Position new tasks below existing ones
let maxY = 0; let maxY = 0;
for (const task of tasks) { for (const task of tasks) {
if (task.position.y > maxY) { if (task.position.y > maxY) maxY = task.position.y;
maxY = task.position.y;
}
} }
const newTask: WorkflowTask = { const newTask: WorkflowTask = {
id: generateTaskId(), id: generateTaskId(),
@@ -262,53 +419,90 @@ export default function WorkflowCanvas({
onSelectTask(newTask.id); onSelectTask(newTask.id);
}, [tasks, onAddTask, onSelectTask]); }, [tasks, onAddTask, onSelectTask]);
// Calculate minimum canvas dimensions based on node positions /** Reset pan/zoom to fit all tasks (or default viewport). */
const canvasDimensions = useMemo(() => { const handleFitView = useCallback(() => {
let maxX = 800; if (tasks.length === 0) {
let maxY = 600; 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) { for (const task of tasks) {
maxX = Math.max(maxX, task.position.x + 340); maxX = Math.max(maxX, task.position.x + 500);
maxY = Math.max(maxY, task.position.y + 220); maxY = Math.max(maxY, task.position.y + 500);
} }
return { width: maxX, height: maxY }; return { width: maxX, height: maxY };
}, [tasks]); }, [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 ( return (
<div <div
className="flex-1 overflow-auto bg-gray-100 relative"
ref={canvasRef} ref={canvasRef}
className={`flex-1 overflow-hidden bg-gray-100 relative ${panningCursor ? "!cursor-grabbing" : ""}`}
style={{
backgroundImage: gridBg.backgroundImage,
backgroundSize: gridBg.backgroundSize,
backgroundPosition: gridBg.backgroundPosition,
}}
onClick={handleCanvasClick} onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove} onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp} onMouseUp={handleCanvasMouseUp}
onContextMenu={handleContextMenu}
> >
{/* Grid background */} {/* Transformed canvas content */}
<div <div
ref={innerRef}
data-canvas-bg="true" data-canvas-bg="true"
className="absolute inset-0"
style={{ style={{
minWidth: canvasDimensions.width, position: "absolute",
minHeight: canvasDimensions.height, transformOrigin: "0 0",
backgroundImage: ` transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
linear-gradient(to right, rgba(0,0,0,0.03) 1px, transparent 1px), width: innerSize.width,
linear-gradient(to bottom, rgba(0,0,0,0.03) 1px, transparent 1px) height: innerSize.height,
`,
backgroundSize: "20px 20px",
}} }}
/> >
{/* Connecting mode indicator */}
{connectingFrom && (
<div className="sticky top-0 left-0 right-0 z-50 flex justify-center pointer-events-none">
<div className="mt-3 px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-full shadow-lg pointer-events-auto">
Drag to a task to connect as{" "}
<span className={PRESET_BANNER_COLORS[connectingFrom.preset]}>
{PRESET_LABELS[connectingFrom.preset]}
</span>{" "}
transition or release to cancel
</div>
</div>
)}
{/* Edge rendering layer */} {/* Edge rendering layer */}
<WorkflowEdges <WorkflowEdges
edges={edges} edges={edges}
@@ -319,6 +513,7 @@ export default function WorkflowCanvas({
selectedEdge={selectedEdge} selectedEdge={selectedEdge}
onWaypointUpdate={handleWaypointUpdate} onWaypointUpdate={handleWaypointUpdate}
onLabelPositionUpdate={handleLabelPositionUpdate} onLabelPositionUpdate={handleLabelPositionUpdate}
screenToCanvas={screenToCanvas}
/> />
{/* Task nodes */} {/* Task nodes */}
@@ -335,18 +530,43 @@ export default function WorkflowCanvas({
onStartConnection={handleStartConnection} onStartConnection={handleStartConnection}
connectingFrom={connectingFrom} connectingFrom={connectingFrom}
onCompleteConnection={handleCompleteConnection} onCompleteConnection={handleCompleteConnection}
screenToCanvas={screenToCanvas}
/> />
))} ))}
</div>
{/* ---- UI chrome (not transformed) ---- */}
{/* Connecting mode indicator */}
{connectingFrom && (
<div className="absolute top-0 left-0 right-0 z-50 flex justify-center pointer-events-none">
<div className="mt-3 px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-full shadow-lg pointer-events-auto">
Drag to a task to connect as{" "}
<span className={PRESET_BANNER_COLORS[connectingFrom.preset]}>
{PRESET_LABELS[connectingFrom.preset]}
</span>{" "}
transition or release to cancel
</div>
</div>
)}
{/* Zoom indicator + fit-view button */}
<div className="absolute bottom-6 left-6 z-40 flex items-center gap-2">
<div className="px-2.5 py-1.5 bg-white/80 backdrop-blur-sm text-xs font-medium text-gray-500 rounded-lg shadow-sm border border-gray-200 select-none tabular-nums">
{zoomPercent}%
</div>
<button
onClick={handleFitView}
className="p-1.5 bg-white/80 backdrop-blur-sm text-gray-500 rounded-lg shadow-sm border border-gray-200 hover:bg-white hover:text-gray-700 transition-colors"
title="Fit view to content"
>
<Maximize className="w-3.5 h-3.5" />
</button>
</div>
{/* Empty state / Add task button */} {/* Empty state / Add task button */}
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<div <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
className="absolute inset-0 flex items-center justify-center pointer-events-none"
style={{
minWidth: canvasDimensions.width,
minHeight: canvasDimensions.height,
}}
>
<div className="text-center pointer-events-auto"> <div className="text-center pointer-events-auto">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-200 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-200 flex items-center justify-center">
<Plus className="w-8 h-8 text-gray-400" /> <Plus className="w-8 h-8 text-gray-400" />
@@ -370,7 +590,7 @@ export default function WorkflowCanvas({
) : ( ) : (
<button <button
onClick={handleAddEmptyTask} onClick={handleAddEmptyTask}
className="fixed bottom-6 right-6 z-40 w-12 h-12 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 transition-colors flex items-center justify-center" className="absolute bottom-6 right-6 z-40 w-12 h-12 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 transition-colors flex items-center justify-center"
title="Add a new task" title="Add a new task"
> >
<Plus className="w-6 h-6" /> <Plus className="w-6 h-6" />

View File

@@ -7,6 +7,7 @@ import type {
} from "@/types/workflow"; } from "@/types/workflow";
import { PRESET_COLORS } from "@/types/workflow"; import { PRESET_COLORS } from "@/types/workflow";
import type { TransitionPreset } from "./TaskNode"; import type { TransitionPreset } from "./TaskNode";
import type { ScreenToCanvas } from "./WorkflowCanvas";
export interface EdgeHoverInfo { export interface EdgeHoverInfo {
taskId: string; taskId: string;
@@ -50,10 +51,12 @@ interface WorkflowEdgesProps {
targetTaskName: string, targetTaskName: string,
position: number | undefined, position: number | undefined,
) => void; ) => void;
/** Convert screen (client) coordinates to canvas-space coordinates. */
screenToCanvas?: ScreenToCanvas;
} }
const NODE_WIDTH = 240; const NODE_WIDTH = 240;
const NODE_HEIGHT = 120; const NODE_HEIGHT = 96;
/** Color for each edge type */ /** Color for each edge type */
const EDGE_COLORS: Record<EdgeType, string> = { const EDGE_COLORS: Record<EdgeType, string> = {
@@ -63,11 +66,14 @@ const EDGE_COLORS: Record<EdgeType, string> = {
custom: "#8b5cf6", // violet-500 custom: "#8b5cf6", // violet-500
}; };
const EDGE_DASH: Record<EdgeType, string> = { /** SVG stroke-dasharray values for each user-facing line style */
success: "", import type { LineStyle } from "@/types/workflow";
failure: "6,4",
complete: "4,4", const LINE_STYLE_DASH: Record<LineStyle, string> = {
custom: "8,4,2,4", solid: "",
dashed: "6,4",
dotted: "2,3",
"dash-dot": "8,4,2,4",
}; };
/** Calculate the center-bottom of a task node */ /** Calculate the center-bottom of a task node */
@@ -110,22 +116,59 @@ function getNodeRightCenter(
}; };
} }
/**
* Pick the closest of top / left / right edges on the destination card
* to an approach point. Bottom is excluded — it's reserved for outgoing
* transitions.
*/
function closestEntryEdge(
task: WorkflowTask,
approach: { x: number; y: number },
nodeWidth: number,
nodeHeight: number,
): { x: number; y: number } {
const candidates = [
getNodeTopCenter(task, nodeWidth),
getNodeLeftCenter(task, nodeHeight),
getNodeRightCenter(task, nodeWidth, nodeHeight),
];
let best = candidates[0];
let bestDist = Infinity;
for (const c of candidates) {
const d = (c.x - approach.x) ** 2 + (c.y - approach.y) ** 2;
if (d < bestDist) {
bestDist = d;
best = c;
}
}
return best;
}
/** /**
* Determine the best connection points between two nodes. * Determine the best connection points between two nodes.
*
* Rules:
* - Origin always exits from the bottom edge (where the output handles are).
* - Destination picks the closest of top / left / right (never bottom).
* - When waypoints exist the last waypoint is used as the approach hint
* for the destination edge, and the first waypoint is ignored for origin
* (origin is always bottom).
*/ */
function getBestConnectionPoints( function getBestConnectionPoints(
fromTask: WorkflowTask, fromTask: WorkflowTask,
toTask: WorkflowTask, toTask: WorkflowTask,
nodeWidth: number, nodeWidth: number,
nodeHeight: number, nodeHeight: number,
waypoints?: { x: number; y: number }[],
): { ): {
start: { x: number; y: number }; start: { x: number; y: number };
end: { x: number; y: number }; end: { x: number; y: number };
selfLoop?: boolean; selfLoop?: boolean;
} { } {
// Self-loop: right side → top
if (fromTask.id === toTask.id) { if (fromTask.id === toTask.id) {
return { return {
start: getNodeRightCenter(fromTask, nodeWidth, nodeHeight), start: getNodeBottomCenter(fromTask, nodeWidth, nodeHeight),
end: { end: {
x: fromTask.position.x + nodeWidth * 0.75, x: fromTask.position.x + nodeWidth * 0.75,
y: fromTask.position.y, y: fromTask.position.y,
@@ -134,47 +177,17 @@ function getBestConnectionPoints(
}; };
} }
const fromCenter = { // Origin always exits from bottom
x: fromTask.position.x + nodeWidth / 2, const start = getNodeBottomCenter(fromTask, nodeWidth, nodeHeight);
y: fromTask.position.y + nodeHeight / 2,
};
const toCenter = {
x: toTask.position.x + nodeWidth / 2,
y: toTask.position.y + nodeHeight / 2,
};
const dx = toCenter.x - fromCenter.x; // Use the last waypoint (if any) as the approach direction for the
const dy = toCenter.y - fromCenter.y; // destination, otherwise use the start point.
const approach =
waypoints && waypoints.length > 0 ? waypoints[waypoints.length - 1] : start;
if (dy > 0 && Math.abs(dy) > Math.abs(dx) * 0.5) { const end = closestEntryEdge(toTask, approach, nodeWidth, nodeHeight);
return {
start: getNodeBottomCenter(fromTask, nodeWidth, nodeHeight),
end: getNodeTopCenter(toTask, nodeWidth),
};
}
if (dy < 0 && Math.abs(dy) > Math.abs(dx) * 0.5) { return { start, end };
const start =
dx >= 0
? getNodeRightCenter(fromTask, nodeWidth, nodeHeight)
: getNodeLeftCenter(fromTask, nodeHeight);
return {
start,
end: getNodeBottomCenter(toTask, nodeWidth, nodeHeight),
};
}
if (dx > 0) {
return {
start: getNodeRightCenter(fromTask, nodeWidth, nodeHeight),
end: getNodeLeftCenter(toTask, nodeHeight),
};
}
return {
start: getNodeLeftCenter(fromTask, nodeHeight),
end: getNodeRightCenter(toTask, nodeWidth, nodeHeight),
};
} }
/** /**
@@ -504,6 +517,7 @@ function WorkflowEdgesInner({
selectedEdge, selectedEdge,
onWaypointUpdate, onWaypointUpdate,
onLabelPositionUpdate, onLabelPositionUpdate,
screenToCanvas: screenToCanvasProp,
}: WorkflowEdgesProps) { }: WorkflowEdgesProps) {
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
@@ -554,9 +568,14 @@ function WorkflowEdgesInner({
segmentIndex: number; segmentIndex: number;
} | null>(null); } | null>(null);
/** Convert client coordinates to SVG coordinates */ /** Convert client coordinates to canvas (SVG) coordinates.
* Uses the parent-provided screenToCanvas when available (handles zoom/pan),
* otherwise falls back to a basic rect-offset calculation. */
const clientToSvg = useCallback( const clientToSvg = useCallback(
(clientX: number, clientY: number): { x: number; y: number } => { (clientX: number, clientY: number): { x: number; y: number } => {
if (screenToCanvasProp) {
return screenToCanvasProp(clientX, clientY);
}
const svg = svgRef.current; const svg = svgRef.current;
if (!svg) return { x: clientX, y: clientY }; if (!svg) return { x: clientX, y: clientY };
const rect = svg.getBoundingClientRect(); const rect = svg.getBoundingClientRect();
@@ -568,7 +587,7 @@ function WorkflowEdgesInner({
y: clientY - rect.top + scrollTop, y: clientY - rect.top + scrollTop,
}; };
}, },
[], [screenToCanvasProp],
); );
// Refs to hold latest callback values (updated via effects) // Refs to hold latest callback values (updated via effects)
@@ -936,16 +955,8 @@ function WorkflowEdgesInner({
const toTask = taskMap.get(edge.to); const toTask = taskMap.get(edge.to);
if (!fromTask || !toTask) return null; if (!fromTask || !toTask) return null;
const { start, end, selfLoop } = getBestConnectionPoints( // Build the current waypoints first so we can pass them into
fromTask, // connection-point selection as an approach hint.
toTask,
nodeWidth,
nodeHeight,
);
const isSelected = edgeMatches(selectedEdge, edge);
// Build the current waypoints, applying drag override if active
let currentWaypoints: NodePosition[] = edge.waypoints let currentWaypoints: NodePosition[] = edge.waypoints
? [...edge.waypoints] ? [...edge.waypoints]
: []; : [];
@@ -957,7 +968,6 @@ function WorkflowEdgesInner({
(activeDrag.type === "waypoint" || (activeDrag.type === "waypoint" ||
activeDrag.type === "new-waypoint") activeDrag.type === "new-waypoint")
) { ) {
// Use the snapshot from state with drag position applied
currentWaypoints = [...activeDrag.waypointsSnapshot]; currentWaypoints = [...activeDrag.waypointsSnapshot];
if (dragPos) { if (dragPos) {
currentWaypoints[activeDrag.waypointIndex] = { currentWaypoints[activeDrag.waypointIndex] = {
@@ -967,6 +977,16 @@ function WorkflowEdgesInner({
} }
} }
const { start, end, selfLoop } = getBestConnectionPoints(
fromTask,
toTask,
nodeWidth,
nodeHeight,
currentWaypoints.length > 0 ? currentWaypoints : undefined,
);
const isSelected = edgeMatches(selectedEdge, edge);
// All points: start → waypoints → end // All points: start → waypoints → end
const allPoints = [start, ...currentWaypoints, end]; const allPoints = [start, ...currentWaypoints, end];
@@ -979,7 +999,7 @@ function WorkflowEdgesInner({
const color = const color =
edge.color || EDGE_COLORS[edge.type] || EDGE_COLORS.complete; edge.color || EDGE_COLORS[edge.type] || EDGE_COLORS.complete;
const dash = EDGE_DASH[edge.type] || ""; const dash = edge.lineStyle ? LINE_STYLE_DASH[edge.lineStyle] : "";
const arrowId = edge.color const arrowId = edge.color
? `arrow-custom-${index}` ? `arrow-custom-${index}`
: `arrow-${edge.type}`; : `arrow-${edge.type}`;
@@ -1180,7 +1200,7 @@ function WorkflowEdgesInner({
stroke="white" stroke="white"
strokeWidth={2} strokeWidth={2}
opacity={1} opacity={1}
className="transition-all" className="transition-[r]"
/> />
{/* Invisible larger hit area */} {/* Invisible larger hit area */}
<circle cx={wp.x} cy={wp.y} r={12} fill="transparent" /> <circle cx={wp.x} cy={wp.y} r={12} fill="transparent" />
@@ -1256,7 +1276,7 @@ function WorkflowEdgesInner({
strokeWidth={1.5} strokeWidth={1.5}
strokeDasharray={isHovered ? "" : "2,2"} strokeDasharray={isHovered ? "" : "2,2"}
opacity={isHovered ? 1 : 0.7} opacity={isHovered ? 1 : 0.7}
className="transition-all duration-150" className="transition-[r,opacity,stroke-dasharray] duration-150"
/> />
{/* Plus icon */} {/* Plus icon */}
<text <text

View File

@@ -23,6 +23,9 @@ export interface NodePosition {
* Transitions are evaluated in order. When `when` is not defined, * Transitions are evaluated in order. When `when` is not defined,
* the transition is unconditional (fires on any completion). * the transition is unconditional (fires on any completion).
*/ */
/** Line style for transition edges */
export type LineStyle = "solid" | "dashed" | "dotted" | "dash-dot";
export interface TaskTransition { export interface TaskTransition {
/** Condition expression (e.g., "{{ succeeded() }}", "{{ failed() }}") */ /** Condition expression (e.g., "{{ succeeded() }}", "{{ failed() }}") */
when?: string; when?: string;
@@ -34,6 +37,8 @@ export interface TaskTransition {
label?: string; label?: string;
/** Custom color for the transition edge (CSS color string, e.g., "#ff6600") */ /** Custom color for the transition edge (CSS color string, e.g., "#ff6600") */
color?: string; color?: string;
/** Custom line style for the transition edge (overrides type-based default) */
line_style?: LineStyle;
/** Intermediate waypoints per target task (keyed by target task name) for edge routing */ /** Intermediate waypoints per target task (keyed by target task name) for edge routing */
edge_waypoints?: Record<string, NodePosition[]>; edge_waypoints?: Record<string, NodePosition[]>;
/** Label position per target task as t-parameter (01) along the edge path */ /** Label position per target task as t-parameter (01) along the edge path */
@@ -116,6 +121,12 @@ export const PRESET_COLORS: Record<TransitionPreset, string> = {
always: "#6b7280", // gray-500 always: "#6b7280", // gray-500
}; };
export const PRESET_STYLES: Record<TransitionPreset, LineStyle> = {
succeeded: "solid",
failed: "dashed",
always: "solid",
};
/** /**
* Classify a `when` expression into an edge visual type. * Classify a `when` expression into an edge visual type.
* Used for edge coloring and labeling. * Used for edge coloring and labeling.
@@ -158,6 +169,8 @@ export interface WorkflowEdge {
transitionIndex: number; transitionIndex: number;
/** Custom color override for the edge (CSS color string) */ /** Custom color override for the edge (CSS color string) */
color?: string; color?: string;
/** Custom line style override for the edge */
lineStyle?: LineStyle;
/** Intermediate waypoints for this specific edge */ /** Intermediate waypoints for this specific edge */
waypoints?: NodePosition[]; waypoints?: NodePosition[];
/** Label position as t-parameter (01) along the edge path; default 0.5 */ /** Label position as t-parameter (01) along the edge path; default 0.5 */
@@ -220,6 +233,8 @@ export interface TransitionChartMeta {
label?: string; label?: string;
/** Custom color for the transition edge (CSS color string) */ /** Custom color for the transition edge (CSS color string) */
color?: string; color?: string;
/** Custom line style for the transition edge */
line_style?: LineStyle;
/** Intermediate waypoints per target task (keyed by target task name) */ /** Intermediate waypoints per target task (keyed by target task name) */
edge_waypoints?: Record<string, NodePosition[]>; edge_waypoints?: Record<string, NodePosition[]>;
/** Label position per target task as t-parameter (01) along the edge path */ /** Label position per target task as t-parameter (01) along the edge path */
@@ -231,7 +246,7 @@ export interface WorkflowYamlTransition {
when?: string; when?: string;
publish?: PublishDirective[]; publish?: PublishDirective[];
do?: string[]; do?: string[];
/** Visual metadata (label, color) — ignored by backend */ /** Visual metadata (label, color, line style, waypoints) — ignored by backend */
__chart_meta__?: TransitionChartMeta; __chart_meta__?: TransitionChartMeta;
} }
@@ -397,13 +412,18 @@ export function builderStateToDefinition(
if (t.when) yt.when = t.when; if (t.when) yt.when = t.when;
if (t.publish && t.publish.length > 0) yt.publish = t.publish; if (t.publish && t.publish.length > 0) yt.publish = t.publish;
if (t.do && t.do.length > 0) yt.do = t.do; if (t.do && t.do.length > 0) yt.do = t.do;
// Store label/color/waypoints in __chart_meta__ to avoid polluting the transition namespace // Store label/color/line_style/waypoints in __chart_meta__
const hasChartMeta = const hasChartMeta =
t.label || t.color || t.edge_waypoints || t.label_positions; t.label ||
t.color ||
t.line_style ||
t.edge_waypoints ||
t.label_positions;
if (hasChartMeta) { if (hasChartMeta) {
yt.__chart_meta__ = {}; yt.__chart_meta__ = {};
if (t.label) yt.__chart_meta__.label = t.label; if (t.label) yt.__chart_meta__.label = t.label;
if (t.color) yt.__chart_meta__.color = t.color; if (t.color) yt.__chart_meta__.color = t.color;
if (t.line_style) yt.__chart_meta__.line_style = t.line_style;
if (t.edge_waypoints && Object.keys(t.edge_waypoints).length > 0) { if (t.edge_waypoints && Object.keys(t.edge_waypoints).length > 0) {
yt.__chart_meta__.edge_waypoints = t.edge_waypoints; yt.__chart_meta__.edge_waypoints = t.edge_waypoints;
} }
@@ -551,6 +571,7 @@ export function definitionToBuilderState(
do: t.do, do: t.do,
label: t.__chart_meta__?.label, label: t.__chart_meta__?.label,
color: t.__chart_meta__?.color, color: t.__chart_meta__?.color,
line_style: t.__chart_meta__?.line_style,
edge_waypoints: t.__chart_meta__?.edge_waypoints, edge_waypoints: t.__chart_meta__?.edge_waypoints,
label_positions: t.__chart_meta__?.label_positions, label_positions: t.__chart_meta__?.label_positions,
})); }));
@@ -636,6 +657,7 @@ export function deriveEdges(tasks: WorkflowTask[]): WorkflowEdge[] {
label, label,
transitionIndex: ti, transitionIndex: ti,
color: transition.color, color: transition.color,
lineStyle: transition.line_style,
waypoints: transition.edge_waypoints?.[targetName], waypoints: transition.edge_waypoints?.[targetName],
labelPosition: transition.label_positions?.[targetName], labelPosition: transition.label_positions?.[targetName],
}); });
@@ -678,10 +700,11 @@ export function findOrCreateTransition(
return { next, index: existingIndex }; return { next, index: existingIndex };
} }
// Create new transition with default label and color for the preset // Create new transition with default label, color, and line style for the preset
const newTransition: TaskTransition = { const newTransition: TaskTransition = {
label: PRESET_LABELS[preset], label: PRESET_LABELS[preset],
color: PRESET_COLORS[preset], color: PRESET_COLORS[preset],
line_style: PRESET_STYLES[preset],
}; };
if (whenExpr) newTransition.when = whenExpr; if (whenExpr) newTransition.when = whenExpr;
next.push(newTransition); next.push(newTransition);