diff --git a/packages/graph-editor/src/components/flow/portSuggestionMenu.module.css b/packages/graph-editor/src/components/flow/portSuggestionMenu.module.css new file mode 100644 index 00000000..c07187a9 --- /dev/null +++ b/packages/graph-editor/src/components/flow/portSuggestionMenu.module.css @@ -0,0 +1,62 @@ +.menuContainer { + position: fixed; + z-index: 9999; + background: var(--color-neutral-canvas-minimal-bg); + color: var(--color-neutral-canvas-minimal-fg); + padding: var(--component-spacing-xs); + border-radius: var(--component-radii-md); + box-shadow: var(--shadow); + min-width: 300px; + font: var(--font-body-md-default); +} + +.searchContainer { + padding: var(--size-100); +} + +.searchInput { + width: 100%; + padding: var(--size-100); + background: var(--color-neutral-surface-sunken); + border: 1px solid var(--color-neutral-stroke-subtle); + border-radius: var(--radius-small); + color: var(--color-neutral-foreground-default); + outline: none; +} + +.searchInput:focus { + border-color: var(--color-accent-stroke-focus); +} + +.portList { + max-height: 400px; + overflow-y: auto; +} + +.portGroup { + margin-bottom: var(--size-100); +} + +.portItem { + padding: var(--component-spacing-xs); + cursor: pointer; + border-radius: var(--radius-small); + transition: background-color 0.2s; +} + +.portItem:hover { + background-color: var(--color-neutral-surface-hover); +} + +.portName { + color: var(--color-accent-foreground-default); +} + +.separator { + color: var(--color-neutral-foreground-subtle); + margin: 0 var(--size-50); +} + +.nodeTitle { + color: var(--color-neutral-canvas-minimal-fg-subtle); +} \ No newline at end of file diff --git a/packages/graph-editor/src/components/flow/portSuggestionMenu.tsx b/packages/graph-editor/src/components/flow/portSuggestionMenu.tsx new file mode 100644 index 00000000..2aa3e048 --- /dev/null +++ b/packages/graph-editor/src/components/flow/portSuggestionMenu.tsx @@ -0,0 +1,90 @@ +import { Port } from '@tokens-studio/graph-engine'; +import { PortInfo } from '../../services/PortRegistry.js'; +import { createPortal } from 'react-dom'; +import { observer } from 'mobx-react-lite'; +import React, { useCallback, useState } from 'react'; +import clsx from 'clsx'; +import styles from './portSuggestionMenu.module.css'; + +interface PortSuggestionMenuProps { + sourcePort: Port; + compatiblePorts: PortInfo[]; + position: { x: number; y: number }; + onSelect: (portInfo: PortInfo) => void; +} + +export const PortSuggestionMenu = observer( + ({ compatiblePorts, position, onSelect }: PortSuggestionMenuProps) => { + const [searchTerm, setSearchTerm] = useState(''); + + const handleSelect = useCallback( + (portInfo: PortInfo) => { + onSelect(portInfo); + }, + [onSelect] + ); + + if (compatiblePorts.length === 0) { + return null; + } + + // Filter ports based on search term + const filteredPorts = compatiblePorts.filter(port => + port.portName.toLowerCase().includes(searchTerm.toLowerCase()) || + port.nodeTitle.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + // Group ports by node type + const groupedPorts = filteredPorts.reduce>( + (acc, port) => { + if (!acc[port.nodeTitle]) { + acc[port.nodeTitle] = []; + } + acc[port.nodeTitle].push(port); + return acc; + }, + {} + ); + + return createPortal( +
+
+ setSearchTerm(e.target.value)} + className={styles.searchInput} + autoFocus + /> +
+
+ {Object.entries(groupedPorts).map(([nodeTitle, ports]) => ( + <> + {ports.map((portInfo) => ( +
handleSelect(portInfo)} + className={styles.portItem} + > + {portInfo.portName} + + {portInfo.nodeTitle} +
+ ))} + + ))} +
+
, + document.body + ); + } +); \ No newline at end of file diff --git a/packages/graph-editor/src/data/version.ts b/packages/graph-editor/src/data/version.ts index 4e793820..065128b0 100644 --- a/packages/graph-editor/src/data/version.ts +++ b/packages/graph-editor/src/data/version.ts @@ -1 +1 @@ -export const version = '4.3.8'; +export const version = '4.3.9'; diff --git a/packages/graph-editor/src/editor/graph.tsx b/packages/graph-editor/src/editor/graph.tsx index 7b9d1344..04b7fca0 100644 --- a/packages/graph-editor/src/editor/graph.tsx +++ b/packages/graph-editor/src/editor/graph.tsx @@ -42,6 +42,14 @@ import noteNode from '../components/flow/nodes/noteNode.js'; import { ActionProvider } from './actions/provider.js'; import { BatchRunError, Graph } from '@tokens-studio/graph-engine'; +import { + CapabilityFactory, + ExternalLoader, + Node as GraphNode, + Port, + SchemaObject, + SerializedGraph, +} from '@tokens-studio/graph-engine'; import { CommandMenu } from '@/components/commandPalette/index.js'; import { EdgeContextMenu } from '../components/contextMenus/edgeContextMenu.js'; import { GraphContextProvider } from '@/context/graph.js'; @@ -53,6 +61,8 @@ import { NodeContextMenu } from '../components/contextMenus/nodeContextMenu.js'; import { NodeV2 } from '@/components/index.js'; import { PaneContextMenu } from '../components/contextMenus/paneContextMenu.js'; import { PassthroughNode } from '@/components/flow/nodes/passthroughNode.js'; +import { PortInfo, PortRegistry } from '@/services/PortRegistry.js'; +import { PortSuggestionMenu } from '@/components/flow/portSuggestionMenu.js'; import { SelectionContextMenu } from '@/components/contextMenus/selectionContextMenu.js'; import { capabilitiesSelector, @@ -249,46 +259,81 @@ export const EditorApp = React.forwardRef< [showPane], ); - const [isHoldingDownOption, setIsHoldingDownOption] = React.useState(false); + const [dragStartPort, setDragStartPort] = useState<{ + port: Port; + } | null>(null); - useEffect(() => { - const down = (e) => { - if (e.altKey) { - e.preventDefault(); + const [draggedPort, setDraggedPort] = useState<{ + port: Port; + position: { x: number; y: number }; + flowPosition?: XYPosition; + } | null>(null); - setIsHoldingDownOption(true); - } else { - setIsHoldingDownOption(false); - } - }; + const onConnectStart = useCallback( + (event: React.MouseEvent | TouchEvent, { nodeId, handleId }) => { + if (!nodeId || !handleId) return; - document.addEventListener('keydown', down); - return () => document.removeEventListener('keydown', down); - }, [dispatch.ui]); + const node = graph.getNode(nodeId); + if (!node) return; + + const port = node.outputs[handleId]; + if (!port) return; + + // Just store which port we started dragging from + setDragStartPort({ port }); + // Clear any existing draggedPort state + setDraggedPort(null); + }, + [graph] + ); const onConnectEnd = useCallback( - (event) => { - if (!isHoldingDownOption) { - return; - } + (event: MouseEvent | TouchEvent) => { + if (!dragStartPort) return; - const targetIsPane = event.target.classList.contains('react-flow__pane'); + console.log('onConnectEnd triggered', { dragStartPort }); - if (targetIsPane) { - dispatch.ui.setShowNodesCmdPalette(true); + // Handle both mouse and touch events + const clientX = 'clientX' in event ? event.clientX : event.touches[0].clientX; + const clientY = 'clientY' in event ? event.clientY : event.touches[0].clientY; + + // Check if we're on the pane or any of its children + const target = event.target as Element; + const targetIsPane = target.closest('.react-flow__pane') !== null; + + console.log('Target and position check:', { targetIsPane, clientX, clientY, target }); - const position = reactFlowInstance?.screenToFlowPosition({ - x: event.clientX, - y: event.clientY, + if (targetIsPane) { + // Convert screen coordinates to flow coordinates + const flowPosition = reactFlowInstance.screenToFlowPosition({ + x: clientX, + y: clientY, }); - dispatch.ui.setNodeInsertPosition(position); - // TODO: After dropping the node we should try to connect the node if it has 1 handler only + console.log('Setting draggedPort with positions:', { clientX, clientY, flowPosition }); + + // Only now set the draggedPort with the position + setDraggedPort({ + port: dragStartPort.port, + position: { + x: clientX, + y: clientY + }, + flowPosition + }); } + + // Clear the start port state + setDragStartPort(null); }, - [dispatch.ui, isHoldingDownOption, reactFlowInstance], + [dragStartPort, reactFlowInstance] ); + // Add a debug effect to monitor draggedPort changes + useEffect(() => { + console.log('draggedPort changed:', draggedPort); + }, [draggedPort]); + const handleEdgeContextMenu = useCallback( (event, edge) => { setContextEdge(edge); @@ -730,6 +775,75 @@ export const EditorApp = React.forwardRef< [handleSelectNewNodeType, reactFlowInstance], ); const nodeCount = nodes.length; + + const portRegistry = useMemo(() => { + return new PortRegistry(fullNodeLookup); + }, [fullNodeLookup]); + + const compatiblePorts = useMemo(() => { + console.log('Computing compatible ports for:', draggedPort); + if (!draggedPort) return []; + const ports = portRegistry.findCompatiblePorts(draggedPort.port); + console.log('Found compatible ports:', ports.length, ports); + return ports; + }, [draggedPort, portRegistry]); + + const onPortSelect = useCallback( + (portInfo: PortInfo) => { + if (!draggedPort || !draggedPort.flowPosition) return; + + // Create the node at the flow position + const result = handleSelectNewNodeType({ + type: portInfo.nodeType, + position: draggedPort.flowPosition, + }); + + if (!result) return; + + // Connect the ports + const sourceNode = graph.getNode(draggedPort.port.node.id); + const targetNode = result.graphNode; + + if (!sourceNode || !targetNode) return; + + const sourcePort = sourceNode.outputs[draggedPort.port.name]; + const targetPort = targetNode.inputs[portInfo.portName]; + + if (!sourcePort || !targetPort) return; + + // Create the connection in the graph engine + const graphEdge = graph.connect(sourceNode, sourcePort, targetNode, targetPort); + + // Create the visual edge for ReactFlow + const newEdge = { + id: graphEdge.id, + source: sourceNode.id, + target: targetNode.id, + sourceHandle: draggedPort.port.name, + targetHandle: portInfo.portName, + type: 'custom' + }; + + // Add the edge to ReactFlow + setEdges(eds => [...eds, newEdge]); + + // Clear the dragged port state + setDraggedPort(null); + }, + [draggedPort, graph, handleSelectNewNodeType, setEdges] + ); + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.altKey) { + e.preventDefault(); + } + }; + + document.addEventListener('keydown', down); + return () => document.removeEventListener('keydown', down); + }, [dispatch.ui]); + return ( @@ -771,21 +887,15 @@ export const EditorApp = React.forwardRef< onNodeDrag={onNodeDrag} onConnect={onConnect} onDrop={onDrop} + onConnectStart={onConnectStart} onConnectEnd={onConnectEnd} selectNodesOnDrag={true} defaultEdgeOptions={defaultEdgeOptions} panOnScroll={true} - //Note that we cannot use pan on drag or it will affect the context menu onPaneContextMenu={contextMenus ? handleContextMenu : undefined} - onEdgeContextMenu={ - contextMenus ? handleEdgeContextMenu : undefined - } - onNodeContextMenu={ - contextMenus ? handleNodeContextMenu : undefined - } - onSelectionContextMenu={ - contextMenus ? handleSelectionContextMenu : undefined - } + onEdgeContextMenu={contextMenus ? handleEdgeContextMenu : undefined} + onNodeContextMenu={contextMenus ? handleNodeContextMenu : undefined} + onSelectionContextMenu={contextMenus ? handleSelectionContextMenu : undefined} selectionMode={SelectionMode.Partial} onDragOver={onDragOver} selectionOnDrag={true} @@ -827,6 +937,14 @@ export const EditorApp = React.forwardRef< {props.children} + {draggedPort && compatiblePorts.length > 0 && ( + + )} {showMinimap && } diff --git a/packages/graph-editor/src/services/PortRegistry.ts b/packages/graph-editor/src/services/PortRegistry.ts new file mode 100644 index 00000000..59db38fc --- /dev/null +++ b/packages/graph-editor/src/services/PortRegistry.ts @@ -0,0 +1,82 @@ +import { Graph, Node, Port, SchemaObject, canConvertSchemaTypes } from '@tokens-studio/graph-engine'; + +export interface PortInfo { + portName: string; + nodeType: string; + nodeTitle: string; + portType: SchemaObject; +} + +export class PortRegistry { + private nodeTypes: Record; + private portsByType: Map = new Map(); + private graph: Graph; + + constructor(nodeTypes: Record) { + this.nodeTypes = nodeTypes; + this.graph = new Graph(); + this.graph.capabilities = new Map(); + this.buildPortIndex(); + } + + private buildPortIndex() { + Object.entries(this.nodeTypes).forEach(([nodeType, NodeClass]) => { + try { + const tempNode = new NodeClass({ graph: this.graph }); + const nodeTitle = tempNode.factory.title || nodeType; + + Object.entries(tempNode.inputs).forEach(([portName, port]) => { + if (!port.variadic) { + this.indexPort(portName, nodeType, nodeTitle, port); + } + }); + } catch (error) { + console.warn(`Failed to index node type: ${nodeType}`, error); + } + }); + } + + private indexPort(portName: string, nodeType: string, nodeTitle: string, port: Port) { + const portType = port.type; + const portInfo: PortInfo = { + portName, + nodeType, + nodeTitle, + portType + }; + + console.log('Indexing port:', { portInfo, portType }); + + if (portType.$id) { + const existing = this.portsByType.get(portType.$id) || []; + this.portsByType.set(portType.$id, [...existing, portInfo]); + } + + if (portType.type === 'array' && portType.items.$id) { + const existing = this.portsByType.get(portType.items.$id) || []; + this.portsByType.set(portType.items.$id, [...existing, portInfo]); + } + } + + public findCompatiblePorts(sourcePort: Port): PortInfo[] { + const sourceType = sourcePort.type; + const compatiblePorts: PortInfo[] = []; + + // Get the type ID we want to match + const typeId = sourceType.$id; + if (!typeId) { + // For arrays, check the item type + if (sourceType.type === 'array' && sourceType.items.$id) { + const arrayPorts = this.portsByType.get(sourceType.items.$id) || []; + compatiblePorts.push(...arrayPorts); + } + return compatiblePorts; + } + + // Get ports that match our exact type + const matchingPorts = this.portsByType.get(typeId) || []; + compatiblePorts.push(...matchingPorts); + + return compatiblePorts; + } +} \ No newline at end of file