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,
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,29 +512,88 @@ export default function TaskInspector({
title={swatch.label}
/>
))}
<div className="flex items-center gap-1 ml-1">
<input
type="color"
value={transition.color || "#6b7280"}
onChange={(e) =>
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"
/>
{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>
{/* 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>
{/* When condition */}

View File

@@ -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<HTMLDivElement>(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(

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 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<TransitionPreset, string> = {
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<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<{
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<SelectedEdgeInfo | null>(
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
// ---- 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);
}
}, []);
// ---- 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]);
},
[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,53 +419,90 @@ 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 (
<div
className="flex-1 overflow-auto bg-gray-100 relative"
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}
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
onContextMenu={handleContextMenu}
>
{/* Grid background */}
{/* Transformed canvas content */}
<div
ref={innerRef}
data-canvas-bg="true"
className="absolute inset-0"
style={{
minWidth: canvasDimensions.width,
minHeight: canvasDimensions.height,
backgroundImage: `
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)
`,
backgroundSize: "20px 20px",
position: "absolute",
transformOrigin: "0 0",
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
width: innerSize.width,
height: innerSize.height,
}}
/>
{/* 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 */}
<WorkflowEdges
edges={edges}
@@ -319,6 +513,7 @@ export default function WorkflowCanvas({
selectedEdge={selectedEdge}
onWaypointUpdate={handleWaypointUpdate}
onLabelPositionUpdate={handleLabelPositionUpdate}
screenToCanvas={screenToCanvas}
/>
{/* Task nodes */}
@@ -335,18 +530,43 @@ export default function WorkflowCanvas({
onStartConnection={handleStartConnection}
connectingFrom={connectingFrom}
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 */}
{tasks.length === 0 ? (
<div
className="absolute inset-0 flex items-center justify-center pointer-events-none"
style={{
minWidth: canvasDimensions.width,
minHeight: canvasDimensions.height,
}}
>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<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">
<Plus className="w-8 h-8 text-gray-400" />
@@ -370,7 +590,7 @@ export default function WorkflowCanvas({
) : (
<button
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"
>
<Plus className="w-6 h-6" />

View File

@@ -7,6 +7,7 @@ import type {
} from "@/types/workflow";
import { PRESET_COLORS } from "@/types/workflow";
import type { TransitionPreset } from "./TaskNode";
import type { ScreenToCanvas } from "./WorkflowCanvas";
export interface EdgeHoverInfo {
taskId: string;
@@ -50,10 +51,12 @@ interface WorkflowEdgesProps {
targetTaskName: string,
position: number | undefined,
) => void;
/** Convert screen (client) coordinates to canvas-space coordinates. */
screenToCanvas?: ScreenToCanvas;
}
const NODE_WIDTH = 240;
const NODE_HEIGHT = 120;
const NODE_HEIGHT = 96;
/** Color for each edge type */
const EDGE_COLORS: Record<EdgeType, string> = {
@@ -63,11 +66,14 @@ const EDGE_COLORS: Record<EdgeType, string> = {
custom: "#8b5cf6", // violet-500
};
const EDGE_DASH: Record<EdgeType, string> = {
success: "",
failure: "6,4",
complete: "4,4",
custom: "8,4,2,4",
/** SVG stroke-dasharray values for each user-facing line style */
import type { LineStyle } from "@/types/workflow";
const LINE_STYLE_DASH: Record<LineStyle, string> = {
solid: "",
dashed: "6,4",
dotted: "2,3",
"dash-dot": "8,4,2,4",
};
/** 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.
*
* 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(
fromTask: WorkflowTask,
toTask: WorkflowTask,
nodeWidth: number,
nodeHeight: number,
waypoints?: { x: number; y: number }[],
): {
start: { x: number; y: number };
end: { x: number; y: number };
selfLoop?: boolean;
} {
// Self-loop: right side → top
if (fromTask.id === toTask.id) {
return {
start: getNodeRightCenter(fromTask, nodeWidth, nodeHeight),
start: getNodeBottomCenter(fromTask, nodeWidth, nodeHeight),
end: {
x: fromTask.position.x + nodeWidth * 0.75,
y: fromTask.position.y,
@@ -134,47 +177,17 @@ function getBestConnectionPoints(
};
}
const fromCenter = {
x: fromTask.position.x + nodeWidth / 2,
y: fromTask.position.y + nodeHeight / 2,
};
const toCenter = {
x: toTask.position.x + nodeWidth / 2,
y: toTask.position.y + nodeHeight / 2,
};
// Origin always exits from bottom
const start = getNodeBottomCenter(fromTask, nodeWidth, nodeHeight);
const dx = toCenter.x - fromCenter.x;
const dy = toCenter.y - fromCenter.y;
// Use the last waypoint (if any) as the approach direction for the
// 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) {
return {
start: getNodeBottomCenter(fromTask, nodeWidth, nodeHeight),
end: getNodeTopCenter(toTask, nodeWidth),
};
}
const end = closestEntryEdge(toTask, approach, nodeWidth, nodeHeight);
if (dy < 0 && Math.abs(dy) > Math.abs(dx) * 0.5) {
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),
};
return { start, end };
}
/**
@@ -504,6 +517,7 @@ function WorkflowEdgesInner({
selectedEdge,
onWaypointUpdate,
onLabelPositionUpdate,
screenToCanvas: screenToCanvasProp,
}: WorkflowEdgesProps) {
const svgRef = useRef<SVGSVGElement>(null);
@@ -554,9 +568,14 @@ function WorkflowEdgesInner({
segmentIndex: number;
} | 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(
(clientX: number, clientY: number): { x: number; y: number } => {
if (screenToCanvasProp) {
return screenToCanvasProp(clientX, clientY);
}
const svg = svgRef.current;
if (!svg) return { x: clientX, y: clientY };
const rect = svg.getBoundingClientRect();
@@ -568,7 +587,7 @@ function WorkflowEdgesInner({
y: clientY - rect.top + scrollTop,
};
},
[],
[screenToCanvasProp],
);
// Refs to hold latest callback values (updated via effects)
@@ -936,16 +955,8 @@ function WorkflowEdgesInner({
const toTask = taskMap.get(edge.to);
if (!fromTask || !toTask) return null;
const { start, end, selfLoop } = getBestConnectionPoints(
fromTask,
toTask,
nodeWidth,
nodeHeight,
);
const isSelected = edgeMatches(selectedEdge, edge);
// Build the current waypoints, applying drag override if active
// Build the current waypoints first so we can pass them into
// connection-point selection as an approach hint.
let currentWaypoints: NodePosition[] = edge.waypoints
? [...edge.waypoints]
: [];
@@ -957,7 +968,6 @@ function WorkflowEdgesInner({
(activeDrag.type === "waypoint" ||
activeDrag.type === "new-waypoint")
) {
// Use the snapshot from state with drag position applied
currentWaypoints = [...activeDrag.waypointsSnapshot];
if (dragPos) {
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
const allPoints = [start, ...currentWaypoints, end];
@@ -979,7 +999,7 @@ function WorkflowEdgesInner({
const color =
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
? `arrow-custom-${index}`
: `arrow-${edge.type}`;
@@ -1180,7 +1200,7 @@ function WorkflowEdgesInner({
stroke="white"
strokeWidth={2}
opacity={1}
className="transition-all"
className="transition-[r]"
/>
{/* Invisible larger hit area */}
<circle cx={wp.x} cy={wp.y} r={12} fill="transparent" />
@@ -1256,7 +1276,7 @@ function WorkflowEdgesInner({
strokeWidth={1.5}
strokeDasharray={isHovered ? "" : "2,2"}
opacity={isHovered ? 1 : 0.7}
className="transition-all duration-150"
className="transition-[r,opacity,stroke-dasharray] duration-150"
/>
{/* Plus icon */}
<text

View File

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