more workflow editor polish
This commit is contained in:
@@ -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 */}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (0–1) along the edge path */
|
/** Label position per target task as t-parameter (0–1) 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 (0–1) along the edge path; default 0.5 */
|
/** Label position as t-parameter (0–1) 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 (0–1) along the edge path */
|
/** Label position per target task as t-parameter (0–1) 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user