Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### Added — custom edge generators receive the `edge`

Custom edge path generators registered via `flowCanvas({ edgeTypes })` are now called with the `edge` as a second argument — `edgeTypes[type](params, edge)` — where before they saw only the endpoint coordinates. That limitation forced any per-edge routing data (waypoints, lane/channel assignments) to be smuggled in through a **separate closure per edge**; because that data lived in a function rather than on the model, it was lost on `toObject()`/`fromObject()`, so a custom-routed edge came back unrouted after a reload or snapshot restore. A generator can now read its route straight off `edge.data`, where it serializes with the edge and survives the round-trip.

Not a breaking change: the `edge` argument is optional and appended after the existing `params`, so every existing single-parameter generator keeps working unchanged. Thanks to [@ronnorthrip](https://github.com/ronnorthrip) for the report and change.

### Added — schema render hooks: `x-flow-schema` customization without forking the directive

`x-flow-schema` owns the DOM it builds, so consumers previously had to replace the whole directive to customize a node. Four new `flowCanvas({ … })` hooks augment its output per-render instead — read from the canvas config on every render, no-ops when unset, and firing only for `x-flow-schema` nodes:
Expand Down
11 changes: 10 additions & 1 deletion docs/configuration/edges.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,26 @@ order: 4

## Custom edge types

A generator receives the endpoint `params` and, as an optional second argument, the
`edge` itself:

```js
flowCanvas({
edgeTypes: {
'custom': ({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }) => ({
'custom': ({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }, edge) => ({
path: `M ${sourceX} ${sourceY} L ${targetX} ${targetY}`,
labelPosition: { x: (sourceX + targetX) / 2, y: (sourceY + targetY) / 2 },
}),
},
})
```

The `edge` argument lets a single generator read per-edge routing data straight off
the edge (e.g. precomputed waypoints stashed on `edge.data`) instead of needing a
separate closure per edge. Because that data lives on the edge, it also survives
`toObject()` / `fromObject()` serialization — a custom-routed edge reloads correctly.
The argument is optional, so existing one-parameter generators keep working unchanged.

## Edge data shape

```js
Expand Down
6 changes: 4 additions & 2 deletions docs/edges/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,17 @@ Register custom edge types via the `edgeTypes` config option. Any `type` string
```js
flowCanvas({
edgeTypes: {
'custom': ({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }) => ({
'custom': ({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }, edge) => ({
path: `M ${sourceX} ${sourceY} L ${targetX} ${targetY}`,
labelPosition: { x: (sourceX + targetX) / 2, y: (sourceY + targetY) / 2 },
}),
},
})
```

The function receives source/target coordinates and positions and must return an object with `path` (an SVG path string) and `labelPosition` (an `{ x, y }` point for label placement).
The function receives source/target coordinates and positions as its first argument, and the `edge` object itself as an optional second argument. It must return an object with `path` (an SVG path string) and `labelPosition` (an `{ x, y }` point for label placement).

The `edge` argument lets one generator read per-edge routing data off the edge (e.g. waypoints on `edge.data`) rather than needing a closure per edge — and because that data lives on the edge, it survives `toObject()` / `fromObject()`. It is optional, so existing single-argument generators are unaffected.

## Edge data shape

Expand Down
78 changes: 78 additions & 0 deletions src/core/edge-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { getEdgePath } from './edge-utils';
import type { FlowEdge, FlowNode } from './types';

// Minimal nodes — getEdgePath skips getHandleCoords when explicit endpoint coords
// are supplied, so the node bodies are unused here.
const node = (id: string) => ({ id, position: { x: 0, y: 0 }, data: {} }) as unknown as FlowNode;
const at = { x: 0, y: 0 };

describe('getEdgePath — custom edge types', () => {
it('hands the edge to a custom generator so it can read per-edge routing data', () => {
// The point of the edge-passing change: a generator no longer needs a closure
// per edge — it reads route data straight off the edge (which, living on the
// edge, survives toObject()/fromObject()).
let received: FlowEdge | undefined;
const edge = {
id: 'e1',
source: 'a',
target: 'b',
type: 'gutter',
data: { gutter: { srcOffset: 7 } },
} as unknown as FlowEdge;

const edgeTypes = {
gutter: (_params: unknown, e?: FlowEdge) => {
received = e;
const off = (e?.data as { gutter?: { srcOffset?: number } })?.gutter?.srcOffset ?? 0;
return { path: `M0 0 L${off} 0`, labelPosition: { x: 0, y: 0 } };
},
};

const result = getEdgePath(edge, node('a'), node('b'), 'bottom', 'top', at, at, edgeTypes);

expect(received).toBe(edge); // the exact edge object is threaded through
expect(result.path).toBe('M0 0 L7 0'); // and its per-edge data drives the path
});

it('still calls a one-arg generator that ignores the edge (backward compatible)', () => {
const edge = { id: 'e2', source: 'a', target: 'b', type: 'custom' } as unknown as FlowEdge;
const edgeTypes = {
custom: (params: { sourceX: number }) => ({
path: `M${params.sourceX} 0`,
labelPosition: { x: 0, y: 0 },
}),
};

const result = getEdgePath(edge, node('a'), node('b'), 'bottom', 'top', { x: 5, y: 0 }, at, edgeTypes);

expect(result.path).toBe('M5 0');
});

it('reads route data off edge.data that survived a serialization round-trip', () => {
// toObject()/fromObject() serialize edges via JSON.parse(JSON.stringify(...)).
// A per-edge closure would not survive that boundary; plain data on edge.data
// does — so a custom-routed edge still renders correctly after reload/restore.
const edge = {
id: 'e3',
source: 'a',
target: 'b',
type: 'gutter',
data: { gutter: { srcOffset: 7 } },
} as unknown as FlowEdge;

// Round-trip through the exact serialization toObject()/fromObject() use.
const restored = JSON.parse(JSON.stringify(edge)) as FlowEdge;

const edgeTypes = {
gutter: (_params: unknown, e?: FlowEdge) => {
const off = (e?.data as { gutter?: { srcOffset?: number } })?.gutter?.srcOffset ?? 0;
return { path: `M0 0 L${off} 0`, labelPosition: { x: 0, y: 0 } };
},
};

const result = getEdgePath(restored, node('a'), node('b'), 'bottom', 'top', at, at, edgeTypes);

expect(result.path).toBe('M0 0 L7 0'); // route data survived the round-trip and drove the path
});
});
9 changes: 6 additions & 3 deletions src/core/edge-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export function getEdgePath(
targetPosition: HandlePosition = 'top',
sourceCoords?: { x: number; y: number },
targetCoords?: { x: number; y: number },
edgeTypes?: Record<string, (params: { sourceX: number; sourceY: number; sourcePosition: 'top' | 'right' | 'bottom' | 'left'; targetX: number; targetY: number; targetPosition: 'top' | 'right' | 'bottom' | 'left' }) => EdgePathResult>,
edgeTypes?: Record<string, (params: { sourceX: number; sourceY: number; sourcePosition: 'top' | 'right' | 'bottom' | 'left'; targetX: number; targetY: number; targetPosition: 'top' | 'right' | 'bottom' | 'left' }, edge?: FlowEdge) => EdgePathResult>,
obstacles?: Rect[],
shapeRegistry?: Record<string, ShapeDefinition>,
globalOrigin?: [number, number],
Expand All @@ -168,9 +168,12 @@ export function getEdgePath(

const edgeType = edge.type ?? defaultEdgeType ?? 'bezier';

// Check custom edge types first
// Check custom edge types first. The edge is passed as a second arg so a custom
// generator can read per-edge routing data off it (e.g. precomputed waypoints on
// edge.data) rather than needing a separate closure per edge — and that data,
// living on the edge, survives toObject()/fromObject() serialization.
if (edgeTypes?.[edgeType]) {
return edgeTypes[edgeType](params);
return edgeTypes[edgeType](params, edge);
}

// For floating edges, use pathType to pick the path generator; otherwise use edge.type
Expand Down
11 changes: 7 additions & 4 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1504,11 +1504,14 @@ export interface FlowCanvasConfig {
shapeTypes?: Record<string, ShapeDefinition>;

/** Map edge type strings to custom path generator functions.
* Receives the same params as built-in generators, returns { path, labelPosition }. */
* Receives the endpoint params (same as built-in generators) plus the edge itself,
* so a generator can read per-edge routing data off it (e.g. precomputed waypoints
* on `edge.data`) — which, living on the edge, survives serialization. Returns
* { path, labelPosition }. The `edge` arg is optional for backward compatibility. */
edgeTypes?: Record<string, (params: {
sourceX: number; sourceY: number; sourcePosition: string;
targetX: number; targetY: number; targetPosition: string;
}) => { path: string; labelPosition: { x: number; y: number } }>;
sourceX: number; sourceY: number; sourcePosition: 'top' | 'right' | 'bottom' | 'left';
targetX: number; targetY: number; targetPosition: 'top' | 'right' | 'bottom' | 'left';
}, edge?: FlowEdge) => { path: string; labelPosition: { x: number; y: number } }>;

/**
* Optional vocabulary of field types for the schema addon's default-UI
Expand Down