@statelyai/flow-react 0.4.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 ADDED
@@ -0,0 +1,103 @@
1
+ # @statelyai/flow-react
2
+
3
+ <!-- description from package.json -->
4
+
5
+ Thin React adapter for `@statelyai/flow` and `@statelyai/flow-dom`.
6
+
7
+ ## Philosophy
8
+
9
+ `@statelyai/flow-react` should be completely event-driven and composable. React should not hide a second flow model behind component internals; it should adapt the same `FlowInstance` that consumers can create, inspect, subscribe to, and trigger directly.
10
+
11
+ The adapter abstracts over two lower layers:
12
+
13
+ - `@statelyai/flow` owns the graph, viewport, selection, interaction state, semantic events, and derived engine queries.
14
+ - `@statelyai/flow-dom` owns browser-only measurement and container attachment.
15
+
16
+ The React package owns React lifecycle, context, selector subscriptions, and a convenience renderer. It should be flexible enough for consumers to build their own stateful flow component from the instance alone.
17
+
18
+ Optimization is part of the architecture, not a later cleanup pass. Components should subscribe only to the data they actually render or use for derived work. For example, an edge layer should not subscribe to the entire store when it only needs edges, viewport, edge-path inputs, and selected edge ids.
19
+
20
+ The graph data structure is the source of truth for graph content, not for transient UI state. Core node and edge data should not grow flags like `selected: true` or `dragging: true`; selection, focus, dragging, hover, connection state, and similar interaction state live in the flow store and are derived into render props such as `selected` or `dragging`.
21
+
22
+ DOM measurement is one-directional by default. Nodes, ports, and edge labels report their inherent rendered size back to the engine, but default renderers should not apply measured `width` or `height` back onto the same DOM entities. Consumers can opt into controlled sizing for custom components, but the built-in DOM path should avoid measurement feedback loops.
23
+
24
+ ## Core API
25
+
26
+ <!-- exported React adapter API from src/index.ts -->
27
+
28
+ | Export | Description |
29
+ | ------------------------------------- | -------------------------------------------------------------------------------- |
30
+ | `createFlowInstance(options)` | Creates the shared instance used by React and custom renderers |
31
+ | `useFlow(options)` | TipTap-style hook for creating a stable flow controller |
32
+ | `useCreateFlowInstance(options)` | React lifecycle hook for creating an instance and attaching listeners |
33
+ | `useFlowSelector(selector, options?)` | Subscribe to any part of the flow store from React |
34
+ | `useFlowValue(selector, isEqual?)` | Short selector hook for the nearest provider |
35
+ | `useFlowContext()` | Subscribe to the full flow store context |
36
+ | `useFlowNode(id, selector?, isEqual?)` | Subscribe to node data plus derived render fields |
37
+ | `useFlowEdge(id, selector?, isEqual?)` | Subscribe to edge data plus derived render fields |
38
+ | `useFlowPort(id, selector?, isEqual?)` | Subscribe to port data plus derived render fields |
39
+ | `useFlowViewport()` | Subscribe to viewport state |
40
+ | `useFlowMeasurement(id)` | Subscribe to measured bounds for an addressable |
41
+ | `FlowInstanceProvider` | Provides a `FlowInstance` through React context |
42
+ | `useFlowInstance()` | Reads the nearest `FlowInstance` |
43
+ | `useOptionalFlowInstance()` | Reads the nearest `FlowInstance` or returns `null` |
44
+ | `StatelyFlow` | Convenience component that renders nodes, edges, backgrounds, and overlays |
45
+ | `FlowColorModeScope` | Applies the current flow color mode as DOM data attributes and `color-scheme` |
46
+ | `useFlowColorMode()` | Reads the store color mode and resolves `system` to `light` or `dark` |
47
+ | `useApplyFlowColorMode(element)` | Applies flow color mode attributes to an existing DOM element from inside a flow |
48
+
49
+ `@statelyai/flow-react/backgrounds` exports `Background`, `DotsBackground`, `LinesBackground`, and `CrossBackground` helpers for viewport-aware canvas backgrounds. Backgrounds are plain React components, so consumers can also build custom ones with `useFlowSelector((context) => context.viewport)`.
50
+
51
+ Color mode is store data, not engine behavior. `@statelyai/flow-dom` exports small helpers for resolving and applying `light`, `dark`, and `system`, while React exposes hooks/components that make those values easy to use with CSS variables.
52
+
53
+ ## Usage
54
+
55
+ ```tsx
56
+ import {
57
+ StatelyFlow,
58
+ useFlow,
59
+ useFlowSelector,
60
+ } from "@statelyai/flow-react";
61
+ import { createEdge, createGraph, createNode } from "@statelyai/flow";
62
+
63
+ const graph = createGraph({
64
+ nodes: [
65
+ createNode({ id: "a", x: 0, y: 0, data: { label: "Start" } }),
66
+ createNode({ id: "b", x: 240, y: 120, data: { label: "Done" } }),
67
+ ],
68
+ edges: [createEdge({ id: "a-b", sourceId: "a", targetId: "b" })],
69
+ });
70
+
71
+ function Flow() {
72
+ const flow = useFlow({
73
+ graph,
74
+ settings: {
75
+ store: { snapToGrid: true, snapGrid: [16, 16] },
76
+ },
77
+ listeners: [
78
+ {
79
+ selector: (context) => context.selection.size,
80
+ onChange: (selectionSize) => {
81
+ console.log("selection size", selectionSize);
82
+ },
83
+ },
84
+ ],
85
+ });
86
+
87
+ return (
88
+ <StatelyFlow
89
+ flow={flow}
90
+ graph={graph}
91
+ renderNode={({ node, selected }) => (
92
+ <div data-selected={selected}>{node.data?.label ?? node.id}</div>
93
+ )}
94
+ />
95
+ );
96
+ }
97
+ ```
98
+
99
+ ## Storybook
100
+
101
+ ```bash
102
+ pnpm --filter @statelyai/flow-react storybook
103
+ ```
@@ -0,0 +1,20 @@
1
+ import * as react0 from "react";
2
+ import { CSSProperties } from "react";
3
+
4
+ //#region src/backgrounds.d.ts
5
+ type BackgroundVariant = "dots" | "lines" | "cross";
6
+ type BackgroundProps = {
7
+ variant?: BackgroundVariant;
8
+ gap?: number;
9
+ size?: number;
10
+ color?: string;
11
+ backgroundColor?: string;
12
+ className?: string;
13
+ style?: CSSProperties;
14
+ };
15
+ declare function Background(props: BackgroundProps): react0.JSX.Element;
16
+ declare function DotsBackground(props: Omit<BackgroundProps, "variant">): react0.JSX.Element;
17
+ declare function LinesBackground(props: Omit<BackgroundProps, "variant">): react0.JSX.Element;
18
+ declare function CrossBackground(props: Omit<BackgroundProps, "variant">): react0.JSX.Element;
19
+ //#endregion
20
+ export { Background, BackgroundProps, BackgroundVariant, CrossBackground, DotsBackground, LinesBackground };
@@ -0,0 +1,53 @@
1
+ import { l as useFlowSelector } from "./hooks.js";
2
+ import { jsx } from "react/jsx-runtime";
3
+ //#region src/backgrounds.tsx
4
+ function Background(props) {
5
+ const viewport = useFlowSelector((context) => context.viewport);
6
+ const variant = props.variant ?? "dots";
7
+ const gap = props.gap ?? 24;
8
+ const size = props.size ?? 1;
9
+ const color = props.color ?? "#cbd5e1";
10
+ const scaledGap = gap * viewport.zoom;
11
+ const scaledSize = size * viewport.zoom;
12
+ return /* @__PURE__ */ jsx("div", {
13
+ "aria-hidden": true,
14
+ className: props.className,
15
+ style: {
16
+ position: "absolute",
17
+ inset: 0,
18
+ backgroundColor: props.backgroundColor,
19
+ backgroundImage: getBackgroundImage(variant, color, scaledSize),
20
+ backgroundPosition: `${viewport.x}px ${viewport.y}px`,
21
+ backgroundSize: `${scaledGap}px ${scaledGap}px`,
22
+ pointerEvents: "none",
23
+ ...props.style
24
+ }
25
+ });
26
+ }
27
+ function DotsBackground(props) {
28
+ return /* @__PURE__ */ jsx(Background, {
29
+ ...props,
30
+ variant: "dots"
31
+ });
32
+ }
33
+ function LinesBackground(props) {
34
+ return /* @__PURE__ */ jsx(Background, {
35
+ ...props,
36
+ variant: "lines"
37
+ });
38
+ }
39
+ function CrossBackground(props) {
40
+ return /* @__PURE__ */ jsx(Background, {
41
+ ...props,
42
+ variant: "cross"
43
+ });
44
+ }
45
+ function getBackgroundImage(variant, color, size) {
46
+ switch (variant) {
47
+ case "dots": return `radial-gradient(circle, ${color} ${size}px, transparent ${size}px)`;
48
+ case "lines": return [`linear-gradient(${color} ${size}px, transparent ${size}px)`, `linear-gradient(90deg, ${color} ${size}px, transparent ${size}px)`].join(", ");
49
+ case "cross": return `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M50 38v24M38 50h24" stroke="${color}" stroke-width="${Math.max(1, size)}" stroke-linecap="round"/></svg>`)}")`;
50
+ }
51
+ }
52
+ //#endregion
53
+ export { Background, CrossBackground, DotsBackground, LinesBackground };
@@ -0,0 +1,255 @@
1
+ import { createContext, useContext, useEffect, useRef, useState } from "react";
2
+ import { createEntityChangeNotifier, createFlow } from "@statelyai/flow";
3
+ import { getAttachedContainer } from "@statelyai/flow-dom";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/createFlowInstance.ts
6
+ function createFlowInstance(options) {
7
+ const flow = createFlow({
8
+ graph: options.graph,
9
+ ...options.settings
10
+ });
11
+ const cleanup = /* @__PURE__ */ new Set();
12
+ function getSnapshot() {
13
+ return flow.store.getSnapshot().context;
14
+ }
15
+ function clientToCanvas(point) {
16
+ const rect = getAttachedContainer(flow.store)?.getBoundingClientRect();
17
+ return flow.screenToCanvas({
18
+ x: point.x - (rect?.left ?? 0),
19
+ y: point.y - (rect?.top ?? 0)
20
+ });
21
+ }
22
+ function listen(selector, listener, listenerOptions = {}) {
23
+ const isEqual = listenerOptions.isEqual ?? Object.is;
24
+ let previousValue = selector(getSnapshot());
25
+ if (listenerOptions.fireImmediately) listener(previousValue, previousValue, getSnapshot());
26
+ const unsubscribe = flow.subscribe((context) => {
27
+ const nextValue = selector(context);
28
+ if (isEqual(previousValue, nextValue)) return;
29
+ const currentPreviousValue = previousValue;
30
+ previousValue = nextValue;
31
+ listener(nextValue, currentPreviousValue, context);
32
+ });
33
+ cleanup.add(unsubscribe);
34
+ return () => {
35
+ cleanup.delete(unsubscribe);
36
+ unsubscribe();
37
+ };
38
+ }
39
+ function subscribeEvent(event, listener) {
40
+ const subscription = flow.on(event, listener);
41
+ const unsubscribe = () => subscription.unsubscribe();
42
+ cleanup.add(unsubscribe);
43
+ return () => {
44
+ cleanup.delete(unsubscribe);
45
+ unsubscribe();
46
+ };
47
+ }
48
+ if (options.events?.connectionEnded) subscribeEvent("connectionEnded", options.events.connectionEnded);
49
+ if (options.events?.layoutRequested) subscribeEvent("layoutRequested", options.events.layoutRequested);
50
+ for (const listener of options.listeners ?? []) listen(listener.selector, listener.onChange, listener);
51
+ return {
52
+ ...flow,
53
+ getSnapshot,
54
+ listen,
55
+ clientToCanvas,
56
+ destroy() {
57
+ for (const unsubscribe of cleanup) unsubscribe();
58
+ cleanup.clear();
59
+ flow.destroy();
60
+ }
61
+ };
62
+ }
63
+ //#endregion
64
+ //#region src/FlowInstanceContext.tsx
65
+ const FlowInstanceContext = createContext(null);
66
+ function FlowInstanceProvider(props) {
67
+ return /* @__PURE__ */ jsx(FlowInstanceContext.Provider, {
68
+ value: props.flow,
69
+ children: props.children
70
+ });
71
+ }
72
+ function useFlowInstance() {
73
+ const flow = useContext(FlowInstanceContext);
74
+ if (!flow) throw new Error("Flow instance is missing. Wrap this tree in <FlowInstanceProvider flow={flow}>.");
75
+ return flow;
76
+ }
77
+ function useOptionalFlowInstance() {
78
+ return useContext(FlowInstanceContext);
79
+ }
80
+ //#endregion
81
+ //#region src/hooks.ts
82
+ function useFlow(options) {
83
+ return useCreateFlowInstance(options);
84
+ }
85
+ function useCreateFlowInstance(options) {
86
+ const instanceRef = useRef(null);
87
+ const listenersRef = useRef(options.listeners);
88
+ const eventsRef = useRef(options.events);
89
+ const didMountRef = useRef(false);
90
+ listenersRef.current = options.listeners;
91
+ eventsRef.current = options.events;
92
+ if (!instanceRef.current) instanceRef.current = createFlowInstance({
93
+ graph: options.graph,
94
+ settings: options.settings
95
+ });
96
+ const flow = instanceRef.current;
97
+ useEffect(() => {
98
+ if (!didMountRef.current) {
99
+ didMountRef.current = true;
100
+ return;
101
+ }
102
+ flow.trigger.setGraph({ graph: options.graph });
103
+ }, [flow, options.graph]);
104
+ useEffect(() => {
105
+ const cleanup = [];
106
+ if (eventsRef.current?.connectionEnded) {
107
+ const subscription = flow.on("connectionEnded", eventsRef.current.connectionEnded);
108
+ cleanup.push(() => subscription.unsubscribe());
109
+ }
110
+ if (eventsRef.current?.layoutRequested) {
111
+ const subscription = flow.on("layoutRequested", eventsRef.current.layoutRequested);
112
+ cleanup.push(() => subscription.unsubscribe());
113
+ }
114
+ for (const listener of listenersRef.current ?? []) cleanup.push(flow.listen(listener.selector, listener.onChange, listener));
115
+ return () => {
116
+ for (const dispose of cleanup) dispose();
117
+ };
118
+ }, [
119
+ flow,
120
+ options.listeners,
121
+ options.events
122
+ ]);
123
+ useEffect(() => () => flow.destroy(), [flow]);
124
+ return flow;
125
+ }
126
+ function useFlowSelector(selector, options = {}) {
127
+ const contextFlow = useOptionalFlowInstance();
128
+ const flow = options.flow ?? contextFlow;
129
+ if (!flow) throw new Error("Flow instance is missing. Pass { flow } or render inside <FlowInstanceProvider>.");
130
+ const selectorRef = useRef(selector);
131
+ const isEqualRef = useRef(options.isEqual ?? Object.is);
132
+ selectorRef.current = selector;
133
+ isEqualRef.current = options.isEqual ?? Object.is;
134
+ const [selected, setSelected] = useState(() => selector(flow.getSnapshot()));
135
+ useEffect(() => {
136
+ setSelected((previous) => {
137
+ const next = selectorRef.current(flow.getSnapshot());
138
+ return isEqualRef.current(previous, next) ? previous : next;
139
+ });
140
+ return flow.listen((context) => selectorRef.current(context), (value) => {
141
+ setSelected((previous) => isEqualRef.current(previous, value) ? previous : value);
142
+ });
143
+ }, [flow]);
144
+ return selected;
145
+ }
146
+ function useFlowContext() {
147
+ return useFlowSelector((context) => context);
148
+ }
149
+ const entityNotifiers = /* @__PURE__ */ new WeakMap();
150
+ function getEntityNotifier(flow) {
151
+ let notifier = entityNotifiers.get(flow.store);
152
+ if (!notifier) {
153
+ notifier = createEntityChangeNotifier(flow.store);
154
+ entityNotifiers.set(flow.store, notifier);
155
+ }
156
+ return notifier;
157
+ }
158
+ /**
159
+ * Like `useFlowSelector`, but subscribed through the entity-change notifier:
160
+ * the selector re-runs only when the entity with `id` may have changed, not
161
+ * on every store transition. This is what makes thousands of mounted entity
162
+ * views affordable — see BENCHMARK.md.
163
+ */
164
+ function useFlowEntity(id, selector, options = {}) {
165
+ const contextFlow = useOptionalFlowInstance();
166
+ const flow = options.flow ?? contextFlow;
167
+ if (!flow) throw new Error("Flow instance is missing. Pass { flow } or render inside <FlowInstanceProvider>.");
168
+ const selectorRef = useRef(selector);
169
+ const isEqualRef = useRef(options.isEqual ?? Object.is);
170
+ selectorRef.current = selector;
171
+ isEqualRef.current = options.isEqual ?? Object.is;
172
+ const [selected, setSelected] = useState(() => selector(flow.getSnapshot()));
173
+ useEffect(() => {
174
+ const update = () => {
175
+ setSelected((previous) => {
176
+ const next = selectorRef.current(flow.getSnapshot());
177
+ return isEqualRef.current(previous, next) ? previous : next;
178
+ });
179
+ };
180
+ update();
181
+ return getEntityNotifier(flow).subscribe(id, update);
182
+ }, [flow, id]);
183
+ return selected;
184
+ }
185
+ function useFlowValue(selector, isEqual) {
186
+ return useFlowSelector(selector, { isEqual });
187
+ }
188
+ function useFlowNode(id, selector, isEqual) {
189
+ return useFlowSelector((context) => {
190
+ const node = context.graph.nodes.find((candidate) => candidate.id === id);
191
+ if (!node) throw new Error(`Flow node "${id}" was not found.`);
192
+ if (selector) return selector(node, context);
193
+ return {
194
+ ...node,
195
+ ...getDerivedFields(context, id, {
196
+ "data-flow-target": "entity",
197
+ "data-flow-entity-id": id,
198
+ "data-entity-id": id
199
+ })
200
+ };
201
+ }, { isEqual });
202
+ }
203
+ function useFlowEdge(id, selector, isEqual) {
204
+ const flow = useOptionalFlowInstance();
205
+ return useFlowSelector((context) => {
206
+ const edge = context.graph.edges.find((candidate) => candidate.id === id);
207
+ if (!edge) throw new Error(`Flow edge "${id}" was not found.`);
208
+ if (selector) return selector(edge, context);
209
+ return {
210
+ ...edge,
211
+ pathData: flow?.getAllEdgePaths().get(id) ?? null,
212
+ ...getDerivedFields(context, id, {
213
+ "data-flow-target": "entity",
214
+ "data-flow-entity-id": id,
215
+ "data-entity-id": id
216
+ })
217
+ };
218
+ }, { isEqual });
219
+ }
220
+ function useFlowPort(id, selector, isEqual) {
221
+ const flow = useFlowInstance();
222
+ return useFlowSelector((context) => {
223
+ const addressable = flow?.getAddressable(id);
224
+ if (!addressable || addressable.kind !== "port") throw new Error(`Flow port "${id}" was not found.`);
225
+ const binding = {
226
+ ...addressable.port,
227
+ id,
228
+ nodeId: addressable.nodeId,
229
+ portName: addressable.portName,
230
+ ...getDerivedFields(context, id, {
231
+ "data-flow-target": "port",
232
+ "data-flow-port-id": id,
233
+ "data-flow-node-id": addressable.nodeId,
234
+ "data-flow-port-name": addressable.portName
235
+ })
236
+ };
237
+ return selector ? selector(binding, context) : binding;
238
+ }, { isEqual });
239
+ }
240
+ function useFlowViewport() {
241
+ return useFlowSelector((context) => context.viewport);
242
+ }
243
+ function useFlowMeasurement(id) {
244
+ return useFlowSelector((context) => context.measurements.get(id) ?? null);
245
+ }
246
+ function getDerivedFields(context, id, props) {
247
+ return {
248
+ selected: context.selection.has(id),
249
+ focused: context.focusedEntityId === id,
250
+ hovered: context.hoveredEntityIds.has(id),
251
+ props
252
+ };
253
+ }
254
+ //#endregion
255
+ export { useFlowEntity as a, useFlowPort as c, useFlowViewport as d, FlowInstanceProvider as f, createFlowInstance as h, useFlowEdge as i, useFlowSelector as l, useOptionalFlowInstance as m, useFlow as n, useFlowMeasurement as o, useFlowInstance as p, useFlowContext as r, useFlowNode as s, useCreateFlowInstance as t, useFlowValue as u };