foxygit / RPA-Remisser Log in
commits tags

/webui/frontend/src/components/Canvas.tsx · 11.24 KB

raw
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 };
}