@particle-academy/fancy-flow 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,15 +1,15 @@
1
1
  import { useFlowState, useFlowRun, applyStatusesToNodes } from './chunk-QSSQRQN4.js';
2
2
  export { applyStatusesToNodes, useFlowRun, useFlowState } from './chunk-QSSQRQN4.js';
3
3
  export { runFlow } from './chunk-NVULCEDX.js';
4
- import { registerBuiltinKinds, buildNodeTypes } from './chunk-M2XKGQQL.js';
4
+ import { buildNodeTypes, registerBuiltinKinds } from './chunk-M2XKGQQL.js';
5
5
  export { BUILTIN_KINDS, RegistryNode, buildNodeTypes, registerBuiltinKinds } from './chunk-M2XKGQQL.js';
6
- import { Position, Handle, index, BackgroundVariant, Background, Controls, MiniMap, ReactFlowProvider, addEdge, applyEdgeChanges, applyNodeChanges, useReactFlow } from './chunk-NF6NPY5N.js';
7
- import { exportWorkflow, workflowToBlob, importWorkflow } from './chunk-BCXECQUC.js';
6
+ import { ReactFlowProvider, useReactFlow, addEdge, applyEdgeChanges, applyNodeChanges, Position, Handle, index, BackgroundVariant, Background, Controls, MiniMap } from './chunk-NF6NPY5N.js';
7
+ import { workflowToBlob, importWorkflow, exportWorkflow } from './chunk-BCXECQUC.js';
8
8
  export { WORKFLOW_SCHEMA_URL, WORKFLOW_SCHEMA_VERSION, exportWorkflow, importWorkflow, workflowToBlob } from './chunk-BCXECQUC.js';
9
- import { onNodeKindsChanged, listNodeKinds, categoryAccent, getNodeKind, validateConfig, defaultConfigFor } from './chunk-WNVBXXOL.js';
9
+ import { onNodeKindsChanged, listNodeKinds, getNodeKind, defaultConfigFor, validateConfig, categoryAccent } from './chunk-WNVBXXOL.js';
10
10
  export { categoryAccent, defaultConfigFor, getNodeKind, listNodeKinds, onNodeKindsChanged, registerNodeKind, validateConfig } from './chunk-WNVBXXOL.js';
11
- import { memo, useMemo, useState, useEffect, useRef } from 'react';
12
- import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
11
+ import { memo, createContext, forwardRef, useState, useEffect, useMemo, useCallback, useImperativeHandle, useRef, useContext } from 'react';
12
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
13
13
 
14
14
  function NodeShellInner({
15
15
  node,
@@ -508,16 +508,52 @@ function formatTime(at) {
508
508
  const d = new Date(at);
509
509
  return `${d.getMinutes().toString().padStart(2, "0")}:${d.getSeconds().toString().padStart(2, "0")}.${Math.floor(d.getMilliseconds() / 100)}`;
510
510
  }
511
- function FlowEditor(props) {
511
+
512
+ // src/components/FlowEditor/graph-ops.ts
513
+ function removeNodes(graph, ids) {
514
+ if (ids.length === 0) return graph;
515
+ const doomed = new Set(ids);
516
+ return {
517
+ nodes: graph.nodes.filter((n) => !doomed.has(n.id)),
518
+ edges: graph.edges.filter((e) => !doomed.has(e.source) && !doomed.has(e.target))
519
+ };
520
+ }
521
+ function removeEdges(edges, ids) {
522
+ if (ids.length === 0) return edges;
523
+ const doomed = new Set(ids);
524
+ return edges.filter((e) => !doomed.has(e.id));
525
+ }
526
+ function duplicateNode(node, id, offset = 40) {
527
+ return {
528
+ ...node,
529
+ id,
530
+ position: { x: node.position.x + offset, y: node.position.y + offset },
531
+ // Deep copy so the clone's config edits don't mutate the original.
532
+ data: JSON.parse(JSON.stringify(node.data ?? {}))
533
+ };
534
+ }
535
+ var FlowEditorContext = createContext(null);
536
+ var FlowEditorProvider = FlowEditorContext.Provider;
537
+ function useFlowEditor() {
538
+ const api = useContext(FlowEditorContext);
539
+ if (api === null) {
540
+ throw new Error("useFlowEditor() must be called inside <FlowEditor>.");
541
+ }
542
+ return api;
543
+ }
544
+ function useFlowEditorOptional() {
545
+ return useContext(FlowEditorContext);
546
+ }
547
+ var FlowEditor = forwardRef(function FlowEditor2(props, ref) {
512
548
  return /* @__PURE__ */ jsx(ReactFlowProvider, { children: /* @__PURE__ */ jsx(
513
549
  "div",
514
550
  {
515
551
  className: ["ff-editor", props.className ?? ""].filter(Boolean).join(" "),
516
552
  style: { height: props.height ?? 720, ...props.style },
517
- children: /* @__PURE__ */ jsx(FlowEditorInner, { ...props })
553
+ children: /* @__PURE__ */ jsx(FlowEditorInner, { ...props, apiRef: ref })
518
554
  }
519
555
  ) });
520
- }
556
+ });
521
557
  function FlowEditorInner({
522
558
  initial = { nodes: [], edges: [] },
523
559
  value,
@@ -527,10 +563,18 @@ function FlowEditorInner({
527
563
  showPanel = true,
528
564
  showFeed = true,
529
565
  extraToolbar,
530
- onChange
566
+ actions = [],
567
+ builtins = {},
568
+ slots = {},
569
+ canvasProps = {},
570
+ onChange,
571
+ onSelectionChange,
572
+ onDelete,
573
+ apiRef
531
574
  }) {
532
575
  const internal = useFlowState(initial);
533
576
  const runner = useFlowRun();
577
+ const rf = useReactFlow();
534
578
  const controlled = value !== void 0;
535
579
  const flow = controlled ? makeControlledFlowAdapter(value, onChange) : internal;
536
580
  const [, force] = useState(0);
@@ -542,29 +586,187 @@ function FlowEditorInner({
542
586
  );
543
587
  const [selectedId, setSelectedId] = useState(null);
544
588
  const selected = useMemo(() => flow.nodes.find((n) => n.id === selectedId) ?? null, [flow.nodes, selectedId]);
589
+ useEffect(() => onSelectionChange?.(selected), [selected, onSelectionChange]);
545
590
  const handleNodeClick = (_e, node) => setSelectedId(node.id);
546
591
  useEffect(() => {
547
592
  if (!controlled) onChange?.({ nodes: flow.nodes, edges: flow.edges });
548
593
  }, [flow.nodes, flow.edges, onChange, controlled]);
594
+ const addNode = useCallback(
595
+ (kindName, position) => {
596
+ const kind = getNodeKind(kindName);
597
+ if (!kind) return null;
598
+ const at = position ?? { x: 80, y: 80 };
599
+ const id = newNodeId();
600
+ flow.setNodes((all) => [
601
+ ...all,
602
+ {
603
+ id,
604
+ type: kind.name,
605
+ position: at,
606
+ data: { kind: kind.name, label: kind.label, config: defaultConfigFor(kind) }
607
+ }
608
+ ]);
609
+ setSelectedId(id);
610
+ return id;
611
+ },
612
+ [flow]
613
+ );
614
+ const deleteNodes = useCallback(
615
+ (ids) => {
616
+ if (ids.length === 0) return;
617
+ const doomed = new Set(ids);
618
+ flow.setNodes((all) => removeNodes({ nodes: all, edges: [] }, ids).nodes);
619
+ flow.setEdges((all) => removeNodes({ nodes: [], edges: all }, ids).edges);
620
+ setSelectedId((cur) => cur !== null && doomed.has(cur) ? null : cur);
621
+ onDelete?.(ids);
622
+ },
623
+ [flow, onDelete]
624
+ );
625
+ const api = useMemo(() => {
626
+ const toWorkflow = () => exportWorkflow({ nodes: flow.nodes, edges: flow.edges }, metadata);
627
+ return {
628
+ graph: { nodes: flow.nodes, edges: flow.edges },
629
+ nodes: flow.nodes,
630
+ edges: flow.edges,
631
+ selectedId,
632
+ selected,
633
+ running: runner.running,
634
+ statuses: runner.statuses,
635
+ select: setSelectedId,
636
+ addNode,
637
+ updateNode: (next) => flow.setNodes((all) => all.map((x) => x.id === next.id ? next : x)),
638
+ deleteNodes,
639
+ deleteSelected: () => deleteNodes(selectedId ? [selectedId] : []),
640
+ deleteEdges: (ids) => flow.setEdges((all) => removeEdges(all, ids)),
641
+ duplicateNode: (id) => {
642
+ const src = flow.nodes.find((n) => n.id === id);
643
+ if (!src) return null;
644
+ const copy = duplicateNode(src, newNodeId());
645
+ flow.setNodes((all) => [...all, copy]);
646
+ setSelectedId(copy.id);
647
+ return copy.id;
648
+ },
649
+ setGraph: (graph) => {
650
+ flow.setNodes(graph.nodes);
651
+ flow.setEdges(graph.edges);
652
+ },
653
+ run: () => runner.run({ nodes: flow.nodes, edges: flow.edges }, executors),
654
+ cancel: runner.cancel,
655
+ reset: runner.reset,
656
+ toWorkflow,
657
+ exportWorkflow: () => downloadWorkflow(toWorkflow(), metadata),
658
+ importWorkflow: () => pickWorkflow((graph) => {
659
+ flow.setNodes(graph.nodes);
660
+ flow.setEdges(graph.edges);
661
+ }),
662
+ fitView: () => rf.fitView({ padding: 0.2 })
663
+ };
664
+ }, [flow, selectedId, selected, runner, executors, metadata, addNode, deleteNodes, rf]);
665
+ useImperativeHandle(apiRef, () => api, [api]);
666
+ const dropHandlers = paletteDropHandlers((kindName, evt) => {
667
+ const point = rf.screenToFlowPosition({ x: evt.clientX, y: evt.clientY });
668
+ addNode(kindName, { x: point.x - 100, y: point.y - 30 });
669
+ });
670
+ const startActions = actions.filter((a) => a.placement === "start");
671
+ const endActions = actions.filter((a) => a.placement !== "start");
672
+ const toolbar = slots.toolbar ? slots.toolbar(api) : /* @__PURE__ */ jsxs(Fragment, { children: [
673
+ startActions.map((a) => renderAction(a, api)),
674
+ builtins.run !== false && /* @__PURE__ */ jsx(FlowRunControls, { running: api.running, onRun: api.run, onCancel: api.cancel, onReset: api.reset }),
675
+ (builtins.delete !== false || builtins.export !== false || builtins.import !== false) && /* @__PURE__ */ jsx("span", { className: "ff-editor__sep" }),
676
+ builtins.delete !== false && /* @__PURE__ */ jsx(
677
+ "button",
678
+ {
679
+ className: "ff-editor__btn",
680
+ "data-action": "delete",
681
+ onClick: api.deleteSelected,
682
+ disabled: api.selected === null,
683
+ title: "Delete the selected node (Del / Backspace)",
684
+ children: "\u2715 Delete"
685
+ }
686
+ ),
687
+ builtins.export !== false && /* @__PURE__ */ jsx("button", { className: "ff-editor__btn", "data-action": "export", onClick: api.exportWorkflow, children: "\u2193 Export" }),
688
+ builtins.import !== false && /* @__PURE__ */ jsx("button", { className: "ff-editor__btn", "data-action": "import", onClick: api.importWorkflow, children: "\u2191 Import" }),
689
+ endActions.map((a) => renderAction(a, api)),
690
+ extraToolbar,
691
+ builtins.count !== false && /* @__PURE__ */ jsxs("span", { className: "ff-editor__count", children: [
692
+ api.nodes.length,
693
+ " nodes \xB7 ",
694
+ api.edges.length,
695
+ " edges"
696
+ ] })
697
+ ] });
698
+ return /* @__PURE__ */ jsxs(FlowEditorProvider, { value: api, children: [
699
+ showPalette && (slots.palette ? slots.palette(api) : /* @__PURE__ */ jsx(NodePalette, { className: "ff-editor__palette" })),
700
+ /* @__PURE__ */ jsxs("div", { className: "ff-editor__main", ...dropHandlers, children: [
701
+ /* @__PURE__ */ jsx(
702
+ FlowCanvas,
703
+ {
704
+ nodes: renderedNodes,
705
+ edges: flow.edges,
706
+ nodeTypes,
707
+ onNodesChange: flow.onNodesChange,
708
+ onEdgesChange: flow.onEdgesChange,
709
+ onConnect: flow.onConnect,
710
+ onNodeClick: handleNodeClick,
711
+ onNodesDelete: (deleted) => onDelete?.(deleted.map((n) => n.id)),
712
+ deleteKeyCode: ["Delete", "Backspace"],
713
+ height: "100%",
714
+ toolbar,
715
+ ...canvasProps
716
+ }
717
+ ),
718
+ slots.empty && api.nodes.length === 0 && /* @__PURE__ */ jsx("div", { className: "ff-editor__empty", children: slots.empty(api) }),
719
+ showFeed && (slots.feed ? slots.feed(api) : /* @__PURE__ */ jsx(FlowRunFeed, { entries: runner.feed, className: "ff-editor__feed" }))
720
+ ] }),
721
+ showPanel && (slots.panel ? slots.panel(api) : /* @__PURE__ */ jsxs("div", { className: "ff-editor__panel-wrap", children: [
722
+ /* @__PURE__ */ jsx(NodeConfigPanel, { className: "ff-editor__panel", node: api.selected, onChange: api.updateNode }),
723
+ slots.panelFooter && /* @__PURE__ */ jsx("div", { className: "ff-editor__panel-footer", children: slots.panelFooter(api) })
724
+ ] }))
725
+ ] });
726
+ }
727
+ function renderAction(action, api) {
728
+ if (action.visible && !action.visible(api)) return null;
729
+ const disabled = action.disabled ? action.disabled(api) : action.requiresSelection === true && api.selected === null;
549
730
  return /* @__PURE__ */ jsx(
550
- FlowEditorBody,
731
+ "button",
551
732
  {
552
- showPalette,
553
- showPanel,
554
- showFeed,
555
- flow,
556
- runner,
557
- executors,
558
- metadata,
559
- nodeTypes,
560
- renderedNodes,
561
- selected,
562
- setSelectedId,
563
- handleNodeClick,
564
- extraToolbar
565
- }
733
+ className: "ff-editor__btn",
734
+ "data-action": action.id,
735
+ title: action.title,
736
+ disabled,
737
+ onClick: () => action.onSelect(api),
738
+ children: action.label
739
+ },
740
+ action.id
566
741
  );
567
742
  }
743
+ function newNodeId() {
744
+ return `n_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
745
+ }
746
+ function downloadWorkflow(schema, metadata) {
747
+ const url = URL.createObjectURL(workflowToBlob(schema));
748
+ const a = document.createElement("a");
749
+ a.href = url;
750
+ a.download = `${metadata?.id ?? "workflow"}.json`;
751
+ a.click();
752
+ URL.revokeObjectURL(url);
753
+ }
754
+ function pickWorkflow(onLoad) {
755
+ const input = document.createElement("input");
756
+ input.type = "file";
757
+ input.accept = "application/json";
758
+ input.onchange = async () => {
759
+ const file = input.files?.[0];
760
+ if (!file) return;
761
+ try {
762
+ const result = importWorkflow(JSON.parse(await file.text()), { lenient: true });
763
+ onLoad(result.graph);
764
+ } catch (e) {
765
+ console.error("import failed", e);
766
+ }
767
+ };
768
+ input.click();
769
+ }
568
770
  function makeControlledFlowAdapter(value, onChange) {
569
771
  const apply = (next) => onChange?.(next);
570
772
  return {
@@ -590,114 +792,6 @@ function makeControlledFlowAdapter(value, onChange) {
590
792
  toGraph: () => value
591
793
  };
592
794
  }
593
- function FlowEditorBody({
594
- showPalette,
595
- showPanel,
596
- showFeed,
597
- flow,
598
- runner,
599
- executors,
600
- metadata,
601
- nodeTypes,
602
- renderedNodes,
603
- selected,
604
- setSelectedId,
605
- handleNodeClick,
606
- extraToolbar
607
- }) {
608
- const rf = useReactFlow();
609
- const dropHandlers = paletteDropHandlers((kindName, evt) => {
610
- const kind = getNodeKind(kindName);
611
- if (!kind) return;
612
- const point = rf.screenToFlowPosition({ x: evt.clientX, y: evt.clientY });
613
- const id = `n_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
614
- flow.setNodes((all) => [
615
- ...all,
616
- {
617
- id,
618
- type: kind.name,
619
- position: { x: point.x - 100, y: point.y - 30 },
620
- data: { kind: kind.name, label: kind.label, config: defaultConfigFor(kind) }
621
- }
622
- ]);
623
- setSelectedId(id);
624
- });
625
- const doExport = () => {
626
- const schema = exportWorkflow({ nodes: flow.nodes, edges: flow.edges }, metadata);
627
- const url = URL.createObjectURL(workflowToBlob(schema));
628
- const a = document.createElement("a");
629
- a.href = url;
630
- a.download = `${metadata?.id ?? "workflow"}.json`;
631
- a.click();
632
- URL.revokeObjectURL(url);
633
- };
634
- const doImport = () => {
635
- const input = document.createElement("input");
636
- input.type = "file";
637
- input.accept = "application/json";
638
- input.onchange = async () => {
639
- const file = input.files?.[0];
640
- if (!file) return;
641
- const text = await file.text();
642
- try {
643
- const result = importWorkflow(JSON.parse(text), { lenient: true });
644
- flow.setNodes(result.graph.nodes);
645
- flow.setEdges(result.graph.edges);
646
- } catch (e) {
647
- console.error("import failed", e);
648
- }
649
- };
650
- input.click();
651
- };
652
- return /* @__PURE__ */ jsxs(Fragment, { children: [
653
- showPalette && /* @__PURE__ */ jsx(NodePalette, { className: "ff-editor__palette" }),
654
- /* @__PURE__ */ jsxs("div", { className: "ff-editor__main", ...dropHandlers, children: [
655
- /* @__PURE__ */ jsx(
656
- FlowCanvas,
657
- {
658
- nodes: renderedNodes,
659
- edges: flow.edges,
660
- nodeTypes,
661
- onNodesChange: flow.onNodesChange,
662
- onEdgesChange: flow.onEdgesChange,
663
- onConnect: flow.onConnect,
664
- onNodeClick: handleNodeClick,
665
- height: "100%",
666
- toolbar: /* @__PURE__ */ jsxs(Fragment, { children: [
667
- /* @__PURE__ */ jsx(
668
- FlowRunControls,
669
- {
670
- running: runner.running,
671
- onRun: () => runner.run({ nodes: flow.nodes, edges: flow.edges }, executors),
672
- onCancel: runner.cancel,
673
- onReset: runner.reset
674
- }
675
- ),
676
- /* @__PURE__ */ jsx("span", { className: "ff-editor__sep" }),
677
- /* @__PURE__ */ jsx("button", { className: "ff-editor__btn", onClick: doExport, children: "\u2193 Export" }),
678
- /* @__PURE__ */ jsx("button", { className: "ff-editor__btn", onClick: doImport, children: "\u2191 Import" }),
679
- extraToolbar,
680
- /* @__PURE__ */ jsxs("span", { className: "ff-editor__count", children: [
681
- flow.nodes.length,
682
- " nodes \xB7 ",
683
- flow.edges.length,
684
- " edges"
685
- ] })
686
- ] })
687
- }
688
- ),
689
- showFeed && /* @__PURE__ */ jsx(FlowRunFeed, { entries: runner.feed, className: "ff-editor__feed" })
690
- ] }),
691
- showPanel && /* @__PURE__ */ jsx(
692
- NodeConfigPanel,
693
- {
694
- className: "ff-editor__panel",
695
- node: selected,
696
- onChange: (next) => flow.setNodes((all) => all.map((x) => x.id === next.id ? next : x))
697
- }
698
- )
699
- ] });
700
- }
701
795
  function defineNode(render) {
702
796
  function Wrapped(props) {
703
797
  return render({
@@ -732,6 +826,6 @@ function NodePort({ side, type, id, style, title, className }) {
732
826
  // src/index.ts
733
827
  registerBuiltinKinds();
734
828
 
735
- export { ActionNode, ConfigFieldRenderer, DecisionNode, FlowCanvas, FlowEditor, FlowRunControls, FlowRunFeed, NodeConfigPanel, NodePalette, NodePort, NodeShell, NoteNode, OutputNode, SubgraphNode, TriggerNode, defaultNodeTypes, defineNode, paletteDropHandlers };
829
+ export { ActionNode, ConfigFieldRenderer, DecisionNode, FlowCanvas, FlowEditor, FlowRunControls, FlowRunFeed, NodeConfigPanel, NodePalette, NodePort, NodeShell, NoteNode, OutputNode, SubgraphNode, TriggerNode, defaultNodeTypes, defineNode, paletteDropHandlers, useFlowEditor, useFlowEditorOptional };
736
830
  //# sourceMappingURL=index.js.map
737
831
  //# sourceMappingURL=index.js.map