mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Closes the trilogy. Three high-value gaps from PRs 2a/2b addressed:
1. Canvas users couldn't save. The Header (with Save / Activate /
Pause / Delete buttons) lived inside FlowBuilder so canvas-only
users had to toggle to List to persist any edit. Lifted into a
new components/flows/header.tsx that consumes useFlowEditor()
and is mounted ONCE in FlowEditorShell, above both views.
2. Validator issues weren't clickable from canvas. The
ValidationPanel was also FlowBuilder-internal. Lifted into
components/flows/validation-panel.tsx + mounted in the shell,
pinned sticky-bottom in either view. Clicks now go through the
editor context's new requestFlash(key) — both views subscribe:
- List view derives an "expanded with flash" set + an effect
scrolls the row into view. The flash class is rendered when
the card's key matches context.flashKey.
- Canvas view's pan-on-flash effect calls
reactFlow.setCenter() to bring the node into the viewport,
and the card renders an amber ring while flashed.
The flash auto-clears in the context after 1600ms; rapid
clicks on different issues cancel the previous timeout.
3. Canvas was unusable on phones — handles are ~10px and live
finger-drags between them aren't a practical workflow. Added
a useMatchMedia('(max-width: 767px)') guard in the shell that
forces list view and hides the toggle below the breakpoint.
The user's preferred view is preserved (just shadowed) so it
comes back when they widen.
Plus minor cleanup: the canvas's empty-state used to direct users
to switch to List view to add a node, but the floating "+" button
shipped in PR 2b made that stale — now shows the same Add-node
dropdown inline.
flow-builder.tsx shrinks by another ~370 lines (Header + StatusBadge
+ ValidationPanel + IssueLine + the local flashedKey + jumpToNode
all moved or replaced by context subscription).
No new tests — the change is structural + UI-only. Existing 319
still pass; typecheck, lint, build all clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
646 lines
22 KiB
TypeScript
646 lines
22 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, useState } from "react";
|
|
import {
|
|
Background,
|
|
Controls,
|
|
Handle,
|
|
MiniMap,
|
|
Panel,
|
|
Position,
|
|
ReactFlow,
|
|
ReactFlowProvider,
|
|
useReactFlow,
|
|
type Connection,
|
|
type Node as RfNode,
|
|
type Edge as RfEdge,
|
|
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,
|
|
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 { rfNodes, rfEdges } = useMemo(() => {
|
|
const canvasEdges = deriveCanvasEdges(builderNodes);
|
|
|
|
// Decide whether to auto-layout. The helper guards against
|
|
// overwriting a user's manual arrangement (only fires when ALL
|
|
// nodes sit at the origin), so we can safely call it
|
|
// unconditionally — if any node has been positioned, this is a
|
|
// no-op.
|
|
const positions = 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;
|
|
|
|
const rfNodes: RfNode<NodeData>[] = builderNodes.map((n) => {
|
|
const fallback = positions?.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,
|
|
},
|
|
};
|
|
});
|
|
|
|
// 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 { rfNodes, rfEdges };
|
|
}, [builderNodes, entryNodeId, flashKey]);
|
|
|
|
// 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 }}
|
|
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>
|
|
);
|
|
}
|