@statelyai/flow-react 0.10.1 → 0.11.1

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 and animated layout sessions, `@statelyai/flow-dom` batches node rectangles, default group child-area and header 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 group headers keep projected styles on a separate element from imperative geometry, preserving projected transforms during drag. Default edge paths expose `data-flow-edge-segment`; custom edge renderers can use the same indexed attribute to join the imperative path updates. Existing custom paths whose `d` exactly matches a routed segment are also bound automatically, including duplicate hit strokes; unrelated decoration paths are left alone. 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 and animated layout sessions, `@statelyai/flow-dom` batches node rectangles, default group child-area and header 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 group headers keep projected styles on a separate element from imperative geometry, preserving projected transforms during drag. Use `EdgePath` and `EdgeEndpoint` in custom edge renderers to receive the same imperative path and endpoint updates. The components declare Flow's geometry attributes for you. Existing custom paths whose `d` exactly matches a routed segment are also bound automatically, including duplicate hit strokes; unrelated decoration paths are left alone. 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
 
@@ -53,7 +53,10 @@ DOM measurement is one-directional. Nodes, ports, and edge labels report their r
53
53
  | `PanePortal` / `ViewportPortal` | Render custom content into fixed screen-coordinate or transformed canvas-coordinate layers |
54
54
  | `useFitViewExclusion()` | Register an overlay element whose measured edge band fit and center commands avoid |
55
55
  | `EntityAnchor` | Position custom controls on entity sides or free-standing canvas bounds with screen-stable offsets and inverse scaling |
56
- | `Port` | Render a measured connection port or a graph-owned readonly inspection port |
56
+ | `Port` | Render a measured or graph-owned port with native div props and a forwarded ref |
57
+ | `EdgePath` | Render routed SVG segments with imperative geometry updates |
58
+ | `EdgeEndpoint` | Render a reconnection circle with an optional canvas-space endpoint offset |
59
+ | `RenderConnectionLineProps` | Connection state, routed geometry, validity, port centers, and directions passed to `renderConnectionLine` |
57
60
  | `RenderEdgeLabelProps` | Edge, path, bounds, interaction state, and projected attrs passed to `renderEdgeLabel` |
58
61
  | `Panel` | Positioned overlay panel for controls and custom UI |
59
62
  | `ReactActiveGraphProjection` | React-typed active projection for `<StatelyFlow projections={...} />` or controller-owned projections |
@@ -67,11 +70,27 @@ DOM measurement is one-directional. Nodes, ports, and edge labels report their r
67
70
 
68
71
  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.
69
72
 
70
- `initialGraph` seeds a graph that the adapter-owned flow instance owns. For caller ownership, create a controller and pass `flow={flow}`. There is no controlled `graph` prop; observe `flow.on('commit', ...)` or `onCommit` for persistence and synchronization. Runtime `settings`, `inputModes`, and `projections` update the existing flow instance. If `projections` is omitted, `StatelyFlow` subscribes to the instance's `flow.projections` lifecycle.
73
+ `initialGraph` seeds a graph that the adapter-owned flow instance owns. For caller ownership, create a controller and pass `flow={flow}`. There is no controlled `graph` prop; observe `flow.on('commit', ...)` for persistence and synchronization. Adapter-owned instances also accept `onCommit`. Runtime `settings`, `inputModes`, and `projections` update the existing flow instance. Changes to `inputModes` affect new gestures without cancelling one in progress. Removing connect mode clears a pending click connection. If `projections` is omitted, `StatelyFlow` subscribes to the instance's `flow.projections` lifecycle.
74
+
75
+ <!-- events and listeners lifecycle from src/hooks.ts and src/StatelyFlow.tsx -->
76
+
77
+ `events` and selector `listeners` work with both adapter-owned and supplied instances. The adapter keeps callbacks current and removes its subscriptions when the view unmounts or the instance changes. Callbacks registered when creating a supplied instance remain owned by that instance. Each separately registered callback still runs.
78
+
79
+ ```tsx
80
+ <StatelyFlow
81
+ flow={flow}
82
+ events={{
83
+ connectionEnded: (event) => console.log(event.status),
84
+ commit: () => console.log('graph changed'),
85
+ }}
86
+ />
87
+ ```
88
+
89
+ See [Keep application state in XState Store](EXTERNAL_STATE.md) for a live Flow graph with separate application state and saved documents.
71
90
 
72
91
  <!-- StatelyFlow renderEdgeLabel and layers props from src/StatelyFlow.tsx plus portal exports from src/LayerPortals.tsx -->
73
92
 
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.
93
+ `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. Empty pane layers use `display: none` and become visible when content mounts. 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
94
 
76
95
  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
96
 
@@ -81,6 +100,36 @@ The default edge renderer honors `edge.style.markerStart` and `markerEnd` values
81
100
 
82
101
  Render layout-owned ports with `<Port measured={false} interaction="inspect" />`. Inspection ports are focusable, selectable addressables (`${node.id}:${port.name}`), emit the normal entity click events, and never start a connection gesture. Omit `interaction` (or use `"connect"`) to retain existing connection behavior.
83
102
 
103
+ <!-- Port props from src/Port.tsx and custom edge geometry from src/EdgePrimitives.tsx -->
104
+
105
+ `Port` accepts native div props, including `aria-*`, `className`, `style`, and event handlers. Its forwarded ref points to the measured div. Flow composes focus handlers with yours and retains its identity attributes.
106
+
107
+ Render custom edge SVG content with `EdgePath` and `EdgeEndpoint`. `EdgePath` accepts native path props except `d` and paints every routed segment. `EdgeEndpoint` accepts native circle props except `cx`, `cy`, `r`, and `offset`. Its `radius` defaults to `10` canvas units. Its `offset` is a canvas-space `{ x, y }` displacement that follows drag and layout geometry without React commits. `end` selects the source or target endpoint, and the ref points to the circle.
108
+
109
+ ```tsx
110
+ import { EdgeEndpoint, EdgePath, StatelyFlow } from '@statelyai/flow-react';
111
+
112
+ <StatelyFlow
113
+ flow={flow}
114
+ renderEdge={({ edge, pathData }) => (
115
+ <>
116
+ <EdgePath pathData={pathData} stroke="currentColor" />
117
+ <EdgeEndpoint
118
+ edgeId={edge.id}
119
+ pathData={pathData}
120
+ end="target"
121
+ radius={10}
122
+ offset={{ x: -6, y: 0 }}
123
+ />
124
+ </>
125
+ )}
126
+ />
127
+ ```
128
+
129
+ <!-- connection preview lifecycle and render props from src/StatelyFlow.tsx -->
130
+
131
+ `renderConnectionLine` renders SVG content in a separate host above nodes. The host exists only during an active preview and is inert to pointer and keyboard input. The callback receives `connection`, `pathData`, `isValid`, `sourcePoint`, `targetPoint`, `sourceDirection`, and `targetDirection`. Points are in canvas coordinates. The source point is the gesture source port center, with the starting point as fallback. The target point is the hovered port center, with the pointer point as fallback. Directions use port sides when present, then routed directions. The gesture origin remains the source even when the resulting edge direction is reversed.
132
+
84
133
  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.
85
134
 
86
135
  For controlled reconnect gestures, `reconnectMode({ commit: false, preview: false })` keeps Flow's input lifecycle while suppressing the adapter's projected preview edge, so an external domain store can render its own temporary geometry without duplicate paths.
@@ -89,11 +138,11 @@ For controlled reconnect gestures, `reconnectMode({ commit: false, preview: fals
89
138
 
90
139
  <!-- namespaced selector roles and entity state attributes from src/StatelyFlow.tsx -->
91
140
 
92
- `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.
141
+ `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. When Flow clears entity focus, the adapter blurs the directly focused node, edge, or inspection port. Child controls and unrelated focused elements retain browser focus. 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.
93
142
 
94
143
  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.
95
144
 
96
- Keyboard handling is scoped to the `StatelyFlow` root by default so multiple flows on one page do not all receive Delete/undo/arrow keys. Pass `keyboardTarget="window"` to restore global handling. Pass `settings={{ keymap }}` to rebind built-in shortcuts. Double-click zoom is enabled in the default input modes; Shift double-click zooms out.
145
+ Keyboard handling is scoped to the `StatelyFlow` root by default so multiple flows on one page do not all receive Delete/undo/arrow keys. Pass `keyboardTarget="window"` to restore global handling. Pass `settings={{ keymap }}` to rebind built-in shortcuts. Double-click zoom is enabled in the default input modes; Shift double-click zooms out. Compose `keyboardMode` and `keyboardNavMode` to configure activation, selection, and Escape behavior. See [core input policies](../flow/README.md#input-policies).
97
146
 
98
147
  Alignment and equal-spacing guides stay a constant screen size across zoom and expose `--flow-snap-line-color` and `--flow-spacing-guide-color` for theming. Equal-spacing guides include their rounded canvas distance.
99
148
 
@@ -1,6 +1,6 @@
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, FitViewExclusionSide, FlowColorMode, FlowCommitEvent, FlowConnection, FlowController, FlowEdge, FlowGraph, FlowInputMode, FlowKeymap, FlowNode, FlowProfile, FlowStoreContext, GraphEdgeAttrs, GraphNodeAttrs, LayoutRequestedEvent, Point, Rect, ResolvedFlowAppearance, Viewport, VisualPort } from "@statelyai/flow";
3
+ import { ActiveGraphProjection, AnchorSpec, ConnectionEndedEvent, ConnectionState, CreateEdgeInput, CreateFlowOptions, Direction, EdgePathData, FitViewExclusionSide, FlowColorMode, FlowCommitEvent, FlowConnection, FlowController, FlowEdge, FlowGraph, FlowInputMode, FlowKeymap, FlowNode, FlowProfile, FlowStoreContext, GraphEdgeAttrs, GraphNodeAttrs, LayoutRequestedEvent, Point, Rect, ResolvedFlowAppearance, Viewport, VisualPort } from "@statelyai/flow";
4
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
@@ -78,7 +78,7 @@ type UseFlowOptions<TNodeData = any, TEdgeData = any, TLabelData = any> = UseCre
78
78
  * Create one Flow instance for a component lifetime.
79
79
  *
80
80
  * Creation settings are initial-only; use `flow.configure()` for runtime
81
- * changes. Event/listener props are rebound when their identities change.
81
+ * changes. Event callbacks stay current; selector listeners rebind on change.
82
82
  */
83
83
  declare function useFlow<TNodeData = any, TEdgeData = any, TLabelData = any>(options: UseFlowOptions<TNodeData, TEdgeData, TLabelData>): FlowInstance<TNodeData, TEdgeData, TLabelData>;
84
84
  /** @deprecated Use `useFlow(options)`. */
@@ -140,9 +140,8 @@ type UseFlowInputOptions = {
140
140
  * Input modes, or a resolver from store context. Defaults to the store
141
141
  * profile's input modes (when a profile is given) and otherwise to the
142
142
  * core default set, which follows the store's `context.mode`
143
- * (interactive / readonly). Initial-only: changes after mount are ignored —
144
- * swap behavior at runtime via `flow.internals.store.trigger.setMode(...)` or a
145
- * context-aware resolver.
143
+ * (interactive / readonly). Changes apply to new gestures without cancelling
144
+ * a gesture already in progress.
146
145
  */
147
146
  modes?: FlowInputMode[] | ((context: FlowStoreContext) => FlowInputMode[]);
148
147
  /** Store profile whose per-mode `input.modes` drive the runtime. */
@@ -201,6 +200,12 @@ type RenderConnectionLineProps = {
201
200
  connection: ConnectionState;
202
201
  pathData: EdgePathData;
203
202
  isValid: boolean;
203
+ /** Current gesture source port center, or its starting canvas point. */
204
+ sourcePoint: Point;
205
+ /** Hovered target port center, or the current pointer canvas point. */
206
+ targetPoint: Point;
207
+ sourceDirection: Direction;
208
+ targetDirection: Direction;
204
209
  };
205
210
  type StatelyFlowStyle = CSSProperties | (CSSProperties & {
206
211
  [customProperty: `--${string}`]: string | number | undefined;
@@ -235,7 +240,8 @@ type StatelyFlowBaseProps<TNodeData = unknown, TEdgeData = unknown> = {
235
240
  */
236
241
  onlyRenderVisible?: boolean;
237
242
  /**
238
- * Override the input modes driving gestures (initial-only). Defaults to
243
+ * Override the input modes driving gestures. Updates apply to new gestures
244
+ * without cancelling the current one. Defaults to
239
245
  * the store profile's input modes when `settings.store.profile` is set,
240
246
  * otherwise to the core defaults. Example — custom connection validity:
241
247
  *
@@ -405,7 +411,7 @@ type EntityAnchorProps = EntityAnchorBaseProps & ({
405
411
  declare function EntityAnchor(props: EntityAnchorProps): import("react").JSX.Element | null;
406
412
  //#endregion
407
413
  //#region src/Port.d.ts
408
- type PortProps = {
414
+ type PortProps = ComponentPropsWithoutRef<"div"> & {
409
415
  name: string;
410
416
  /**
411
417
  * `inspect` makes a graph-owned port focusable/selectable without starting
@@ -427,9 +433,6 @@ type PortProps = {
427
433
  * feedback loop and runs away.
428
434
  */
429
435
  measured?: boolean;
430
- className?: string;
431
- style?: CSSProperties;
432
- children?: ReactNode;
433
436
  };
434
437
  /**
435
438
  * A connection port. Purely declarative: it renders the
@@ -441,7 +444,29 @@ type PortProps = {
441
444
  * observe results via `flow.on("connectionEnded", …)` or the store's
442
445
  * `connection` state.
443
446
  */
444
- declare function Port(props: PortProps): import("react").JSX.Element | null;
447
+ declare const Port: import("react").ForwardRefExoticComponent<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref"> & {
448
+ name: string;
449
+ /**
450
+ * `inspect` makes a graph-owned port focusable/selectable without starting
451
+ * a connection. Defaults to `connect`.
452
+ */
453
+ interaction?: "connect" | "inspect";
454
+ /**
455
+ * Whether the DOM element reports its bounds back to the graph (default
456
+ * true). Ports are unidirectional, one way or the other:
457
+ *
458
+ * - DOM-owned (default): position the element with your own CSS (offsets,
459
+ * percentages, flex…) — NEVER from `port.x`/`port.y` — and the graph's
460
+ * port geometry follows the DOM.
461
+ * - Graph-owned (`measured={false}`): position the element FROM
462
+ * `port.x`/`port.y` (layout results, "virtual" ports) and nothing is
463
+ * written back.
464
+ *
465
+ * Mixing the two (positioning from `port.x` while measuring) creates a
466
+ * feedback loop and runs away.
467
+ */
468
+ measured?: boolean;
469
+ } & import("react").RefAttributes<HTMLDivElement>>;
445
470
  //#endregion
446
471
  //#region src/DragHandle.d.ts
447
472
  type DragHandleProps = HTMLAttributes<HTMLDivElement> & {
@@ -488,7 +513,7 @@ declare function DragHandle({ children, style, ...rest }: DragHandleProps): impo
488
513
  * - `data-flow-selection-bounds` — the established multi-selection frame.
489
514
  */
490
515
  declare const FLOW_BASE_STYLES_ID = "flow-base-styles";
491
- 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-custom-node] :where(\n button,\n input,\n select,\n textarea,\n a[href],\n [role=\"button\"],\n [tabindex],\n [data-flow-drag-handle],\n [data-flow-port-name]\n) {\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";
516
+ 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-layer=\"pane\"]:empty {\n display: none;\n}\n\n[data-flow-connection-preview],\n[data-flow-connection-preview] * {\n pointer-events: none !important;\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-custom-node] :where(\n button,\n input,\n select,\n textarea,\n a[href],\n [role=\"button\"],\n [tabindex],\n [data-flow-drag-handle],\n [data-flow-port-name]\n) {\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";
492
517
  /** Inject the base stylesheet once per document (deduped by element id). */
493
518
  declare function useFlowBaseStyles(): void;
494
519
  //#endregion
@@ -505,4 +530,29 @@ declare function useFlowColorMode(): {
505
530
  };
506
531
  declare function useApplyFlowColorMode(element: HTMLElement | null): void;
507
532
  //#endregion
508
- 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, PanePortal, Panel, type PanelPosition, type PanelProps, Port, type PortProps, type ReactActiveGraphProjection, type ReactAppearanceRenderers, 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 };
533
+ //#region src/EdgePrimitives.d.ts
534
+ type EdgePathProps = Omit<ComponentPropsWithoutRef<"path">, "d"> & {
535
+ pathData: EdgePathData;
536
+ };
537
+ /** Paint routed segments while Flow updates their geometry during gestures. */
538
+ declare function EdgePath({ pathData, ...props }: EdgePathProps): import("react").JSX.Element;
539
+ type EdgeEndpointProps = Omit<ComponentPropsWithoutRef<"circle">, "cx" | "cy" | "r" | "offset"> & {
540
+ edgeId: string;
541
+ pathData: EdgePathData;
542
+ end: "source" | "target";
543
+ /** Reconnection hit radius in canvas units. Defaults to 10. */
544
+ radius?: number;
545
+ /** Reconnection hit offset from the route endpoint in canvas units. */
546
+ offset?: Point;
547
+ };
548
+ declare const EdgeEndpoint: import("react").ForwardRefExoticComponent<Omit<Omit<import("react").SVGProps<SVGCircleElement>, "ref">, "cx" | "cy" | "offset" | "r"> & {
549
+ edgeId: string;
550
+ pathData: EdgePathData;
551
+ end: "source" | "target";
552
+ /** Reconnection hit radius in canvas units. Defaults to 10. */
553
+ radius?: number;
554
+ /** Reconnection hit offset from the route endpoint in canvas units. */
555
+ offset?: Point;
556
+ } & import("react").RefAttributes<SVGCircleElement>>;
557
+ //#endregion
558
+ export { Background, type BackgroundProps, type BackgroundVariant, Controls, type ControlsProps, type CreateFlowInstanceOptions, CrossBackground, DotsBackground, DragHandle, type DragHandleProps, EdgeEndpoint, type EdgeEndpointProps, EdgePath, type EdgePathProps, 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, PanePortal, Panel, type PanelPosition, type PanelProps, Port, type PortProps, type ReactActiveGraphProjection, type ReactAppearanceRenderers, 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
@@ -1,7 +1,7 @@
1
1
  import { a as FlowInstanceProvider, i as LinesBackground, n as CrossBackground, o as useFlowInstance, r as DotsBackground, s as useOptionalFlowInstance, t as Background } from "./backgrounds2.js";
2
2
  import { applyFlowColorMode, attachContainer, attachInput, attachViewportTransform, clientToCanvas, computeRenderableIds, createEntityGeometryObserver, createFlowView, createMeasurementObserver, nodePositionStyle, observeAppearanceBounds, renderableIdsEqual, resolveFlowColorMode, viewportStyle } from "@statelyai/flow-dom";
3
- import { CONNECTION_PREVIEW_EDGE_ID, appearanceMeasurementId, areNodesMeasured, createConnectionPreviewGraph, createEntityChangeNotifier, createFlow, createFlowInputRuntime, getAnchorTransform, getAutoPanDelta, getMinimapProjection, getMinimapViewportCenter, getProjectedEdgeAttrs, getProjectedNodeAttrs, getToolbarPlacement, getVisibleBounds, markerToSVG, pathToSVG, profileModesForContext, profilePoliciesForContext, viewportCenteredOn } from "@statelyai/flow";
4
- import { Fragment, createContext, memo, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
+ import { CONNECTION_PREVIEW_EDGE_ID, appearanceMeasurementId, areNodesMeasured, createConnectionPreviewGraph, createEntityChangeNotifier, createFlow, createFlowInputRuntime, defaultModesForContext, getAnchorTransform, getAutoPanDelta, getMinimapProjection, getMinimapViewportCenter, getProjectedEdgeAttrs, getProjectedNodeAttrs, getToolbarPlacement, getVisibleBounds, markerToSVG, pathToSVG, profileModesForContext, profilePoliciesForContext, viewportCenteredOn } from "@statelyai/flow";
4
+ import { Fragment, createContext, forwardRef, memo, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
5
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
6
  import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
7
7
  import { createPortal } from "react-dom";
@@ -79,7 +79,7 @@ function createFlowInstance(options) {
79
79
  * Create one Flow instance for a component lifetime.
80
80
  *
81
81
  * Creation settings are initial-only; use `flow.configure()` for runtime
82
- * changes. Event/listener props are rebound when their identities change.
82
+ * changes. Event callbacks stay current; selector listeners rebind on change.
83
83
  */
84
84
  function useFlow(options) {
85
85
  return useCreateFlowInstance(options);
@@ -87,56 +87,66 @@ function useFlow(options) {
87
87
  /** @deprecated Use `useFlow(options)`. */
88
88
  function useCreateFlowInstance(options) {
89
89
  const instanceRef = useRef(null);
90
- const listenersRef = useRef(options.listeners);
91
- const eventsRef = useRef(options.events);
92
90
  const lifecycleRef = useRef(0);
93
- listenersRef.current = options.listeners;
94
- eventsRef.current = options.events;
95
91
  if (!instanceRef.current) instanceRef.current = createFlowInstance({
96
92
  initialGraph: options.initialGraph,
97
93
  settings: options.settings
98
94
  });
99
95
  const flow = instanceRef.current;
96
+ useFlowSubscriptions(flow, options);
97
+ useEffect(() => {
98
+ const lifecycle = lifecycleRef;
99
+ const generation = ++lifecycle.current;
100
+ return () => {
101
+ queueMicrotask(() => {
102
+ if (lifecycle.current !== generation) return;
103
+ lifecycle.current++;
104
+ flow.destroy();
105
+ });
106
+ };
107
+ }, [flow]);
108
+ return flow;
109
+ }
110
+ function useFlowSubscriptions(flow, options) {
111
+ const callbacks = useRef(options);
112
+ useLayoutEffect(() => {
113
+ callbacks.current = options;
114
+ });
115
+ const hasConnectionEnded = Boolean(options.events?.connectionEnded);
116
+ const hasLayoutRequested = Boolean(options.events?.layoutRequested);
117
+ const hasCommit = Boolean(options.events?.commit || options.onCommit);
100
118
  useEffect(() => {
101
119
  const cleanup = [];
102
- if (eventsRef.current?.connectionEnded) {
103
- const subscription = flow.on("connectionEnded", eventsRef.current.connectionEnded);
104
- cleanup.push(() => subscription.unsubscribe());
105
- }
106
- if (eventsRef.current?.layoutRequested) {
107
- const subscription = flow.on("layoutRequested", eventsRef.current.layoutRequested);
120
+ if (hasConnectionEnded) {
121
+ const subscription = flow.on("connectionEnded", (event) => callbacks.current.events?.connectionEnded?.(event));
108
122
  cleanup.push(() => subscription.unsubscribe());
109
123
  }
110
- if (eventsRef.current?.commit) {
111
- const subscription = flow.on("commit", eventsRef.current.commit);
124
+ if (hasLayoutRequested) {
125
+ const subscription = flow.on("layoutRequested", (event) => callbacks.current.events?.layoutRequested?.(event));
112
126
  cleanup.push(() => subscription.unsubscribe());
113
127
  }
114
- if (options.onCommit) {
115
- const subscription = flow.on("commit", options.onCommit);
128
+ if (hasCommit) {
129
+ const subscription = flow.on("commit", (event) => {
130
+ callbacks.current.events?.commit?.(event);
131
+ callbacks.current.onCommit?.(event);
132
+ });
116
133
  cleanup.push(() => subscription.unsubscribe());
117
134
  }
118
- for (const listener of listenersRef.current ?? []) cleanup.push(flow.listen(listener.selector, listener.onChange, listener));
119
135
  return () => {
120
- for (const dispose of cleanup) dispose();
136
+ for (const unsubscribe of cleanup) unsubscribe();
121
137
  };
122
138
  }, [
123
139
  flow,
124
- options.listeners,
125
- options.events,
126
- options.onCommit
140
+ hasConnectionEnded,
141
+ hasLayoutRequested,
142
+ hasCommit
127
143
  ]);
128
144
  useEffect(() => {
129
- const lifecycle = lifecycleRef;
130
- const generation = ++lifecycle.current;
145
+ const cleanup = options.listeners?.map((listener) => flow.listen(listener.selector, listener.onChange, listener));
131
146
  return () => {
132
- queueMicrotask(() => {
133
- if (lifecycle.current !== generation) return;
134
- lifecycle.current++;
135
- flow.destroy();
136
- });
147
+ for (const unsubscribe of cleanup ?? []) unsubscribe();
137
148
  };
138
- }, [flow]);
139
- return flow;
149
+ }, [flow, options.listeners]);
140
150
  }
141
151
  /** Subscribe to a derived slice of Flow context with optional equality. */
142
152
  function useFlowSelector(selector, options = {}) {
@@ -303,6 +313,9 @@ function getDerivedFields(context, id, props) {
303
313
  */
304
314
  function useFlowInput(flow, containerRef, options = {}) {
305
315
  const { modes, profile, keyboardTarget, keymap } = options;
316
+ const modesRef = useRef(modes);
317
+ const runtimeRef = useRef(null);
318
+ modesRef.current = modes;
306
319
  const effectiveKeymap = keymap ?? flow.inputSettings?.keymap;
307
320
  const keymapKey = effectiveKeymap ? JSON.stringify(effectiveKeymap) : void 0;
308
321
  const keymapRef = useRef(effectiveKeymap);
@@ -314,25 +327,36 @@ function useFlowInput(flow, containerRef, options = {}) {
314
327
  const runtime = createFlowInputRuntime({
315
328
  store: flow.internals.store,
316
329
  engine: flow.internals.engine,
317
- modes: modes ?? (profile ? profileModesForContext(profile) : void 0),
330
+ modes: (context) => {
331
+ const activeModes = modesRef.current;
332
+ if (activeModes) return typeof activeModes === "function" ? activeModes(context) : activeModes;
333
+ return profile ? profileModesForContext(profile)(context) : defaultModesForContext(context, activeKeymap);
334
+ },
318
335
  policies: profile ? profilePoliciesForContext(profile) : void 0,
319
336
  ...activeKeymap ? { keymap: activeKeymap } : {}
320
337
  });
321
- return attachInput({
338
+ runtimeRef.current = runtime;
339
+ const detach = attachInput({
322
340
  container,
323
341
  store: flow.internals.store,
324
342
  runtime,
325
343
  keyboardTarget,
326
344
  autoPan: getAutoPanDelta
327
345
  });
346
+ return () => {
347
+ runtimeRef.current = null;
348
+ detach();
349
+ };
328
350
  }, [
329
351
  flow,
330
352
  containerRef,
331
- modes,
332
353
  profile,
333
354
  keyboardTarget,
334
355
  keymapKey
335
356
  ]);
357
+ useEffect(() => {
358
+ runtimeRef.current?.reconcileModes();
359
+ }, [modes]);
336
360
  }
337
361
  //#endregion
338
362
  //#region src/MeasurementContext.tsx
@@ -431,6 +455,15 @@ const FLOW_BASE_CSS = `
431
455
  outline: var(--flow-focus-outline, none);
432
456
  }
433
457
 
458
+ [data-flow-layer="pane"]:empty {
459
+ display: none;
460
+ }
461
+
462
+ [data-flow-connection-preview],
463
+ [data-flow-connection-preview] * {
464
+ pointer-events: none !important;
465
+ }
466
+
434
467
  [data-flow-interactive-when-selected] {
435
468
  pointer-events: none;
436
469
  }
@@ -502,6 +535,14 @@ function clearEntityFocusOnBlur(flow, entityId, event) {
502
535
  if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
503
536
  if (flow.getSnapshot().focusedEntityId === entityId) flow.internals.store.trigger.clearFocus();
504
537
  }
538
+ function blurClearedEntityFocus(root, previousId, nextId) {
539
+ if (!previousId || nextId !== null) return;
540
+ const active = root.ownerDocument.activeElement;
541
+ const view = root.ownerDocument.defaultView;
542
+ if (!view || !(active instanceof view.HTMLElement || active instanceof view.SVGElement) || !root.contains(active)) return;
543
+ if (!active.matches(".stately-flow__node, .stately-flow__edge, [data-flow-port-interaction=\"inspect\"]")) return;
544
+ if ((active.getAttribute("data-flow-port-id") ?? active.getAttribute("data-entity-id")) === previousId) active.blur();
545
+ }
505
546
  //#endregion
506
547
  //#region src/LayerPortals.tsx
507
548
  const FlowLayerTargetsContext = createContext(null);
@@ -625,10 +666,7 @@ function StatelyFlow(props) {
625
666
  function OwnedStatelyFlow(props) {
626
667
  const flow = useFlow({
627
668
  initialGraph: props.initialGraph,
628
- settings: props.settings,
629
- listeners: props.listeners,
630
- events: props.events,
631
- onCommit: props.onCommit
669
+ settings: props.settings
632
670
  });
633
671
  return /* @__PURE__ */ jsx(StatelyFlowView, {
634
672
  ...props,
@@ -636,6 +674,7 @@ function OwnedStatelyFlow(props) {
636
674
  });
637
675
  }
638
676
  function StatelyFlowView(props) {
677
+ useFlowSubscriptions(props.flow, props);
639
678
  const containerRef = useRef(null);
640
679
  const [rootLayer, setRootLayer] = useState(null);
641
680
  const setContainerRef = useCallback((element) => {
@@ -661,6 +700,9 @@ function StatelyFlowView(props) {
661
700
  measurementObserver.disconnect();
662
701
  geometryObserver.disconnect();
663
702
  }, [geometryObserver, measurementObserver]);
703
+ useLayoutEffect(() => props.flow.subscribe((context) => context.focusedEntityId, (nextId, previousId) => {
704
+ if (containerRef.current) blurClearedEntityFocus(containerRef.current, previousId, nextId);
705
+ }), [props.flow]);
664
706
  useViewportTransform(viewportRef, props.flow);
665
707
  useFlowBaseStyles();
666
708
  const projections = useActiveProjections(props.flow, props.projections);
@@ -733,7 +775,6 @@ function StatelyFlowView(props) {
733
775
  /* @__PURE__ */ jsx(EdgeLayer, {
734
776
  geometryObserver,
735
777
  renderEdge: props.renderEdge,
736
- renderConnectionLine: props.renderConnectionLine,
737
778
  projections,
738
779
  onlyRenderVisible: props.onlyRenderVisible
739
780
  }),
@@ -744,6 +785,11 @@ function StatelyFlowView(props) {
744
785
  projections,
745
786
  onlyRenderVisible: props.onlyRenderVisible
746
787
  }),
788
+ /* @__PURE__ */ jsx(ConnectionPreviewEdge, {
789
+ flow: props.flow,
790
+ renderEdge: props.renderEdge,
791
+ renderConnectionLine: props.renderConnectionLine
792
+ }),
747
793
  /* @__PURE__ */ jsx(SnapLinesLayer, {}),
748
794
  /* @__PURE__ */ jsx(EdgeLabelLayer, {
749
795
  geometryObserver,
@@ -995,7 +1041,7 @@ function EdgeLayer(props) {
995
1041
  overflow: "visible",
996
1042
  pointerEvents: "none"
997
1043
  },
998
- children: [/* @__PURE__ */ jsx(DefaultMarkerDefinitions, { prefix: markerPrefix }), /* @__PURE__ */ jsxs("g", { children: [edgeIds.map((id) => {
1044
+ children: [/* @__PURE__ */ jsx(DefaultMarkerDefinitions, { prefix: markerPrefix }), /* @__PURE__ */ jsx("g", { children: edgeIds.map((id) => {
999
1045
  if (renderableIds && !renderableIds.edgeIds.has(id)) return null;
1000
1046
  return /* @__PURE__ */ jsx(EdgeView, {
1001
1047
  flow: instance,
@@ -1005,12 +1051,7 @@ function EdgeLayer(props) {
1005
1051
  renderEdge,
1006
1052
  markerPrefix
1007
1053
  }, id);
1008
- }), /* @__PURE__ */ jsx(ConnectionPreviewEdge, {
1009
- flow: instance,
1010
- renderEdge,
1011
- renderConnectionLine: props.renderConnectionLine,
1012
- markerPrefix
1013
- })] })]
1054
+ }) })]
1014
1055
  });
1015
1056
  }
1016
1057
  const EdgeView = memo(function EdgeView(props) {
@@ -1053,8 +1094,7 @@ const EdgeView = memo(function EdgeView(props) {
1053
1094
  const focusProps = {
1054
1095
  role: "group",
1055
1096
  "aria-roledescription": "flow edge",
1056
- "aria-label": props.flow.internals.engine.getAriaLabel(props.id),
1057
- "aria-selected": selected,
1097
+ "aria-label": props.flow.internals.engine.getAriaLabel(props.id) + (selected ? ", selected" : ""),
1058
1098
  tabIndex: focusable ? 0 : void 0,
1059
1099
  onFocus: (event) => {
1060
1100
  if (descendantOwnsFlowFocus(event)) return;
@@ -1106,34 +1146,59 @@ const EdgeView = memo(function EdgeView(props) {
1106
1146
  });
1107
1147
  /** The in-progress connection gesture's preview edge (renders only mid-gesture). */
1108
1148
  function ConnectionPreviewEdge(props) {
1109
- const connection = useFlowSelector((context) => context.connection);
1149
+ const markerPrefix = `stately-flow-preview-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
1150
+ const connection = useFlowSelector((context) => context.connection?.active ? context : null)?.connection;
1110
1151
  if (!connection?.active || connection.preview === false) return null;
1111
1152
  const previewGraph = createConnectionPreviewGraph(props.flow.getSnapshot().graph, connection);
1112
1153
  const pathData = props.flow.getAllEdgePaths({ graph: previewGraph }).get(CONNECTION_PREVIEW_EDGE_ID);
1113
1154
  const edge = previewGraph.edges.find((candidate) => candidate.id === CONNECTION_PREVIEW_EDGE_ID);
1114
1155
  if (!edge || !pathData) return null;
1115
- return /* @__PURE__ */ jsx("g", {
1116
- style: { pointerEvents: "none" },
1117
- children: props.renderConnectionLine ? props.renderConnectionLine({
1118
- connection,
1119
- pathData,
1120
- isValid: connection.isValid
1121
- }) : props.renderEdge ? props.renderEdge({
1122
- flow: props.flow,
1123
- edge,
1124
- pathData,
1125
- attrs: {},
1126
- selected: false,
1127
- hovered: false,
1128
- highlights: []
1129
- }) : /* @__PURE__ */ jsx(DefaultEdge, {
1130
- flow: props.flow,
1131
- edge,
1132
- pathData,
1133
- attrs: {},
1134
- selected: false,
1135
- markerPrefix: props.markerPrefix,
1136
- preview: true
1156
+ return /* @__PURE__ */ jsx("div", {
1157
+ ref: (element) => {
1158
+ element?.setAttribute("inert", "");
1159
+ },
1160
+ "aria-hidden": "true",
1161
+ className: "stately-flow__connection-preview",
1162
+ "data-flow-connection-preview": "",
1163
+ "data-flow-canvas-layer": "",
1164
+ style: {
1165
+ position: "absolute",
1166
+ inset: 0,
1167
+ zIndex: "var(--flow-layer-interaction, 30)",
1168
+ pointerEvents: "none"
1169
+ },
1170
+ children: /* @__PURE__ */ jsxs("svg", {
1171
+ style: {
1172
+ width: "100%",
1173
+ height: "100%",
1174
+ overflow: "visible",
1175
+ pointerEvents: "none"
1176
+ },
1177
+ children: [/* @__PURE__ */ jsx(DefaultMarkerDefinitions, { prefix: markerPrefix }), props.renderConnectionLine ? props.renderConnectionLine({
1178
+ connection,
1179
+ pathData,
1180
+ isValid: connection.isValid,
1181
+ sourcePoint: connection.sourcePort ? props.flow.internals.engine.getPortPosition(connection.sourceId, connection.sourcePort) ?? connection.startPosition : connection.startPosition,
1182
+ targetPoint: connection.targetId && connection.targetPort ? props.flow.internals.engine.getPortPosition(connection.targetId, connection.targetPort) ?? connection.pointerPosition : connection.pointerPosition,
1183
+ sourceDirection: props.flow.getNode(connection.sourceId)?.ports?.find((port) => port.name === connection.sourcePort)?.side ?? pathData.segments[0]?.sourceDirection ?? "right",
1184
+ targetDirection: (connection.targetId ? props.flow.getNode(connection.targetId)?.ports?.find((port) => port.name === connection.targetPort)?.side : void 0) ?? pathData.segments.at(-1)?.targetDirection ?? "left"
1185
+ }) : props.renderEdge ? props.renderEdge({
1186
+ flow: props.flow,
1187
+ edge,
1188
+ pathData,
1189
+ attrs: {},
1190
+ selected: false,
1191
+ hovered: false,
1192
+ highlights: []
1193
+ }) : /* @__PURE__ */ jsx(DefaultEdge, {
1194
+ flow: props.flow,
1195
+ edge,
1196
+ pathData,
1197
+ attrs: {},
1198
+ selected: false,
1199
+ markerPrefix,
1200
+ preview: true
1201
+ })]
1137
1202
  })
1138
1203
  });
1139
1204
  }
@@ -1548,7 +1613,8 @@ const NodeView = memo(function NodeView(props) {
1548
1613
  props.id,
1549
1614
  props.observer,
1550
1615
  props.flow,
1551
- hidden
1616
+ hidden,
1617
+ node?.group?.type
1552
1618
  ]);
1553
1619
  useLayoutEffect(() => {
1554
1620
  const element = ref.current;
@@ -1590,7 +1656,6 @@ const NodeView = memo(function NodeView(props) {
1590
1656
  className: classNames$1("stately-flow__node", attrs.className),
1591
1657
  role: "group",
1592
1658
  "aria-roledescription": "flow node",
1593
- "aria-selected": selected,
1594
1659
  "aria-label": props.flow.internals.engine.getAriaLabel(props.id) + (selected ? ", selected" : ""),
1595
1660
  tabIndex: focusable ? 0 : void 0,
1596
1661
  onFocus: (event) => {
@@ -2329,17 +2394,30 @@ function classNames(...values) {
2329
2394
  * observe results via `flow.on("connectionEnded", …)` or the store's
2330
2395
  * `connection` state.
2331
2396
  */
2332
- function Port(props) {
2397
+ const Port = forwardRef(function Port(props, forwardedRef) {
2398
+ const { name, interaction: interactionProp, measured: measuredProp, onFocus, onBlur, ...nativeProps } = props;
2333
2399
  const flow = useFlowInstance();
2334
2400
  const { node } = useNodeRenderContext();
2335
2401
  const ref = useRef(null);
2402
+ const forwardedCleanup = useRef(void 0);
2403
+ const setRef = useCallback((element) => {
2404
+ const cleanup = forwardedCleanup.current;
2405
+ forwardedCleanup.current = void 0;
2406
+ cleanup?.();
2407
+ ref.current = element;
2408
+ if (typeof forwardedRef === "function") {
2409
+ if (!element && cleanup) return;
2410
+ const dispose = forwardedRef(element);
2411
+ if (typeof dispose === "function") forwardedCleanup.current = dispose;
2412
+ } else if (forwardedRef) forwardedRef.current = element;
2413
+ }, [forwardedRef]);
2336
2414
  const observerRef = useRef(null);
2337
2415
  const observer = useMeasurementObserver();
2338
- const measured = props.measured ?? true;
2339
- const interaction = props.interaction ?? "connect";
2340
- const permitted = interaction !== "connect" || !flow.internals.engine.resolveAppearance(node).registered || flow.internals.engine.isPortAllowed(node, props.name);
2341
- const port = node.ports?.find((candidate) => candidate.name === props.name);
2342
- const portId = `${node.id}:${props.name}`;
2416
+ const measured = measuredProp ?? true;
2417
+ const interaction = interactionProp ?? "connect";
2418
+ const permitted = interaction !== "connect" || !flow.internals.engine.resolveAppearance(node).registered || flow.internals.engine.isPortAllowed(node, name);
2419
+ const port = node.ports?.find((candidate) => candidate.name === name);
2420
+ const portId = `${node.id}:${name}`;
2343
2421
  const focusable = flow.internals.engine.resolveEntityConfig(node).focusable;
2344
2422
  const selected = useFlowSelector((context) => context.selection.has(portId), { flow });
2345
2423
  useLayoutEffect(() => {
@@ -2348,7 +2426,7 @@ function Port(props) {
2348
2426
  observerRef.current = observer;
2349
2427
  observer.observe(element, {
2350
2428
  nodeId: node.id,
2351
- portName: props.name
2429
+ portName: name
2352
2430
  });
2353
2431
  return () => {
2354
2432
  observerRef.current = null;
@@ -2357,7 +2435,7 @@ function Port(props) {
2357
2435
  }, [
2358
2436
  observer,
2359
2437
  node.id,
2360
- props.name,
2438
+ name,
2361
2439
  measured,
2362
2440
  permitted
2363
2441
  ]);
@@ -2370,32 +2448,38 @@ function Port(props) {
2370
2448
  const candidate = useFlowSelector((context) => {
2371
2449
  const connection = context.connection;
2372
2450
  if (!connection?.active) return null;
2373
- if (connection.targetId !== node.id || connection.targetPort !== props.name) return null;
2451
+ if (connection.targetId !== node.id || connection.targetPort !== name) return null;
2374
2452
  return connection.isValid === false ? "invalid" : "valid";
2375
2453
  }, { flow });
2376
2454
  if (!permitted) return null;
2377
2455
  return /* @__PURE__ */ jsx("div", {
2378
- ref,
2379
- className: props.className,
2380
- "data-flow-port-name": props.name,
2456
+ role: interaction === "inspect" ? "button" : void 0,
2457
+ "aria-label": interaction === "inspect" ? `${port?.label ?? name} port` : void 0,
2458
+ "aria-pressed": interaction === "inspect" ? selected : void 0,
2459
+ tabIndex: interaction === "inspect" && focusable ? 0 : void 0,
2460
+ ...nativeProps,
2461
+ ref: setRef,
2462
+ "data-flow-port-name": name,
2381
2463
  "data-flow-node-id": node.id,
2382
2464
  "data-flow-port-id": portId,
2383
2465
  "data-flow-port-interaction": interaction,
2384
2466
  "data-flow-selected": selected ? "true" : void 0,
2385
2467
  "data-connection-candidate": candidate ?? void 0,
2386
- role: interaction === "inspect" ? "button" : void 0,
2387
- "aria-label": interaction === "inspect" ? `${port?.label ?? props.name} port` : void 0,
2388
- "aria-pressed": interaction === "inspect" ? selected : void 0,
2389
- tabIndex: interaction === "inspect" && focusable ? 0 : void 0,
2390
- onFocus: interaction === "inspect" && focusable ? () => flow.internals.store.trigger.focus({ entityId: portId }) : void 0,
2391
- onBlur: interaction === "inspect" ? (event) => clearEntityFocusOnBlur(flow, portId, event) : void 0,
2468
+ onFocus: (event) => {
2469
+ if (interaction === "inspect" && focusable) flow.internals.store.trigger.focus({ entityId: portId });
2470
+ onFocus?.(event);
2471
+ },
2472
+ onBlur: (event) => {
2473
+ if (interaction === "inspect") clearEntityFocusOnBlur(flow, portId, event);
2474
+ onBlur?.(event);
2475
+ },
2392
2476
  style: {
2393
2477
  pointerEvents: "auto",
2394
2478
  ...props.style
2395
2479
  },
2396
2480
  children: props.children
2397
2481
  });
2398
- }
2482
+ });
2399
2483
  //#endregion
2400
2484
  //#region src/DragHandle.tsx
2401
2485
  /**
@@ -2473,4 +2557,36 @@ function useApplyFlowColorMode(element) {
2473
2557
  ]);
2474
2558
  }
2475
2559
  //#endregion
2476
- export { Background, Controls, CrossBackground, DotsBackground, DragHandle, EntityAnchor, FLOW_BASE_CSS, FLOW_BASE_STYLES_ID, FlowColorModeScope, FlowInstanceProvider, FlowToolbar, LinesBackground, MiniMap, 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 };
2560
+ //#region src/EdgePrimitives.tsx
2561
+ /** Paint routed segments while Flow updates their geometry during gestures. */
2562
+ function EdgePath({ pathData, ...props }) {
2563
+ return /* @__PURE__ */ jsx(Fragment$1, { children: pathData.segments.map((segment, index) => /* @__PURE__ */ jsx("path", {
2564
+ fill: "none",
2565
+ ...props,
2566
+ d: pathToSVG(segment),
2567
+ "data-flow-edge-segment": index
2568
+ }, index)) });
2569
+ }
2570
+ const EdgeEndpoint = forwardRef(function EdgeEndpoint({ edgeId, pathData, end, radius = 10, offset = {
2571
+ x: 0,
2572
+ y: 0
2573
+ }, ...props }, ref) {
2574
+ const point = end === "source" ? pathData.segments[0]?.points[0] : pathData.segments.at(-1)?.points.at(-1);
2575
+ if (!point) return null;
2576
+ return /* @__PURE__ */ jsx("circle", {
2577
+ fill: "transparent",
2578
+ pointerEvents: "all",
2579
+ ...props,
2580
+ ref,
2581
+ "data-entity-id": edgeId,
2582
+ "data-flow-part": `edge-endpoint:${end}`,
2583
+ "data-flow-edge-endpoint": end,
2584
+ "data-flow-edge-endpoint-offset-x": offset.x,
2585
+ "data-flow-edge-endpoint-offset-y": offset.y,
2586
+ cx: point.x + offset.x,
2587
+ cy: point.y + offset.y,
2588
+ r: radius
2589
+ });
2590
+ });
2591
+ //#endregion
2592
+ export { Background, Controls, CrossBackground, DotsBackground, DragHandle, EdgeEndpoint, EdgePath, EntityAnchor, FLOW_BASE_CSS, FLOW_BASE_STYLES_ID, FlowColorModeScope, FlowInstanceProvider, FlowToolbar, LinesBackground, MiniMap, 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.10.1",
3
+ "version": "0.11.1",
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.12.1",
38
- "@statelyai/flow-dom": "3.5.1"
37
+ "@statelyai/flow": "0.13.1",
38
+ "@statelyai/flow-dom": "3.5.3"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": "^18.2.0 || ^19.0.0",