@particle-academy/fancy-flow 0.8.0 → 0.10.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.
Files changed (55) hide show
  1. package/README.md +104 -0
  2. package/dist/{chunk-BCXECQUC.js → chunk-6GGFEZH7.js} +3 -3
  3. package/dist/{chunk-BCXECQUC.js.map → chunk-6GGFEZH7.js.map} +1 -1
  4. package/dist/{chunk-WNVBXXOL.js → chunk-CPSOC27D.js} +43 -2
  5. package/dist/chunk-CPSOC27D.js.map +1 -0
  6. package/dist/chunk-F5RPRB7A.js +56 -0
  7. package/dist/chunk-F5RPRB7A.js.map +1 -0
  8. package/dist/{chunk-NVULCEDX.js → chunk-HNBO4HP3.js} +8 -4
  9. package/dist/chunk-HNBO4HP3.js.map +1 -0
  10. package/dist/{chunk-M2XKGQQL.js → chunk-NCPQDVUE.js} +178 -16
  11. package/dist/chunk-NCPQDVUE.js.map +1 -0
  12. package/dist/chunk-TITD5W4Y.js +26 -0
  13. package/dist/chunk-TITD5W4Y.js.map +1 -0
  14. package/dist/{chunk-QSSQRQN4.js → chunk-VEI743ZX.js} +3 -3
  15. package/dist/{chunk-QSSQRQN4.js.map → chunk-VEI743ZX.js.map} +1 -1
  16. package/dist/engine.cjs +32 -2
  17. package/dist/engine.cjs.map +1 -1
  18. package/dist/engine.js +3 -1
  19. package/dist/index.cjs +716 -40
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.cts +45 -6
  22. package/dist/index.d.ts +45 -6
  23. package/dist/index.js +417 -23
  24. package/dist/index.js.map +1 -1
  25. package/dist/registry/index.d.cts +112 -4
  26. package/dist/registry/index.d.ts +112 -4
  27. package/dist/registry.cjs +312 -31
  28. package/dist/registry.cjs.map +1 -1
  29. package/dist/registry.js +4 -2
  30. package/dist/rich-input.cjs +56 -0
  31. package/dist/rich-input.cjs.map +1 -0
  32. package/dist/rich-input.d.cts +27 -0
  33. package/dist/rich-input.d.ts +27 -0
  34. package/dist/rich-input.js +37 -0
  35. package/dist/rich-input.js.map +1 -0
  36. package/dist/runtime.cjs +32 -2
  37. package/dist/runtime.cjs.map +1 -1
  38. package/dist/runtime.js +4 -2
  39. package/dist/schema.cjs +41 -0
  40. package/dist/schema.cjs.map +1 -1
  41. package/dist/schema.js +2 -2
  42. package/dist/styles.css +191 -0
  43. package/dist/styles.css.map +1 -1
  44. package/dist/types-BocBFh6l.d.ts +221 -0
  45. package/dist/types-DKqaUjF_.d.cts +221 -0
  46. package/dist/ux.cjs.map +1 -1
  47. package/dist/ux.d.cts +1 -1
  48. package/dist/ux.d.ts +1 -1
  49. package/dist/ux.js +1 -1
  50. package/package.json +24 -2
  51. package/dist/chunk-M2XKGQQL.js.map +0 -1
  52. package/dist/chunk-NVULCEDX.js.map +0 -1
  53. package/dist/chunk-WNVBXXOL.js.map +0 -1
  54. package/dist/types-C0wdN6QX.d.cts +0 -121
  55. package/dist/types-DnMe9Vsf.d.ts +0 -121
package/dist/index.js CHANGED
@@ -1,13 +1,16 @@
1
- import { useFlowState, useFlowRun, applyStatusesToNodes } from './chunk-QSSQRQN4.js';
2
- export { applyStatusesToNodes, useFlowRun, useFlowState } from './chunk-QSSQRQN4.js';
3
- export { runFlow } from './chunk-NVULCEDX.js';
4
- import { buildNodeTypes, registerBuiltinKinds } from './chunk-M2XKGQQL.js';
5
- export { BUILTIN_KINDS, RegistryNode, buildNodeTypes, registerBuiltinKinds } from './chunk-M2XKGQQL.js';
1
+ import { useFlowState, useFlowRun, applyStatusesToNodes } from './chunk-VEI743ZX.js';
2
+ export { applyStatusesToNodes, useFlowRun, useFlowState } from './chunk-VEI743ZX.js';
3
+ export { runFlow } from './chunk-HNBO4HP3.js';
4
+ import { buildNodeTypes, registerBuiltinKinds } from './chunk-NCPQDVUE.js';
5
+ export { BUILTIN_KINDS, RegistryNode, buildNodeTypes, registerBuiltinKinds } from './chunk-NCPQDVUE.js';
6
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
- export { WORKFLOW_SCHEMA_URL, WORKFLOW_SCHEMA_VERSION, exportWorkflow, importWorkflow, workflowToBlob } from './chunk-BCXECQUC.js';
9
- import { onNodeKindsChanged, listNodeKinds, getNodeKind, defaultConfigFor, validateConfig, categoryAccent } from './chunk-WNVBXXOL.js';
10
- export { categoryAccent, defaultConfigFor, getNodeKind, listNodeKinds, onNodeKindsChanged, registerNodeKind, validateConfig } from './chunk-WNVBXXOL.js';
7
+ export { resolveNodePorts, resolvePortSpec } from './chunk-TITD5W4Y.js';
8
+ import { workflowToBlob, importWorkflow, exportWorkflow } from './chunk-6GGFEZH7.js';
9
+ export { WORKFLOW_SCHEMA_URL, WORKFLOW_SCHEMA_VERSION, exportWorkflow, importWorkflow, workflowToBlob } from './chunk-6GGFEZH7.js';
10
+ import { onNodeKindsChanged, listNodeKinds, getNodeKind, defaultConfigFor, validateConfig, categoryAccent } from './chunk-CPSOC27D.js';
11
+ export { categoryAccent, defaultConfigFor, getNodeKind, listNodeKinds, onNodeKindsChanged, registerNodeKind, validateConfig } from './chunk-CPSOC27D.js';
12
+ import { getRichInputAdapter } from './chunk-F5RPRB7A.js';
13
+ export { RichInputPreview, getRichInputAdapter, isRichInputEnabled, onRichInputAdapterChanged, registerRichInputAdapter } from './chunk-F5RPRB7A.js';
11
14
  import { memo, createContext, forwardRef, useState, useEffect, useMemo, useCallback, useImperativeHandle, useRef, useContext } from 'react';
12
15
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
13
16
 
@@ -274,9 +277,18 @@ function paletteDropHandlers(onDrop) {
274
277
  }
275
278
  };
276
279
  }
277
- function ConfigFieldRenderer({ field, value, onChange, renderCredentialField }) {
280
+ function ConfigFieldRenderer({
281
+ field,
282
+ value,
283
+ onChange,
284
+ renderCredentialField,
285
+ renderDocumentField
286
+ }) {
278
287
  switch (field.type) {
279
- case "text":
288
+ case "text": {
289
+ if (field.choices?.length) {
290
+ return /* @__PURE__ */ jsx(ChoiceField, { field, value, onChange });
291
+ }
280
292
  return /* @__PURE__ */ jsx(
281
293
  "input",
282
294
  {
@@ -287,6 +299,7 @@ function ConfigFieldRenderer({ field, value, onChange, renderCredentialField })
287
299
  onChange: (e) => onChange(e.target.value)
288
300
  }
289
301
  );
302
+ }
290
303
  case "textarea":
291
304
  return /* @__PURE__ */ jsx(
292
305
  "textarea",
@@ -364,10 +377,244 @@ function ConfigFieldRenderer({ field, value, onChange, renderCredentialField })
364
377
  onChange: (e) => onChange(e.target.value)
365
378
  }
366
379
  );
380
+ case "repeater":
381
+ return /* @__PURE__ */ jsx(
382
+ RepeaterField,
383
+ {
384
+ field,
385
+ value,
386
+ onChange,
387
+ renderCredentialField,
388
+ renderDocumentField
389
+ }
390
+ );
391
+ case "keyvalue":
392
+ return /* @__PURE__ */ jsx(KeyValueField, { field, value, onChange });
393
+ case "document":
394
+ if (renderDocumentField) {
395
+ return /* @__PURE__ */ jsx(Fragment, { children: renderDocumentField({ documentType: field.documentType, value, onChange }) });
396
+ }
397
+ return /* @__PURE__ */ jsxs("p", { className: "ff-panel__hint ff-panel__hint--missing", children: [
398
+ "No document editor supplied. Pass ",
399
+ /* @__PURE__ */ jsx("code", { children: "renderDocumentField" }),
400
+ " to NodeConfigPanel to author this field."
401
+ ] });
367
402
  default:
368
403
  return null;
369
404
  }
370
405
  }
406
+ function normalizeChoices(choices) {
407
+ return choices.map(
408
+ (c) => typeof c === "string" ? { value: c, label: c } : { value: c.value, label: c.label ?? c.value }
409
+ );
410
+ }
411
+ function ChoiceField({
412
+ field,
413
+ value,
414
+ onChange
415
+ }) {
416
+ const options = normalizeChoices(field.choices ?? []);
417
+ const current = typeof value === "string" ? value : "";
418
+ const known = options.some((o) => o.value === current);
419
+ return /* @__PURE__ */ jsxs(
420
+ "select",
421
+ {
422
+ className: "ff-panel__input",
423
+ value: current,
424
+ onChange: (e) => onChange(e.target.value),
425
+ children: [
426
+ /* @__PURE__ */ jsx("option", { value: "", disabled: true, children: field.placeholder ?? "\u2014" }),
427
+ options.map((o) => /* @__PURE__ */ jsx("option", { value: o.value, children: o.label }, o.value)),
428
+ current !== "" && !known && /* @__PURE__ */ jsxs("option", { value: current, children: [
429
+ current,
430
+ " (not in list)"
431
+ ] })
432
+ ]
433
+ }
434
+ );
435
+ }
436
+ function RepeaterField({
437
+ field,
438
+ value,
439
+ onChange,
440
+ renderCredentialField,
441
+ renderDocumentField
442
+ }) {
443
+ const rows = Array.isArray(value) ? value : [];
444
+ const max = field.maxItems ?? Infinity;
445
+ const min = field.minItems ?? 0;
446
+ const replace = (next) => onChange(next);
447
+ const addRow = () => {
448
+ const blank = {};
449
+ for (const f of field.fields) {
450
+ if ("default" in f && f.default !== void 0) blank[f.key] = f.default;
451
+ }
452
+ replace([...rows, blank]);
453
+ };
454
+ const removeRow = (i) => replace(rows.filter((_, idx) => idx !== i));
455
+ const moveRow = (i, delta) => {
456
+ const target = i + delta;
457
+ if (target < 0 || target >= rows.length) return;
458
+ const next = [...rows];
459
+ [next[i], next[target]] = [next[target], next[i]];
460
+ replace(next);
461
+ };
462
+ const setCell = (i, key, cell) => replace(rows.map((row, idx) => idx === i ? { ...row, [key]: cell } : row));
463
+ const rowTitle = (row, i) => {
464
+ const key = field.titleKey ?? field.fields[0]?.key;
465
+ const raw = key ? row[key] : void 0;
466
+ if (typeof raw === "string" && raw.trim() !== "") return raw;
467
+ if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
468
+ return `Item ${i + 1}`;
469
+ };
470
+ return /* @__PURE__ */ jsxs("div", { className: "ff-repeater", "data-ff-repeater": field.key, children: [
471
+ rows.length === 0 && /* @__PURE__ */ jsx("p", { className: "ff-repeater__empty", children: "None yet." }),
472
+ rows.map((row, i) => /* @__PURE__ */ jsxs("div", { className: "ff-repeater__row", "data-ff-repeater-row": i, children: [
473
+ /* @__PURE__ */ jsxs("div", { className: "ff-repeater__row-head", children: [
474
+ /* @__PURE__ */ jsx("span", { className: "ff-repeater__row-title", children: rowTitle(row, i) }),
475
+ /* @__PURE__ */ jsxs("div", { className: "ff-repeater__row-actions", children: [
476
+ /* @__PURE__ */ jsx(
477
+ "button",
478
+ {
479
+ type: "button",
480
+ className: "ff-repeater__btn",
481
+ onClick: () => moveRow(i, -1),
482
+ disabled: i === 0,
483
+ "aria-label": `Move ${rowTitle(row, i)} up`,
484
+ children: "\u2191"
485
+ }
486
+ ),
487
+ /* @__PURE__ */ jsx(
488
+ "button",
489
+ {
490
+ type: "button",
491
+ className: "ff-repeater__btn",
492
+ onClick: () => moveRow(i, 1),
493
+ disabled: i === rows.length - 1,
494
+ "aria-label": `Move ${rowTitle(row, i)} down`,
495
+ children: "\u2193"
496
+ }
497
+ ),
498
+ /* @__PURE__ */ jsx(
499
+ "button",
500
+ {
501
+ type: "button",
502
+ className: "ff-repeater__btn ff-repeater__btn--danger",
503
+ onClick: () => removeRow(i),
504
+ disabled: rows.length <= min,
505
+ "aria-label": `Remove ${rowTitle(row, i)}`,
506
+ children: "\u2715"
507
+ }
508
+ )
509
+ ] })
510
+ ] }),
511
+ field.fields.map((sub) => /* @__PURE__ */ jsxs("div", { className: "ff-repeater__cell", children: [
512
+ /* @__PURE__ */ jsxs("label", { className: "ff-panel__label ff-panel__label--sub", children: [
513
+ sub.label,
514
+ sub.required && /* @__PURE__ */ jsx("span", { className: "ff-panel__required", "aria-hidden": true, children: " *" })
515
+ ] }),
516
+ /* @__PURE__ */ jsx(
517
+ ConfigFieldRenderer,
518
+ {
519
+ field: sub,
520
+ value: row[sub.key],
521
+ onChange: (cell) => setCell(i, sub.key, cell),
522
+ renderCredentialField,
523
+ renderDocumentField
524
+ }
525
+ )
526
+ ] }, sub.key))
527
+ ] }, i)),
528
+ /* @__PURE__ */ jsxs(
529
+ "button",
530
+ {
531
+ type: "button",
532
+ className: "ff-repeater__add",
533
+ onClick: addRow,
534
+ disabled: rows.length >= max,
535
+ children: [
536
+ "+ ",
537
+ field.addLabel ?? "Add"
538
+ ]
539
+ }
540
+ )
541
+ ] });
542
+ }
543
+ function KeyValueField({
544
+ field,
545
+ value,
546
+ onChange
547
+ }) {
548
+ const map = value && typeof value === "object" && !Array.isArray(value) ? value : {};
549
+ const entries = Object.entries(map);
550
+ const commit = (next) => {
551
+ const obj = {};
552
+ for (const [k, v] of next) {
553
+ if (k === "") continue;
554
+ obj[k] = v;
555
+ }
556
+ onChange(obj);
557
+ };
558
+ const setKey = (i, key) => commit(entries.map(([k, v], idx) => idx === i ? [key, v] : [k, v]));
559
+ const setVal = (i, val) => commit(entries.map(([k, v], idx) => idx === i ? [k, val] : [k, v]));
560
+ const remove = (i) => commit(entries.filter((_, idx) => idx !== i));
561
+ const add = () => commit([...entries, ["", ""]]);
562
+ return /* @__PURE__ */ jsxs("div", { className: "ff-keyvalue", "data-ff-keyvalue": field.key, children: [
563
+ entries.length > 0 && /* @__PURE__ */ jsxs("div", { className: "ff-keyvalue__head", children: [
564
+ /* @__PURE__ */ jsx("span", { children: field.keyLabel ?? "Key" }),
565
+ /* @__PURE__ */ jsx("span", { children: field.valueLabel ?? "Value" }),
566
+ /* @__PURE__ */ jsx("span", {})
567
+ ] }),
568
+ entries.map(([k, v], i) => /* @__PURE__ */ jsxs("div", { className: "ff-keyvalue__row", "data-ff-keyvalue-row": i, children: [
569
+ /* @__PURE__ */ jsx(
570
+ "input",
571
+ {
572
+ className: "ff-panel__input",
573
+ value: k,
574
+ placeholder: field.keyPlaceholder,
575
+ "aria-label": `${field.keyLabel ?? "Key"} ${i + 1}`,
576
+ onChange: (e) => setKey(i, e.target.value)
577
+ }
578
+ ),
579
+ field.valueOptions ? /* @__PURE__ */ jsxs(
580
+ "select",
581
+ {
582
+ className: "ff-panel__input",
583
+ value: v ?? "",
584
+ "aria-label": `${field.valueLabel ?? "Value"} ${i + 1}`,
585
+ onChange: (e) => setVal(i, e.target.value),
586
+ children: [
587
+ /* @__PURE__ */ jsx("option", { value: "", disabled: true, children: "\u2014" }),
588
+ field.valueOptions.map((o) => /* @__PURE__ */ jsx("option", { value: o.value, children: o.label }, o.value))
589
+ ]
590
+ }
591
+ ) : /* @__PURE__ */ jsx(
592
+ "input",
593
+ {
594
+ className: "ff-panel__input",
595
+ value: v ?? "",
596
+ placeholder: field.valuePlaceholder,
597
+ "aria-label": `${field.valueLabel ?? "Value"} ${i + 1}`,
598
+ onChange: (e) => setVal(i, e.target.value)
599
+ }
600
+ ),
601
+ /* @__PURE__ */ jsx(
602
+ "button",
603
+ {
604
+ type: "button",
605
+ className: "ff-repeater__btn ff-repeater__btn--danger",
606
+ onClick: () => remove(i),
607
+ "aria-label": `Remove ${k || `entry ${i + 1}`}`,
608
+ children: "\u2715"
609
+ }
610
+ )
611
+ ] }, i)),
612
+ /* @__PURE__ */ jsxs("button", { type: "button", className: "ff-repeater__add", onClick: add, children: [
613
+ "+ ",
614
+ field.addLabel ?? "Add"
615
+ ] })
616
+ ] });
617
+ }
371
618
  function JsonField({ value, onChange, rows }) {
372
619
  const initial = useMemo(() => {
373
620
  try {
@@ -402,6 +649,7 @@ function NodeConfigPanel({
402
649
  onChange,
403
650
  header,
404
651
  renderCredentialField,
652
+ renderDocumentField,
405
653
  className,
406
654
  style
407
655
  }) {
@@ -427,6 +675,8 @@ function NodeConfigPanel({
427
675
  const setDescription = (description) => onChange({ ...node, data: { ...node.data, description } });
428
676
  const setConfigValue = (key, value) => onChange({ ...node, data: { ...node.data, config: { ...config, [key]: value } } });
429
677
  const issues = validateConfig(kind, config);
678
+ const adapter = getRichInputAdapter();
679
+ const documentField = renderDocumentField ?? (adapter?.renderEditor ? ({ value, onChange: set }) => adapter.renderEditor({ value, onChange: set }) : void 0);
430
680
  return /* @__PURE__ */ jsxs("aside", { className: ["ff-panel", className ?? ""].filter(Boolean).join(" "), style, children: [
431
681
  header,
432
682
  /* @__PURE__ */ jsxs("header", { className: "ff-panel__header", children: [
@@ -475,7 +725,8 @@ function NodeConfigPanel({
475
725
  field,
476
726
  value: config[field.key],
477
727
  onChange: (v) => setConfigValue(field.key, v),
478
- renderCredentialField
728
+ renderCredentialField,
729
+ renderDocumentField: documentField
479
730
  }
480
731
  )
481
732
  ] }, field.key))
@@ -523,6 +774,17 @@ function removeEdges(edges, ids) {
523
774
  const doomed = new Set(ids);
524
775
  return edges.filter((e) => !doomed.has(e.id));
525
776
  }
777
+ function setEdgeLabel(edges, id, label) {
778
+ const next = label?.trim();
779
+ return edges.map((e) => {
780
+ if (e.id !== id) return e;
781
+ if (!next) {
782
+ const { label: _drop, ...rest } = e;
783
+ return rest;
784
+ }
785
+ return { ...e, label: next };
786
+ });
787
+ }
526
788
  function duplicateNode(node, id, offset = 40) {
527
789
  return {
528
790
  ...node,
@@ -570,6 +832,7 @@ function FlowEditorInner({
570
832
  onChange,
571
833
  onSelectionChange,
572
834
  onDelete,
835
+ onEdgeDelete,
573
836
  apiRef
574
837
  }) {
575
838
  const internal = useFlowState(initial);
@@ -587,18 +850,39 @@ function FlowEditorInner({
587
850
  const [selectedId, setSelectedId] = useState(null);
588
851
  const selected = useMemo(() => flow.nodes.find((n) => n.id === selectedId) ?? null, [flow.nodes, selectedId]);
589
852
  useEffect(() => onSelectionChange?.(selected), [selected, onSelectionChange]);
590
- const handleNodeClick = (_e, node) => setSelectedId(node.id);
853
+ const [selectedEdgeId, setSelectedEdgeId] = useState(null);
854
+ const selectedEdge = useMemo(
855
+ () => flow.edges.find((e) => e.id === selectedEdgeId) ?? null,
856
+ [flow.edges, selectedEdgeId]
857
+ );
858
+ const handleNodeClick = (_e, node) => {
859
+ setSelectedId(node.id);
860
+ setSelectedEdgeId(null);
861
+ };
591
862
  const [menu, setMenu] = useState(null);
863
+ const [labelEdit, setLabelEdit] = useState(null);
592
864
  const closeMenu = useCallback(() => setMenu(null), []);
593
865
  const handleNodeContextMenu = (event, node) => {
594
866
  event.preventDefault();
595
867
  setSelectedId(node.id);
596
- setMenu({ x: event.clientX, y: event.clientY, nodeId: node.id });
868
+ setSelectedEdgeId(null);
869
+ setMenu({ x: event.clientX, y: event.clientY, target: { type: "node", id: node.id } });
870
+ };
871
+ const handleEdgeClick = (_e, edge) => {
872
+ setSelectedEdgeId(edge.id);
873
+ };
874
+ const handleEdgeContextMenu = (event, edge) => {
875
+ event.preventDefault();
876
+ setSelectedEdgeId(edge.id);
877
+ setMenu({ x: event.clientX, y: event.clientY, target: { type: "edge", id: edge.id } });
597
878
  };
598
879
  useEffect(() => {
599
- if (menu === null) return;
600
- const close = () => setMenu(null);
601
- const onKey = (e) => e.key === "Escape" && setMenu(null);
880
+ if (menu === null && labelEdit === null) return;
881
+ const close = () => {
882
+ setMenu(null);
883
+ setLabelEdit(null);
884
+ };
885
+ const onKey = (e) => e.key === "Escape" && close();
602
886
  window.addEventListener("click", close);
603
887
  window.addEventListener("scroll", close, true);
604
888
  window.addEventListener("keydown", onKey);
@@ -607,7 +891,7 @@ function FlowEditorInner({
607
891
  window.removeEventListener("scroll", close, true);
608
892
  window.removeEventListener("keydown", onKey);
609
893
  };
610
- }, [menu]);
894
+ }, [menu, labelEdit]);
611
895
  useEffect(() => {
612
896
  if (!controlled) onChange?.({ nodes: flow.nodes, edges: flow.edges });
613
897
  }, [flow.nodes, flow.edges, onChange, controlled]);
@@ -650,14 +934,29 @@ function FlowEditorInner({
650
934
  edges: flow.edges,
651
935
  selectedId,
652
936
  selected,
937
+ selectedEdgeId,
938
+ selectedEdge,
653
939
  running: runner.running,
654
940
  statuses: runner.statuses,
655
941
  select: setSelectedId,
942
+ selectEdge: setSelectedEdgeId,
656
943
  addNode,
657
944
  updateNode: (next) => flow.setNodes((all) => all.map((x) => x.id === next.id ? next : x)),
658
945
  deleteNodes,
659
946
  deleteSelected: () => deleteNodes(selectedId ? [selectedId] : []),
660
- deleteEdges: (ids) => flow.setEdges((all) => removeEdges(all, ids)),
947
+ deleteEdges: (ids) => {
948
+ if (ids.length === 0) return;
949
+ flow.setEdges((all) => removeEdges(all, ids));
950
+ setSelectedEdgeId((cur) => cur !== null && ids.includes(cur) ? null : cur);
951
+ onEdgeDelete?.(ids);
952
+ },
953
+ deleteSelectedEdge: () => {
954
+ if (!selectedEdgeId) return;
955
+ flow.setEdges((all) => removeEdges(all, [selectedEdgeId]));
956
+ setSelectedEdgeId(null);
957
+ onEdgeDelete?.([selectedEdgeId]);
958
+ },
959
+ setEdgeLabel: (id, label) => flow.setEdges((all) => setEdgeLabel(all, id, label)),
661
960
  duplicateNode: (id) => {
662
961
  const src = flow.nodes.find((n) => n.id === id);
663
962
  if (!src) return null;
@@ -729,6 +1028,9 @@ function FlowEditorInner({
729
1028
  onConnect: flow.onConnect,
730
1029
  onNodeClick: handleNodeClick,
731
1030
  onNodeContextMenu: builtins.contextMenu === false ? void 0 : handleNodeContextMenu,
1031
+ onEdgeClick: handleEdgeClick,
1032
+ onEdgeContextMenu: builtins.edgeContextMenu === false ? void 0 : handleEdgeContextMenu,
1033
+ onEdgesDelete: (deleted) => onEdgeDelete?.(deleted.map((e) => e.id)),
732
1034
  onNodesDelete: (deleted) => onDelete?.(deleted.map((n) => n.id)),
733
1035
  deleteKeyCode: ["Delete", "Backspace"],
734
1036
  height: "100%",
@@ -737,14 +1039,14 @@ function FlowEditorInner({
737
1039
  }
738
1040
  ),
739
1041
  slots.empty && api.nodes.length === 0 && /* @__PURE__ */ jsx("div", { className: "ff-editor__empty", children: slots.empty(api) }),
740
- menu !== null && builtins.contextMenu !== false && /* @__PURE__ */ jsx(
1042
+ menu?.target.type === "node" && builtins.contextMenu !== false && /* @__PURE__ */ jsx(
741
1043
  "div",
742
1044
  {
743
1045
  className: "ff-editor__ctx",
744
1046
  style: { top: menu.y, left: menu.x },
745
1047
  role: "menu",
746
1048
  onClick: (e) => e.stopPropagation(),
747
- children: slots.contextMenu ? slots.contextMenu(api, menu.nodeId, closeMenu) : /* @__PURE__ */ jsxs(Fragment, { children: [
1049
+ children: slots.contextMenu ? slots.contextMenu(api, menu.target.id, closeMenu) : /* @__PURE__ */ jsxs(Fragment, { children: [
748
1050
  /* @__PURE__ */ jsx(
749
1051
  "button",
750
1052
  {
@@ -753,7 +1055,7 @@ function FlowEditorInner({
753
1055
  className: "ff-editor__ctx-item",
754
1056
  "data-action": "ctx-duplicate",
755
1057
  onClick: () => {
756
- api.duplicateNode(menu.nodeId);
1058
+ api.duplicateNode(menu.target.id);
757
1059
  closeMenu();
758
1060
  },
759
1061
  children: "Duplicate"
@@ -767,7 +1069,7 @@ function FlowEditorInner({
767
1069
  className: "ff-editor__ctx-item ff-editor__ctx-item--danger",
768
1070
  "data-action": "ctx-delete",
769
1071
  onClick: () => {
770
- api.deleteNodes([menu.nodeId]);
1072
+ api.deleteNodes([menu.target.id]);
771
1073
  closeMenu();
772
1074
  },
773
1075
  children: "Delete"
@@ -776,6 +1078,58 @@ function FlowEditorInner({
776
1078
  ] })
777
1079
  }
778
1080
  ),
1081
+ menu?.target.type === "edge" && builtins.edgeContextMenu !== false && /* @__PURE__ */ jsx(
1082
+ "div",
1083
+ {
1084
+ className: "ff-editor__ctx",
1085
+ style: { top: menu.y, left: menu.x },
1086
+ role: "menu",
1087
+ onClick: (e) => e.stopPropagation(),
1088
+ children: slots.edgeContextMenu ? slots.edgeContextMenu(api, menu.target.id, closeMenu) : /* @__PURE__ */ jsxs(Fragment, { children: [
1089
+ /* @__PURE__ */ jsx(
1090
+ "button",
1091
+ {
1092
+ type: "button",
1093
+ role: "menuitem",
1094
+ className: "ff-editor__ctx-item",
1095
+ "data-action": "ctx-edge-label",
1096
+ onClick: () => {
1097
+ setLabelEdit({ x: menu.x, y: menu.y, edgeId: menu.target.id });
1098
+ setMenu(null);
1099
+ },
1100
+ children: "Label\u2026"
1101
+ }
1102
+ ),
1103
+ /* @__PURE__ */ jsx(
1104
+ "button",
1105
+ {
1106
+ type: "button",
1107
+ role: "menuitem",
1108
+ className: "ff-editor__ctx-item ff-editor__ctx-item--danger",
1109
+ "data-action": "ctx-edge-delete",
1110
+ onClick: () => {
1111
+ api.deleteEdges([menu.target.id]);
1112
+ closeMenu();
1113
+ },
1114
+ children: "Delete connection"
1115
+ }
1116
+ )
1117
+ ] })
1118
+ }
1119
+ ),
1120
+ labelEdit !== null && /* @__PURE__ */ jsx(
1121
+ EdgeLabelEditor,
1122
+ {
1123
+ x: labelEdit.x,
1124
+ y: labelEdit.y,
1125
+ initial: flow.edges.find((e) => e.id === labelEdit.edgeId)?.label ?? "",
1126
+ onCommit: (text) => {
1127
+ api.setEdgeLabel(labelEdit.edgeId, text);
1128
+ setLabelEdit(null);
1129
+ },
1130
+ onCancel: () => setLabelEdit(null)
1131
+ }
1132
+ ),
779
1133
  showFeed && (slots.feed ? slots.feed(api) : /* @__PURE__ */ jsx(FlowRunFeed, { entries: runner.feed, className: "ff-editor__feed" }))
780
1134
  ] }),
781
1135
  showPanel && (slots.panel ? slots.panel(api) : /* @__PURE__ */ jsxs("div", { className: "ff-editor__panel-wrap", children: [
@@ -852,6 +1206,46 @@ function makeControlledFlowAdapter(value, onChange) {
852
1206
  toGraph: () => value
853
1207
  };
854
1208
  }
1209
+ function EdgeLabelEditor({
1210
+ x,
1211
+ y,
1212
+ initial,
1213
+ onCommit,
1214
+ onCancel
1215
+ }) {
1216
+ const [text, setText] = useState(initial);
1217
+ const ref = useRef(null);
1218
+ useEffect(() => {
1219
+ ref.current?.focus();
1220
+ ref.current?.select();
1221
+ }, []);
1222
+ return /* @__PURE__ */ jsx(
1223
+ "div",
1224
+ {
1225
+ className: "ff-editor__ctx ff-editor__edge-label",
1226
+ style: { top: y, left: x },
1227
+ onClick: (e) => e.stopPropagation(),
1228
+ children: /* @__PURE__ */ jsx(
1229
+ "input",
1230
+ {
1231
+ ref,
1232
+ className: "ff-panel__input",
1233
+ value: text,
1234
+ placeholder: "Label this connection",
1235
+ "aria-label": "Connection label",
1236
+ "data-action": "edge-label-input",
1237
+ onChange: (e) => setText(e.target.value),
1238
+ onKeyDown: (e) => {
1239
+ e.stopPropagation();
1240
+ if (e.key === "Enter") onCommit(text);
1241
+ if (e.key === "Escape") onCancel();
1242
+ },
1243
+ onBlur: () => onCommit(text)
1244
+ }
1245
+ )
1246
+ }
1247
+ );
1248
+ }
855
1249
  function defineNode(render) {
856
1250
  function Wrapped(props) {
857
1251
  return render({