mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
681 lines
24 KiB
TypeScript
681 lines
24 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Canvas / mind-map view of a flow. Editable, in parity with the
|
|
* list view for everything except the trigger / header / fallback
|
|
* panels (those are list-only — they don't fit visually inside a
|
|
* node graph and the user can switch to List for them).
|
|
*
|
|
* What this view does:
|
|
* - Renders every flow_node as a draggable tile, pan + zoom +
|
|
* minimap. Drag positions persist via the editor context
|
|
* (writing on dragStop, not every frame).
|
|
* - Renders edges between nodes, labeled per slot (button title,
|
|
* "true" / "false", list row title) so a branching flow reads
|
|
* as a real decision tree.
|
|
* - Click a node → side-sheet opens with the same per-node form
|
|
* the list view uses, plus "Set as entry" / "Delete".
|
|
* - Drag from a source handle on one node to a target handle on
|
|
* another → wires that slot's `next_node_key`. Per-slot handles
|
|
* for multi-outgoing types (condition, send_buttons, send_list)
|
|
* so the user picks which branch they're wiring.
|
|
* - Backspace / Delete on a selected node → removes it AND clears
|
|
* every inbound `next_node_key` reference (no dangling arrows).
|
|
* - Delete on a selected edge → clears just that slot.
|
|
* - "+ Add node" floating button drops a new node at the visible
|
|
* viewport center.
|
|
* - Runs dagre auto-layout once on mount for flows whose
|
|
* `position_x` / `position_y` are all zero (pre-canvas flows
|
|
* and brand-new flows) — otherwise everything would pile at
|
|
* the origin.
|
|
*
|
|
* The toggle in `flow-editor-shell.tsx` swaps this in for
|
|
* `<FlowBuilder>` on the same page. Both views share the same
|
|
* `BuilderState` via `useFlowEditor()` — toggling never resets
|
|
* unsaved edits, and a drag here updates the same nodes array the
|
|
* list view reads.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
applyNodeChanges,
|
|
Background,
|
|
Controls,
|
|
Handle,
|
|
MiniMap,
|
|
Panel,
|
|
Position,
|
|
ReactFlow,
|
|
ReactFlowProvider,
|
|
useReactFlow,
|
|
type Connection,
|
|
type Node as RfNode,
|
|
type Edge as RfEdge,
|
|
type NodeChange,
|
|
type NodeProps,
|
|
type OnNodeDrag,
|
|
} from "@xyflow/react";
|
|
import "@xyflow/react/dist/style.css";
|
|
import { Plus, Trash2 } from "lucide-react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
import {
|
|
applyEdgeConnection,
|
|
deriveCanvasEdges,
|
|
outgoingSlots,
|
|
} from "@/lib/flows/edges";
|
|
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout";
|
|
import {
|
|
NODE_META,
|
|
summarizeNode,
|
|
type BuilderNode,
|
|
type NodeType,
|
|
} from "./shared";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { useFlowEditor } from "./flow-editor-state";
|
|
import { NodeConfigForm } from "./forms/node-config-form";
|
|
|
|
// React-Flow node `data` payload — the bits our custom renderer needs.
|
|
interface NodeData extends Record<string, unknown> {
|
|
node: BuilderNode;
|
|
isEntry: boolean;
|
|
/** Validator's "look here" pulse — flashes the card border for
|
|
* ~1.6s. Drives a CSS animation, doesn't change layout. */
|
|
isFlashed: boolean;
|
|
}
|
|
|
|
const NODE_WIDTH = 240;
|
|
// Best-effort default; actual height varies by summary length but
|
|
// dagre needs SOMETHING to compute rank spacing. Underestimating is
|
|
// safer than over (tighter layout that still doesn't overlap).
|
|
const NODE_HEIGHT = 90;
|
|
|
|
// ============================================================
|
|
// Custom node — one card per flow node, styled to match the list
|
|
// view's collapsed card so the two views feel like the same product.
|
|
// ============================================================
|
|
|
|
function FlowNodeCard({ data, selected }: NodeProps) {
|
|
const { node, isEntry, isFlashed } = data as NodeData;
|
|
const meta = NODE_META[node.node_type];
|
|
const summary = summarizeNode(node);
|
|
const Icon = meta.icon;
|
|
const slots = outgoingSlots(node);
|
|
// Start nodes are entry-only; nothing ever targets them, so they
|
|
// don't need an incoming Handle. Every other node type accepts
|
|
// incoming edges (including terminal handoff / end — they're the
|
|
// common targets).
|
|
const hasTarget = node.node_type !== "start";
|
|
// Single-slot nodes get a single source handle floated on the right
|
|
// edge of the card. Multi-slot nodes (condition, send_buttons,
|
|
// send_list) render slot rows inline so each handle visually sits
|
|
// next to the slot it represents.
|
|
const isMultiSlot = slots.length > 1;
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative min-w-[220px] max-w-[260px] rounded-lg border bg-slate-900/95 px-3 py-2 text-left shadow-lg backdrop-blur transition-colors",
|
|
selected
|
|
? "border-primary ring-1 ring-primary/40"
|
|
: "border-slate-700 hover:border-slate-600",
|
|
// Flash overrides hover/selected colors briefly. Tailwind's
|
|
// built-in `animate-pulse` is too gentle; a ring with the
|
|
// amber accent matches the list view's flash semantics.
|
|
isFlashed && "!border-amber-400 ring-2 ring-amber-400/60",
|
|
)}
|
|
>
|
|
{hasTarget && (
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
className="!h-2.5 !w-2.5 !border-slate-600 !bg-slate-700"
|
|
/>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Icon className={cn("h-3.5 w-3.5 shrink-0", meta.color)} />
|
|
<span className="truncate text-[11px] font-medium uppercase tracking-wide text-slate-400">
|
|
{meta.label}
|
|
</span>
|
|
{isEntry && (
|
|
<span className="ml-auto rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-300">
|
|
Entry
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 truncate font-mono text-[11px] text-slate-300">
|
|
{node.node_key}
|
|
</div>
|
|
{summary && (
|
|
<div className="mt-1 line-clamp-2 text-xs text-slate-400">
|
|
{summary}
|
|
</div>
|
|
)}
|
|
|
|
{isMultiSlot && (
|
|
<div className="mt-2 flex flex-col gap-1 border-t border-slate-800 pt-2">
|
|
{slots.map((slot) => (
|
|
<div
|
|
key={slot.id}
|
|
className="relative flex items-center justify-between gap-2 rounded px-1 py-0.5 text-[11px] text-slate-300"
|
|
>
|
|
<span className="truncate" title={slot.label}>
|
|
{slot.label}
|
|
</span>
|
|
<Handle
|
|
type="source"
|
|
id={slot.id}
|
|
position={Position.Right}
|
|
// Override default absolute positioning so the handle
|
|
// sits flush with the right edge of the card instead
|
|
// of floating at vertical center. The negative offset
|
|
// matches the card's px-3 + the handle's own radius.
|
|
className="!relative !right-auto !top-auto !h-2.5 !w-2.5 !translate-x-[12px] !transform-none !border-slate-600 !bg-slate-700"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!isMultiSlot && slots.length === 1 && (
|
|
<Handle
|
|
type="source"
|
|
id={slots[0].id}
|
|
position={Position.Right}
|
|
className="!h-2.5 !w-2.5 !border-slate-600 !bg-slate-700"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const NODE_TYPES = { flow: FlowNodeCard };
|
|
|
|
// ============================================================
|
|
// Root canvas
|
|
// ============================================================
|
|
|
|
/**
|
|
* Outer wrapper provides the React-Flow context to the inner body,
|
|
* so `useReactFlow()` works from anywhere in `FlowCanvasInner`
|
|
* (notably, the pan-to-flash effect). The split is required because
|
|
* useReactFlow() must be called inside a ReactFlowProvider.
|
|
*/
|
|
export function FlowCanvas() {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<FlowCanvasInner />
|
|
</ReactFlowProvider>
|
|
);
|
|
}
|
|
|
|
function FlowCanvasInner() {
|
|
const {
|
|
state,
|
|
setState,
|
|
updateNodeConfig,
|
|
updateNodePosition,
|
|
updateNodePositions,
|
|
removeNode,
|
|
flashKey,
|
|
} = useFlowEditor();
|
|
const reactFlow = useReactFlow();
|
|
const builderNodes = state.nodes;
|
|
const entryNodeId = state.entry_node_id;
|
|
|
|
// Side-panel state — which node's form is open. Canvas-only UI; the
|
|
// list view's analogue is the per-card expanded set in
|
|
// flow-builder.tsx.
|
|
const [selectedNodeKey, setSelectedNodeKey] = useState<string | null>(null);
|
|
const selectedNode = useMemo(
|
|
() =>
|
|
selectedNodeKey
|
|
? builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null
|
|
: null,
|
|
[selectedNodeKey, builderNodes],
|
|
);
|
|
|
|
const autoLayoutPositions = useMemo(() => {
|
|
const canvasEdges = deriveCanvasEdges(builderNodes);
|
|
|
|
return shouldAutoLayout(builderNodes)
|
|
? autoLayout(
|
|
builderNodes.map((n) => ({
|
|
id: n.node_key,
|
|
width: NODE_WIDTH,
|
|
height: NODE_HEIGHT,
|
|
})),
|
|
canvasEdges.map((e) => ({ source: e.source, target: e.target })),
|
|
{ direction: "TB" },
|
|
)
|
|
: null;
|
|
}, [builderNodes]);
|
|
|
|
// If dagre had to place an all-zero flow, persist the generated
|
|
// positions into editor state once. Otherwise the next drag would
|
|
// save only the dragged node and every other node would fall back
|
|
// to (0,0), which feels like nodes teleporting around the canvas.
|
|
const persistedAutoLayoutRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!autoLayoutPositions || persistedAutoLayoutRef.current) return;
|
|
persistedAutoLayoutRef.current = true;
|
|
updateNodePositions(
|
|
Object.fromEntries(
|
|
[...autoLayoutPositions].map(([key, pos]) => [key, pos]),
|
|
),
|
|
);
|
|
}, [autoLayoutPositions, updateNodePositions]);
|
|
|
|
const derivedRfNodes = useMemo(() => {
|
|
const nodes: RfNode<NodeData>[] = builderNodes.map((n) => {
|
|
const fallback = autoLayoutPositions?.get(n.node_key);
|
|
return {
|
|
id: n.node_key,
|
|
type: "flow",
|
|
position: {
|
|
x: fallback?.x ?? n.position_x ?? 0,
|
|
y: fallback?.y ?? n.position_y ?? 0,
|
|
},
|
|
data: {
|
|
node: n,
|
|
isEntry: n.node_key === entryNodeId,
|
|
isFlashed: n.node_key === flashKey,
|
|
},
|
|
};
|
|
});
|
|
|
|
return nodes;
|
|
}, [builderNodes, entryNodeId, flashKey, autoLayoutPositions]);
|
|
|
|
const [rfNodes, setRfNodes] = useState<RfNode<NodeData>[]>(derivedRfNodes);
|
|
|
|
useEffect(() => {
|
|
setRfNodes(derivedRfNodes);
|
|
}, [derivedRfNodes]);
|
|
|
|
const rfEdges = useMemo(() => {
|
|
const canvasEdges = deriveCanvasEdges(builderNodes);
|
|
|
|
// sourceHandle is now wired up — the FlowNodeCard renders a Handle
|
|
// per slot whose id matches the scheme in edges.ts, so React-Flow
|
|
// can hang the arrow off the right place on each card.
|
|
const rfEdges: RfEdge[] = canvasEdges.map((e) => ({
|
|
id: e.id,
|
|
source: e.source,
|
|
target: e.target,
|
|
sourceHandle: e.sourceHandle,
|
|
label: e.label,
|
|
labelStyle: { fill: "#cbd5e1", fontSize: 11 },
|
|
labelBgStyle: { fill: "#0f172a" },
|
|
labelBgPadding: [4, 2] as [number, number],
|
|
labelBgBorderRadius: 4,
|
|
style: { stroke: "#475569", strokeWidth: 1.5 },
|
|
}));
|
|
|
|
return rfEdges;
|
|
}, [builderNodes]);
|
|
|
|
const handleNodesChange = useCallback(
|
|
(changes: NodeChange<RfNode<NodeData>>[]) => {
|
|
setRfNodes((nodes) => applyNodeChanges(changes, nodes));
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Drag-to-position: React-Flow tracks the visual drag internally and
|
|
// fires this once on release. We write the final coordinate back to
|
|
// the editor context (which flips `dirty`); save then ships the new
|
|
// positions in the existing PUT /api/flows/[id] body (the route
|
|
// already destructures position_x / position_y per migration 010).
|
|
// Writing only on dragStop (not on every position-change tick during
|
|
// the drag) keeps state updates cheap on long drags.
|
|
const handleNodeDragStop = useCallback<OnNodeDrag<RfNode<NodeData>>>(
|
|
(_event, node) => {
|
|
updateNodePosition(node.id, node.position.x, node.position.y);
|
|
},
|
|
[updateNodePosition],
|
|
);
|
|
|
|
// Pan to the flashed node when the validator panel requests one.
|
|
// Animate over 400ms; landing zoom is whatever the user already has
|
|
// (don't force a zoom reset — that would be jarring mid-edit).
|
|
useEffect(() => {
|
|
if (!flashKey) return;
|
|
const node = builderNodes.find((n) => n.node_key === flashKey);
|
|
if (!node) return;
|
|
const x = (node.position_x ?? 0) + NODE_WIDTH / 2;
|
|
const y = (node.position_y ?? 0) + NODE_HEIGHT / 2;
|
|
reactFlow.setCenter(x, y, {
|
|
zoom: reactFlow.getZoom(),
|
|
duration: 400,
|
|
});
|
|
}, [flashKey, builderNodes, reactFlow]);
|
|
|
|
const handleNodeClick = useCallback(
|
|
(_event: React.MouseEvent, node: RfNode<NodeData>) => {
|
|
setSelectedNodeKey(node.id);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Drag-to-connect: React-Flow fires onConnect when the user drops a
|
|
// handle drag onto a target handle. We look up the source node,
|
|
// compute the right config patch via applyEdgeConnection (matches
|
|
// the same slot scheme as deriveCanvasEdges), and dispatch via
|
|
// updateNodeConfig. The resulting state change re-derives edges on
|
|
// the next render — no need to maintain a separate edge list.
|
|
const handleConnect = useCallback(
|
|
(connection: Connection) => {
|
|
if (!connection.source || !connection.target || !connection.sourceHandle) {
|
|
return;
|
|
}
|
|
const sourceNode = builderNodes.find(
|
|
(n) => n.node_key === connection.source,
|
|
);
|
|
if (!sourceNode) return;
|
|
// Self-loops are a footgun (a button whose target is its own
|
|
// node = infinite reprompt). Reject silently — the user can
|
|
// still wire one via the per-node dropdown if they really want.
|
|
if (connection.source === connection.target) return;
|
|
const patch = applyEdgeConnection(
|
|
sourceNode,
|
|
connection.sourceHandle,
|
|
connection.target,
|
|
);
|
|
if (patch) updateNodeConfig(connection.source, patch);
|
|
},
|
|
[builderNodes, updateNodeConfig],
|
|
);
|
|
|
|
// Keyboard delete (Backspace / Delete) + drag-to-trash. React-Flow
|
|
// fires this with the set of deleted-node objects; we route each
|
|
// through the editor context's removeNode (which now also unlinks
|
|
// inbound references so no dangling arrows survive). Closing the
|
|
// side panel on delete keeps the UI honest if the user deleted the
|
|
// node currently being edited.
|
|
const handleNodesDelete = useCallback(
|
|
(deleted: RfNode<NodeData>[]) => {
|
|
for (const n of deleted) {
|
|
removeNode(n.id);
|
|
if (selectedNodeKey === n.id) setSelectedNodeKey(null);
|
|
}
|
|
},
|
|
[removeNode, selectedNodeKey],
|
|
);
|
|
|
|
// Edge delete: clear the source node's slot rather than removing
|
|
// anything. Edges are derived from configs, so the only way to
|
|
// "delete" one is to null out its underlying next_node_key.
|
|
const handleEdgesDelete = useCallback(
|
|
(deleted: RfEdge[]) => {
|
|
for (const e of deleted) {
|
|
if (!e.sourceHandle) continue;
|
|
const sourceNode = builderNodes.find((n) => n.node_key === e.source);
|
|
if (!sourceNode) continue;
|
|
const patch = applyEdgeConnection(sourceNode, e.sourceHandle, "");
|
|
if (patch) updateNodeConfig(e.source, patch);
|
|
}
|
|
},
|
|
[builderNodes, updateNodeConfig],
|
|
);
|
|
|
|
// Wrapped mutators that target the currently-selected node — pass to
|
|
// the form so each keystroke goes through the editor context (which
|
|
// flips `dirty` and feeds the validator).
|
|
const onSelectedUpdateConfig = useCallback(
|
|
(patch: Record<string, unknown>) => {
|
|
if (selectedNodeKey) updateNodeConfig(selectedNodeKey, patch);
|
|
},
|
|
[selectedNodeKey, updateNodeConfig],
|
|
);
|
|
|
|
const handleDeleteSelected = useCallback(() => {
|
|
if (!selectedNodeKey) return;
|
|
removeNode(selectedNodeKey);
|
|
setSelectedNodeKey(null);
|
|
}, [selectedNodeKey, removeNode]);
|
|
|
|
const handleSetEntry = useCallback(() => {
|
|
if (!selectedNodeKey) return;
|
|
setState((s) => ({ ...s, entry_node_id: selectedNodeKey }));
|
|
}, [selectedNodeKey, setState]);
|
|
|
|
if (rfNodes.length === 0) {
|
|
return (
|
|
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-slate-700 bg-slate-950 text-sm text-slate-500">
|
|
<p>No nodes yet.</p>
|
|
<CanvasAddNodeButton />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="h-[70vh] w-full overflow-hidden rounded-lg border border-slate-800 bg-slate-950">
|
|
<ReactFlow
|
|
nodes={rfNodes}
|
|
edges={rfEdges}
|
|
nodeTypes={NODE_TYPES}
|
|
fitView
|
|
fitViewOptions={{ padding: 0.2, maxZoom: 1 }}
|
|
proOptions={{ hideAttribution: true }}
|
|
onNodesChange={handleNodesChange}
|
|
onNodeDragStop={handleNodeDragStop}
|
|
onNodeClick={handleNodeClick}
|
|
onConnect={handleConnect}
|
|
onNodesDelete={handleNodesDelete}
|
|
onEdgesDelete={handleEdgesDelete}
|
|
// Default is "Backspace" only — accept both so Mac users
|
|
// hitting Delete (Fn+Backspace) get the same behavior.
|
|
deleteKeyCode={["Backspace", "Delete"]}
|
|
nodesConnectable={true}
|
|
edgesFocusable={true}
|
|
elementsSelectable={true}
|
|
// Lower default min/max zoom than the lib's defaults; the
|
|
// tiles already truncate their summary at a reasonable
|
|
// size, so we don't need to zoom past 1.5x.
|
|
minZoom={0.2}
|
|
maxZoom={1.5}
|
|
>
|
|
<Background gap={24} size={1} color="#1e293b" />
|
|
<Controls
|
|
className="!border-slate-700 !bg-slate-900 [&_button]:!border-slate-700 [&_button]:!bg-slate-900 [&_button:hover]:!bg-slate-800"
|
|
showInteractive={false}
|
|
/>
|
|
<MiniMap
|
|
pannable
|
|
zoomable
|
|
nodeColor="#334155"
|
|
maskColor="rgba(15, 23, 42, 0.7)"
|
|
className="!border !border-slate-700 !bg-slate-900"
|
|
/>
|
|
<Panel position="bottom-right" className="!bottom-4 !right-4">
|
|
<CanvasAddNodeButton />
|
|
</Panel>
|
|
</ReactFlow>
|
|
</div>
|
|
|
|
<NodeEditSheet
|
|
node={selectedNode}
|
|
isEntry={selectedNode?.node_key === entryNodeId}
|
|
allNodes={builderNodes}
|
|
onClose={() => setSelectedNodeKey(null)}
|
|
onUpdateConfig={onSelectedUpdateConfig}
|
|
onDelete={handleDeleteSelected}
|
|
onSetEntry={handleSetEntry}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// Side panel — opens when a canvas node is clicked. Mounts the
|
|
// shared NodeConfigForm dispatcher so edits made here behave
|
|
// identically to the list view's per-card editor.
|
|
// ============================================================
|
|
|
|
function NodeEditSheet({
|
|
node,
|
|
isEntry,
|
|
allNodes,
|
|
onClose,
|
|
onUpdateConfig,
|
|
onDelete,
|
|
onSetEntry,
|
|
}: {
|
|
node: BuilderNode | null;
|
|
isEntry: boolean;
|
|
allNodes: BuilderNode[];
|
|
onClose: () => void;
|
|
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
|
onDelete: () => void;
|
|
onSetEntry: () => void;
|
|
}) {
|
|
// Sheet is controlled — opens when a node is selected, closes via
|
|
// Esc / overlay / close button (all delegated to onClose).
|
|
const open = node !== null;
|
|
if (!node) {
|
|
return (
|
|
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
|
<SheetContent side="right" className="w-full sm:max-w-md" />
|
|
</Sheet>
|
|
);
|
|
}
|
|
const meta = NODE_META[node.node_type];
|
|
const Icon = meta.icon;
|
|
return (
|
|
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
|
<SheetContent
|
|
side="right"
|
|
className="flex w-full flex-col gap-0 border-l border-slate-800 bg-slate-950 p-0 sm:max-w-md"
|
|
>
|
|
<SheetHeader className="border-b border-slate-800 px-5 py-4">
|
|
<SheetTitle className="flex items-center gap-2 text-slate-100">
|
|
<Icon className={cn("h-4 w-4 shrink-0", meta.color)} />
|
|
<span>{meta.label}</span>
|
|
{isEntry && (
|
|
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-300">
|
|
Entry
|
|
</span>
|
|
)}
|
|
</SheetTitle>
|
|
<SheetDescription className="font-mono text-[11px] text-slate-400">
|
|
{node.node_key}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-5 py-4">
|
|
<NodeConfigForm
|
|
node={node}
|
|
allNodes={allNodes}
|
|
showAdvanced={false}
|
|
onUpdateConfig={onUpdateConfig}
|
|
/>
|
|
</div>
|
|
|
|
<SheetFooter className="border-t border-slate-800 px-5 py-3 sm:flex-row sm:justify-between">
|
|
{!isEntry ? (
|
|
<Button variant="ghost" size="sm" onClick={onSetEntry}>
|
|
Set as entry
|
|
</Button>
|
|
) : (
|
|
<span />
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onDelete}
|
|
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Delete node
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// Floating add-node button — bottom-right of the canvas. Mirrors
|
|
// the list view's AddNodeButton (same dropdown menu, same NodeType
|
|
// list, same icons via NODE_META) but drops the new node into the
|
|
// center of the visible viewport rather than appending to a list.
|
|
// ============================================================
|
|
|
|
const ADD_NODE_TYPES: NodeType[] = [
|
|
"start",
|
|
"send_buttons",
|
|
"send_list",
|
|
"send_message",
|
|
"send_media",
|
|
"collect_input",
|
|
"condition",
|
|
"set_tag",
|
|
"handoff",
|
|
"end",
|
|
];
|
|
|
|
function CanvasAddNodeButton() {
|
|
const reactFlow = useReactFlow();
|
|
const { addNode, updateNodePosition } = useFlowEditor();
|
|
|
|
const handleAdd = (type: NodeType) => {
|
|
const key = addNode(type);
|
|
// Place the new node at the visible canvas center. The Panel's
|
|
// own DOM lives inside ReactFlow so we can climb up to find the
|
|
// .react-flow root and read its bounding rect. If we can't find
|
|
// it (test envs, etc.), addNode's default (0, 0) is the fallback
|
|
// and the user can drag the node into view.
|
|
const root = document.querySelector(".react-flow") as HTMLElement | null;
|
|
if (!root) return;
|
|
const rect = root.getBoundingClientRect();
|
|
const center = reactFlow.screenToFlowPosition({
|
|
x: rect.left + rect.width / 2,
|
|
y: rect.top + rect.height / 2,
|
|
});
|
|
// NODE_WIDTH / NODE_HEIGHT are the dagre layout defaults; offset
|
|
// so the card sits visually centered rather than top-left at the
|
|
// viewport center.
|
|
updateNodePosition(key, center.x - NODE_WIDTH / 2, center.y - NODE_HEIGHT / 2);
|
|
};
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-xs font-medium text-slate-200 shadow-lg transition-colors hover:bg-slate-800"
|
|
aria-label="Add node"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Add node
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="border-slate-700 bg-slate-900">
|
|
{ADD_NODE_TYPES.map((t) => {
|
|
const meta = NODE_META[t];
|
|
const Icon = meta.icon;
|
|
return (
|
|
<DropdownMenuItem key={t} onClick={() => handleAdd(t)}>
|
|
<Icon className={cn("h-3.5 w-3.5", meta.color)} />
|
|
{meta.label}
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|