Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Handle } from '../handles.js';
import { HandleContainer } from '../handles.js';
import { Stack, Text } from '@tokens-studio/ui';
import { extractType, extractTypeIcon } from '../wrapper/nodeV2.js';
import { icons } from '@/registry/icon.js';
import { useLocalGraph } from '@/context/graph.js';
import { useSelector } from 'react-redux';
import React from 'react';

export const TypeConverterNode = (args) => {
const { id } = args;
const graph = useLocalGraph();
const node = graph.getNode(id);
const iconTypeRegistry = useSelector(icons);

if (!node) return null;

const inputPort = node.inputs.value;
const outputPort = node.outputs.value;
const inputTypeCol = extractTypeIcon(inputPort, iconTypeRegistry);
const outputTypeCol = extractTypeIcon(outputPort, iconTypeRegistry);
const inputType = extractType(inputPort.type);
const outputType = extractType(outputPort.type);

// Get conversion description from node annotations
const conversionDescription =
(node.annotations['conversion.description'] as string) || 'TYPE';

return (
<Stack
direction="column"
style={{
background: 'var(--color-node-bg)',
borderRadius: 'var(--component-radii-md)',
padding: 'var(--component-spacing-xs)',
border: '2px solid var(--colors-nodeBorder)',
boxShadow: 'var(--shadows-contextMenu)',
minWidth: '60px',
position: 'relative',
}}
>
<Stack direction="row" align="center" justify="between">
<HandleContainer type="target" full>
<Handle
{...inputTypeCol}
visible={inputPort.visible != false || inputPort.isConnected}
id={inputPort.name}
type={inputType}
isConnected={inputPort.isConnected}
isAnchor={true}
/>
</HandleContainer>

<Stack
direction="column"
align="center"
style={{ flex: 1, minWidth: 0 }}
>
<Text
size="xsmall"
style={{
fontSize: '10px',
fontWeight: 'bold',
color: 'var(--colors-fg-muted)',
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
}}
>
{conversionDescription}
</Text>
</Stack>

<HandleContainer type="source" full>
<Handle
{...outputTypeCol}
visible={outputPort.visible != false || outputPort.isConnected}
id={outputPort.name}
type={outputType}
isConnected={outputPort.isConnected}
isAnchor={true}
/>
</HandleContainer>
</Stack>
</Stack>
);
};
108 changes: 107 additions & 1 deletion packages/graph-editor/src/editor/actions/connect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Connection, Edge } from 'reactflow';
import { Dispatch } from '@/redux/store.js';
import { Graph } from '@tokens-studio/graph-engine';
import { Graph, canConvertSchemaTypesExtended } from '@tokens-studio/graph-engine';
import { TYPE_CONVERTER } from '@/ids.js';
import { deletable } from '@/annotations/index.js';
import { getVariadicIndex, stripVariadic } from '@/utils/stripVariadic.js';

Expand All @@ -9,10 +10,14 @@ export const connectNodes =
graph,
setEdges,
dispatch,
nodeLookup,
reactFlowInstance,
}: {
graph: Graph;
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
dispatch: Dispatch;
nodeLookup: Record<string, any>;
reactFlowInstance: any;
}) =>
(params: Connection) => {
//Create the connection in the underlying graph
Expand Down Expand Up @@ -70,6 +75,107 @@ export const connectNodes =
graph.removeEdge(targetPort._edges[0].id);
}

// Check if types are compatible and need conversion
const sourceType = sourcePort.type;
const targetType = targetPort.type;
const needsConversion = sourceType.$id !== targetType.$id &&
canConvertSchemaTypesExtended(sourceType, targetType) &&
sourceType.$id !== 'https://schemas.tokens.studio/any.json' &&
targetType.$id !== 'https://schemas.tokens.studio/any.json';

if (needsConversion) {
// Create a type converter node
const TypeConverterClass = nodeLookup[TYPE_CONVERTER];
if (TypeConverterClass) {
const converterNode = new TypeConverterClass({ graph });

// Set the conversion types
converterNode.setConversionTypes(sourceType, targetType);

// Calculate position between source and target
const sourcePosition = reactFlowInstance?.getNode(params.source!)?.position || { x: 0, y: 0 };
const targetPosition = reactFlowInstance?.getNode(params.target!)?.position || { x: 100, y: 0 };
const converterPosition = {
x: (sourcePosition.x + targetPosition.x) / 2,
y: (sourcePosition.y + targetPosition.y) / 2
};

// Add the converter node to the graph
graph.addNode(converterNode);

// Add the converter node to React Flow
const converterFlowNode = {
id: converterNode.id,
type: TYPE_CONVERTER,
position: converterPosition,
data: {},
};

reactFlowInstance?.addNodes(converterFlowNode);

// Connect source to converter
const sourceToConverterEdge = graph.connect(
sourceNode,
sourcePort,
converterNode,
converterNode.inputs.value
);

// Connect converter to target
const converterToTargetEdge = graph.connect(
converterNode,
converterNode.outputs.value,
targetNode,
targetPort
);

// Create React Flow edges
const sourceToConverterFlowEdge = {
id: sourceToConverterEdge.id,
source: params.source!,
sourceHandle: params.sourceHandle!,
target: converterNode.id,
targetHandle: 'value',
type: 'custom',
};

const converterToTargetFlowEdge = {
id: converterToTargetEdge.id,
source: converterNode.id,
sourceHandle: 'value',
target: params.target!,
targetHandle: params.targetHandle!,
type: 'custom',
};

dispatch.graph.appendLog({
time: new Date(),
type: 'info',
data: {
msg: `Type converter inserted between ${sourceType.$id} and ${targetType.$id}`,
},
});

return setEdges((eds) => {
const newEdgs = eds.reduce(
(acc, edge) => {
//All our inputs take a single input only, disconnect if we have a connection already
if (
edge.targetHandle == params.targetHandle &&
edge.target === params.target
) {
return acc;
}
acc.push(edge);
return acc;
},
[sourceToConverterFlowEdge, converterToTargetFlowEdge] as Edge[],
);
return newEdgs;
});
}
}

const newGraphEdge = graph.connect(
sourceNode,
sourcePort,
Expand Down
15 changes: 12 additions & 3 deletions packages/graph-editor/src/editor/graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,13 @@ 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 { NOTE, PASSTHROUGH, TYPE_CONVERTER } from '@/ids.js';
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 { SelectionContextMenu } from '@/components/contextMenus/selectionContextMenu.js';
import { TypeConverterNode } from '@/components/flow/nodes/typeConverterNode.js';
import {
capabilitiesSelector,
nodeTypesSelector,
Expand Down Expand Up @@ -373,6 +374,7 @@ export const EditorApp = React.forwardRef<
...customNodeUI,
GenericNode: NodeV2,
[PASSTHROUGH]: PassthroughNode,
[TYPE_CONVERTER]: TypeConverterNode,
[EditorNodeTypes.GROUP]: groupNode,
[NOTE]: noteNode,
});
Expand Down Expand Up @@ -530,8 +532,15 @@ export const EditorApp = React.forwardRef<
}, []);

const onConnect = useMemo(
() => connectNodes({ graph, setEdges, dispatch }),
[dispatch, graph, setEdges],
() =>
connectNodes({
graph,
setEdges,
dispatch,
nodeLookup: fullNodeLookup,
reactFlowInstance,
}),
[dispatch, graph, setEdges, fullNodeLookup, reactFlowInstance],
);

const onNodeDragStop = useCallback(
Expand Down
1 change: 1 addition & 0 deletions packages/graph-editor/src/ids.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const INPUT = 'studio.tokens.generic.input';
export const OUTPUT = 'studio.tokens.generic.output';
export const PASSTHROUGH = 'studio.tokens.generic.passthrough';
export const TYPE_CONVERTER = 'studio.tokens.generic.typeConverter';
export const NOTE = 'studio.tokens.generic.note';
4 changes: 3 additions & 1 deletion packages/graph-engine/src/nodes/generic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import panic from './panic.js';
import passthrough from './passthrough.js';
import subgraph from './subgraph.js';
import time from './time.js';
import typeConverter from './typeConverter.js';

export const nodes = [
constant,
Expand All @@ -23,5 +24,6 @@ export const nodes = [
objectPath,
objectMerge,
time,
delay
delay,
typeConverter
];
72 changes: 72 additions & 0 deletions packages/graph-engine/src/nodes/generic/typeConverter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
AnySchema,
convertSchemaTypeExtended,
getConversionDescriptionExtended
} from '../../schemas/index.js';
import { INodeDefinition, ToInput, ToOutput } from '../../index.js';
import { Node } from '../../programmatic/node.js';

/**
* A node that converts between different types automatically.
* This node is typically inserted automatically when connecting incompatible but convertible types.
*/
export default class NodeDefinition<TInput = any, TOutput = any> extends Node {
static title = 'Type Converter';
static type = 'studio.tokens.generic.typeConverter';
static description = 'Automatically converts between compatible types';

declare inputs: ToInput<{
value: TInput;
}>;
declare outputs: ToOutput<{
value: TOutput;
}>;

private sourceType: any = AnySchema;
private targetType: any = AnySchema;

constructor(props: INodeDefinition) {
super(props);
this.addInput('value', {
type: AnySchema
});
this.addOutput('value', {
type: AnySchema
});
}

/**
* Sets the source and target types for this converter
*/
setConversionTypes(sourceType: any, targetType: any) {
this.sourceType = sourceType;
this.targetType = targetType;

// Update the input and output types using the proper API
this.inputs.value.setType(sourceType);
this.outputs.value.setType(targetType);

// Store conversion info in annotations for UI display
this.annotations['conversion.source'] = sourceType.$id;
this.annotations['conversion.target'] = targetType.$id;
this.annotations['conversion.description'] =
getConversionDescriptionExtended(sourceType, targetType);
}

/**
* Gets the conversion description for display
*/
getConversionDescription(): string {
return (this.annotations['conversion.description'] as string) || 'ANY→ANY';
}

execute(): void | Promise<void> {
const input = this.inputs.value;
const convertedValue = convertSchemaTypeExtended(
this.sourceType,
this.targetType,
input.value
);
this.outputs.value.set(convertedValue, this.targetType);
}
}
Loading
Loading