@statelyai/flow-react 0.7.1 → 0.8.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
@@ -17,7 +17,7 @@ The React package owns React lifecycle, context, selector subscriptions, and a c
17
17
 
18
18
  <!-- transient entity rendering split between src/StatelyFlow.tsx and flow-dom src/entity-geometry.ts -->
19
19
 
20
- Optimization is part of the architecture, not a later cleanup pass. Components subscribe only to durable content and interaction state. During drag, resize, and animated layout sessions, `@statelyai/flow-dom` batches node rectangles, edge paths, endpoint handles, and label positions into imperative animation-frame writes, so React commits only at transient-session boundaries. Default edge paths expose `data-flow-edge-segment`; custom edge renderers can use the same indexed attribute to join the imperative path updates. Router-provided `PathData.svgPath` geometry is honored by both initial React rendering and those imperative updates.
20
+ Optimization is part of the architecture, not a later cleanup pass. Components subscribe only to durable content and interaction state. During drag, resize, and animated layout sessions, `@statelyai/flow-dom` batches node rectangles, edge paths, endpoint handles, label positions, and multi-selection bounds into imperative animation-frame writes, so React commits only at transient-session boundaries. Default edge paths expose `data-flow-edge-segment`; custom edge renderers can use the same indexed attribute to join the imperative path updates. Router-provided `PathData.svgPath` geometry is honored by both initial React rendering and those imperative updates.
21
21
 
22
22
  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`.
23
23
 
@@ -27,40 +27,41 @@ DOM measurement is one-directional. Nodes, ports, and edge labels report their r
27
27
 
28
28
  <!-- exported React adapter API from src/index.ts -->
29
29
 
30
- | Export | Description |
31
- | ------------------------------------- | -------------------------------------------------------------------------------- |
32
- | `createFlowInstance(options)` | Creates the shared instance used by React and custom renderers |
33
- | `useFlow(options)` | TipTap-style hook for creating a stable flow controller |
34
- | `useCreateFlowInstance(options)` | Deprecated alias for `useFlow(options)` |
35
- | `useFlowSelector(selector, options?)` | Subscribe to any part of the flow store from React |
36
- | `useFlowValue(selector, isEqual?)` | Short selector hook for the nearest provider |
37
- | `useFlowContext()` | Subscribe to the full flow store context |
38
- | `useConnection()` | Subscribe to the active connection gesture state |
39
- | `useFlowConnections(query)` | Subscribe to stable, port-aware connections for one node |
40
- | `useFlowView()` | Access view-owned image export and measurement refresh |
41
- | `useKeyPress(keys)` | Track whether one or more keys are currently pressed |
42
- | `useNodesInitialized()` | Check whether visible auto-size nodes have measurements |
43
- | `useFlowNode(id, selector?, isEqual?)` | Subscribe to node data plus derived render fields |
44
- | `useFlowEdge(id, selector?, isEqual?)` | Subscribe to edge data plus derived render fields |
45
- | `useFlowPort(id, selector?, isEqual?)` | Subscribe to port data plus derived render fields |
46
- | `useFlowViewport()` | Subscribe to viewport state |
47
- | `useFlowMeasurement(id)` | Subscribe to measured bounds for an entity ref |
48
- | `FlowInstanceProvider` | Provides a `FlowInstance` through React context |
49
- | `useFlowInstance()` | Reads the nearest `FlowInstance` |
50
- | `useOptionalFlowInstance()` | Reads the nearest `FlowInstance` or returns `null` |
51
- | `StatelyFlow` | Convenience component that renders nodes, edges, backgrounds, projections, and overlays |
52
- | `createEntityGeometryObserver(store)` | Batches transient node/label rectangles and edge paths into imperative animation-frame DOM writes |
53
- | `PanePortal` / `ViewportPortal` | Render custom content into fixed screen-coordinate or transformed canvas-coordinate layers |
54
- | `EntityAnchor` | Position custom controls on entity sides or free-standing canvas bounds with screen-stable offsets and inverse scaling |
55
- | `RenderEdgeLabelProps` | Edge, path, bounds, interaction state, and projected attrs passed to `renderEdgeLabel` |
56
- | `Panel` | Positioned overlay panel for controls and custom UI |
57
- | `NodeResizer` | Resize handles for custom node renderers; constraints live on `node.resize` |
58
- | `ReactActiveGraphProjection` | React-typed active projection for `<StatelyFlow projections={...} />` or controller-owned projections |
59
- | `ReactGraphNodeAttrs` / `ReactGraphEdgeAttrs` | React-typed visual attrs exposed to custom node/edge renderers |
60
- | `FlowColorModeScope` | Applies the current flow color mode as DOM data attributes and `color-scheme` |
61
- | `useFlowColorMode()` | Reads the store color mode and resolves `system` to `light` or `dark` |
62
- | `useApplyFlowColorMode(element)` | Applies flow color mode attributes to an existing DOM element from inside a flow |
63
- | `FLOW_BASE_CSS` / `useFlowBaseStyles()` | Base data-attribute stylesheet injected by `StatelyFlow` |
30
+ | Export | Description |
31
+ | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
32
+ | `createFlowInstance(options)` | Creates the shared instance used by React and custom renderers |
33
+ | `useFlow(options)` | TipTap-style hook for creating a stable flow controller |
34
+ | `useCreateFlowInstance(options)` | Deprecated alias for `useFlow(options)` |
35
+ | `useFlowSelector(selector, options?)` | Subscribe to any part of the flow store from React |
36
+ | `useFlowValue(selector, isEqual?)` | Short selector hook for the nearest provider |
37
+ | `useFlowContext()` | Subscribe to the full flow store context |
38
+ | `useConnection()` | Subscribe to the active connection gesture state |
39
+ | `useFlowConnections(query)` | Subscribe to stable, port-aware connections for one node |
40
+ | `useFlowView()` | Access view-owned image export and measurement refresh |
41
+ | `useKeyPress(keys)` | Track whether one or more keys are currently pressed |
42
+ | `useNodesInitialized()` | Check whether visible auto-size nodes have measurements |
43
+ | `useFlowNode(id, selector?, isEqual?)` | Subscribe to node data plus derived render fields |
44
+ | `useFlowEdge(id, selector?, isEqual?)` | Subscribe to edge data plus derived render fields |
45
+ | `useFlowPort(id, selector?, isEqual?)` | Subscribe to port data plus derived render fields |
46
+ | `useFlowViewport()` | Subscribe to viewport state |
47
+ | `useFlowMeasurement(id)` | Subscribe to measured bounds for an entity ref |
48
+ | `FlowInstanceProvider` | Provides a `FlowInstance` through React context |
49
+ | `useFlowInstance()` | Reads the nearest `FlowInstance` |
50
+ | `useOptionalFlowInstance()` | Reads the nearest `FlowInstance` or returns `null` |
51
+ | `StatelyFlow` | Convenience component that renders nodes, edges, backgrounds, projections, and overlays |
52
+ | `createEntityGeometryObserver(store)` | Batches transient node/label rectangles, multi-entity bounds, and edge paths into imperative animation-frame DOM writes |
53
+ | `PanePortal` / `ViewportPortal` | Render custom content into fixed screen-coordinate or transformed canvas-coordinate layers |
54
+ | `useFitViewExclusion()` | Register an overlay element whose measured edge band fit and center commands avoid |
55
+ | `EntityAnchor` | Position custom controls on entity sides or free-standing canvas bounds with screen-stable offsets and inverse scaling |
56
+ | `RenderEdgeLabelProps` | Edge, path, bounds, interaction state, and projected attrs passed to `renderEdgeLabel` |
57
+ | `Panel` | Positioned overlay panel for controls and custom UI |
58
+ | `NodeResizer` | Resize handles for custom node renderers; constraints live on `node.resize` |
59
+ | `ReactActiveGraphProjection` | React-typed active projection for `<StatelyFlow projections={...} />` or controller-owned projections |
60
+ | `ReactGraphNodeAttrs` / `ReactGraphEdgeAttrs` | React-typed visual attrs exposed to custom node/edge renderers |
61
+ | `FlowColorModeScope` | Applies the current flow color mode as DOM data attributes and `color-scheme` |
62
+ | `useFlowColorMode()` | Reads the store color mode and resolves `system` to `light` or `dark` |
63
+ | `useApplyFlowColorMode(element)` | Applies flow color mode attributes to an existing DOM element from inside a flow |
64
+ | `FLOW_BASE_CSS` / `useFlowBaseStyles()` | Base data-attribute stylesheet injected by `StatelyFlow` |
64
65
 
65
66
  `@statelyai/flow-react` and `@statelyai/flow-react/backgrounds` export `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)`.
66
67
 
@@ -70,7 +71,11 @@ Color mode is store data, not engine behavior. `@statelyai/flow-dom` exports sma
70
71
 
71
72
  <!-- StatelyFlow renderEdgeLabel and layers props from src/StatelyFlow.tsx plus portal exports from src/LayerPortals.tsx -->
72
73
 
73
- `renderEdgeLabel` replaces the built-in label content while retaining Flow's positioned, measured, interactive label wrapper. Set `edge.label.autoSize: true` for content-driven custom labels; the wrapper then leaves width/height unset while its measurement drives stable routed geometry. Render props include the edge's current `pathData`: the same `EdgePathData` object already routed by `StatelyFlow`, including both source-to-label and label-to-target segments. Transient drag geometry remains on Flow's imperative DOM path, and the renderer receives current committed geometry at gesture boundaries. Use `layers.viewport` for canvas-coordinate content and `layers.pane` for screen-coordinate content. Descendants can target the same named layers with `ViewportPortal` and `PanePortal`. Layer wrappers ignore pointer input; opt individual controls in with `pointerEvents: "auto"` and mark controls inside labels or canvas layers with `data-flow-interactive` when Flow gestures should ignore them.
74
+ `renderEdgeLabel` replaces the built-in label content while retaining Flow's positioned, measured, interactive label wrapper. Set `edge.label.autoSize: true` for content-driven custom labels; the wrapper then leaves width/height unset while its measurement drives stable routed geometry. Render props include the edge's current `pathData`: the same `EdgePathData` object already routed by `StatelyFlow`, including both source-to-label and label-to-target segments. Transient drag geometry remains on Flow's imperative DOM path, and the renderer receives current committed geometry at gesture boundaries. Use `layers.viewport` for canvas-coordinate interaction content and `layers.pane` for screen-coordinate content. Descendants can target the same named layers with `ViewportPortal` and `PanePortal`. Flow enforces paint bands (`edges` → `entities` → `labels` → `interaction` → `guides` → `chrome`) so entity-local z-index cannot cover labels, selection chrome, anchors, or portaled toolbars. Override the namespaced `--flow-layer-*` variables only when intentionally changing that contract. Layer wrappers ignore pointer input; opt individual controls in with `pointerEvents: "auto"` and mark controls inside labels or canvas layers with `data-flow-interactive` when Flow gestures should ignore them.
75
+
76
+ Attach the ref from `useFitViewExclusion({ side, gap })` to floating controls that cover the canvas. The hook measures the element relative to the Flow root at command time, including content portaled outside the root, so responsive toolbars and panel-relative chrome do not need guessed fit padding.
77
+
78
+ Set `showSelectionBounds` to render a zoom-stable, square frame around established multi-selections. Its geometry follows drag, resize, and layout frames through Flow DOM without React commits; theme `[data-flow-selection-bounds]`, `--flow-selection-color`, or the screen-pixel `--flow-selection-bounds-outset`. Set `preserveViewportCenterOnResize` when root container resizes should retain the world point at its center.
74
79
 
75
80
  Custom node and edge renderers should branch on the application-defined `entity.kind`. Keep `entity.type` as the structural `"node"` or `"edge"` discriminant so core selection, drag, connection, and CRUD behavior remains valid.
76
81
 
@@ -78,7 +83,9 @@ For controlled reconnect gestures, `reconnectMode({ commit: false, preview: fals
78
83
 
79
84
  `EntityAnchor` can be nested inside a node or edge-label renderer with `side`, a child `anchor` point, cross-axis `align`, and an outward `offset`. The nested anchor follows the parent entity's imperative drag geometry without React commits. With `inverseScale`, its size and offset remain constant in screen pixels and its transform origin matches the selected child anchor. The existing `bounds` plus `AnchorSpec` form remains available for free-standing canvas-space overlays.
80
85
 
81
- `StatelyFlow` exposes stable, namespaced selector roles while keeping `data-flow-*` attributes authoritative: `.stately-flow` / `.stately-flow__pane` (interactive root), `.stately-flow__viewport` (transformed canvas), `.stately-flow__edges`, `.stately-flow__nodes`, `.stately-flow__edge-labels`, their singular entity classes, `.stately-flow__background`, `.stately-flow__viewport-layer`, `.stately-flow__pane-layer`, and `.stately-flow__overlays`. Consumer `className` values are merged with these roles.
86
+ <!-- namespaced selector roles and entity state attributes from src/StatelyFlow.tsx -->
87
+
88
+ `StatelyFlow` exposes stable, namespaced selector roles while keeping `data-flow-*` attributes authoritative: `.stately-flow` / `.stately-flow__pane` (interactive root), `.stately-flow__viewport` (transformed canvas), `.stately-flow__edges`, `.stately-flow__nodes`, `.stately-flow__edge-labels`, `.stately-flow__selection-bounds`, their singular entity classes, `.stately-flow__background`, `.stately-flow__viewport-layer`, `.stately-flow__pane-layer`, and `.stately-flow__overlays`. Consumer `className` values are merged with these roles. Focus presentation is consumer-owned and visually off by default: set `--flow-focus-outline` or target `:focus-visible` for browser focus, and use `[data-flow-focused="true"]` for synchronized Flow focus state.
82
89
 
83
90
  Use `rootRef` and `rootProps` to access that input-owned root or attach ordinary div attributes and events such as context menu, double-click, and drag/drop. `rootProps.className` and `rootProps.style` merge with the top-level values; Flow's input runtime remains the sole gesture owner.
84
91
 
@@ -96,14 +103,14 @@ import {
96
103
  StatelyFlow,
97
104
  useFlow,
98
105
  useFlowSelector,
99
- } from "@statelyai/flow-react";
106
+ } from '@statelyai/flow-react';
100
107
 
101
108
  const graph = createGraph({
102
109
  nodes: [
103
- createNode({ id: "a", x: 0, y: 0, data: { label: "Start" } }),
104
- createNode({ id: "b", x: 240, y: 120, data: { label: "Done" } }),
110
+ createNode({ id: 'a', x: 0, y: 0, data: { label: 'Start' } }),
111
+ createNode({ id: 'b', x: 240, y: 120, data: { label: 'Done' } }),
105
112
  ],
106
- edges: [createEdge({ id: "a-b", sourceId: "a", targetId: "b" })],
113
+ edges: [createEdge({ id: 'a-b', sourceId: 'a', targetId: 'b' })],
107
114
  });
108
115
 
109
116
  function Flow() {
@@ -116,7 +123,7 @@ function Flow() {
116
123
  {
117
124
  selector: (context) => context.selection.size,
118
125
  onChange: (selectionSize) => {
119
- console.log("selection size", selectionSize);
126
+ console.log('selection size', selectionSize);
120
127
  },
121
128
  },
122
129
  ],
@@ -127,9 +134,9 @@ function Flow() {
127
134
  flow={flow}
128
135
  projections={[
129
136
  {
130
- projection: { id: "coverage", nodes: { a: "visited" } },
137
+ projection: { id: 'coverage', nodes: { a: 'visited' } },
131
138
  mapper: {
132
- getNodeAttrs: () => ({ color: "#16a34a", badge: "visited" }),
139
+ getNodeAttrs: () => ({ color: '#16a34a', badge: 'visited' }),
133
140
  },
134
141
  },
135
142
  ]}
@@ -140,7 +147,6 @@ function Flow() {
140
147
  );
141
148
  }
142
149
  ```
143
-
144
150
  ## Storybook
145
151
 
146
152
  ```bash
@@ -1,7 +1,7 @@
1
1
  import { Background, BackgroundProps, BackgroundVariant, CrossBackground, DotsBackground, LinesBackground } from "./backgrounds.js";
2
2
  import { FlowView, ResolvedFlowColorMode } from "@statelyai/flow-dom";
3
- import { ActiveGraphProjection, AnchorSpec, ConnectionEndedEvent, ConnectionState, CreateEdgeInput, CreateFlowOptions, EdgePathData, FlowColorMode, FlowCommitEvent, FlowConnection, FlowController, FlowEdge, FlowGraph, FlowInputMode, FlowKeymap, FlowNode, FlowProfile, FlowStoreContext, GraphEdgeAttrs, GraphNodeAttrs, LayoutRequestedEvent, Point, Rect, ResizeHandle, Viewport, VisualPort } from "@statelyai/flow";
4
- import { CSSProperties, ComponentPropsWithoutRef, HTMLAttributes, ReactNode, Ref, RefObject } from "react";
3
+ import { ActiveGraphProjection, AnchorSpec, ConnectionEndedEvent, ConnectionState, CreateEdgeInput, CreateFlowOptions, EdgePathData, FitViewExclusionSide, FlowColorMode, FlowCommitEvent, FlowConnection, FlowController, FlowEdge, FlowGraph, FlowInputMode, FlowKeymap, FlowNode, FlowProfile, FlowStoreContext, GraphEdgeAttrs, GraphNodeAttrs, LayoutRequestedEvent, Point, Rect, ResizeHandle, Viewport, VisualPort } from "@statelyai/flow";
4
+ import { CSSProperties, ComponentPropsWithoutRef, HTMLAttributes, ReactNode, Ref, RefCallback, RefObject } from "react";
5
5
  export * from "@statelyai/flow-dom";
6
6
  //#region src/createFlowInstance.d.ts
7
7
  type FlowSelector<T> = (context: FlowStoreContext) => T;
@@ -186,7 +186,13 @@ type RenderConnectionLineProps = {
186
186
  pathData: EdgePathData;
187
187
  isValid: boolean;
188
188
  };
189
- type StatelyFlowRootProps = Omit<ComponentPropsWithoutRef<"div">, "children" | "ref">;
189
+ type StatelyFlowStyle = CSSProperties | (CSSProperties & {
190
+ [customProperty: `--${string}`]: string | number | undefined;
191
+ });
192
+ type StatelyFlowRootProps = Omit<ComponentPropsWithoutRef<"div">, "children" | "ref" | "style"> & {
193
+ style?: StatelyFlowStyle;
194
+ [dataAttribute: `data-${string}`]: string | number | boolean | null | undefined;
195
+ };
190
196
  type StatelyFlowBaseProps<TNodeData = unknown, TEdgeData = unknown> = {
191
197
  settings?: FlowInstanceSettings<TNodeData, TEdgeData>;
192
198
  listeners?: FlowStoreListener[];
@@ -197,6 +203,10 @@ type StatelyFlowBaseProps<TNodeData = unknown, TEdgeData = unknown> = {
197
203
  renderEdge?: (props: RenderEdgeProps<TEdgeData>) => ReactNode;
198
204
  renderEdgeLabel?: (props: RenderEdgeLabelProps<TEdgeData>) => ReactNode;
199
205
  renderConnectionLine?: (props: RenderConnectionLineProps) => ReactNode;
206
+ /** Render a zoom-stable frame around established multi-selections. */
207
+ showSelectionBounds?: boolean;
208
+ /** Keep the same world point centered when the root container resizes. */
209
+ preserveViewportCenterOnResize?: boolean;
200
210
  /**
201
211
  * Only mount nodes/edges that intersect the viewport (plus an overscan
202
212
  * margin). Recommended for large graphs.
@@ -218,7 +228,7 @@ type StatelyFlowBaseProps<TNodeData = unknown, TEdgeData = unknown> = {
218
228
  /** Keyboard listener target. Defaults to this flow's root container. */
219
229
  keyboardTarget?: "window" | "container" | HTMLElement;
220
230
  className?: string;
221
- style?: CSSProperties;
231
+ style?: StatelyFlowStyle;
222
232
  /** Ref to the same root element owned by Flow's input runtime. */
223
233
  rootRef?: Ref<HTMLDivElement>;
224
234
  /** Native root div attributes and events; className/style merge with the top-level props. */
@@ -256,6 +266,12 @@ declare function ViewportPortal(props: {
256
266
  declare function PanePortal(props: {
257
267
  children: ReactNode;
258
268
  }): import("react").ReactPortal | null;
269
+ type UseFitViewExclusionOptions = {
270
+ side?: FitViewExclusionSide;
271
+ gap?: number;
272
+ };
273
+ /** Register an overlay element whose measured edge band fit commands avoid. */
274
+ declare function useFitViewExclusion(options?: UseFitViewExclusionOptions): RefCallback<HTMLElement>;
259
275
  //#endregion
260
276
  //#region src/FlowToolbar.d.ts
261
277
  /** Border-box size of an element, kept current via ResizeObserver. */
@@ -455,9 +471,10 @@ declare function NodeResizer(props: NodeResizerProps): import("react").JSX.Eleme
455
471
  * inline `pointer-events` on the same element — inline wins over this rule.
456
472
  * - `data-flow-selection-box` — the box-select marquee (styled here; geometry
457
473
  * is set inline by the renderer).
474
+ * - `data-flow-selection-bounds` — the established multi-selection frame.
458
475
  */
459
476
  declare const FLOW_BASE_STYLES_ID = "flow-base-styles";
460
- declare const FLOW_BASE_CSS = "\n[data-flow-root] {\n --flow-selection-color: #2563eb;\n --flow-selection-bg: rgba(37, 99, 235, 0.12);\n touch-action: none;\n}\n\n[data-flow-interactive-when-selected] {\n pointer-events: none;\n}\n[data-flow-selected=\"true\"] [data-flow-interactive-when-selected] {\n pointer-events: auto;\n}\n\n[data-flow-selection-box] {\n position: absolute;\n box-sizing: border-box;\n border: calc(1px / var(--flow-zoom, 1)) solid var(--flow-selection-color);\n border-radius: calc(2px / var(--flow-zoom, 1));\n background: var(--flow-selection-bg);\n pointer-events: none;\n}\n";
477
+ declare const FLOW_BASE_CSS = "\n[data-flow-root] {\n --flow-selection-color: #2563eb;\n --flow-selection-bg: rgba(37, 99, 235, 0.12);\n --flow-selection-bounds-outset: 1px;\n --flow-layer-edges: 0;\n --flow-layer-entities: 10;\n --flow-layer-labels: 20;\n --flow-layer-interaction: 30;\n --flow-layer-guides: 40;\n --flow-layer-chrome: 50;\n touch-action: none;\n}\n\n[data-flow-root]:focus {\n outline: var(--flow-focus-outline, none);\n}\n\n[data-flow-interactive-when-selected] {\n pointer-events: none;\n}\n[data-flow-selected=\"true\"] [data-flow-interactive-when-selected] {\n pointer-events: auto;\n}\n\n[data-flow-selection-box] {\n position: absolute;\n box-sizing: border-box;\n border: calc(1px / var(--flow-zoom, 1)) solid var(--flow-selection-color);\n border-radius: 0;\n background: var(--flow-selection-bg);\n pointer-events: none;\n}\n\n[data-flow-selection-bounds] {\n position: absolute;\n box-sizing: border-box;\n outline: calc(1px / var(--flow-zoom, 1)) solid var(--flow-selection-color);\n outline-offset: calc(var(--flow-selection-bounds-outset) / var(--flow-zoom, 1));\n border-radius: 0;\n pointer-events: none;\n}\n";
461
478
  /** Inject the base stylesheet once per document (deduped by element id). */
462
479
  declare function useFlowBaseStyles(): void;
463
480
  //#endregion
@@ -474,4 +491,4 @@ declare function useFlowColorMode(): {
474
491
  };
475
492
  declare function useApplyFlowColorMode(element: HTMLElement | null): void;
476
493
  //#endregion
477
- export { Background, type BackgroundProps, type BackgroundVariant, Controls, type ControlsProps, type CreateFlowInstanceOptions, CrossBackground, DotsBackground, DragHandle, type DragHandleProps, EntityAnchor, type EntityAnchorAlign, type EntityAnchorPoint, type EntityAnchorProps, type EntityAnchorSide, FLOW_BASE_CSS, FLOW_BASE_STYLES_ID, FlowColorModeScope, type FlowColorModeScopeProps, type FlowEdgeBinding, type FlowEventListeners, type FlowInstance, FlowInstanceProvider, type FlowInstanceSettings, type FlowNodeBinding, type FlowPortBinding, type FlowSelector, type FlowSelectorEquality, type FlowStoreListener, FlowToolbar, type FlowToolbarProps, type FlowToolbarState, LinesBackground, MiniMap, type MiniMapProps, NodeResizer, type NodeResizerProps, PanePortal, Panel, type PanelPosition, type PanelProps, Port, type PortProps, type ReactActiveGraphProjection, type ReactGraphEdgeAttrs, type ReactGraphNodeAttrs, type RenderConnectionLineProps, type RenderEdgeLabelProps, type RenderEdgeProps, type RenderNodeProps, StatelyFlow, type StatelyFlowProps, type StatelyFlowRootProps, type UseCreateFlowInstanceOptions, type UseFlowInputOptions, type UseFlowOptions, type UseFlowToolbarOptions, ViewportPortal, createFlowInstance, useApplyFlowColorMode, useConnection, useCreateFlowInstance, useFlow, useFlowBaseStyles, useFlowColorMode, useFlowConnections, useFlowContext, useFlowEdge, useFlowEntity, useFlowInput, useFlowInstance, useFlowMeasurement, useFlowNode, useFlowPort, useFlowSelector, useFlowToolbar, useFlowValue, useFlowView, useFlowViewport, useKeyPress, useMeasuredSize, useNodesInitialized, useOptionalFlowInstance };
494
+ export { Background, type BackgroundProps, type BackgroundVariant, Controls, type ControlsProps, type CreateFlowInstanceOptions, CrossBackground, DotsBackground, DragHandle, type DragHandleProps, EntityAnchor, type EntityAnchorAlign, type EntityAnchorPoint, type EntityAnchorProps, type EntityAnchorSide, FLOW_BASE_CSS, FLOW_BASE_STYLES_ID, FlowColorModeScope, type FlowColorModeScopeProps, type FlowEdgeBinding, type FlowEventListeners, type FlowInstance, FlowInstanceProvider, type FlowInstanceSettings, type FlowNodeBinding, type FlowPortBinding, type FlowSelector, type FlowSelectorEquality, type FlowStoreListener, FlowToolbar, type FlowToolbarProps, type FlowToolbarState, LinesBackground, MiniMap, type MiniMapProps, NodeResizer, type NodeResizerProps, PanePortal, Panel, type PanelPosition, type PanelProps, Port, type PortProps, type ReactActiveGraphProjection, type ReactGraphEdgeAttrs, type ReactGraphNodeAttrs, type RenderConnectionLineProps, type RenderEdgeLabelProps, type RenderEdgeProps, type RenderNodeProps, StatelyFlow, type StatelyFlowProps, type StatelyFlowRootProps, type StatelyFlowStyle, type UseCreateFlowInstanceOptions, type UseFitViewExclusionOptions, type UseFlowInputOptions, type UseFlowOptions, type UseFlowToolbarOptions, ViewportPortal, createFlowInstance, useApplyFlowColorMode, useConnection, useCreateFlowInstance, useFitViewExclusion, useFlow, useFlowBaseStyles, useFlowColorMode, useFlowConnections, useFlowContext, useFlowEdge, useFlowEntity, useFlowInput, useFlowInstance, useFlowMeasurement, useFlowNode, useFlowPort, useFlowSelector, useFlowToolbar, useFlowValue, useFlowView, useFlowViewport, useKeyPress, useMeasuredSize, useNodesInitialized, useOptionalFlowInstance };
package/dist/esm/index.js CHANGED
@@ -375,15 +375,27 @@ function useFlowView() {
375
375
  * inline `pointer-events` on the same element — inline wins over this rule.
376
376
  * - `data-flow-selection-box` — the box-select marquee (styled here; geometry
377
377
  * is set inline by the renderer).
378
+ * - `data-flow-selection-bounds` — the established multi-selection frame.
378
379
  */
379
380
  const FLOW_BASE_STYLES_ID = "flow-base-styles";
380
381
  const FLOW_BASE_CSS = `
381
382
  [data-flow-root] {
382
383
  --flow-selection-color: #2563eb;
383
384
  --flow-selection-bg: rgba(37, 99, 235, 0.12);
385
+ --flow-selection-bounds-outset: 1px;
386
+ --flow-layer-edges: 0;
387
+ --flow-layer-entities: 10;
388
+ --flow-layer-labels: 20;
389
+ --flow-layer-interaction: 30;
390
+ --flow-layer-guides: 40;
391
+ --flow-layer-chrome: 50;
384
392
  touch-action: none;
385
393
  }
386
394
 
395
+ [data-flow-root]:focus {
396
+ outline: var(--flow-focus-outline, none);
397
+ }
398
+
387
399
  [data-flow-interactive-when-selected] {
388
400
  pointer-events: none;
389
401
  }
@@ -395,10 +407,19 @@ const FLOW_BASE_CSS = `
395
407
  position: absolute;
396
408
  box-sizing: border-box;
397
409
  border: calc(1px / var(--flow-zoom, 1)) solid var(--flow-selection-color);
398
- border-radius: calc(2px / var(--flow-zoom, 1));
410
+ border-radius: 0;
399
411
  background: var(--flow-selection-bg);
400
412
  pointer-events: none;
401
413
  }
414
+
415
+ [data-flow-selection-bounds] {
416
+ position: absolute;
417
+ box-sizing: border-box;
418
+ outline: calc(1px / var(--flow-zoom, 1)) solid var(--flow-selection-color);
419
+ outline-offset: calc(var(--flow-selection-bounds-outset) / var(--flow-zoom, 1));
420
+ border-radius: 0;
421
+ pointer-events: none;
422
+ }
402
423
  `;
403
424
  /** Inject the base stylesheet once per document (deduped by element id). */
404
425
  function useFlowBaseStyles() {
@@ -444,6 +465,43 @@ function PanePortal(props) {
444
465
  const target = useFlowLayerTargets().pane;
445
466
  return target ? createPortal(props.children, target) : null;
446
467
  }
468
+ /** Register an overlay element whose measured edge band fit commands avoid. */
469
+ function useFitViewExclusion(options = {}) {
470
+ const flow = useFlowInstance();
471
+ const root = useFlowLayerTargets().root;
472
+ const [element, setElement] = useState(null);
473
+ const ref = useCallback((next) => setElement(next), []);
474
+ useLayoutEffect(() => {
475
+ if (!root || !element) return;
476
+ element.setAttribute("data-flow-fit-exclusion", options.side ?? "nearest");
477
+ const unregister = flow.viewport.registerFitExclusion({
478
+ side: options.side,
479
+ gap: options.gap,
480
+ getRect: () => {
481
+ if (!element.isConnected || !root.isConnected) return null;
482
+ const rootRect = root.getBoundingClientRect();
483
+ const rect = element.getBoundingClientRect();
484
+ return {
485
+ x: rect.left - rootRect.left,
486
+ y: rect.top - rootRect.top,
487
+ width: rect.width,
488
+ height: rect.height
489
+ };
490
+ }
491
+ });
492
+ return () => {
493
+ unregister();
494
+ element.removeAttribute("data-flow-fit-exclusion");
495
+ };
496
+ }, [
497
+ element,
498
+ flow,
499
+ options.gap,
500
+ options.side,
501
+ root
502
+ ]);
503
+ return ref;
504
+ }
447
505
  //#endregion
448
506
  //#region src/StatelyFlow.tsx
449
507
  function stringArraysEqual(a, b) {
@@ -459,6 +517,11 @@ function nodePaintEntriesEqual(a, b) {
459
517
  function classNames$1(...values) {
460
518
  return values.filter(Boolean).join(" ");
461
519
  }
520
+ function clearEntityFocusOnBlur(flow, entityId, event) {
521
+ const nextTarget = event.relatedTarget;
522
+ if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
523
+ if (flow.getSnapshot().focusedEntityId === entityId) flow.internals.store.trigger.clearFocus();
524
+ }
462
525
  function assignRef(ref, value) {
463
526
  if (typeof ref === "function") ref(value);
464
527
  else if (ref) ref.current = value;
@@ -495,17 +558,24 @@ function OwnedStatelyFlow(props) {
495
558
  }
496
559
  function StatelyFlowView(props) {
497
560
  const containerRef = useRef(null);
561
+ const [rootLayer, setRootLayer] = useState(null);
498
562
  const setContainerRef = useCallback((element) => {
499
563
  containerRef.current = element;
564
+ setRootLayer(element);
500
565
  assignRef(props.rootRef, element);
501
566
  }, [props.rootRef]);
502
567
  const viewportRef = useRef(null);
503
568
  const [viewportLayer, setViewportLayer] = useState(null);
504
569
  const [paneLayer, setPaneLayer] = useState(null);
505
570
  const layerTargets = useMemo(() => ({
571
+ root: rootLayer,
506
572
  viewport: viewportLayer,
507
573
  pane: paneLayer
508
- }), [viewportLayer, paneLayer]);
574
+ }), [
575
+ rootLayer,
576
+ viewportLayer,
577
+ paneLayer
578
+ ]);
509
579
  const measurementObserver = useMemo(() => createMeasurementObserver(props.flow.internals.store, { container: () => containerRef.current }), [props.flow]);
510
580
  const geometryObserver = useMemo(() => createEntityGeometryObserver(props.flow.internals.store, { getEdgeDependents: props.flow.internals.engine.getEdgeSourceDependents }), [props.flow]);
511
581
  useLayoutEffect(() => () => {
@@ -519,8 +589,8 @@ function StatelyFlowView(props) {
519
589
  useLayoutEffect(() => {
520
590
  const element = containerRef.current;
521
591
  if (!element) return;
522
- return attachContainer(props.flow.internals.store, element);
523
- }, [props.flow]);
592
+ return attachContainer(props.flow.internals.store, element, { preserveViewportCenter: props.preserveViewportCenterOnResize });
593
+ }, [props.flow, props.preserveViewportCenterOnResize]);
524
594
  useFlowInput(props.flow, containerRef, {
525
595
  modes: props.inputModes,
526
596
  profile: props.settings?.store?.profile,
@@ -576,7 +646,10 @@ function StatelyFlowView(props) {
576
646
  ref: viewportRef,
577
647
  className: "stately-flow__viewport",
578
648
  "data-flow-viewport": true,
579
- style: viewportStyle(),
649
+ style: {
650
+ ...viewportStyle(),
651
+ isolation: "isolate"
652
+ },
580
653
  children: [
581
654
  /* @__PURE__ */ jsx(EdgeLayer, {
582
655
  geometryObserver,
@@ -598,6 +671,7 @@ function StatelyFlowView(props) {
598
671
  projections,
599
672
  onlyRenderVisible: props.onlyRenderVisible
600
673
  }),
674
+ props.showSelectionBounds ? /* @__PURE__ */ jsx(SelectionBoundsLayer, { geometryObserver }) : null,
601
675
  /* @__PURE__ */ jsx(SelectionBoxLayer, {}),
602
676
  /* @__PURE__ */ jsx("div", {
603
677
  ref: setViewportLayer,
@@ -607,6 +681,7 @@ function StatelyFlowView(props) {
607
681
  style: {
608
682
  position: "absolute",
609
683
  inset: 0,
684
+ zIndex: "var(--flow-layer-interaction, 30)",
610
685
  pointerEvents: "none"
611
686
  },
612
687
  children: props.layers?.viewport
@@ -621,6 +696,7 @@ function StatelyFlowView(props) {
621
696
  style: {
622
697
  position: "absolute",
623
698
  inset: 0,
699
+ zIndex: "var(--flow-layer-chrome, 50)",
624
700
  pointerEvents: "none"
625
701
  },
626
702
  children: props.layers?.pane
@@ -668,6 +744,7 @@ function SnapLinesLayer() {
668
744
  inset: 0,
669
745
  width: "100%",
670
746
  height: "100%",
747
+ zIndex: "var(--flow-layer-guides, 40)",
671
748
  overflow: "visible",
672
749
  pointerEvents: "none"
673
750
  },
@@ -782,7 +859,39 @@ function SelectionBoxLayer() {
782
859
  left: Math.min(a.x, b.x),
783
860
  top: Math.min(a.y, b.y),
784
861
  width: Math.abs(b.x - a.x),
785
- height: Math.abs(b.y - a.y)
862
+ height: Math.abs(b.y - a.y),
863
+ zIndex: "var(--flow-layer-interaction, 30)"
864
+ }
865
+ });
866
+ }
867
+ function SelectionBoundsLayer(props) {
868
+ const flow = useFlowInstance();
869
+ const selectedIds = useFlowSelector((context) => [...context.selection], { isEqual: stringArraysEqual });
870
+ const ref = useRef(null);
871
+ const bounds = selectedIds.length > 1 ? flow.getSelectionBounds() : null;
872
+ useLayoutEffect(() => {
873
+ const element = ref.current;
874
+ if (!element || selectedIds.length < 2) return;
875
+ return props.geometryObserver.observeBounds(element, {
876
+ ids: selectedIds,
877
+ getRect: () => flow.getSelectionBounds()
878
+ });
879
+ }, [
880
+ flow,
881
+ props.geometryObserver,
882
+ selectedIds
883
+ ]);
884
+ if (!bounds) return null;
885
+ return /* @__PURE__ */ jsx("div", {
886
+ ref,
887
+ "aria-hidden": "true",
888
+ className: "stately-flow__selection-bounds",
889
+ "data-flow-selection-bounds": "",
890
+ style: {
891
+ transform: `translate(${bounds.x}px, ${bounds.y}px)`,
892
+ width: bounds.width,
893
+ height: bounds.height,
894
+ zIndex: "var(--flow-layer-interaction, 30)"
786
895
  }
787
896
  });
788
897
  }
@@ -800,6 +909,7 @@ function EdgeLayer(props) {
800
909
  inset: 0,
801
910
  width: "100%",
802
911
  height: "100%",
912
+ zIndex: "var(--flow-layer-edges, 0)",
803
913
  overflow: "visible",
804
914
  pointerEvents: "none"
805
915
  },
@@ -860,23 +970,20 @@ const EdgeView = memo(function EdgeView(props) {
860
970
  "aria-label": props.flow.internals.engine.getAriaLabel(props.id),
861
971
  "aria-selected": selected,
862
972
  tabIndex: focusable ? 0 : void 0,
863
- onFocus: () => props.flow.internals.store.trigger.focus({ entityId: props.id })
973
+ onFocus: () => props.flow.internals.store.trigger.focus({ entityId: props.id }),
974
+ onBlur: (event) => clearEntityFocusOnBlur(props.flow, props.id, event)
864
975
  };
865
976
  if (props.renderEdge) return /* @__PURE__ */ jsxs("g", {
866
977
  ref,
867
978
  ...focusProps,
868
979
  "data-entity-id": props.id,
869
980
  "data-flow-selected": selected ? "true" : void 0,
981
+ "data-flow-focused": focused ? "true" : void 0,
870
982
  className: classNames$1("stately-flow__edge", attrs.className),
871
- style: {
872
- pointerEvents: "auto",
873
- outline: focused ? "2px solid #2563eb" : "none"
874
- },
983
+ style: { pointerEvents: "auto" },
875
984
  children: [/* @__PURE__ */ jsx(EdgeHitTarget, {
876
- flow: props.flow,
877
985
  edge,
878
- pathData,
879
- selected
986
+ pathData
880
987
  }), props.renderEdge({
881
988
  flow: props.flow,
882
989
  edge,
@@ -892,11 +999,11 @@ const EdgeView = memo(function EdgeView(props) {
892
999
  ...focusProps,
893
1000
  "data-entity-id": props.id,
894
1001
  "data-flow-selected": selected ? "true" : void 0,
1002
+ "data-flow-focused": focused ? "true" : void 0,
895
1003
  className: classNames$1("stately-flow__edge", attrs.className),
896
1004
  style: {
897
1005
  pointerEvents: "auto",
898
- opacity: attrs.opacity,
899
- outline: focused ? "2px solid #2563eb" : "none"
1006
+ opacity: attrs.opacity
900
1007
  },
901
1008
  children: /* @__PURE__ */ jsx(DefaultEdge, {
902
1009
  flow: props.flow,
@@ -968,7 +1075,7 @@ function DefaultEdge(props) {
968
1075
  })] });
969
1076
  }
970
1077
  function EdgeHitTarget(props) {
971
- return /* @__PURE__ */ jsxs(Fragment, { children: [props.pathData.segments.map((segment, index) => {
1078
+ return /* @__PURE__ */ jsx(Fragment, { children: props.pathData.segments.map((segment, index) => {
972
1079
  return /* @__PURE__ */ jsx("path", {
973
1080
  d: pathToSVG(segment),
974
1081
  "data-flow-edge-segment": index,
@@ -979,7 +1086,7 @@ function EdgeHitTarget(props) {
979
1086
  strokeWidth: 18,
980
1087
  pointerEvents: "stroke"
981
1088
  }, index);
982
- }), /* @__PURE__ */ jsx(EdgeEndpointHandles, { ...props })] });
1089
+ }) });
983
1090
  }
984
1091
  function EdgeEndpointHandles(props) {
985
1092
  if (!props.selected) return null;
@@ -1025,6 +1132,7 @@ function EdgeLabelLayer(props) {
1025
1132
  style: {
1026
1133
  position: "absolute",
1027
1134
  inset: 0,
1135
+ zIndex: "var(--flow-layer-labels, 20)",
1028
1136
  pointerEvents: "none"
1029
1137
  },
1030
1138
  children: useMemo(() => {
@@ -1202,6 +1310,7 @@ function NodeLayer(props) {
1202
1310
  style: {
1203
1311
  position: "absolute",
1204
1312
  inset: 0,
1313
+ zIndex: "var(--flow-layer-entities, 10)",
1205
1314
  transformOrigin: "0 0",
1206
1315
  pointerEvents: "none"
1207
1316
  },
@@ -1345,6 +1454,7 @@ const NodeView = memo(function NodeView(props) {
1345
1454
  ref,
1346
1455
  "data-entity-id": props.id,
1347
1456
  "data-flow-selected": selected ? "true" : void 0,
1457
+ "data-flow-focused": focused ? "true" : void 0,
1348
1458
  className: classNames$1("stately-flow__node", attrs.className),
1349
1459
  role: "group",
1350
1460
  "aria-roledescription": "flow node",
@@ -1352,6 +1462,7 @@ const NodeView = memo(function NodeView(props) {
1352
1462
  "aria-label": props.flow.internals.engine.getAriaLabel(props.id) + (selected ? ", selected" : ""),
1353
1463
  tabIndex: focusable ? 0 : void 0,
1354
1464
  onFocus: () => props.flow.internals.store.trigger.focus({ entityId: props.id }),
1465
+ onBlur: (event) => clearEntityFocusOnBlur(props.flow, props.id, event),
1355
1466
  style: {
1356
1467
  ...nodePositionStyle(bounds, {
1357
1468
  autoSize: contentOwnsSize,
@@ -1361,8 +1472,6 @@ const NodeView = memo(function NodeView(props) {
1361
1472
  pointerEvents: "auto",
1362
1473
  userSelect: "none",
1363
1474
  zIndex: props.orderIndex,
1364
- outline: focused ? "2px solid #2563eb" : "none",
1365
- outlineOffset: 2,
1366
1475
  opacity: attrs.opacity
1367
1476
  },
1368
1477
  children: props.renderNode ? props.renderNode({
@@ -2098,4 +2207,4 @@ function useApplyFlowColorMode(element) {
2098
2207
  ]);
2099
2208
  }
2100
2209
  //#endregion
2101
- export { Background, Controls, CrossBackground, DotsBackground, DragHandle, EntityAnchor, FLOW_BASE_CSS, FLOW_BASE_STYLES_ID, FlowColorModeScope, FlowInstanceProvider, FlowToolbar, LinesBackground, MiniMap, NodeResizer, PanePortal, Panel, Port, StatelyFlow, ViewportPortal, createFlowInstance, useApplyFlowColorMode, useConnection, useCreateFlowInstance, useFlow, useFlowBaseStyles, useFlowColorMode, useFlowConnections, useFlowContext, useFlowEdge, useFlowEntity, useFlowInput, useFlowInstance, useFlowMeasurement, useFlowNode, useFlowPort, useFlowSelector, useFlowToolbar, useFlowValue, useFlowView, useFlowViewport, useKeyPress, useMeasuredSize, useNodesInitialized, useOptionalFlowInstance };
2210
+ export { Background, Controls, CrossBackground, DotsBackground, DragHandle, EntityAnchor, FLOW_BASE_CSS, FLOW_BASE_STYLES_ID, FlowColorModeScope, FlowInstanceProvider, FlowToolbar, LinesBackground, MiniMap, NodeResizer, PanePortal, Panel, Port, StatelyFlow, ViewportPortal, createFlowInstance, useApplyFlowColorMode, useConnection, useCreateFlowInstance, useFitViewExclusion, useFlow, useFlowBaseStyles, useFlowColorMode, useFlowConnections, useFlowContext, useFlowEdge, useFlowEntity, useFlowInput, useFlowInstance, useFlowMeasurement, useFlowNode, useFlowPort, useFlowSelector, useFlowToolbar, useFlowValue, useFlowView, useFlowViewport, useKeyPress, useMeasuredSize, useNodesInitialized, useOptionalFlowInstance };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statelyai/flow-react",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Thin React adapter for @statelyai/flow with core and DOM re-exports.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -34,8 +34,8 @@
34
34
  "license": "MIT",
35
35
  "dependencies": {
36
36
  "use-sync-external-store": "^1.6.0",
37
- "@statelyai/flow": "0.6.0",
38
- "@statelyai/flow-dom": "3.2.1"
37
+ "@statelyai/flow": "0.7.0",
38
+ "@statelyai/flow-dom": "3.3.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": "^18.2.0 || ^19.0.0",