diff --git a/packages/graph-editor/src/components/panels/index.tsx b/packages/graph-editor/src/components/panels/index.tsx index af9aad22d..3ac76bf0a 100644 --- a/packages/graph-editor/src/components/panels/index.tsx +++ b/packages/graph-editor/src/components/panels/index.tsx @@ -9,3 +9,4 @@ export * from './nodeSettings/index.js'; export * from './output/index.js'; export * from './play/index.js'; export * from './settings/index.js'; +export * from './navigation/index.js'; diff --git a/packages/graph-editor/src/components/panels/navigation/index.module.css b/packages/graph-editor/src/components/panels/navigation/index.module.css new file mode 100644 index 000000000..b24e91c10 --- /dev/null +++ b/packages/graph-editor/src/components/panels/navigation/index.module.css @@ -0,0 +1,38 @@ +.scrollWrapper { + height: 100%; + flex: 1; + padding: var(--component-spacing-md); + overflow: auto; +} + +.componentSpacingWrapper { + padding: var(--component-spacing-md); +} + +.listWrapper { + display: flex; + flex-direction: column; + font-size: var(--font-body-small-default); + gap: var(--component-spacing-xs); + list-style-type: none; + margin: 0; + padding: 0; + user-select: none; +} + +.listItem { + cursor: pointer; + display: inline-flex; + gap: var(--component-spacing-sm); + padding-left: 0; + padding-left: calc(var(--tree-depth, 0px) * var(--component-spacing-xs, 1px)); +} + +.listItemSelected { + font-weight: bold; +} + +.listItemCount { + color: var(--fg-subtle); + font-weight: normal; +} diff --git a/packages/graph-editor/src/components/panels/navigation/index.tsx b/packages/graph-editor/src/components/panels/navigation/index.tsx new file mode 100644 index 000000000..bab3f8e78 --- /dev/null +++ b/packages/graph-editor/src/components/panels/navigation/index.tsx @@ -0,0 +1,106 @@ +import React from 'react'; + +import { MAIN_GRAPH_ID } from '@/constants.js'; +import { Stack } from '@tokens-studio/ui'; +import { TreeNode, graphNodesSelector } from '@/redux/selectors/graph.js'; +import { currentPanelIdSelector } from '@/redux/selectors/graph.js'; +import { dockerSelector } from '@/redux/selectors/refs.js'; +import { flow, get, size } from 'lodash-es'; +import { useSelector } from 'react-redux'; +import { useSubgraphExplorerCallback } from '@/hooks/useSubgraphExplorerCallback.js'; +import cx from 'classnames'; +import styles from './index.module.css'; + +type ListItemProps = { + label: string; + count?: number; + isSelected?: boolean; + onClick?: () => void; + depth?: number; +}; + +const ListItem = function ({ + label, + count, + isSelected, + onClick, + depth = 0, +}: ListItemProps) { + const style = { + '--tree-depth': depth, + } as React.CSSProperties; + + return ( +
  • + {label} + {count && ({count})} +
  • + ); +}; + +const SubgraphNodeItem = function ({ node, isSelected, depth }) { + const nodeType = node.factory.title || node.nodeType(); + const onNodeClick = useSubgraphExplorerCallback(node); + const childNodesCount = flow( + (x) => get(x, ['_innerGraph', 'nodes'], []), + size, + )(node); + + return ( + 0 && childNodesCount} + /> + ); +}; + +const RootGraphNodeItem = function () { + const dockerRef = useSelector(dockerSelector); + const activeGraphId = useSelector(currentPanelIdSelector); + + return ( + dockerRef.current.updateTab(MAIN_GRAPH_ID, null, true)} + /> + ); +}; + +export const NavigationPanel = () => { + const nodes = useSelector(graphNodesSelector); + const activeGraphId = useSelector(currentPanelIdSelector); + + return ( + +
    +
      + + {Object.values(nodes || {}).map(({ node, depth }: TreeNode) => { + const innerGraph = node['_innerGraph']; + if (!innerGraph) return null; + return ( + + ); + })} +
    +
    +
    + ); +}; diff --git a/packages/graph-editor/src/components/toolbar/dropdowns/layout.tsx b/packages/graph-editor/src/components/toolbar/dropdowns/layout.tsx index 4a51af251..47b3d275c 100644 --- a/packages/graph-editor/src/components/toolbar/dropdowns/layout.tsx +++ b/packages/graph-editor/src/components/toolbar/dropdowns/layout.tsx @@ -77,6 +77,9 @@ export const LayoutDropdown = () => { onClick('dropPanel')}> Nodes + onClick('navigationPanel')}> + Navigator + diff --git a/packages/graph-editor/src/components/toolbar/layoutButtons.tsx b/packages/graph-editor/src/components/toolbar/layoutButtons.tsx index 8a7ff794d..319779034 100644 --- a/packages/graph-editor/src/components/toolbar/layoutButtons.tsx +++ b/packages/graph-editor/src/components/toolbar/layoutButtons.tsx @@ -4,6 +4,7 @@ import { GraphPanel } from '../panels/graph/index.js'; import { Inputsheet } from '../panels/inputs/index.js'; import { Legend } from '../panels/legend/index.js'; import { LogsPanel } from '../panels/logs/index.js'; +import { NavigationPanel } from '../panels/navigation/index.js'; import { NodeSettingsPanel } from '../panels/nodeSettings/index.js'; import { OutputSheet } from '../panels/output/index.js'; import { Settings } from '../panels/settings/index.js'; @@ -55,6 +56,11 @@ export const layoutButtons = { title: 'Nodes', content: , }, + navigationPanel: { + id: 'navigationPanel', + title: 'Navigation', + content: , + }, }; export type LayoutButtons = keyof typeof layoutButtons; diff --git a/packages/graph-editor/src/data/version.ts b/packages/graph-editor/src/data/version.ts index 065128b05..23911495f 100644 --- a/packages/graph-editor/src/data/version.ts +++ b/packages/graph-editor/src/data/version.ts @@ -1 +1 @@ -export const version = '4.3.9'; +export const version = '4.3.6'; diff --git a/packages/graph-editor/src/editor/graphEditor.tsx b/packages/graph-editor/src/editor/graphEditor.tsx index c9ebe960a..34c49b6db 100644 --- a/packages/graph-editor/src/editor/graphEditor.tsx +++ b/packages/graph-editor/src/editor/graphEditor.tsx @@ -1,4 +1,6 @@ import { EditorApp } from './graph.js'; +import { ErrorBoundary } from 'react-error-boundary'; +import { ErrorBoundaryContent } from '@/components/ErrorBoundaryContent.js'; import { GraphEditorProps, ImperativeEditorRef } from './editorTypes.js'; import { ReactFlowProvider } from 'reactflow'; import React from 'react'; @@ -16,3 +18,15 @@ export const GraphEditor = React.forwardRef< ); }); + +// HACK: Workaround for circular dependency not allowed for nextjs +// E.g.: when trying to create a new graph editor instance as a tab +if (typeof window !== 'undefined') { + window['newGraphEditor'] = function (ref, id) { + return ( + }> + + + ); + }; +} diff --git a/packages/graph-editor/src/hooks/useSubgraphExplorerCallback.ts b/packages/graph-editor/src/hooks/useSubgraphExplorerCallback.ts new file mode 100644 index 000000000..bdb25ce5d --- /dev/null +++ b/packages/graph-editor/src/hooks/useSubgraphExplorerCallback.ts @@ -0,0 +1,47 @@ +import { ImperativeEditorRef } from '../index.js'; +import { title as annotatedTitle } from '@/annotations/index.js'; +import { dockerSelector } from '@/redux/selectors/refs.js'; +import { useCallback } from 'react'; +import { useSelector } from 'react-redux'; + +export const useSubgraphExplorerCallback = (node) => { + const dockerRef = useSelector(dockerSelector); + + const callback = useCallback(() => { + if (!dockerRef?.current) { + return; + } + + let oneShot = false; + const innerGraph = node['_innerGraph']; + const graphId = innerGraph.annotations['engine.id']; + const title = + node.annotations[annotatedTitle] || + innerGraph.annotations['engine.title'] || + 'Subgraph'; + const existing = dockerRef.current.find(graphId); + + if (!existing) { + const ref = (o: ImperativeEditorRef) => { + if (o && !oneShot) { + o.load(innerGraph); + oneShot = true; + } + }; + + const newTab = { + cached: true, + closable: true, + id: graphId, + group: 'graph', + title, + content: window && window['newGraphEditor'](ref, graphId), + }; + dockerRef.current.dockMove(newTab, 'graphs', 'middle'); + } else { + dockerRef.current.updateTab(graphId, null, true); + } + }, [dockerRef, node['_innerGraph'], node.annotations]); + + return callback; +}; diff --git a/packages/graph-editor/src/redux/selectors/graph.ts b/packages/graph-editor/src/redux/selectors/graph.ts index 94a70e03c..945019ecb 100644 --- a/packages/graph-editor/src/redux/selectors/graph.ts +++ b/packages/graph-editor/src/redux/selectors/graph.ts @@ -1,3 +1,4 @@ +import { Graph, Node } from '@tokens-studio/graph-engine'; import { MAIN_GRAPH_ID } from '@/constants.js'; import { createSelector } from 'reselect'; import { graph } from './roots.js'; @@ -18,6 +19,36 @@ export const mainGraphSelector = createSelector( (state) => state.panels[MAIN_GRAPH_ID], ); +export type TreeNode = { + node: Node; + depth: number; +}; + +const collectNodes = function ( + graph: Graph, + coll: Record = {}, + depth = 1, +): Record { + for (const id in graph.nodes) { + const node: Node = graph.nodes[id]; + const innerGraph = node['_innerGraph']; + coll[id] = { node, depth } as TreeNode; + + if (innerGraph) { + collectNodes(innerGraph, coll, ++depth); + } + } + return coll; +}; + +export const graphNodesSelector = createSelector(graph, (state) => { + const graph = state.panels[MAIN_GRAPH_ID]?.graph; + + if (!graph) return; + + return collectNodes(graph); +}); + export const graphEditorSelector = createSelector( graph, (state) => state.currentPanel?.ref, diff --git a/packages/graph-editor/src/registry/specifics.tsx b/packages/graph-editor/src/registry/specifics.tsx index efb719c7c..3b465ad08 100644 --- a/packages/graph-editor/src/registry/specifics.tsx +++ b/packages/graph-editor/src/registry/specifics.tsx @@ -16,31 +16,31 @@ import { useSelector } from 'react-redux'; import Eye from '@tokens-studio/icons/Eye.js'; import React, { useCallback } from 'react'; -const SubgraphExplorer = ({ node }) => { +export const useSubgraphExplorerCallback = (node) => { const dockerRef = useSelector(dockerSelector); - const onToggle = useCallback(() => { + + const callback = useCallback(() => { if (!dockerRef?.current) { return; } let oneShot = false; - const innerGraph = node._innerGraph; + const innerGraph = node['_innerGraph']; const graphId = innerGraph.annotations['engine.id']; const title = node.annotations[annotatedTitle] || innerGraph.annotations['engine.title'] || 'Subgraph'; - //Find the container const existing = dockerRef.current.find(graphId); - const ref = (o: ImperativeEditorRef) => { - if (o && !oneShot) { - o.load(innerGraph); - oneShot = true; - } - }; - if (!existing) { + const ref = (o: ImperativeEditorRef) => { + if (o && !oneShot) { + o.load(innerGraph); + oneShot = true; + } + }; + const newTab = { cached: true, closable: true, @@ -53,15 +53,20 @@ const SubgraphExplorer = ({ node }) => { ), }; - dockerRef.current.dockMove(newTab, 'graphs', 'middle'); } else { dockerRef.current.updateTab(graphId, null, true); } - }, [dockerRef, node._innerGraph, node.annotations]); + }, [dockerRef, node['_innerGraph'], node.annotations]); + + return callback; +}; + +const SubgraphExplorer = ({ node }: { node: Node }) => { + const onClick = useSubgraphExplorerCallback(node); return ( - );