diff --git a/CHANGELOG.md b/CHANGELOG.md index 0750add..60ec950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/docs/configuration/edges.md b/docs/configuration/edges.md index 7c76daf..336954d 100644 --- a/docs/configuration/edges.md +++ b/docs/configuration/edges.md @@ -18,10 +18,13 @@ 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 }, }), @@ -29,6 +32,12 @@ flowCanvas({ }) ``` +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 diff --git a/docs/edges/types.md b/docs/edges/types.md index b05f30c..89ce8e6 100644 --- a/docs/edges/types.md +++ b/docs/edges/types.md @@ -117,7 +117,7 @@ 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 }, }), @@ -125,7 +125,9 @@ flowCanvas({ }) ``` -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 diff --git a/src/core/edge-utils.test.ts b/src/core/edge-utils.test.ts new file mode 100644 index 0000000..9151586 --- /dev/null +++ b/src/core/edge-utils.test.ts @@ -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 + }); +}); diff --git a/src/core/edge-utils.ts b/src/core/edge-utils.ts index 78df96f..ba7d170 100644 --- a/src/core/edge-utils.ts +++ b/src/core/edge-utils.ts @@ -152,7 +152,7 @@ export function getEdgePath( targetPosition: HandlePosition = 'top', sourceCoords?: { x: number; y: number }, targetCoords?: { x: number; y: number }, - edgeTypes?: Record EdgePathResult>, + edgeTypes?: Record EdgePathResult>, obstacles?: Rect[], shapeRegistry?: Record, globalOrigin?: [number, number], @@ -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 diff --git a/src/core/types.ts b/src/core/types.ts index 601d49f..5d1a94a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1504,11 +1504,14 @@ export interface FlowCanvasConfig { shapeTypes?: Record; /** 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 { 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