@mindlogic-ai/logician-ui 4.0.0-alpha.36 → 4.0.0-alpha.37
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/dist/components/Form/FormSection/CollapsibleFormSection.js +1 -0
- package/dist/components/Form/FormSection/CollapsibleFormSection.js.map +1 -1
- package/dist/components/Form/FormSection/CollapsibleFormSection.mjs +1 -0
- package/dist/components/Form/FormSection/CollapsibleFormSection.mjs.map +1 -1
- package/dist/components/Workflow/Workflow.d.ts +1 -1
- package/dist/components/Workflow/Workflow.d.ts.map +1 -1
- package/dist/components/Workflow/Workflow.js +2 -2
- package/dist/components/Workflow/Workflow.js.map +1 -1
- package/dist/components/Workflow/Workflow.mjs +2 -2
- package/dist/components/Workflow/Workflow.mjs.map +1 -1
- package/dist/components/Workflow/Workflow.types.d.ts +9 -0
- package/dist/components/Workflow/Workflow.types.d.ts.map +1 -1
- package/dist/components/Workflow/Workflow.types.js.map +1 -1
- package/dist/components/Workflow/Workflow.types.mjs.map +1 -1
- package/dist/components/Workflow/canvas/Canvas/Canvas.d.ts +6 -2
- package/dist/components/Workflow/canvas/Canvas/Canvas.d.ts.map +1 -1
- package/dist/components/Workflow/canvas/Canvas/Canvas.js +29 -23
- package/dist/components/Workflow/canvas/Canvas/Canvas.js.map +1 -1
- package/dist/components/Workflow/canvas/Canvas/Canvas.mjs +31 -25
- package/dist/components/Workflow/canvas/Canvas/Canvas.mjs.map +1 -1
- package/dist/components/Workflow/createNode.d.ts +25 -1
- package/dist/components/Workflow/createNode.d.ts.map +1 -1
- package/dist/components/Workflow/createNode.js +39 -1
- package/dist/components/Workflow/createNode.js.map +1 -1
- package/dist/components/Workflow/createNode.mjs +38 -2
- package/dist/components/Workflow/createNode.mjs.map +1 -1
- package/dist/components/Workflow/index.d.ts +2 -0
- package/dist/components/Workflow/index.d.ts.map +1 -1
- package/dist/components/Workflow/layout/autoLayout.d.ts +10 -0
- package/dist/components/Workflow/layout/autoLayout.d.ts.map +1 -1
- package/dist/components/Workflow/layout/autoLayout.js +4 -0
- package/dist/components/Workflow/layout/autoLayout.js.map +1 -1
- package/dist/components/Workflow/layout/autoLayout.mjs +1 -1
- package/dist/components/Workflow/layout/autoLayout.mjs.map +1 -1
- package/dist/components/Workflow/placement.d.ts +65 -0
- package/dist/components/Workflow/placement.d.ts.map +1 -0
- package/dist/components/Workflow/placement.js +100 -0
- package/dist/components/Workflow/placement.js.map +1 -0
- package/dist/components/Workflow/placement.mjs +96 -0
- package/dist/components/Workflow/placement.mjs.map +1 -0
- package/dist/components/Workflow/useWorkflowActions.d.ts +54 -0
- package/dist/components/Workflow/useWorkflowActions.d.ts.map +1 -0
- package/dist/components/Workflow/useWorkflowActions.js +105 -0
- package/dist/components/Workflow/useWorkflowActions.js.map +1 -0
- package/dist/components/Workflow/useWorkflowActions.mjs +103 -0
- package/dist/components/Workflow/useWorkflowActions.mjs.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/Workflow/README.md +64 -0
- package/src/components/Workflow/Workflow.tsx +2 -0
- package/src/components/Workflow/Workflow.types.ts +10 -0
- package/src/components/Workflow/canvas/Canvas/Canvas.tsx +57 -53
- package/src/components/Workflow/createNode.test.ts +40 -2
- package/src/components/Workflow/createNode.ts +70 -2
- package/src/components/Workflow/index.ts +8 -0
- package/src/components/Workflow/layout/autoLayout.ts +4 -4
- package/src/components/Workflow/placement.test.ts +156 -0
- package/src/components/Workflow/placement.ts +172 -0
- package/src/components/Workflow/stories/Workflow.stories.tsx +79 -0
- package/src/components/Workflow/useWorkflowActions.test.tsx +345 -0
- package/src/components/Workflow/useWorkflowActions.ts +196 -0
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
GraphNode,
|
|
3
|
+
NodeTypeDef,
|
|
4
|
+
Position,
|
|
5
|
+
WorkflowTranslate,
|
|
6
|
+
} from './Workflow.types';
|
|
7
|
+
import { resolveDefaultConfig } from './Workflow.types';
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Unique id for edges and other internal graph elements that aren't referenced
|
|
@@ -29,7 +35,10 @@ export function nextNodeId(
|
|
|
29
35
|
nodes: ReadonlyArray<GraphNode>
|
|
30
36
|
): string {
|
|
31
37
|
if (kind === 'start') return 'start';
|
|
32
|
-
|
|
38
|
+
// Escape the kind: namespaced kinds (`math.add`) would otherwise read `.` as
|
|
39
|
+
// a wildcard and count unrelated ids toward the counter.
|
|
40
|
+
const escaped = kind.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
|
+
const re = new RegExp(`^${escaped}_(\\d+)$`);
|
|
33
42
|
let max = 0;
|
|
34
43
|
for (const n of nodes) {
|
|
35
44
|
const m = re.exec(n.id);
|
|
@@ -65,3 +74,62 @@ export function cloneNode(
|
|
|
65
74
|
config: structuredClone(node.config),
|
|
66
75
|
};
|
|
67
76
|
}
|
|
77
|
+
|
|
78
|
+
/** String-only translator handed to `NodeTypeDef.localizeDefaults`. */
|
|
79
|
+
export type DefaultsTranslate = (
|
|
80
|
+
key: string,
|
|
81
|
+
params?: Record<string, string>
|
|
82
|
+
) => string;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Tear the string lookup off the host translator so `localizeDefaults` doesn't
|
|
86
|
+
* depend on the React-fragment interpolation path.
|
|
87
|
+
*/
|
|
88
|
+
export function toDefaultsTranslate(
|
|
89
|
+
translate: WorkflowTranslate
|
|
90
|
+
): DefaultsTranslate {
|
|
91
|
+
return (key, params) => translate(key, params) as string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a fresh node of `def`'s kind — the ONE place a node is created from a
|
|
96
|
+
* node type, shared by the palette drop and `useWorkflowActions().addNode`, so
|
|
97
|
+
* both paths produce the same id and starting config:
|
|
98
|
+
*
|
|
99
|
+
* - id: `nextNodeId` (`{kind}_{N}`), so authors can type it into `{{...}}`
|
|
100
|
+
* references — the same scheme duplicate/paste use;
|
|
101
|
+
* - config: `defaultConfig`, then `localizeDefaults(translate)`, then
|
|
102
|
+
* `hostDefaults(hostBridge)`, each shallow-merged over the previous.
|
|
103
|
+
*/
|
|
104
|
+
export function createNodeFromType(
|
|
105
|
+
def: NodeTypeDef,
|
|
106
|
+
{
|
|
107
|
+
position,
|
|
108
|
+
nodes,
|
|
109
|
+
translate,
|
|
110
|
+
hostBridge,
|
|
111
|
+
}: {
|
|
112
|
+
position: Position;
|
|
113
|
+
/** Current node list — continues the per-kind id counter. */
|
|
114
|
+
nodes: ReadonlyArray<GraphNode>;
|
|
115
|
+
translate: DefaultsTranslate;
|
|
116
|
+
hostBridge?: unknown;
|
|
117
|
+
}
|
|
118
|
+
): GraphNode {
|
|
119
|
+
const base = resolveDefaultConfig(def) as Record<string, unknown>;
|
|
120
|
+
const overlay = def.localizeDefaults?.(translate) as
|
|
121
|
+
| Record<string, unknown>
|
|
122
|
+
| undefined;
|
|
123
|
+
// Host-data overlay (e.g. a starting LLM picked from the tenant's live model
|
|
124
|
+
// list) — applied last so it wins over any static placeholder in
|
|
125
|
+
// `defaultConfig`. Absent on a generic host, leaving the static defaults.
|
|
126
|
+
const bridged = def.hostDefaults?.(hostBridge) as
|
|
127
|
+
| Record<string, unknown>
|
|
128
|
+
| undefined;
|
|
129
|
+
return {
|
|
130
|
+
id: nextNodeId(def.kind, nodes),
|
|
131
|
+
kind: def.kind,
|
|
132
|
+
position,
|
|
133
|
+
config: { ...base, ...overlay, ...bridged },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -35,6 +35,14 @@ export {
|
|
|
35
35
|
} from './graphObserver';
|
|
36
36
|
export type { GraphAction } from './graphReducer';
|
|
37
37
|
export { emptyGraph, graphReducer } from './graphReducer';
|
|
38
|
+
export type {
|
|
39
|
+
AddAfterSource,
|
|
40
|
+
AddNodeOptions,
|
|
41
|
+
AddNodeResult,
|
|
42
|
+
AddNodeTarget,
|
|
43
|
+
WorkflowActions,
|
|
44
|
+
} from './useWorkflowActions';
|
|
45
|
+
export { useWorkflowActions } from './useWorkflowActions';
|
|
38
46
|
export { Workflow } from './Workflow';
|
|
39
47
|
export type {
|
|
40
48
|
ConnectionCtx,
|
|
@@ -122,11 +122,11 @@ function orderEdgesForDagre(
|
|
|
122
122
|
* height is a typical two-row card. Callers should pass real measured sizes
|
|
123
123
|
* when available so ranks pack tightly.
|
|
124
124
|
*/
|
|
125
|
-
const FALLBACK_WIDTH = parseInt(CARD_WIDTH, 10);
|
|
126
|
-
const FALLBACK_HEIGHT = 76;
|
|
125
|
+
export const FALLBACK_WIDTH = parseInt(CARD_WIDTH, 10);
|
|
126
|
+
export const FALLBACK_HEIGHT = 76;
|
|
127
127
|
|
|
128
|
-
const DEFAULT_RANK_GAP = 80;
|
|
129
|
-
const DEFAULT_NODE_GAP = 40;
|
|
128
|
+
export const DEFAULT_RANK_GAP = 80;
|
|
129
|
+
export const DEFAULT_NODE_GAP = 40;
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
132
|
* Compute a tidy, hierarchical layout for a workflow graph and return a new
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { findFreeExit, planNodePlacement } from './placement';
|
|
4
|
+
import type { Graph, GraphNode, NodeTypeDef } from './Workflow.types';
|
|
5
|
+
|
|
6
|
+
// Handle shapes of FactChat's start / agent / end / note / classify types.
|
|
7
|
+
const HANDLES: Record<string, NodeTypeDef['handles']> = {
|
|
8
|
+
start: () => ({ inputs: [], outputs: [{ id: 'out' }] }),
|
|
9
|
+
agent: () => ({ inputs: [{ id: 'in' }], outputs: [{ id: 'out' }] }),
|
|
10
|
+
end: () => ({ inputs: [{ id: 'in' }], outputs: [] }),
|
|
11
|
+
note: () => ({ inputs: [], outputs: [] }),
|
|
12
|
+
classify: () => ({
|
|
13
|
+
inputs: [{ id: 'in' }],
|
|
14
|
+
outputs: [{ id: 'cat_a' }, { id: 'cat_b' }],
|
|
15
|
+
}),
|
|
16
|
+
};
|
|
17
|
+
const getNodeType = (kind: string) =>
|
|
18
|
+
HANDLES[kind] ? ({ kind, handles: HANDLES[kind] } as NodeTypeDef) : undefined;
|
|
19
|
+
|
|
20
|
+
const node = (id: string, kind: string, x = 0, y = 0): GraphNode => ({
|
|
21
|
+
id,
|
|
22
|
+
kind,
|
|
23
|
+
position: { x, y },
|
|
24
|
+
config: {},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const plan = (
|
|
28
|
+
graph: Graph,
|
|
29
|
+
kind: string,
|
|
30
|
+
after: string | null,
|
|
31
|
+
types: typeof getNodeType = getNodeType
|
|
32
|
+
) =>
|
|
33
|
+
planNodePlacement({
|
|
34
|
+
graph,
|
|
35
|
+
getNodeType: types,
|
|
36
|
+
node: node(`${kind}_9`, kind),
|
|
37
|
+
target: after ? { after } : { at: 'viewportCenter' },
|
|
38
|
+
viewportCenter: { x: 500, y: 300 },
|
|
39
|
+
edgeId: 'e_new',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('planNodePlacement', () => {
|
|
43
|
+
const start = node('start', 'start');
|
|
44
|
+
|
|
45
|
+
it('places the node right of the source and connects its free exit', () => {
|
|
46
|
+
const result = plan({ nodes: [start], edges: [] }, 'agent', 'start');
|
|
47
|
+
|
|
48
|
+
// 174 (card width) + 80 (rank gap).
|
|
49
|
+
expect(result.position).toEqual({ x: 254, y: 0 });
|
|
50
|
+
expect(result.edge).toEqual({
|
|
51
|
+
id: 'e_new',
|
|
52
|
+
source: 'start',
|
|
53
|
+
target: 'agent_9',
|
|
54
|
+
sourceHandle: 'out',
|
|
55
|
+
targetHandle: 'in',
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('uses the measured source width when React Flow has one', () => {
|
|
60
|
+
const result = planNodePlacement({
|
|
61
|
+
graph: { nodes: [start], edges: [] },
|
|
62
|
+
getNodeType,
|
|
63
|
+
node: node('agent_9', 'agent'),
|
|
64
|
+
target: { after: 'start' },
|
|
65
|
+
viewportCenter: { x: 0, y: 0 },
|
|
66
|
+
edgeId: 'e_new',
|
|
67
|
+
getNodeSize: (id) =>
|
|
68
|
+
id === 'start' ? { width: 300, height: 90 } : undefined,
|
|
69
|
+
});
|
|
70
|
+
expect(result.position).toEqual({ x: 380, y: 0 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('moves the node down when the spot to the right is taken', () => {
|
|
74
|
+
const blocker = node('agent_1', 'agent', 254, 0);
|
|
75
|
+
const result = plan({ nodes: [start, blocker], edges: [] }, 'end', 'start');
|
|
76
|
+
|
|
77
|
+
// 0 + 76 (fallback height) + 40 (node gap).
|
|
78
|
+
expect(result.position).toEqual({ x: 254, y: 116 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('centres an unconnected node in the viewport for { at: viewportCenter }', () => {
|
|
82
|
+
const result = plan({ nodes: [start], edges: [] }, 'agent', null);
|
|
83
|
+
|
|
84
|
+
expect(result.position).toEqual({ x: 413, y: 262 });
|
|
85
|
+
expect(result.edge).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('falls back to the viewport centre when the node has no free exit', () => {
|
|
89
|
+
const end = node('end_1', 'end', 400, 0);
|
|
90
|
+
const taken: Graph = {
|
|
91
|
+
nodes: [start, end],
|
|
92
|
+
edges: [{ id: 'e_1', source: 'start', target: 'end_1' }],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// From End (no exits), and from Start whose only exit is taken.
|
|
96
|
+
for (const after of ['end_1', 'start', 'missing']) {
|
|
97
|
+
const result = plan(taken, 'agent', after);
|
|
98
|
+
expect(result.edge).toBeNull();
|
|
99
|
+
expect(result.position).toEqual({ x: 413, y: 262 });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('places a Note right of the source but adds no edge into it', () => {
|
|
104
|
+
const result = plan({ nodes: [start], edges: [] }, 'note', 'start');
|
|
105
|
+
|
|
106
|
+
expect(result.position).toEqual({ x: 254, y: 0 });
|
|
107
|
+
expect(result.edge).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('wires the next free exit of a multi-exit node', () => {
|
|
111
|
+
const classify = node('classify_1', 'classify');
|
|
112
|
+
const graph: Graph = {
|
|
113
|
+
nodes: [classify],
|
|
114
|
+
edges: [
|
|
115
|
+
{ id: 'e_1', source: 'classify_1', target: 'x', sourceHandle: 'cat_a' },
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
expect(plan(graph, 'agent', 'classify_1').edge?.sourceHandle).toBe('cat_b');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('respects canConnect on either endpoint', () => {
|
|
122
|
+
const rejectingTarget = (kind: string) => {
|
|
123
|
+
const def = getNodeType(kind);
|
|
124
|
+
return def && kind === 'agent'
|
|
125
|
+
? { ...def, canConnect: () => 'agents cannot follow start' }
|
|
126
|
+
: def;
|
|
127
|
+
};
|
|
128
|
+
const result = plan(
|
|
129
|
+
{ nodes: [start], edges: [] },
|
|
130
|
+
'agent',
|
|
131
|
+
'start',
|
|
132
|
+
rejectingTarget
|
|
133
|
+
);
|
|
134
|
+
// Still placed after the source, just not wired.
|
|
135
|
+
expect(result.position).toEqual({ x: 254, y: 0 });
|
|
136
|
+
expect(result.edge).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('findFreeExit', () => {
|
|
141
|
+
it('treats an edge without sourceHandle as sitting on the first exit', () => {
|
|
142
|
+
const graph: Graph = {
|
|
143
|
+
nodes: [node('classify_1', 'classify')],
|
|
144
|
+
edges: [{ id: 'e_1', source: 'classify_1', target: 'x' }],
|
|
145
|
+
};
|
|
146
|
+
expect(findFreeExit(graph, getNodeType, 'classify_1')?.sourceHandle).toBe(
|
|
147
|
+
'cat_b'
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns null for no node id', () => {
|
|
152
|
+
expect(findFreeExit({ nodes: [], edges: [] }, getNodeType, null)).toBe(
|
|
153
|
+
null
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { isValidConnection } from './connectionRules';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_NODE_GAP,
|
|
4
|
+
DEFAULT_RANK_GAP,
|
|
5
|
+
FALLBACK_HEIGHT,
|
|
6
|
+
FALLBACK_WIDTH,
|
|
7
|
+
} from './layout/autoLayout';
|
|
8
|
+
import type {
|
|
9
|
+
Graph,
|
|
10
|
+
GraphEdge,
|
|
11
|
+
GraphNode,
|
|
12
|
+
NodeTypeDef,
|
|
13
|
+
Position,
|
|
14
|
+
} from './Workflow.types';
|
|
15
|
+
|
|
16
|
+
type GetNodeType = (kind: string) => NodeTypeDef | undefined;
|
|
17
|
+
|
|
18
|
+
export type NodeSize = { width: number; height: number };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Size assumed for a node React Flow has not measured yet — including the node
|
|
22
|
+
* being added. Same fallback auto-arrange uses.
|
|
23
|
+
*/
|
|
24
|
+
export const FALLBACK_NODE_SIZE: NodeSize = {
|
|
25
|
+
width: FALLBACK_WIDTH,
|
|
26
|
+
height: FALLBACK_HEIGHT,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Where `addNode` puts a new node.
|
|
31
|
+
*
|
|
32
|
+
* - `{ after: nodeId }` — to the right of that node, wired from its first free
|
|
33
|
+
* exit. Falls back to `{ at: 'viewportCenter' }` when the node is unknown or
|
|
34
|
+
* has no free exit (End, Note, every exit already holds its one edge).
|
|
35
|
+
* - `{ at: 'viewportCenter' }` — unconnected, centred in the visible canvas.
|
|
36
|
+
*/
|
|
37
|
+
export type AddNodeTarget = { after: string } | { at: 'viewportCenter' };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A node's first exit with no outbound edge, or `null` when the node is
|
|
41
|
+
* unknown, has no exits (End, Note) or every exit already holds its one edge.
|
|
42
|
+
* An edge without a `sourceHandle` sits on the first exit (see
|
|
43
|
+
* `existingEdgeFromHandle`).
|
|
44
|
+
*/
|
|
45
|
+
export function findFreeExit(
|
|
46
|
+
graph: Graph,
|
|
47
|
+
getNodeType: GetNodeType,
|
|
48
|
+
nodeId: string | null | undefined
|
|
49
|
+
): { node: GraphNode; sourceHandle: string } | null {
|
|
50
|
+
if (!nodeId) return null;
|
|
51
|
+
const node = graph.nodes.find((n) => n.id === nodeId);
|
|
52
|
+
if (!node) return null;
|
|
53
|
+
const outputs = getNodeType(node.kind)?.handles(node.config).outputs ?? [];
|
|
54
|
+
const free = outputs.find(
|
|
55
|
+
(handle) =>
|
|
56
|
+
!graph.edges.some(
|
|
57
|
+
(edge) =>
|
|
58
|
+
edge.source === node.id &&
|
|
59
|
+
(edge.sourceHandle ?? outputs[0].id) === handle.id
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
return free ? { node, sourceHandle: free.id } : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const overlaps = (a: Position, aSize: NodeSize, b: Position, bSize: NodeSize) =>
|
|
66
|
+
a.x < b.x + bSize.width &&
|
|
67
|
+
a.x + aSize.width > b.x &&
|
|
68
|
+
a.y < b.y + bSize.height &&
|
|
69
|
+
a.y + aSize.height > b.y;
|
|
70
|
+
|
|
71
|
+
/** Moves `start` down until a fallback-sized card there covers no node. */
|
|
72
|
+
function nudgeToFreeSpot(
|
|
73
|
+
start: Position,
|
|
74
|
+
nodes: ReadonlyArray<GraphNode>,
|
|
75
|
+
getNodeSize: (id: string) => NodeSize | undefined
|
|
76
|
+
): Position {
|
|
77
|
+
const position = { ...start };
|
|
78
|
+
// Each pass clears at least one node, so this ends within nodes.length passes.
|
|
79
|
+
for (let pass = 0; pass <= nodes.length; pass += 1) {
|
|
80
|
+
const blocker = nodes.find((n) =>
|
|
81
|
+
overlaps(
|
|
82
|
+
position,
|
|
83
|
+
FALLBACK_NODE_SIZE,
|
|
84
|
+
n.position,
|
|
85
|
+
getNodeSize(n.id) ?? FALLBACK_NODE_SIZE
|
|
86
|
+
)
|
|
87
|
+
);
|
|
88
|
+
if (!blocker) break;
|
|
89
|
+
const blockerHeight = (getNodeSize(blocker.id) ?? FALLBACK_NODE_SIZE)
|
|
90
|
+
.height;
|
|
91
|
+
position.y = blocker.position.y + blockerHeight + DEFAULT_NODE_GAP;
|
|
92
|
+
}
|
|
93
|
+
return position;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type PlanNodePlacementArgs = {
|
|
97
|
+
graph: Graph;
|
|
98
|
+
getNodeType: GetNodeType;
|
|
99
|
+
/** The node being added; its `position` is ignored and replaced. */
|
|
100
|
+
node: GraphNode;
|
|
101
|
+
target: AddNodeTarget;
|
|
102
|
+
/** Flow coordinates of the visible canvas centre. */
|
|
103
|
+
viewportCenter: Position;
|
|
104
|
+
/** Id for the auto-connect edge, used only when one is created. */
|
|
105
|
+
edgeId: string;
|
|
106
|
+
/** Measured size of an existing node, when React Flow has one. */
|
|
107
|
+
getNodeSize?: (id: string) => NodeSize | undefined;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Where an added node goes and which edge, if any, wires it in. Pure — the
|
|
112
|
+
* `addNode` action feeds it the live graph, viewport and measured sizes.
|
|
113
|
+
*
|
|
114
|
+
* With `{ after }` on a node that has a free exit, the new node goes to that
|
|
115
|
+
* node's right and an edge runs from the exit into the new node's first entry.
|
|
116
|
+
* The edge is dropped (the node still goes to the right) when the new kind has
|
|
117
|
+
* no entry (Note) or the connection fails `isValidConnection` — which runs both
|
|
118
|
+
* node types' `canConnect` hooks, the same rules a manual drag obeys. Every
|
|
119
|
+
* other case lands unconnected at the viewport centre. Either way the node
|
|
120
|
+
* moves down past any node already in that spot.
|
|
121
|
+
*/
|
|
122
|
+
export function planNodePlacement({
|
|
123
|
+
graph,
|
|
124
|
+
getNodeType,
|
|
125
|
+
node,
|
|
126
|
+
target,
|
|
127
|
+
viewportCenter,
|
|
128
|
+
edgeId,
|
|
129
|
+
getNodeSize = () => undefined,
|
|
130
|
+
}: PlanNodePlacementArgs): { position: Position; edge: GraphEdge | null } {
|
|
131
|
+
const source =
|
|
132
|
+
'after' in target ? findFreeExit(graph, getNodeType, target.after) : null;
|
|
133
|
+
|
|
134
|
+
if (!source) {
|
|
135
|
+
const centered = {
|
|
136
|
+
x: viewportCenter.x - FALLBACK_NODE_SIZE.width / 2,
|
|
137
|
+
y: viewportCenter.y - FALLBACK_NODE_SIZE.height / 2,
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
position: nudgeToFreeSpot(centered, graph.nodes, getNodeSize),
|
|
141
|
+
edge: null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const sourceSize = getNodeSize(source.node.id) ?? FALLBACK_NODE_SIZE;
|
|
146
|
+
const position = nudgeToFreeSpot(
|
|
147
|
+
{
|
|
148
|
+
x: source.node.position.x + sourceSize.width + DEFAULT_RANK_GAP,
|
|
149
|
+
y: source.node.position.y,
|
|
150
|
+
},
|
|
151
|
+
graph.nodes,
|
|
152
|
+
getNodeSize
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const placed: GraphNode = { ...node, position };
|
|
156
|
+
const targetHandle = getNodeType(node.kind)?.handles(node.config).inputs[0]
|
|
157
|
+
?.id;
|
|
158
|
+
if (!targetHandle) return { position, edge: null };
|
|
159
|
+
|
|
160
|
+
const connection = {
|
|
161
|
+
source: source.node.id,
|
|
162
|
+
target: node.id,
|
|
163
|
+
sourceHandle: source.sourceHandle,
|
|
164
|
+
targetHandle,
|
|
165
|
+
};
|
|
166
|
+
const withNode: Graph = { ...graph, nodes: [...graph.nodes, placed] };
|
|
167
|
+
if (!isValidConnection(connection, withNode, getNodeType)) {
|
|
168
|
+
return { position, edge: null };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { position, edge: { id: edgeId, ...connection } };
|
|
172
|
+
}
|
|
@@ -2,11 +2,16 @@ import { Box, HStack, VStack } from '@chakra-ui/react';
|
|
|
2
2
|
import type { Meta, StoryFn } from '@storybook/react';
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
|
|
5
|
+
import { Button } from '@/components/Button';
|
|
6
|
+
import { PlusIcon } from '@/components/Icon';
|
|
7
|
+
import { IconButton } from '@/components/IconButton';
|
|
5
8
|
import { Subtext, Subtitle } from '@/components/Typography';
|
|
6
9
|
|
|
7
10
|
import { NodeInspector } from '../canvas/DrawerShell';
|
|
11
|
+
import { useWorkflowActions } from '../useWorkflowActions';
|
|
8
12
|
import { Workflow } from '../Workflow';
|
|
9
13
|
import type { Graph, WorkflowSelection } from '../Workflow.types';
|
|
14
|
+
import { useWorkflow } from '../WorkflowContext';
|
|
10
15
|
import { toyGraph, toyNodeTypes } from './toyNodeTypes';
|
|
11
16
|
|
|
12
17
|
/**
|
|
@@ -109,3 +114,77 @@ export const LeftDockedInspector: StoryFn<typeof Workflow> = () => (
|
|
|
109
114
|
<NodeInspector dock="left" />
|
|
110
115
|
</Workflow>
|
|
111
116
|
);
|
|
117
|
+
|
|
118
|
+
/** "+" beside the selected node: adds an Add node wired after it. */
|
|
119
|
+
function AddAfterButton({ nodeId }: { nodeId: string }) {
|
|
120
|
+
const { addNode, getAddAfterSource } = useWorkflowActions();
|
|
121
|
+
const source = getAddAfterSource(nodeId);
|
|
122
|
+
if (!source) return null;
|
|
123
|
+
return (
|
|
124
|
+
<IconButton
|
|
125
|
+
aria-label={`Add after ${source.label}`}
|
|
126
|
+
size="sm"
|
|
127
|
+
variant="solid"
|
|
128
|
+
colorPalette="primary"
|
|
129
|
+
borderRadius="full"
|
|
130
|
+
onClick={() => addNode('math.add', { after: nodeId })}
|
|
131
|
+
>
|
|
132
|
+
<PlusIcon boxSize="xs" aria-hidden />
|
|
133
|
+
</IconButton>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Click-to-add list — what a touch palette does instead of drag-and-drop. */
|
|
138
|
+
function TapPalette() {
|
|
139
|
+
const { nodeTypes, editor } = useWorkflow();
|
|
140
|
+
const { addNode, getAddAfterSource } = useWorkflowActions();
|
|
141
|
+
const source = getAddAfterSource(editor.selectedNodeId);
|
|
142
|
+
return (
|
|
143
|
+
<VStack
|
|
144
|
+
w="240px"
|
|
145
|
+
p={4}
|
|
146
|
+
align="stretch"
|
|
147
|
+
borderLeftWidth="1px"
|
|
148
|
+
borderColor="border.default"
|
|
149
|
+
bg="bg.surface"
|
|
150
|
+
>
|
|
151
|
+
<Subtitle>Tap to add</Subtitle>
|
|
152
|
+
<Subtext color="slate.500" data-testid="add-target">
|
|
153
|
+
{source ? `Connect after ${source.label}` : 'Add at the centre'}
|
|
154
|
+
</Subtext>
|
|
155
|
+
{Object.values(nodeTypes).map((def) => (
|
|
156
|
+
<Button
|
|
157
|
+
key={def.kind}
|
|
158
|
+
size="sm"
|
|
159
|
+
variant="outline"
|
|
160
|
+
onClick={() =>
|
|
161
|
+
addNode(
|
|
162
|
+
def.kind,
|
|
163
|
+
source ? { after: source.nodeId } : { at: 'viewportCenter' }
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
>
|
|
167
|
+
{def.label}
|
|
168
|
+
</Button>
|
|
169
|
+
))}
|
|
170
|
+
</VStack>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* `useWorkflowActions().addNode` + `renderSelectedNodeToolbar`: add nodes
|
|
176
|
+
* without drag-and-drop. Select a node and press its "+" (or a button in the
|
|
177
|
+
* side list) to add a node wired from its first free exit; with nothing
|
|
178
|
+
* selected — or a node with no free exit — the node lands at the view centre.
|
|
179
|
+
* One undo (⌘Z) reverts node and edge together.
|
|
180
|
+
*/
|
|
181
|
+
export const AddNodeWithoutDrag: StoryFn<typeof Workflow> = () => (
|
|
182
|
+
<Workflow
|
|
183
|
+
nodeTypes={toyNodeTypes}
|
|
184
|
+
defaultGraph={toyGraph as Graph}
|
|
185
|
+
showPalette={false}
|
|
186
|
+
renderSelectedNodeToolbar={(node) => <AddAfterButton nodeId={node.id} />}
|
|
187
|
+
>
|
|
188
|
+
<TapPalette />
|
|
189
|
+
</Workflow>
|
|
190
|
+
);
|