{
if (a.type === b.type) {
return 0;
}
- return a.type === NodeTypes.GROUP && b.type !== NodeTypes.GROUP ? -1 : 1;
+ return a.type === GROUP && b.type !== GROUP ? -1 : 1;
};
export const getId = (prefix = 'node') => `${prefix}_${Math.random() * 10000}`;
diff --git a/packages/graph-editor/src/components/panels/dropPanel/data.tsx b/packages/graph-editor/src/components/panels/dropPanel/data.tsx
index a88ea082b..3b5b6c774 100644
--- a/packages/graph-editor/src/components/panels/dropPanel/data.tsx
+++ b/packages/graph-editor/src/components/panels/dropPanel/data.tsx
@@ -1,3 +1,4 @@
+import { GROUP } from '@/ids.js';
import { nodes } from '@tokens-studio/graph-engine';
import { observable } from 'mobx';
@@ -99,36 +100,39 @@ function CapitalCase(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
+const nodesToIgnoreInPanel = [GROUP];
+
export const defaultPanelGroupsFactory = (): DropPanelStore => {
const auto = Object.values
(
- nodes.reduce(
- (acc, node) => {
- const defaultGroup = node.type.split('.');
- const groups = node.groups || [defaultGroup[defaultGroup.length - 2]];
+ nodes.filter(node => !nodesToIgnoreInPanel.includes(node.type))
+ .reduce(
+ (acc, node) => {
+ const defaultGroup = node.type.split('.');
+ const groups = node.groups || [defaultGroup[defaultGroup.length - 2]];
- groups.forEach((group) => {
- //If the group does not exist, create it
- if (!acc[group]) {
- acc[group] = new PanelGroup({
- title: CapitalCase(group),
- key: group,
- items: [],
- });
- }
- acc[group].items.push(
- new PanelItem({
- type: node.type,
- text: CapitalCase(
- node.title || defaultGroup[defaultGroup.length - 1],
- ),
- description: node.description,
- }),
- );
- });
- return acc;
- },
- {} as Record,
- ),
+ groups.forEach((group) => {
+ //If the group does not exist, create it
+ if (!acc[group]) {
+ acc[group] = new PanelGroup({
+ title: CapitalCase(group),
+ key: group,
+ items: [],
+ });
+ }
+ acc[group].items.push(
+ new PanelItem({
+ type: node.type,
+ text: CapitalCase(
+ node.title || defaultGroup[defaultGroup.length - 1],
+ ),
+ description: node.description,
+ }),
+ );
+ });
+ return acc;
+ },
+ {} as Record,
+ ),
);
return new DropPanelStore(auto);
diff --git a/packages/graph-editor/src/constants.ts b/packages/graph-editor/src/constants.ts
index 9d2212db0..077530fd5 100644
--- a/packages/graph-editor/src/constants.ts
+++ b/packages/graph-editor/src/constants.ts
@@ -1 +1,2 @@
export const MAIN_GRAPH_ID = 'graph1';
+export const GROUP_NODE_PADDING = 25;
diff --git a/packages/graph-editor/src/css/cmdk.css b/packages/graph-editor/src/css/cmdk.css
index 7035cfb34..8cc117fc9 100644
--- a/packages/graph-editor/src/css/cmdk.css
+++ b/packages/graph-editor/src/css/cmdk.css
@@ -47,11 +47,12 @@
[cmdk-item] {
font-size: var(--fontSizes-small);
padding: var(--space-3) var(--space-5);
- border-radius: 0px
+ border-radius: 0px;
+ cursor: pointer;
}
[cmdk-group-heading] {
- padding: var(--space-1) var(--space-5);
+ padding: var(--space-1);
font-weight: var(--fontWeights-sansMedium);
color: var(--colors-fgDefault);
font-size: var(--fontSizes-xxsmall);
diff --git a/packages/graph-editor/src/css/reactflow.css b/packages/graph-editor/src/css/reactflow.css
index cd265862b..813e9f1a3 100644
--- a/packages/graph-editor/src/css/reactflow.css
+++ b/packages/graph-editor/src/css/reactflow.css
@@ -49,7 +49,7 @@
border-radius: var(--radii-medium, 8px);
}
-.react-flow__node-studio_tokens_group {
+.react-flow__node-studio.tokens.generic.group {
background: transparent;
}
diff --git a/packages/graph-editor/src/editor/actions/createNode.tsx b/packages/graph-editor/src/editor/actions/createNode.tsx
index e3d762220..6042c4988 100644
--- a/packages/graph-editor/src/editor/actions/createNode.tsx
+++ b/packages/graph-editor/src/editor/actions/createNode.tsx
@@ -1,6 +1,7 @@
import { Dispatch } from '@/redux/store.js';
import { Graph, Node, NodeFactory } from '@tokens-studio/graph-engine';
import { ReactFlowInstance, Node as ReactFlowNode } from 'reactflow';
+import { uiNodeType, xpos, ypos } from '@/annotations/index.js';
export type NodeRequest = {
type: string;
@@ -69,11 +70,11 @@ export const createNode = ({
const finalPos = position || { x: 0, y: 0 };
- node.annotations['xpos'] = finalPos.x;
- node.annotations['ypos'] = finalPos.y;
+ node.annotations[xpos] = finalPos.x;
+ node.annotations[ypos] = finalPos.y;
if (customUI[nodeRequest.type]) {
- node.annotations['uiNodeType'] = customUI[nodeRequest.type];
+ node.annotations[uiNodeType] = customUI[nodeRequest.type];
}
//Set values from the request
diff --git a/packages/graph-editor/src/editor/actions/duplicate.ts b/packages/graph-editor/src/editor/actions/duplicate.ts
index 6e19a0163..7f223d5ea 100644
--- a/packages/graph-editor/src/editor/actions/duplicate.ts
+++ b/packages/graph-editor/src/editor/actions/duplicate.ts
@@ -1,10 +1,12 @@
import { Edge, Node, ReactFlowInstance } from 'reactflow';
+import { GROUP } from '@/ids.js';
import {
Graph,
NodeFactory,
Port,
annotatedSingleton,
} from '@tokens-studio/graph-engine';
+import { parentId } from '@/annotations/index.js';
import { v4 as uuidv4 } from 'uuid';
export interface IDuplicate {
@@ -20,6 +22,20 @@ export interface IDuplicate {
export const duplicateNodes =
({ graph, reactFlowInstance }: IDuplicate) =>
(nodeIds: string[]) => {
+ nodeIds = nodeIds.reduce((acc, nodeId) => {
+ const node = reactFlowInstance.getNode(nodeId);
+ if (node?.type == GROUP) {
+ const children = reactFlowInstance.getNodes()
+ .filter((x) => x.parentId === nodeId)
+ .map((x) => x.id);
+ return [...acc, nodeId, ...children];
+ }
+
+ return [...acc, nodeId];
+ }, [] as string[]);
+
+ const oldToNewIdMap = new Map();
+
const { addNodes, addEdges } = nodeIds.reduce(
(acc, nodeId) => {
const node = reactFlowInstance.getNode(nodeId);
@@ -36,6 +52,7 @@ export const duplicateNodes =
}
const clonedNode = graphNode.clone(graph);
+ oldToNewIdMap.set(nodeId, clonedNode.id);
const newPosition = {
x: node.position.x + 20,
@@ -45,6 +62,11 @@ export const duplicateNodes =
clonedNode.annotations['ui.position.x'] = newPosition.x;
clonedNode.annotations['ui.position.y'] = newPosition.y;
+ // Set new parentId annotation if it exists
+ if (node.parentId && oldToNewIdMap.get(node.parentId)) {
+ clonedNode.annotations[parentId] = oldToNewIdMap.get(node.parentId);
+ }
+
graph.addNode(clonedNode);
const newEdges = Object.entries(graphNode.inputs)
@@ -76,6 +98,7 @@ export const duplicateNodes =
id: clonedNode.id,
selected: true,
position: newPosition,
+ parentId: node.parentId ? oldToNewIdMap.get(node.parentId) : undefined,
},
];
diff --git a/packages/graph-editor/src/editor/graph.tsx b/packages/graph-editor/src/editor/graph.tsx
index b75c1a2b8..d2eae421e 100644
--- a/packages/graph-editor/src/editor/graph.tsx
+++ b/packages/graph-editor/src/editor/graph.tsx
@@ -11,12 +11,12 @@ import {
SelectionMode,
SnapGrid,
XYPosition,
+ getNodesBounds,
useEdgesState,
useNodesState,
useReactFlow,
useStoreApi,
} from 'reactflow';
-import { NodeTypes as EditorNodeTypes } from '../components/flow/types.js';
import { createNode } from './actions/createNode.js';
import {
getNodePositionInsideParent,
@@ -45,11 +45,12 @@ import { BatchRunError, Graph } from '@tokens-studio/graph-engine';
import { Box } from '@tokens-studio/ui';
import { CommandMenu } from '@/components/commandPalette/index.js';
import { EdgeContextMenu } from '../components/contextMenus/edgeContextMenu.js';
+import { GROUP, NOTE, PASSTHROUGH } from '@/ids.js';
+import { GROUP_NODE_PADDING } from '@/constants.js';
import { GraphContextProvider } from '@/context/graph.js';
import { GraphEditorProps, ImperativeEditorRef } from './editorTypes.js';
import { GraphToolbar } from '@/components/toolbar/index.js';
import { HotKeys } from '@/components/hotKeys/index.js';
-import { NOTE, PASSTHROUGH } from '@/ids.js';
import { NodeContextMenu } from '../components/contextMenus/nodeContextMenu.js';
import { NodeV2 } from '@/components/index.js';
import { PaneContextMenu } from '../components/contextMenus/paneContextMenu.js';
@@ -74,10 +75,13 @@ import { currentPanelIdSelector } from '@/redux/selectors/graph.js';
import { deleteNode } from './actions/deleteNode.js';
import {
description,
+ height,
+ parentId as parentIdAnnotation,
title,
uiNodeType,
uiVersion,
uiViewport,
+ width,
xpos,
ypos,
} from '@/annotations/index.js';
@@ -194,6 +198,8 @@ export const EditorApp = React.forwardRef<
node.annotations || (node.annotations = {});
node.annotations[xpos] = flowNode.position.x;
node.annotations[ypos] = flowNode.position.y;
+ node.annotations[width] = flowNode.width;
+ node.annotations[height] = flowNode.height;
});
return serialized;
@@ -416,7 +422,7 @@ export const EditorApp = React.forwardRef<
...customNodeUI,
GenericNode: NodeV2,
[PASSTHROUGH]: PassthroughNode,
- [EditorNodeTypes.GROUP]: groupNode,
+ [GROUP]: groupNode,
[NOTE]: noteNode,
});
@@ -426,6 +432,7 @@ export const EditorApp = React.forwardRef<
Object.entries({
...customNodeUI,
[NOTE]: NOTE,
+ [GROUP]: GROUP,
'studio.tokens.generic.preview': 'studio.tokens.generic.preview',
}).map(([k]) => [k, k]),
);
@@ -485,21 +492,61 @@ export const EditorApp = React.forwardRef<
reactFlowInstance.setViewport(viewport);
}
let offset = -550;
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
+
+ const groupsChildren: Record = {};
const nodes = Object.entries(loadedGraph.nodes).map(([_, node]) => {
//Generate the react flow nodes
- return {
+ let reactFlowNode: Node = {
id: node.id,
- type: node.annotations[uiNodeType] || 'GenericNode',
+ type: (node.annotations[uiNodeType] || 'GenericNode') as string,
data: {
icon: iconLookup[node.factory.type],
},
position: {
- x: node.annotations[xpos] || (offset += 550),
- y: node.annotations[ypos] || 0,
+ x: (node.annotations[xpos] || (offset += 550)) as number,
+ y: (node.annotations[ypos] || 0) as number,
},
- } as Node;
- });
+ width: (node.annotations[width] as number) || null,
+ height: (node.annotations[height] as number) || null,
+ };
+
+ if (node.annotations[parentIdAnnotation]) {
+ const parentId = node.annotations[parentIdAnnotation] as string;
+ reactFlowNode = {
+ ...reactFlowNode,
+ parentId,
+ extent: 'parent',
+ };
+ (groupsChildren[parentId] || (groupsChildren[parentId] = [])).push(reactFlowNode);
+ } else if (node.annotations[uiNodeType] === GROUP) {
+ reactFlowNode = {
+ ...reactFlowNode,
+ data: {
+ expandable: true,
+ expanded: true,
+ },
+ };
+ }
+
+ return reactFlowNode as Node;
+ })
+ .map((node) => {
+ if (node.type === GROUP && groupsChildren[node.id]) {
+ const bounds = getNodesBounds(groupsChildren[node.id]);
+
+ return {
+ ...node,
+ style: {
+ width: bounds.width + GROUP_NODE_PADDING * 2,
+ height: bounds.height + GROUP_NODE_PADDING * 2,
+ },
+ };
+ }
+
+ return node;
+ })
+ .sort(sortNodes);
+
const edges = Object.values(loadedGraph.edges).map((edge) => {
//This is the only point of difference for the edges
const indexed = edge.annotations['engine.index'];
@@ -576,17 +623,17 @@ export const EditorApp = React.forwardRef<
const onNodeDragStop = useCallback(
(_: MouseEvent, node: Node) => {
- if (!node.parentNode) {
+ if (!node.parentId) {
return;
}
const intersections = getIntersectingNodes(node).filter(
- (n) => n.type === EditorNodeTypes.GROUP,
+ (n) => n.type === GROUP,
);
const groupNode = intersections[0];
// when there is an intersection on drag stop, we want to attach the node to its new parent
- if (intersections.length && node.parentNode !== groupNode?.id) {
+ if (intersections.length && node.parentId !== groupNode?.id) {
const nextNodes: Node[] = store
.getState()
.getNodes()
@@ -605,7 +652,7 @@ export const EditorApp = React.forwardRef<
return {
...n,
position,
- parentNode: groupNode.id,
+ parentId: groupNode.id,
extent: 'parent' as const,
};
}
@@ -705,21 +752,21 @@ export const EditorApp = React.forwardRef<
const onNodeDrag = useCallback(
(_: MouseEvent, node: Node) => {
- if (!node.parentNode) {
+ if (!node.parentId) {
return;
}
const intersections = getIntersectingNodes(node).filter(
- (n) => n.type === EditorNodeTypes.GROUP,
+ (n) => n.type === GROUP,
);
const groupClassName =
- intersections.length && node.parentNode !== intersections[0]?.id
+ intersections.length && node.parentId !== intersections[0]?.id
? 'active'
: '';
setNodes((nds) => {
return nds.map((n) => {
- if (n.type === EditorNodeTypes.GROUP) {
+ if (n.type === GROUP) {
return {
...n,
className: groupClassName,
@@ -860,8 +907,8 @@ export const EditorApp = React.forwardRef<
css={{
position: 'absolute',
top: '$7',
- left: 0,
- right: 0,
+ left: '50%',
+ transform: 'translateX(-50%)',
display: 'grid',
placeItems: 'center',
zIndex: '99',
diff --git a/packages/graph-editor/src/hooks/useDetachNodes.ts b/packages/graph-editor/src/hooks/useDetachNodes.ts
index ca7cf48f9..3b1e825bc 100644
--- a/packages/graph-editor/src/hooks/useDetachNodes.ts
+++ b/packages/graph-editor/src/hooks/useDetachNodes.ts
@@ -9,18 +9,18 @@ function useDetachNodes() {
(ids: string[], removeParentId?: string) => {
const { nodeInternals } = store.getState();
const nextNodes = Array.from(nodeInternals.values()).map((n) => {
- if (ids.includes(n.id) && n.parentNode) {
- const parentNode = nodeInternals.get(n.parentNode);
+ if (ids.includes(n.id) && n.parentId) {
+ const parentId = nodeInternals.get(n.parentId);
//Remove parent reference and recalculate in global space
return {
...n,
position: {
- x: n.position.x + (parentNode?.positionAbsolute?.x ?? 0),
- y: n.position.y + (parentNode?.positionAbsolute?.y ?? 0),
+ x: n.position.x + (parentId?.positionAbsolute?.x ?? 0),
+ y: n.position.y + (parentId?.positionAbsolute?.y ?? 0),
},
extent: undefined,
- parentNode: undefined,
+ parentId: undefined,
};
}
return n;
diff --git a/packages/graph-editor/src/ids.ts b/packages/graph-editor/src/ids.ts
index 3ace98332..15993bbc9 100644
--- a/packages/graph-editor/src/ids.ts
+++ b/packages/graph-editor/src/ids.ts
@@ -2,3 +2,4 @@ export const INPUT = 'studio.tokens.generic.input';
export const OUTPUT = 'studio.tokens.generic.output';
export const PASSTHROUGH = 'studio.tokens.generic.passthrough';
export const NOTE = 'studio.tokens.generic.note';
+export const GROUP = 'studio.tokens.generic.group';
diff --git a/packages/graph-engine/src/graph/graph.ts b/packages/graph-engine/src/graph/graph.ts
index 184e32c47..34abccd5e 100644
--- a/packages/graph-engine/src/graph/graph.ts
+++ b/packages/graph-engine/src/graph/graph.ts
@@ -560,6 +560,13 @@ export class Graph {
}
});
+ // Update parent ids for children on groups
+ Object.values(clonedGraph.nodes).forEach(node => {
+ if (node.annotations['ui.parentId']) {
+ node.annotations['ui.parentId'] = oldToNewIdMap.get(node.annotations['ui.parentId'] as string);
+ }
+ });
+
// Clone capabilities
Object.entries(this.capabilities).forEach(([key, value]) => {
clonedGraph.capabilities[key] = value;
diff --git a/packages/graph-engine/src/nodes/generic/group.ts b/packages/graph-engine/src/nodes/generic/group.ts
new file mode 100644
index 000000000..c2c03bb8d
--- /dev/null
+++ b/packages/graph-engine/src/nodes/generic/group.ts
@@ -0,0 +1,8 @@
+import { Node } from '../../programmatic/node.js';
+
+export default class NodeDefinition extends Node {
+ static title = 'Group';
+ static type = 'studio.tokens.generic.group';
+
+ static description = 'A group of nodes';
+}
diff --git a/packages/graph-engine/src/nodes/generic/index.ts b/packages/graph-engine/src/nodes/generic/index.ts
index 978149dbc..e61862026 100644
--- a/packages/graph-engine/src/nodes/generic/index.ts
+++ b/packages/graph-engine/src/nodes/generic/index.ts
@@ -1,5 +1,6 @@
import constant from './constant.js';
import delay from './delay.js';
+import group from './group.js'
import input from './input.js';
import note from './note.js';
import objectMerge from './objectMerge.js';
@@ -23,5 +24,6 @@ export const nodes = [
objectPath,
objectMerge,
time,
- delay
+ delay,
+ group
];