@cyoda/workflow-viewer 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # `@cyoda/workflow-viewer`
2
2
 
3
- Read-only SVG workflow viewer for rendering Cyoda workflow graphs in React
4
- applications.
3
+ Slim read-only SVG viewer for Cyoda workflow graphs. No React Flow, no
4
+ Monaco, no editor-only dependencies.
5
5
 
6
6
  ## Install
7
7
 
@@ -9,17 +9,68 @@ applications.
9
9
  npm install @cyoda/workflow-core @cyoda/workflow-graph @cyoda/workflow-viewer react react-dom
10
10
  ```
11
11
 
12
- ## Highlights
12
+ ## Usage
13
13
 
14
- - Render workflow graphs as lightweight SVG
15
- - Support selection, pan, and zoom interactions
16
- - Expose theme tokens for consumer customization
14
+ ```tsx
15
+ import { parseImportPayload } from "@cyoda/workflow-core";
16
+ import { projectToGraph } from "@cyoda/workflow-graph";
17
+ import { WorkflowViewer } from "@cyoda/workflow-viewer";
18
+
19
+ const { document } = parseImportPayload(workflowJson);
20
+
21
+ export function Embed() {
22
+ return (
23
+ <WorkflowViewer
24
+ graph={projectToGraph(document)}
25
+ width="100%"
26
+ height={600}
27
+ onSelectionChange={(id) => console.log("selected", id)}
28
+ />
29
+ );
30
+ }
31
+ ```
32
+
33
+ ## Optional ELK layout
34
+
35
+ ```tsx
36
+ import { layoutGraph } from "@cyoda/workflow-layout";
37
+
38
+ const layout = await layoutGraph(graph, { preset: "configuratorReadable" });
39
+ <WorkflowViewer graph={graph} layout={layout} />
40
+ ```
41
+
42
+ Without a `layout` prop the viewer uses its own simple fallback layout.
43
+
44
+ ## What this package provides
45
+
46
+ - SVG rendering of states and transitions using Cyoda visual conventions
47
+ (initial marker, terminal pill, role-coloured borders, dashed loopbacks,
48
+ manual/disabled/criteria/processor badges).
49
+ - Pan and zoom via mouse drag and Ctrl+wheel.
50
+ - Click-to-select; selection value is the synthetic node UUID.
51
+ - Theme tokens from `@cyoda/workflow-viewer/theme` (override via CSS
52
+ custom properties).
53
+
54
+ ## What this package does NOT provide
55
+
56
+ - No drag-connect, delete, or edit affordances — use `@cyoda/workflow-react`.
57
+ - No JSON editor — pair with `@cyoda/workflow-monaco`.
58
+ - No React Flow — this package is intentionally free of React Flow to keep
59
+ the display-only bundle small.
60
+ - No editor metadata (layout positions, comments) — those live in
61
+ `@cyoda/workflow-core`'s `WorkflowUiMeta` and are managed by the editor
62
+ shell, not the viewer.
63
+
64
+ ## Bundle boundary guarantee
65
+
66
+ `@cyoda/workflow-viewer` depends only on `@cyoda/workflow-graph` and React.
67
+ It has no dependency on `@cyoda/workflow-react`, `@cyoda/workflow-layout`,
68
+ `@cyoda/workflow-monaco`, or `reactflow`. This boundary is enforced by
69
+ the package manifest and verified in the bundle audit.
17
70
 
18
71
  ## Documentation
19
72
 
20
- See the
21
- [repository README](https://github.com/Cyoda-platform/cyoda-workflow-editor#readme)
22
- for package relationships, usage examples, and release notes.
73
+ See the [repository README](https://github.com/Cyoda-platform/cyoda-workflow-editor#readme).
23
74
 
24
75
  ## License
25
76
 
package/dist/index.cjs CHANGED
@@ -27,6 +27,7 @@ module.exports = __toCommonJS(index_exports);
27
27
 
28
28
  // src/components/WorkflowViewer.tsx
29
29
  var import_react2 = require("react");
30
+ var import_workflow_graph = require("@cyoda/workflow-graph");
30
31
 
31
32
  // src/theme/tokens.ts
32
33
  var workflowPalette = {
@@ -152,6 +153,29 @@ function simpleLayout(graph) {
152
153
  }
153
154
  return { positions, width: maxWidth + 24, height: yCursor };
154
155
  }
156
+ function nudgeLabels(items) {
157
+ const sorted = [...items].sort((a, b) => a.midX - b.midX || a.midY - b.midY);
158
+ const placed = [];
159
+ const result = /* @__PURE__ */ new Map();
160
+ for (const item of sorted) {
161
+ const { midX } = item;
162
+ let { midY } = item;
163
+ const halfW = item.pillW / 2;
164
+ const halfH = item.pillH / 2;
165
+ let attempts = 0;
166
+ while (attempts < 20) {
167
+ const overlaps = placed.some(
168
+ (p) => midX + halfW > p.x - p.w / 2 && midX - halfW < p.x + p.w / 2 && midY + halfH > p.y - p.h / 2 && midY - halfH < p.y + p.h / 2
169
+ );
170
+ if (!overlaps) break;
171
+ midY += item.pillH + 4;
172
+ attempts++;
173
+ }
174
+ placed.push({ x: midX, y: midY, w: item.pillW, h: item.pillH });
175
+ result.set(item.id, { midX, midY });
176
+ }
177
+ return result;
178
+ }
155
179
  function groupByWorkflow(nodes) {
156
180
  const out = /* @__PURE__ */ new Map();
157
181
  for (const n of nodes) {
@@ -272,8 +296,7 @@ function laneColor(edge, opts) {
272
296
  return e.automated;
273
297
  }
274
298
  function laneDashArray(edge) {
275
- if (edge.disabled) return "3 2";
276
- if (edge.isLoopback) return "6 4";
299
+ if (edge.manual) return "2 4";
277
300
  return void 0;
278
301
  }
279
302
 
@@ -288,9 +311,9 @@ function polylineToPath(points) {
288
311
  }
289
312
  function computeEdgeGeometry(edge, source, target) {
290
313
  const sx = source.x + source.width / 2;
291
- const sy = source.y + source.height / 2;
314
+ const sy = source.y + source.height;
292
315
  const tx = target.x + target.width / 2;
293
- const ty = target.y + target.height / 2;
316
+ const ty = target.y;
294
317
  if (edge.isSelf) {
295
318
  const rightX = source.x + source.width;
296
319
  const topY = source.y + source.height / 3;
@@ -326,7 +349,6 @@ function EdgePath({
326
349
  const d = route && route.points.length >= 2 ? polylineToPath(route.points) : computeEdgeGeometry(edge, source, target).d;
327
350
  const strokeWidth = selected || highlighted ? geometry.edge.strokeWidth + 0.8 : edge.isLoopback ? geometry.edge.loopStrokeWidth : geometry.edge.strokeWidth;
328
351
  const opacity = dimmed ? 0.25 : 1;
329
- const isManualSolid = edge.manual && !edge.disabled && !edge.isLoopback;
330
352
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
331
353
  "g",
332
354
  {
@@ -351,16 +373,6 @@ function EdgePath({
351
373
  strokeDasharray: dash,
352
374
  markerEnd: `url(#wf-arrow-${colorKey(color)})`
353
375
  }
354
- ),
355
- isManualSolid && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
356
- "path",
357
- {
358
- d,
359
- fill: "none",
360
- stroke: workflowPalette.neutrals.white,
361
- strokeWidth: 0.6,
362
- pointerEvents: "none"
363
- }
364
376
  )
365
377
  ]
366
378
  }
@@ -449,6 +461,7 @@ function StartMarker({ position }) {
449
461
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("g", { "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
450
462
  "circle",
451
463
  {
464
+ "data-testid": "start-marker",
452
465
  cx,
453
466
  cy,
454
467
  r,
@@ -464,7 +477,7 @@ function roleCategoryLabel(node) {
464
477
  if (node.role === "initial" || node.role === "initial-terminal") return "INITIAL";
465
478
  if (node.role === "terminal") return "TERMINAL";
466
479
  if (node.category === "MANUAL_REVIEW") return "MANUAL REVIEW";
467
- if (node.category === "PROCESSING_STATE") return "PROCESSING STATE";
480
+ if (node.category === "PROCESSING_STATE") return "PROCESSING";
468
481
  return "STATE";
469
482
  }
470
483
 
@@ -481,7 +494,6 @@ function paletteFor(node) {
481
494
  // src/theme/badges.ts
482
495
  function badgesFor(summary, flags) {
483
496
  const out = [];
484
- if (flags.manual) out.push({ key: "manual", label: "Manual" });
485
497
  if (summary.processor) {
486
498
  if (summary.processor.kind === "single") {
487
499
  out.push({ key: "processor", label: summary.processor.name });
@@ -497,11 +509,6 @@ function badgesFor(summary, flags) {
497
509
  out.push({ key: "criterion", label: "Criterion" });
498
510
  }
499
511
  }
500
- if (summary.execution?.kind === "sync") {
501
- out.push({ key: "execution", label: "SYNC" });
502
- } else if (summary.execution?.kind === "asyncSameTx") {
503
- out.push({ key: "execution", label: "ASYNC_SAME_TX" });
504
- }
505
512
  if (flags.disabled) out.push({ key: "disabled", label: "Disabled" });
506
513
  return out;
507
514
  }
@@ -745,25 +752,47 @@ function pickBadgePalette(key) {
745
752
  // src/components/WorkflowViewer.tsx
746
753
  var import_jsx_runtime6 = require("react/jsx-runtime");
747
754
  function WorkflowViewer({
748
- graph,
755
+ graph: graphInput,
756
+ document,
749
757
  layout,
750
758
  width = "100%",
751
759
  height = "100%",
752
760
  selectedId,
753
761
  onSelectionChange,
762
+ surface = "website",
763
+ layoutMode,
764
+ viewerLayout,
765
+ interaction = "hover-highlight",
766
+ onInspect,
767
+ showStartMarker = false,
754
768
  className
755
769
  }) {
770
+ const graph = (0, import_react2.useMemo)(() => {
771
+ if (graphInput) return graphInput;
772
+ if (document) return (0, import_workflow_graph.projectToGraph)(document);
773
+ throw new Error("WorkflowViewer requires either graph or document.");
774
+ }, [graphInput, document]);
775
+ const visibleGraph = (0, import_react2.useMemo)(() => {
776
+ if (showStartMarker) return graph;
777
+ return {
778
+ ...graph,
779
+ nodes: graph.nodes.filter((node) => node.kind !== "startMarker"),
780
+ edges: graph.edges.filter((edge) => edge.kind !== "startMarker")
781
+ };
782
+ }, [graph, showStartMarker]);
783
+ const graphLayout = typeof layout === "string" ? void 0 : layout;
784
+ const productLayout = viewerLayout ?? layoutMode ?? (typeof layout === "string" ? layout : void 0) ?? "embedded";
756
785
  const effectiveLayout = (0, import_react2.useMemo)(
757
- () => layout ?? simpleLayout(graph),
758
- [graph, layout]
786
+ () => normalizeLayoutForVisibleGraph(graphLayout, visibleGraph) ?? simpleLayout(visibleGraph),
787
+ [graphLayout, visibleGraph]
759
788
  );
760
789
  const pan = usePanZoom();
761
790
  const [internalSelection, setInternalSelection] = (0, import_react2.useState)(null);
762
791
  const [hovered, setHovered] = (0, import_react2.useState)(null);
763
792
  const selection = selectedId ?? internalSelection;
764
793
  const stateNodes = (0, import_react2.useMemo)(
765
- () => graph.nodes.filter((n) => n.kind === "state"),
766
- [graph.nodes]
794
+ () => visibleGraph.nodes.filter((n) => n.kind === "state"),
795
+ [visibleGraph.nodes]
767
796
  );
768
797
  const stateById = (0, import_react2.useMemo)(() => {
769
798
  const m = /* @__PURE__ */ new Map();
@@ -771,22 +800,71 @@ function WorkflowViewer({
771
800
  return m;
772
801
  }, [stateNodes]);
773
802
  const transitionEdges = (0, import_react2.useMemo)(
774
- () => graph.edges.filter((e) => e.kind === "transition"),
775
- [graph.edges]
776
- );
777
- const highlightSet = (0, import_react2.useMemo)(
778
- () => computeHighlightSet(hovered ?? selection, graph.nodes, graph.edges),
779
- [hovered, selection, graph.nodes, graph.edges]
803
+ () => visibleGraph.edges.filter((e) => e.kind === "transition"),
804
+ [visibleGraph.edges]
780
805
  );
806
+ (0, import_react2.useEffect)(() => {
807
+ if (process.env.NODE_ENV === "production" || graphLayout) return;
808
+ const sourceCounts = /* @__PURE__ */ new Map();
809
+ for (const e of graph.edges) {
810
+ if (e.kind !== "transition") continue;
811
+ sourceCounts.set(e.sourceId, (sourceCounts.get(e.sourceId) ?? 0) + 1);
812
+ }
813
+ if ([...sourceCounts.values()].some((n) => n > 1)) {
814
+ console.warn(
815
+ "[WorkflowViewer] Rendering without an ELK layout \u2014 branching graphs may not look polished. Pass a layout from `layoutGraph()` (@cyoda/workflow-layout) for best results."
816
+ );
817
+ }
818
+ }, [graphLayout, visibleGraph.edges]);
819
+ const fallbackLabelPositions = (0, import_react2.useMemo)(() => {
820
+ if (effectiveLayout.edges) return null;
821
+ const CHAR_W = 6.5;
822
+ const PILL_H = 24;
823
+ const items = transitionEdges.flatMap((edge) => {
824
+ const source = effectiveLayout.positions.get(edge.sourceId);
825
+ const target = effectiveLayout.positions.get(edge.targetId);
826
+ if (!source || !target) return [];
827
+ const { midX, midY } = computeEdgeGeometry(edge, source, target);
828
+ const pillW = Math.max(40, edge.summary.display.length * CHAR_W + 12);
829
+ return [{ id: edge.id, midX, midY, pillW, pillH: PILL_H }];
830
+ });
831
+ return nudgeLabels(items);
832
+ }, [effectiveLayout, transitionEdges]);
833
+ const focusId = hovered ?? selection;
834
+ const highlightSet = (0, import_react2.useMemo)(() => {
835
+ if (interaction === "none") return null;
836
+ if (interaction === "select") {
837
+ return (0, import_workflow_graph.computeHighlightSet)(selection, visibleGraph.nodes, visibleGraph.edges);
838
+ }
839
+ return (0, import_workflow_graph.computeHighlightSet)(focusId, visibleGraph.nodes, visibleGraph.edges);
840
+ }, [interaction, focusId, selection, visibleGraph.nodes, visibleGraph.edges]);
781
841
  const anythingFocused = highlightSet !== null;
782
842
  const handleSelect = (id) => {
843
+ if (interaction === "none") return;
783
844
  setInternalSelection(id);
784
845
  onSelectionChange?.(id);
785
846
  };
786
847
  const handleBackgroundClick = () => {
848
+ if (interaction === "none") return;
787
849
  setInternalSelection(null);
788
850
  onSelectionChange?.(null);
789
851
  };
852
+ const handleHoverEnter = (id) => {
853
+ if (interaction === "hover-highlight" || interaction === "hover-path") {
854
+ setHovered(id);
855
+ }
856
+ if (interaction === "hover-path") {
857
+ onInspect?.((0, import_workflow_graph.inspectGraphFocus)(visibleGraph, id));
858
+ }
859
+ };
860
+ const handleHoverLeave = () => {
861
+ if (interaction === "hover-highlight" || interaction === "hover-path") {
862
+ setHovered(null);
863
+ }
864
+ if (interaction === "hover-path") {
865
+ onInspect?.(null);
866
+ }
867
+ };
790
868
  return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
791
869
  "svg",
792
870
  {
@@ -804,8 +882,12 @@ function WorkflowViewer({
804
882
  style: {
805
883
  background: workflowPalette.neutrals.white,
806
884
  fontFamily: "inherit",
807
- userSelect: "none"
885
+ userSelect: "none",
886
+ ...productLayout === "fullWidth" ? { display: "block", width: "100%", height: "100%" } : null
808
887
  },
888
+ "data-surface": surface,
889
+ "data-layout": productLayout,
890
+ "data-interaction": interaction,
809
891
  "data-testid": "workflow-viewer",
810
892
  children: [
811
893
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(Defs, {}),
@@ -835,8 +917,8 @@ function WorkflowViewer({
835
917
  dimmed: isDimmed,
836
918
  selected: isEdgeSelected,
837
919
  onSelect: handleSelect,
838
- onHoverEnter: setHovered,
839
- onHoverLeave: () => setHovered(null)
920
+ onHoverEnter: handleHoverEnter,
921
+ onHoverLeave: handleHoverLeave
840
922
  },
841
923
  edge.id
842
924
  );
@@ -846,7 +928,7 @@ function WorkflowViewer({
846
928
  const target = effectiveLayout.positions.get(edge.targetId);
847
929
  if (!source || !target) return null;
848
930
  const route = effectiveLayout.edges?.get(edge.id);
849
- const labelPos = route ? { midX: route.labelX, midY: route.labelY } : computeEdgeGeometry(edge, source, target);
931
+ const labelPos = route ? { midX: route.labelX, midY: route.labelY } : fallbackLabelPositions?.get(edge.id) ?? computeEdgeGeometry(edge, source, target);
850
932
  const isHighlighted = highlightSet?.has(edge.id) ?? false;
851
933
  const isDimmed = anythingFocused && !isHighlighted;
852
934
  return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
@@ -862,13 +944,13 @@ function WorkflowViewer({
862
944
  `label-${edge.id}`
863
945
  );
864
946
  }),
865
- graph.nodes.map((node) => renderNode(node, effectiveLayout, {
947
+ visibleGraph.nodes.map((node) => renderNode(node, effectiveLayout, {
866
948
  selection,
867
949
  highlightSet,
868
950
  anythingFocused,
869
951
  onSelect: handleSelect,
870
- onHoverEnter: setHovered,
871
- onHoverLeave: () => setHovered(null)
952
+ onHoverEnter: handleHoverEnter,
953
+ onHoverLeave: handleHoverLeave
872
954
  }))
873
955
  ]
874
956
  }
@@ -877,6 +959,30 @@ function WorkflowViewer({
877
959
  }
878
960
  );
879
961
  }
962
+ function normalizeLayoutForVisibleGraph(layout, graph) {
963
+ if (!layout) return void 0;
964
+ const visibleNodeIds = new Set(graph.nodes.map((node) => node.id));
965
+ const positions = new Map(
966
+ Array.from(layout.positions.entries()).filter(([nodeId]) => visibleNodeIds.has(nodeId))
967
+ );
968
+ const visibleEdgeIds = new Set(graph.edges.map((edge) => edge.id));
969
+ const edges = layout.edges ? new Map(Array.from(layout.edges.entries()).filter(([edgeId]) => visibleEdgeIds.has(edgeId))) : void 0;
970
+ return {
971
+ ...layout,
972
+ positions,
973
+ edges,
974
+ width: computeLayoutBound(positions, "x"),
975
+ height: computeLayoutBound(positions, "y")
976
+ };
977
+ }
978
+ function computeLayoutBound(positions, axis) {
979
+ let max = 0;
980
+ for (const position of positions.values()) {
981
+ const bound = axis === "x" ? position.x + position.width : position.y + position.height;
982
+ if (bound > max) max = bound;
983
+ }
984
+ return Math.max(max + 24, 72);
985
+ }
880
986
  function renderNode(node, layout, ctx) {
881
987
  const pos = layout.positions.get(node.id);
882
988
  if (!pos) return null;
@@ -910,29 +1016,6 @@ function smallPositionForMarker(pos) {
910
1016
  height: size
911
1017
  };
912
1018
  }
913
- function computeHighlightSet(focusedId, nodes, edges) {
914
- if (!focusedId) return null;
915
- const set = /* @__PURE__ */ new Set();
916
- set.add(focusedId);
917
- const node = nodes.find((n) => n.id === focusedId);
918
- if (node) {
919
- for (const e of edges) {
920
- if (e.kind !== "transition") continue;
921
- if (e.sourceId === focusedId || e.targetId === focusedId) {
922
- set.add(e.id);
923
- set.add(e.sourceId);
924
- set.add(e.targetId);
925
- }
926
- }
927
- return set;
928
- }
929
- const edge = edges.find((e) => e.id === focusedId);
930
- if (edge && edge.kind === "transition") {
931
- set.add(edge.sourceId);
932
- set.add(edge.targetId);
933
- }
934
- return set;
935
- }
936
1019
  // Annotate the CommonJS export names for ESM import in node:
937
1020
  0 && (module.exports = {
938
1021
  WorkflowViewer,