From 13cd2290916b61a2bc1197a3acc50e41182ea384 Mon Sep 17 00:00:00 2001 From: George Buciuman Date: Tue, 16 Jul 2024 18:35:56 +0300 Subject: [PATCH 1/4] Save groups in the graph --- .../graph-editor/src/annotations/index.ts | 2 + .../contextMenus/nodeContextMenu.tsx | 5 +- .../contextMenus/selectionContextMenu.tsx | 84 ++++++++++-------- .../src/components/flow/nodes/groupNode.tsx | 20 +++-- .../graph-editor/src/components/flow/utils.ts | 4 +- .../src/components/panels/dropPanel/data.tsx | 58 +++++++------ packages/graph-editor/src/constants.ts | 1 + packages/graph-editor/src/css/reactflow.css | 2 +- .../src/editor/actions/createNode.tsx | 3 +- packages/graph-editor/src/editor/graph.tsx | 86 +++++++++++++++---- .../graph-editor/src/hooks/useDetachNodes.ts | 10 +-- packages/graph-editor/src/ids.ts | 1 + .../graph-engine/src/nodes/generic/group.ts | 8 ++ .../graph-engine/src/nodes/generic/index.ts | 4 +- 14 files changed, 189 insertions(+), 99 deletions(-) create mode 100644 packages/graph-engine/src/nodes/generic/group.ts diff --git a/packages/graph-editor/src/annotations/index.ts b/packages/graph-editor/src/annotations/index.ts index fbda7ac55..808c81d49 100644 --- a/packages/graph-editor/src/annotations/index.ts +++ b/packages/graph-editor/src/annotations/index.ts @@ -19,6 +19,8 @@ export const hidden = 'ui.hidden'; //Used on nodes to indicate meta from a ui export const xpos = 'ui.position.x'; export const ypos = 'ui.position.y'; +export const width = 'ui.width'; +export const height = 'ui.height'; //Used on nodes and graph export const title = 'ui.title'; diff --git a/packages/graph-editor/src/components/contextMenus/nodeContextMenu.tsx b/packages/graph-editor/src/components/contextMenus/nodeContextMenu.tsx index 0374d44d5..1510b14c0 100644 --- a/packages/graph-editor/src/components/contextMenus/nodeContextMenu.tsx +++ b/packages/graph-editor/src/components/contextMenus/nodeContextMenu.tsx @@ -1,3 +1,4 @@ +import { GROUP } from '@/ids.js'; import { Graph } from 'graphlib'; import { Item, Menu, Separator } from 'react-contexify'; import { Node, ReactFlowInstance, useReactFlow } from 'reactflow'; @@ -159,9 +160,11 @@ export const NodeContextMenu = ({ id, nodes }: INodeContextMenuProps) => { } }, [graph, nodes]); + const canDuplicate = nodes[0]?.type !== GROUP; + return ( - Duplicate + {canDuplicate && Duplicate} Focus Delete diff --git a/packages/graph-editor/src/components/contextMenus/selectionContextMenu.tsx b/packages/graph-editor/src/components/contextMenus/selectionContextMenu.tsx index afc9cdcd0..7a128f829 100644 --- a/packages/graph-editor/src/components/contextMenus/selectionContextMenu.tsx +++ b/packages/graph-editor/src/components/contextMenus/selectionContextMenu.tsx @@ -1,8 +1,8 @@ import { Edge, Graph } from '@tokens-studio/graph-engine'; +import { GROUP } from '@/ids.js'; +import { GROUP_NODE_PADDING } from '@/constants.js'; import { Item, Menu, Separator } from 'react-contexify'; -import { Node, getRectOfNodes, useReactFlow, useStoreApi } from 'reactflow'; -import { NodeTypes } from '../flow/types.js'; -import { getId } from '../flow/utils.js'; +import { Node, getNodesBounds, useReactFlow, useStoreApi } from 'reactflow'; import { useAction } from '@/editor/actions/provider.js'; import { useLocalGraph } from '@/hooks/index.js'; import { v4 as uuid } from 'uuid'; @@ -13,8 +13,6 @@ export type INodeContextMenuProps = { nodes: Node[]; }; -const padding = 25; - export const SelectionContextMenu = ({ id, nodes }: INodeContextMenuProps) => { const reactFlowInstance = useReactFlow(); const graph = useLocalGraph(); @@ -24,54 +22,64 @@ export const SelectionContextMenu = ({ id, nodes }: INodeContextMenuProps) => { //Note that we use a filter here to prevent getting nodes that have a parent node, ie are part of a group const selectedNodes = nodes.filter( - (node) => node.selected && !node.parentNode, + (node) => node.selected && !node.parentId, ); const selectedNodeIds = selectedNodes.map((node) => node.id); const onGroup = useCallback(() => { - const rectOfNodes = getRectOfNodes(nodes); - const groupId = getId('group'); + const bounds = getNodesBounds(nodes); const parentPosition = { - x: rectOfNodes.x, - y: rectOfNodes.y, + x: bounds.x, + y: bounds.y, }; - const groupNode = { - id: groupId, - type: NodeTypes.GROUP, - position: parentPosition, - style: { - width: rectOfNodes.width + padding * 2, - height: rectOfNodes.height + padding * 2, - }, - data: { - expandable: true, - expanded: true, - }, - } as Node; store.getState().resetSelectedElements(); store.setState({ nodesSelectionActive: false }); + + const newNodes = createNode({ + type: GROUP, + position: parentPosition, + }); + + if (!newNodes) { + return; + } + + const { flowNode } = newNodes; + reactFlowInstance.setNodes((nodes) => { - //Note that group nodes should always occur before their parents - return [groupNode].concat( - nodes.map((node) => { + // Note that group nodes should always occur before their children + return [{ + ...flowNode, + dragHandle: undefined, + style: { + width: bounds.width + GROUP_NODE_PADDING * 2, + height: bounds.height + GROUP_NODE_PADDING * 2, + }, + data: { + expandable: true, + expanded: true, + } + } as Node] + .concat(nodes) + .map((node) => { if (selectedNodeIds.includes(node.id)) { return { ...node, position: { - x: node.position.x - parentPosition.x + padding, - y: node.position.y - parentPosition.y + padding, + x: node.position.x - parentPosition.x + GROUP_NODE_PADDING, + y: node.position.y - parentPosition.y + GROUP_NODE_PADDING, }, extent: 'parent' as const, - parentNode: groupId, + parentId: flowNode.id, }; } return node; - }), - ); + }); }); - }, [nodes, reactFlowInstance, selectedNodeIds, store]); + + }, [createNode, nodes, reactFlowInstance, selectedNodeIds, store]); const onCreateSubgraph = useCallback(() => { //We need to work out which nodes do not have parents in the selection @@ -294,12 +302,18 @@ export const SelectionContextMenu = ({ id, nodes }: INodeContextMenuProps) => { duplicateNodes(selectedNodeIds); }; + const hasGroup = selectedNodes.some((node) => node.type === GROUP); + return ( - Create group + {!hasGroup && Create group} Create Subgraph - - Duplicate + {!hasGroup && ( + <> + + Duplicate + + )} ); }; diff --git a/packages/graph-editor/src/components/flow/nodes/groupNode.tsx b/packages/graph-editor/src/components/flow/nodes/groupNode.tsx index a11d6a849..64c14f5e0 100644 --- a/packages/graph-editor/src/components/flow/nodes/groupNode.tsx +++ b/packages/graph-editor/src/components/flow/nodes/groupNode.tsx @@ -1,34 +1,36 @@ import { Button, Stack } from '@tokens-studio/ui'; +import { GROUP_NODE_PADDING } from '@/constants.js'; import { NodeProps, NodeToolbar, - getRectOfNodes, + getNodesBounds, useReactFlow, useStore, useStoreApi, } from 'reactflow'; import { NodeResizer } from '@reactflow/node-resizer'; import { useCallback } from 'react'; +import { useLocalGraph } from '@/context/graph.js'; import React from 'react'; import useDetachNodes from '../../../hooks/useDetachNodes.js'; const lineStyle = { borderColor: 'white' }; -const padding = 25; function GroupNode(props: NodeProps) { const { id, data } = props; const store = useStoreApi(); const { deleteElements } = useReactFlow(); const detachNodes = useDetachNodes(); + const graph = useLocalGraph() const { minWidth, minHeight, hasChildNodes } = useStore((store) => { const childNodes = Array.from(store.nodeInternals.values()).filter( - (n) => n.parentNode === id, + (n) => n.parentId === id, ); - const rect = getRectOfNodes(childNodes); + const bounds = getNodesBounds(childNodes); return { - minWidth: rect.width + padding * 2, - minHeight: rect.height + padding * 2, + minWidth: bounds.width + GROUP_NODE_PADDING * 2, + minHeight: bounds.height + GROUP_NODE_PADDING * 2, hasChildNodes: childNodes.length > 0, }; }, isEqual); @@ -39,11 +41,13 @@ function GroupNode(props: NodeProps) { const onDetach = useCallback(() => { const childNodeIds = Array.from(store.getState().nodeInternals.values()) - .filter((n) => n.parentNode === id) + .filter((n) => n.parentId === id) .map((n) => n.id); detachNodes(childNodeIds, id); - }, [detachNodes, id, store]); + + graph.removeNode(id); + }, [detachNodes, graph, id, store]); return (
{ 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/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..e34996667 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 } from '@/annotations/index.js'; export type NodeRequest = { type: string; @@ -73,7 +74,7 @@ export const createNode = ({ 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/graph.tsx b/packages/graph-editor/src/editor/graph.tsx index b75c1a2b8..bb50fc49a 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,12 @@ import { currentPanelIdSelector } from '@/redux/selectors/graph.js'; import { deleteNode } from './actions/deleteNode.js'; import { description, + height, title, uiNodeType, uiVersion, uiViewport, + width, xpos, ypos, } from '@/annotations/index.js'; @@ -194,6 +197,9 @@ 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; + node.annotations['parentId'] = flowNode.parentId; }); 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,64 @@ 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['parentId']) { + const parentId = node.annotations['parentId'] as string; + reactFlowNode = { + ...reactFlowNode, + parentId, + extent: 'parent', + }; + (groupsChildren[parentId] || (groupsChildren[parentId] = [])).push(reactFlowNode); + + delete node?.annotations['parentId']; + } else if (node.annotations[uiNodeType] === GROUP) { + reactFlowNode = { + ...reactFlowNode, + data: { + expandable: true, + expanded: true, + }, + }; + } + + return reactFlowNode as Node; + }) + .map((node) => { + if (node.type === GROUP) { + console.log('this is group: ', node, groupsChildren); + 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 +626,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 +655,7 @@ export const EditorApp = React.forwardRef< return { ...n, position, - parentNode: groupNode.id, + parentId: groupNode.id, extent: 'parent' as const, }; } @@ -705,21 +755,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, 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/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 ]; From 89214442023166193c97d7c8a85c4ae3bf10fd02 Mon Sep 17 00:00:00 2001 From: George Buciuman Date: Wed, 17 Jul 2024 12:28:51 +0300 Subject: [PATCH 2/4] Remove console.log --- packages/graph-editor/src/editor/graph.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/graph-editor/src/editor/graph.tsx b/packages/graph-editor/src/editor/graph.tsx index bb50fc49a..1cd97ea01 100644 --- a/packages/graph-editor/src/editor/graph.tsx +++ b/packages/graph-editor/src/editor/graph.tsx @@ -534,7 +534,6 @@ export const EditorApp = React.forwardRef< }) .map((node) => { if (node.type === GROUP) { - console.log('this is group: ', node, groupsChildren); const bounds = getNodesBounds(groupsChildren[node.id]); return { From e50a116fef6be9ace6a608ab277db1b8b5b30e62 Mon Sep 17 00:00:00 2001 From: George Buciuman Date: Thu, 18 Jul 2024 12:38:14 +0300 Subject: [PATCH 3/4] Fix duplicate and create subgraph from group --- .../graph-editor/src/annotations/index.ts | 4 +- .../src/components/commandPalette/index.tsx | 16 ++--- .../contextMenus/nodeContextMenu.tsx | 5 +- .../contextMenus/selectionContextMenu.tsx | 58 ++++++++++++++----- .../src/components/flow/nodes/groupNode.tsx | 7 +++ packages/graph-editor/src/css/cmdk.css | 5 +- .../src/editor/actions/createNode.tsx | 6 +- .../src/editor/actions/duplicate.ts | 24 ++++++++ packages/graph-editor/src/editor/graph.tsx | 9 +-- packages/graph-engine/src/graph/graph.ts | 7 +++ 10 files changed, 103 insertions(+), 38 deletions(-) diff --git a/packages/graph-editor/src/annotations/index.ts b/packages/graph-editor/src/annotations/index.ts index 808c81d49..48ed65d95 100644 --- a/packages/graph-editor/src/annotations/index.ts +++ b/packages/graph-editor/src/annotations/index.ts @@ -19,8 +19,8 @@ export const hidden = 'ui.hidden'; //Used on nodes to indicate meta from a ui export const xpos = 'ui.position.x'; export const ypos = 'ui.position.y'; -export const width = 'ui.width'; -export const height = 'ui.height'; +export const width = 'ui.dimension.width'; +export const height = 'ui.dimension.height'; //Used on nodes and graph export const title = 'ui.title'; diff --git a/packages/graph-editor/src/components/commandPalette/index.tsx b/packages/graph-editor/src/components/commandPalette/index.tsx index 59d07e24e..ec8ff4af4 100644 --- a/packages/graph-editor/src/components/commandPalette/index.tsx +++ b/packages/graph-editor/src/components/commandPalette/index.tsx @@ -21,9 +21,9 @@ export interface ICommandMenu { items: DropPanelStore; handleSelectNewNodeType: (node: NodeRequest) => | { - graphNode: Node; - flowNode: ReactFlowNode; - } + graphNode: Node; + flowNode: ReactFlowNode; + } | undefined; } @@ -74,14 +74,16 @@ const CommandMenuGroup = observer( + {group.icon} - {group.title} + + {group.title} + } > {group.items.map((item) => ( - + ))} ); @@ -183,7 +185,7 @@ const CommandMenu = ({ items, handleSelectNewNodeType }: ICommandMenu) => { direction="row" css={{ overflowY: 'scroll', - maxHeight: '450px', + maxHeight: '500px', scrollbarColor: 'var(--colors-bgSubtle) transparent', scrollbarWidth: 'thin', }} diff --git a/packages/graph-editor/src/components/contextMenus/nodeContextMenu.tsx b/packages/graph-editor/src/components/contextMenus/nodeContextMenu.tsx index 1510b14c0..0374d44d5 100644 --- a/packages/graph-editor/src/components/contextMenus/nodeContextMenu.tsx +++ b/packages/graph-editor/src/components/contextMenus/nodeContextMenu.tsx @@ -1,4 +1,3 @@ -import { GROUP } from '@/ids.js'; import { Graph } from 'graphlib'; import { Item, Menu, Separator } from 'react-contexify'; import { Node, ReactFlowInstance, useReactFlow } from 'reactflow'; @@ -160,11 +159,9 @@ export const NodeContextMenu = ({ id, nodes }: INodeContextMenuProps) => { } }, [graph, nodes]); - const canDuplicate = nodes[0]?.type !== GROUP; - return ( - {canDuplicate && Duplicate} + Duplicate Focus Delete diff --git a/packages/graph-editor/src/components/contextMenus/selectionContextMenu.tsx b/packages/graph-editor/src/components/contextMenus/selectionContextMenu.tsx index 7a128f829..5e2b89605 100644 --- a/packages/graph-editor/src/components/contextMenus/selectionContextMenu.tsx +++ b/packages/graph-editor/src/components/contextMenus/selectionContextMenu.tsx @@ -1,12 +1,13 @@ -import { Edge, Graph } from '@tokens-studio/graph-engine'; +import { Edge, Graph, Node as GraphNode } from '@tokens-studio/graph-engine'; import { GROUP } from '@/ids.js'; import { GROUP_NODE_PADDING } from '@/constants.js'; import { Item, Menu, Separator } from 'react-contexify'; import { Node, getNodesBounds, useReactFlow, useStoreApi } from 'reactflow'; +import { height, width, xpos, ypos } from '@/annotations/index.js'; import { useAction } from '@/editor/actions/provider.js'; import { useLocalGraph } from '@/hooks/index.js'; import { v4 as uuid } from 'uuid'; -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; export type INodeContextMenuProps = { id: string; @@ -20,7 +21,9 @@ export const SelectionContextMenu = ({ id, nodes }: INodeContextMenuProps) => { const createNode = useAction('createNode'); const duplicateNodes = useAction('duplicateNodes'); - //Note that we use a filter here to prevent getting nodes that have a parent node, ie are part of a group + const reactFlowNodes = reactFlowInstance.getNodes(); + + // Note that we use a filter here to prevent getting nodes that have a parent node, ie are part of a group const selectedNodes = nodes.filter( (node) => node.selected && !node.parentId, ); @@ -79,11 +82,42 @@ export const SelectionContextMenu = ({ id, nodes }: INodeContextMenuProps) => { }); }); - }, [createNode, nodes, reactFlowInstance, selectedNodeIds, store]); + const reactFlowNodesMap = new Map( + reactFlowNodes.map((node) => [node.id, node]), + ); + + // Set annotations for all items in the group + nodes.forEach((node) => { + const graphNode = graph.getNode(node.id); + if (graphNode) { + graphNode.annotations[xpos] = node.position.x - parentPosition.x + GROUP_NODE_PADDING; + graphNode.annotations[ypos] = node.position.y - parentPosition.y + GROUP_NODE_PADDING; + graphNode.annotations[width] = reactFlowNodesMap.get(node.id)?.width || 200; + graphNode.annotations[height] = reactFlowNodesMap.get(node.id)?.height || 100; + graphNode.annotations['parentId'] = flowNode.id; + } + }); + + }, [createNode, graph, nodes, reactFlowInstance, reactFlowNodes, selectedNodeIds, store]); const onCreateSubgraph = useCallback(() => { - //We need to work out which nodes do not have parents in the selection + // Get all selected node ids, including children of groups + const selectedNodeIds = selectedNodes + .reduce((acc, node) => { + if (node.type !== GROUP) { + return [...acc, node.id]; + } + const children = reactFlowNodes + .filter((n) => n.parentId === node.id) + .map((x) => x.id); + + if (children.length > 0) { + return [...acc, node.id, ...children]; + } + + return acc; + }, [] as string[]); const lookup = new Set(selectedNodeIds); //Lets create a new subgraph node @@ -103,17 +137,17 @@ export const SelectionContextMenu = ({ id, nodes }: INodeContextMenuProps) => { y: position.y / selectedNodes.length, }; - const nodes = createNode({ + const newNodes = createNode({ type: 'studio.tokens.generic.subgraph', position: finalPosition, }); //Request failed in some way - if (!nodes) { + if (!newNodes) { return; } - const { graphNode, flowNode } = nodes; + const { graphNode, flowNode } = newNodes; //@ts-expect-error const internalGraph = graphNode._innerGraph as unknown as Graph; @@ -308,12 +342,8 @@ export const SelectionContextMenu = ({ id, nodes }: INodeContextMenuProps) => { {!hasGroup && Create group} Create Subgraph - {!hasGroup && ( - <> - - Duplicate - - )} + + Duplicate ); }; diff --git a/packages/graph-editor/src/components/flow/nodes/groupNode.tsx b/packages/graph-editor/src/components/flow/nodes/groupNode.tsx index 64c14f5e0..744fa65eb 100644 --- a/packages/graph-editor/src/components/flow/nodes/groupNode.tsx +++ b/packages/graph-editor/src/components/flow/nodes/groupNode.tsx @@ -47,6 +47,13 @@ function GroupNode(props: NodeProps) { detachNodes(childNodeIds, id); graph.removeNode(id); + + childNodeIds.forEach((nodeId) => { + const graphNode = graph.getNode(nodeId); + if (graphNode) { + delete graphNode.annotations['parentId']; + } + }); }, [detachNodes, graph, id, store]); return ( 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/editor/actions/createNode.tsx b/packages/graph-editor/src/editor/actions/createNode.tsx index e34996667..6042c4988 100644 --- a/packages/graph-editor/src/editor/actions/createNode.tsx +++ b/packages/graph-editor/src/editor/actions/createNode.tsx @@ -1,7 +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 } from '@/annotations/index.js'; +import { uiNodeType, xpos, ypos } from '@/annotations/index.js'; export type NodeRequest = { type: string; @@ -70,8 +70,8 @@ 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]; diff --git a/packages/graph-editor/src/editor/actions/duplicate.ts b/packages/graph-editor/src/editor/actions/duplicate.ts index 6e19a0163..1aada64f6 100644 --- a/packages/graph-editor/src/editor/actions/duplicate.ts +++ b/packages/graph-editor/src/editor/actions/duplicate.ts @@ -1,4 +1,5 @@ import { Edge, Node, ReactFlowInstance } from 'reactflow'; +import { GROUP } from '@/ids.js'; import { Graph, NodeFactory, @@ -20,6 +21,22 @@ 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[]); + + console.log('duplicate'); + + const oldToNewIdMap = new Map(); + const { addNodes, addEdges } = nodeIds.reduce( (acc, nodeId) => { const node = reactFlowInstance.getNode(nodeId); @@ -36,6 +53,7 @@ export const duplicateNodes = } const clonedNode = graphNode.clone(graph); + oldToNewIdMap.set(nodeId, clonedNode.id); const newPosition = { x: node.position.x + 20, @@ -45,6 +63,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 +99,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 1cd97ea01..40a6c5645 100644 --- a/packages/graph-editor/src/editor/graph.tsx +++ b/packages/graph-editor/src/editor/graph.tsx @@ -199,7 +199,6 @@ export const EditorApp = React.forwardRef< node.annotations[ypos] = flowNode.position.y; node.annotations[width] = flowNode.width; node.annotations[height] = flowNode.height; - node.annotations['parentId'] = flowNode.parentId; }); return serialized; @@ -518,8 +517,6 @@ export const EditorApp = React.forwardRef< extent: 'parent', }; (groupsChildren[parentId] || (groupsChildren[parentId] = [])).push(reactFlowNode); - - delete node?.annotations['parentId']; } else if (node.annotations[uiNodeType] === GROUP) { reactFlowNode = { ...reactFlowNode, @@ -533,7 +530,7 @@ export const EditorApp = React.forwardRef< return reactFlowNode as Node; }) .map((node) => { - if (node.type === GROUP) { + if (node.type === GROUP && groupsChildren[node.id]) { const bounds = getNodesBounds(groupsChildren[node.id]); return { @@ -909,8 +906,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-engine/src/graph/graph.ts b/packages/graph-engine/src/graph/graph.ts index 184e32c47..afd1cfff7 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['parentId']) { + node.annotations['parentId'] = oldToNewIdMap.get(node.annotations['parentId'] as string); + } + }); + // Clone capabilities Object.entries(this.capabilities).forEach(([key, value]) => { clonedGraph.capabilities[key] = value; From 2648a32f310ff90eaeeb1dd9facea3e0c6813d9a Mon Sep 17 00:00:00 2001 From: George Buciuman Date: Fri, 19 Jul 2024 16:33:51 +0300 Subject: [PATCH 4/4] Some fixes --- packages/graph-editor/src/annotations/index.ts | 1 + .../components/contextMenus/selectionContextMenu.tsx | 10 +++++----- .../src/components/flow/nodes/groupNode.tsx | 3 ++- packages/graph-editor/src/editor/actions/duplicate.ts | 5 ++--- packages/graph-editor/src/editor/graph.tsx | 5 +++-- packages/graph-engine/src/graph/graph.ts | 4 ++-- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/graph-editor/src/annotations/index.ts b/packages/graph-editor/src/annotations/index.ts index 48ed65d95..df031ce68 100644 --- a/packages/graph-editor/src/annotations/index.ts +++ b/packages/graph-editor/src/annotations/index.ts @@ -21,6 +21,7 @@ export const xpos = 'ui.position.x'; export const ypos = 'ui.position.y'; export const width = 'ui.dimension.width'; export const height = 'ui.dimension.height'; +export const parentId = 'ui.parentId'; //Used on nodes and graph export const title = 'ui.title'; diff --git a/packages/graph-editor/src/components/contextMenus/selectionContextMenu.tsx b/packages/graph-editor/src/components/contextMenus/selectionContextMenu.tsx index 5e2b89605..fc4816ff9 100644 --- a/packages/graph-editor/src/components/contextMenus/selectionContextMenu.tsx +++ b/packages/graph-editor/src/components/contextMenus/selectionContextMenu.tsx @@ -1,9 +1,9 @@ -import { Edge, Graph, Node as GraphNode } from '@tokens-studio/graph-engine'; +import { Edge, Graph } from '@tokens-studio/graph-engine'; import { GROUP } from '@/ids.js'; import { GROUP_NODE_PADDING } from '@/constants.js'; import { Item, Menu, Separator } from 'react-contexify'; import { Node, getNodesBounds, useReactFlow, useStoreApi } from 'reactflow'; -import { height, width, xpos, ypos } from '@/annotations/index.js'; +import { height, parentId, width, xpos, ypos } from '@/annotations/index.js'; import { useAction } from '@/editor/actions/provider.js'; import { useLocalGraph } from '@/hooks/index.js'; import { v4 as uuid } from 'uuid'; @@ -94,7 +94,7 @@ export const SelectionContextMenu = ({ id, nodes }: INodeContextMenuProps) => { graphNode.annotations[ypos] = node.position.y - parentPosition.y + GROUP_NODE_PADDING; graphNode.annotations[width] = reactFlowNodesMap.get(node.id)?.width || 200; graphNode.annotations[height] = reactFlowNodesMap.get(node.id)?.height || 100; - graphNode.annotations['parentId'] = flowNode.id; + graphNode.annotations[parentId] = flowNode.id; } }); @@ -330,13 +330,13 @@ export const SelectionContextMenu = ({ id, nodes }: INodeContextMenuProps) => { ); //We then need to find all the downstream nodes from those nodes for the output - }, [createNode, graph, reactFlowInstance, selectedNodeIds, selectedNodes]); + }, [createNode, graph, reactFlowInstance, reactFlowNodes, selectedNodes]); const onDuplicate = () => { duplicateNodes(selectedNodeIds); }; - const hasGroup = selectedNodes.some((node) => node.type === GROUP); + const hasGroup = useMemo(() => selectedNodes.some((node) => node.type === GROUP), [selectedNodes]); return ( diff --git a/packages/graph-editor/src/components/flow/nodes/groupNode.tsx b/packages/graph-editor/src/components/flow/nodes/groupNode.tsx index 744fa65eb..744b0d606 100644 --- a/packages/graph-editor/src/components/flow/nodes/groupNode.tsx +++ b/packages/graph-editor/src/components/flow/nodes/groupNode.tsx @@ -9,6 +9,7 @@ import { useStoreApi, } from 'reactflow'; import { NodeResizer } from '@reactflow/node-resizer'; +import { parentId } from '@/annotations/index.js'; import { useCallback } from 'react'; import { useLocalGraph } from '@/context/graph.js'; import React from 'react'; @@ -51,7 +52,7 @@ function GroupNode(props: NodeProps) { childNodeIds.forEach((nodeId) => { const graphNode = graph.getNode(nodeId); if (graphNode) { - delete graphNode.annotations['parentId']; + delete graphNode.annotations[parentId]; } }); }, [detachNodes, graph, id, store]); diff --git a/packages/graph-editor/src/editor/actions/duplicate.ts b/packages/graph-editor/src/editor/actions/duplicate.ts index 1aada64f6..7f223d5ea 100644 --- a/packages/graph-editor/src/editor/actions/duplicate.ts +++ b/packages/graph-editor/src/editor/actions/duplicate.ts @@ -6,6 +6,7 @@ import { Port, annotatedSingleton, } from '@tokens-studio/graph-engine'; +import { parentId } from '@/annotations/index.js'; import { v4 as uuidv4 } from 'uuid'; export interface IDuplicate { @@ -33,8 +34,6 @@ export const duplicateNodes = return [...acc, nodeId]; }, [] as string[]); - console.log('duplicate'); - const oldToNewIdMap = new Map(); const { addNodes, addEdges } = nodeIds.reduce( @@ -65,7 +64,7 @@ export const duplicateNodes = // Set new parentId annotation if it exists if (node.parentId && oldToNewIdMap.get(node.parentId)) { - clonedNode.annotations['parentId'] = oldToNewIdMap.get(node.parentId); + clonedNode.annotations[parentId] = oldToNewIdMap.get(node.parentId); } graph.addNode(clonedNode); diff --git a/packages/graph-editor/src/editor/graph.tsx b/packages/graph-editor/src/editor/graph.tsx index 40a6c5645..d2eae421e 100644 --- a/packages/graph-editor/src/editor/graph.tsx +++ b/packages/graph-editor/src/editor/graph.tsx @@ -76,6 +76,7 @@ import { deleteNode } from './actions/deleteNode.js'; import { description, height, + parentId as parentIdAnnotation, title, uiNodeType, uiVersion, @@ -509,8 +510,8 @@ export const EditorApp = React.forwardRef< height: (node.annotations[height] as number) || null, }; - if (node.annotations['parentId']) { - const parentId = node.annotations['parentId'] as string; + if (node.annotations[parentIdAnnotation]) { + const parentId = node.annotations[parentIdAnnotation] as string; reactFlowNode = { ...reactFlowNode, parentId, diff --git a/packages/graph-engine/src/graph/graph.ts b/packages/graph-engine/src/graph/graph.ts index afd1cfff7..34abccd5e 100644 --- a/packages/graph-engine/src/graph/graph.ts +++ b/packages/graph-engine/src/graph/graph.ts @@ -562,8 +562,8 @@ export class Graph { // Update parent ids for children on groups Object.values(clonedGraph.nodes).forEach(node => { - if (node.annotations['parentId']) { - node.annotations['parentId'] = oldToNewIdMap.get(node.annotations['parentId'] as string); + if (node.annotations['ui.parentId']) { + node.annotations['ui.parentId'] = oldToNewIdMap.get(node.annotations['ui.parentId'] as string); } });