commits
tags
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type DragEvent,
type ForwardedRef,
} from "react";
import {
Background,
Controls,
MiniMap,
Panel,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Connection,
type Edge,
type EdgeChange,
type Node,
type NodeChange,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { api, type GraphEdge, type Step } from "../lib/api";
import type { Schema } from "../lib/schema";
import type { Lang, T } from "../lib/i18n";
import { autoLayout, normalizeFlow, pickStart, remapIds } from "../lib/graph";
import { handleLabel, outputHandles } from "../lib/handles";
import { newNode } from "../lib/stepops";
import { StepNode } from "./StepNode";
import { DND_BLOCK, DND_STEP } from "./Palette";
const nodeTypes = { step: StepNode };
export interface CanvasGraph {
nodes: Step[];
edges: GraphEdge[];
start?: string | null;
}
interface Props {
graph: CanvasGraph;
schema: Schema;
lang: Lang;
t: T;
/** lokala subgrafer (för call_flow-målväljaren) och externa flödesnamn */
subflowTargets: { id: string; name: string }[];
externalFlows: string[];
onOpenSubflow: (id: string) => void;
onOpenExternal: (name: string) => void;
onChange: (partial: { nodes: Step[]; edges: GraphEdge[]; start: string | null }) => void;
onSelectionChange: (ids: string[]) => void;
}
/** Imperativt API så paletten/inspektören kan lägga till noder utan att remounta. */
export interface CanvasHandle {
addNode: (node: Step) => void;
addNodeType: (type: string) => void;
insertGraph: (nodes: Step[], edges: GraphEdge[]) => void;
}
export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(props, ref) {
return (
<ReactFlowProvider>
<CanvasInner {...props} innerRef={ref} />
</ReactFlowProvider>
);
});
function CanvasInner({
graph,
schema,
lang,
t,
subflowTargets,
externalFlows,
onOpenSubflow,
onOpenExternal,
onChange,
onSelectionChange,
innerRef,
}: Props & { innerRef: ForwardedRef<CanvasHandle> }) {
const rf = useReactFlow();
const seed = useMemo(() => normalizeFlow(graph), [graph]);
const [nodes, setNodes] = useState<Step[]>(() => withPositions(seed.nodes, seed.edges));
const [edges, setEdges] = useState<GraphEdge[]>(() => seed.edges);
const firstEmit = useRef(true);
useEffect(() => {
if (firstEmit.current) {
firstEmit.current = false;
return;
}
onChange({ nodes, edges, start: pickStart(nodes, edges) });
}, [nodes, edges, onChange]);
const updateNode = useCallback(
(id: string, next: Step) => setNodes((prev) => prev.map((n) => (n.id === id ? next : n))),
[],
);
const deleteNode = useCallback((id: string) => {
setNodes((prev) => prev.filter((n) => n.id !== id));
setEdges((prev) => prev.filter((e) => e.source !== id && e.target !== id));
}, []);
const fitSoon = useCallback(() => {
window.setTimeout(() => rf.fitView({ duration: 300, padding: 0.15 }), 60);
}, [rf]);
useImperativeHandle(
innerRef,
() => ({
addNode: (node: Step) => {
setNodes((prev) => [...prev, placeLoose(node, prev)]);
fitSoon();
},
addNodeType: (type: string) => {
setNodes((prev) => [...prev, placeLoose(newNode(schema, type), prev)]);
fitSoon();
},
insertGraph: (ns: Step[], es: GraphEdge[]) => {
const m = remapIds(ns, es);
setNodes((prev) => [...prev, ...m.nodes.map((n, i) => ({ ...n, position: offsetFor(prev, i, n.position) }))]);
setEdges((prev) => [...prev, ...m.edges]);
fitSoon();
},
}),
[schema, fitSoon],
);
// --- RF-noder / RF-kanter ---
const rfNodes: Node[] = useMemo(
() =>
nodes.map((step) => ({
id: step.id!,
type: "step",
position: step.position ?? { x: 0, y: 0 },
data: {
step,
schema,
lang,
t,
onChange: (s: Step) => updateNode(step.id!, s),
onDelete: () => deleteNode(step.id!),
subflowTargets,
externalFlows,
onOpenSubflow,
onOpenExternal,
},
})),
[nodes, schema, lang, t, updateNode, deleteNode, subflowTargets, externalFlows, onOpenSubflow, onOpenExternal],
);
const startId = useMemo(() => pickStart(nodes, edges), [nodes, edges]);
const rfEdges: Edge[] = useMemo(
() =>
edges.map((e) => {
const sh = e.sourceHandle && e.sourceHandle !== "out" ? e.sourceHandle : undefined;
return {
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? null,
targetHandle: e.targetHandle ?? null,
label: sh ? handleLabel(sh) : undefined,
animated: e.sourceHandle === "each",
style: e.sourceHandle === "each" ? { stroke: "#8a3ffc" } : undefined,
};
}),
[edges],
);
const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((prev) => {
let next = prev;
for (const c of changes) {
if (c.type === "position" && c.position && c.dragging) {
if (next === prev) next = [...prev];
const i = next.findIndex((n) => n.id === c.id);
if (i >= 0) next[i] = { ...next[i], position: { x: Math.round(c.position.x), y: Math.round(c.position.y) } };
}
if (c.type === "remove") {
next = next.filter((n) => n.id !== c.id);
}
}
return next;
});
}, []);
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
const removed = changes.filter((c) => c.type === "remove").map((c) => c.id);
if (removed.length) setEdges((prev) => prev.filter((e) => !removed.includes(e.id)));
}, []);
const isValidConnection = useCallback(
(c: Connection | Edge) => {
if (!c.source || !c.target || c.source === c.target) return false;
const th = c.targetHandle ?? "in";
if (th !== "in") return false;
const sh = c.sourceHandle ?? "out";
const src = nodes.find((n) => n.id === c.source);
if (!src || !outputHandles(schema, src).includes(sh)) return false;
// Ett utgångshandtag styr bara EN väg (motorn följer targets[0]).
if (edges.some((e) => e.source === c.source && (e.sourceHandle ?? "out") === sh)) return false;
return true;
},
[nodes, edges, schema],
);
const onConnect = useCallback(
(c: Connection) => {
if (!isValidConnection(c)) return;
setEdges((prev) => [
...prev,
{
id: `e-${Math.random().toString(36).slice(2, 10)}`,
source: c.source!,
target: c.target!,
sourceHandle: c.sourceHandle ?? "out",
targetHandle: "in",
},
]);
},
[isValidConnection],
);
// --- selektion ---
const lastSel = useRef("");
const handleSelectionChange = useCallback(
({ nodes: sel }: { nodes: Node[] }) => {
const key = sel.map((n) => n.id).sort().join(",");
if (key !== lastSel.current) {
lastSel.current = key;
onSelectionChange(key ? key.split(",") : []);
}
},
[onSelectionChange],
);
// --- drag & drop från paletten ---
const onDragOver = useCallback((e: DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}, []);
const onDrop = useCallback(
async (e: DragEvent) => {
e.preventDefault();
const stepType = e.dataTransfer.getData(DND_STEP);
const blockName = e.dataTransfer.getData(DND_BLOCK);
let pos = { x: 0, y: 0 };
try {
pos = rf.screenToFlowPosition({ x: e.clientX, y: e.clientY });
} catch {
/* canvasen kanske inte mätts än */
}
if (stepType) {
setNodes((prev) => [...prev, newNode(schema, stepType, { x: Math.round(pos.x), y: Math.round(pos.y) })]);
return;
}
if (blockName) {
try {
const block = await api.getBlock(blockName);
const m = remapIds(block.nodes ?? [], block.edges ?? []);
const base = m.nodes[0]?.position ?? { x: 0, y: 0 };
setNodes((prev) => [
...prev,
...m.nodes.map((n) => ({
...n,
position: {
x: Math.round(pos.x + ((n.position?.x ?? 0) - base.x)),
y: Math.round(pos.y + ((n.position?.y ?? 0) - base.y)),
},
})),
]);
setEdges((prev) => [...prev, ...m.edges]);
} catch {
/* ignore */
}
}
},
[rf, schema],
);
const relayout = useCallback(() => {
setNodes((prev) => {
const pos = autoLayout(prev, edges);
return prev.map((n) => ({ ...n, position: pos[n.id!] ?? n.position }));
});
fitSoon();
}, [edges, fitSoon]);
return (
<div className="canvas">
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
onSelectionChange={handleSelectionChange}
onDragOver={onDragOver}
onDrop={onDrop}
nodesConnectable
fitView
minZoom={0.2}
proOptions={{ hideAttribution: true }}
>
<Background />
<MiniMap pannable zoomable />
<Controls />
<Panel position="top-left" className="canvas-panel">
<button
className="secondary"
onClick={() => {
setNodes((prev) => [...prev, placeLoose(newNode(schema, "goto"), prev)]);
fitSoon();
}}
>
{t("add_step")}
</button>
<button className="secondary" onClick={relayout}>
{t("relayout")}
</button>
{startId && <span className="canvas-start-hint">{t("start_node")}</span>}
</Panel>
</ReactFlow>
</div>
);
}
// --- hjälpare ---
function withPositions(nodes: Step[], edges: GraphEdge[]): Step[] {
if (nodes.length === 0) return [];
const missing = nodes.some((n) => !n.position);
// Migrerade/gamla flöden ligger som en smal vertikal kolumn med för tät
// radhöjd. Auto-placera (uppifrån och ner, med luft) när noderna knappt
// spretar i x-led. Ett flöde användaren dragit fritt lämnas orört.
const xs = nodes.map((n) => n.position?.x ?? 0);
const xSpread = Math.max(...xs) - Math.min(...xs);
const looksLikeColumn = nodes.length > 3 && xSpread < 120;
if (missing || looksLikeColumn) {
const pos = autoLayout(nodes, edges);
return nodes.map((n) => ({ ...n, position: pos[n.id!] ?? n.position ?? { x: 0, y: 0 } }));
}
return nodes.map((n) => ({ ...n }));
}
function placeLoose(node: Step, existing: Step[]): Step {
if (node.position) return node;
if (existing.length === 0) return { ...node, position: { x: 0, y: 0 } };
const last = existing[existing.length - 1].position ?? { x: 0, y: 0 };
const maxY = existing.reduce((m, n) => Math.max(m, n.position?.y ?? 0), 0);
return { ...node, position: { x: last.x, y: maxY + 230 } };
}
function offsetFor(existing: Step[], i: number, p?: { x: number; y: number }): { x: number; y: number } {
const maxY = existing.reduce((m, n) => Math.max(m, n.position?.y ?? 0), 0);
const base = existing.length ? maxY + 230 : 0;
return { x: p?.x ?? 0, y: base + i * 230 };
}