Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Save groups in the graph #455

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/graph-editor/src/annotations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.dimension.width';
export const height = 'ui.dimension.height';

//Used on nodes and graph
export const title = 'ui.title';
Expand Down
16 changes: 9 additions & 7 deletions packages/graph-editor/src/components/commandPalette/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ export interface ICommandMenu {
items: DropPanelStore;
handleSelectNewNodeType: (node: NodeRequest) =>
| {
graphNode: Node;
flowNode: ReactFlowNode;
}
graphNode: Node;
flowNode: ReactFlowNode;
}
| undefined;
}

Expand Down Expand Up @@ -74,14 +74,16 @@ const CommandMenuGroup = observer(
<Command.Group
key={group.key}
heading={
<Stack align="center">
<Stack align="center" gap={2} css={{ margin: '$3 0 $2' }}>
{group.icon}
{group.title}
<Text size="small" css={{ fontWeight: '$sansMedium' }}>
{group.title}
</Text>
</Stack>
}
>
{group.items.map((item) => (
<CommandItem item={item} handleSelectItem={handleSelectItem} />
<CommandItem key={item.type} item={item} handleSelectItem={handleSelectItem} />
))}
</Command.Group>
);
Expand Down Expand Up @@ -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',
}}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,81 +1,123 @@
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, 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 { 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;
nodes: Node[];
};

const padding = 25;

export const SelectionContextMenu = ({ id, nodes }: INodeContextMenuProps) => {
const reactFlowInstance = useReactFlow();
const graph = useLocalGraph();
const store = useStoreApi();
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.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;
}),
);
});
});

const reactFlowNodesMap = new Map<string, Node>(
reactFlowNodes.map((node) => [node.id, node]),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be called each render which will hamper performance

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is inside the onGroup callback. Will be called only once when you group nodes

);

// 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would these persist if they are changed afterwards ? We don't seem to ever recompute these on changes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not needed. After group creation you don't need to update this. If you move the node inside the group it will automatically change it's xpos and ypos. If you move the group, xpos and ypos of nodes inside should still be the same

graphNode.annotations[width] = reactFlowNodesMap.get(node.id)?.width || 200;
graphNode.annotations[height] = reactFlowNodesMap.get(node.id)?.height || 100;
graphNode.annotations['parentId'] = flowNode.id;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be scoped to indicate its for the UI

});
}, [nodes, reactFlowInstance, selectedNodeIds, store]);

}, [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];
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You wouldn't need to spread each time, this would create a new array, since its bound to the reduce, just push the new node and return the accumulator


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
Expand All @@ -95,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;
Expand Down Expand Up @@ -294,9 +336,11 @@ export const SelectionContextMenu = ({ id, nodes }: INodeContextMenuProps) => {
duplicateNodes(selectedNodeIds);
};

const hasGroup = selectedNodes.some((node) => node.type === GROUP);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memoized

return (
<Menu id={id}>
<Item onClick={onGroup}>Create group</Item>
{!hasGroup && <Item onClick={onGroup}>Create group</Item>}
<Item onClick={onCreateSubgraph}>Create Subgraph</Item>
<Separator />
<Item onClick={onDuplicate}>Duplicate</Item>
Expand Down
27 changes: 19 additions & 8 deletions packages/graph-editor/src/components/flow/nodes/groupNode.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -39,11 +41,20 @@ 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);

childNodeIds.forEach((nodeId) => {
const graphNode = graph.getNode(nodeId);
if (graphNode) {
delete graphNode.annotations['parentId'];
}
});
}, [detachNodes, graph, id, store]);

return (
<div
Expand Down
4 changes: 2 additions & 2 deletions packages/graph-editor/src/components/flow/utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { GROUP } from '@/ids.js';
import { Node } from 'reactflow';
import { NodeTypes } from './types.js';

// we have to make sure that parent nodes are rendered before their children
export const sortNodes = (a: Node, b: Node): number => {
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}`;
Expand Down
58 changes: 31 additions & 27 deletions packages/graph-editor/src/components/panels/dropPanel/data.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { GROUP } from '@/ids.js';
import { nodes } from '@tokens-studio/graph-engine';
import { observable } from 'mobx';

Expand Down Expand Up @@ -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<PanelGroup>(
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<string, PanelGroup>,
),
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<string, PanelGroup>,
),
);

return new DropPanelStore(auto);
Expand Down
Loading
Loading