168 lines
5.6 KiB
TypeScript
168 lines
5.6 KiB
TypeScript
import { useDraggable } from '@neodrag/react';
|
|
import { useReactFlow, type XYPosition } from '@xyflow/react';
|
|
import { type ReactNode, useCallback, useRef, useState } from 'react';
|
|
import useFlowStore from '../VisProgStores';
|
|
import styles from '../../VisProg.module.css';
|
|
import { NodeDefaults, type NodeTypes } from '../NodeRegistry'
|
|
|
|
/**
|
|
* Props for a draggable node within the drag-and-drop toolbar.
|
|
*
|
|
* @property className - Optional custom CSS classes for styling.
|
|
* @property children - The visual content or label rendered inside the draggable node.
|
|
* @property nodeType - The type of node represented (key from `NodeTypes`).
|
|
* @property onDrop - Function called when the node is dropped on the flow pane.
|
|
*/
|
|
interface DraggableNodeProps {
|
|
className?: string;
|
|
children: ReactNode;
|
|
nodeType: keyof typeof NodeTypes;
|
|
onDrop: (nodeType: keyof typeof NodeTypes, position: XYPosition) => void;
|
|
}
|
|
|
|
/**
|
|
* A draggable node element used in the drag-and-drop toolbar.
|
|
*
|
|
* Integrates with the NeoDrag library to handle drag events.
|
|
* On drop, it calls the provided `onDrop` function with the node type and drop position.
|
|
*
|
|
* @param props - The draggable node configuration.
|
|
* @returns A React element representing a draggable node.
|
|
*/
|
|
function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) {
|
|
const draggableRef = useRef<HTMLDivElement>(null);
|
|
const [position, setPosition] = useState<XYPosition>({ x: 0, y: 0 });
|
|
|
|
// The NeoDrag hook enables smooth drag functionality for this element.
|
|
// @ts-expect-error: NeoDrag typing incompatibility — safe to ignore.
|
|
useDraggable(draggableRef, {
|
|
position,
|
|
onDrag: ({ offsetX, offsetY }) => {
|
|
setPosition({ x: offsetX, y: offsetY });
|
|
},
|
|
onDragEnd: ({ event }) => {
|
|
setPosition({ x: 0, y: 0 });
|
|
onDrop(nodeType, { x: event.clientX, y: event.clientY });
|
|
},
|
|
});
|
|
|
|
return (
|
|
<div className={className}
|
|
ref={draggableRef}
|
|
id={`draggable-${nodeType}`}
|
|
data-testid={`draggable-${nodeType}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Adds a new node to the flow graph.
|
|
*
|
|
* Handles:
|
|
* - Automatic node ID generation based on existing nodes of the same type.
|
|
* - Loading of default data from the `NodeDefaults` registry.
|
|
* - Integration with the flow store to update global node state.
|
|
*
|
|
* @param nodeType - The type of node to create (from `NodeTypes`).
|
|
* @param position - The XY position in the flow canvas where the node will appear.
|
|
*/
|
|
function addNodeToFlow(nodeType: keyof typeof NodeTypes, position: XYPosition) {
|
|
const { nodes, addNode } = useFlowStore.getState();
|
|
|
|
// Load any predefined data for this node type.
|
|
const defaultData = NodeDefaults[nodeType] ?? {}
|
|
|
|
// Currently, we find out what the Id is by checking the last node and adding one.
|
|
const sameTypeNodes = nodes.filter((node) => node.type === nodeType);
|
|
const nextNumber =
|
|
sameTypeNodes.length > 0
|
|
? (() => {
|
|
const lastNode = sameTypeNodes[sameTypeNodes.length - 1];
|
|
const parts = lastNode.id.split('-');
|
|
const lastNum = Number(parts[1]);
|
|
return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1;
|
|
})()
|
|
: 1;
|
|
const id = `${nodeType}-${nextNumber}`;
|
|
|
|
// Create new node
|
|
const newNode = {
|
|
id: id,
|
|
type: nodeType,
|
|
position,
|
|
data: JSON.parse(JSON.stringify(defaultData))
|
|
}
|
|
addNode(newNode);
|
|
}
|
|
|
|
/**
|
|
* The drag-and-drop toolbar component for the visual programming interface.
|
|
*
|
|
* Displays draggable node templates based on entries in `NodeDefaults`.
|
|
* Each droppable node can be dragged into the flow pane to instantiate it.
|
|
*
|
|
* Automatically filters nodes whose `droppable` flag is set to `true`.
|
|
*
|
|
* @returns A React element representing the drag-and-drop toolbar.
|
|
*/
|
|
export function DndToolbar() {
|
|
const { screenToFlowPosition } = useReactFlow();
|
|
|
|
/**
|
|
* Handles dropping a node onto the flow pane.
|
|
* Translates screen coordinates into flow coordinates using React Flow utilities.
|
|
*/
|
|
const handleNodeDrop = useCallback(
|
|
(nodeType: keyof typeof NodeTypes, screenPosition: XYPosition) => {
|
|
const flow = document.querySelector('.react-flow');
|
|
const flowRect = flow?.getBoundingClientRect();
|
|
|
|
// Only add the node if it is inside the flow canvas area.
|
|
const isInFlow =
|
|
flowRect &&
|
|
screenPosition.x >= flowRect.left &&
|
|
screenPosition.x <= flowRect.right &&
|
|
screenPosition.y >= flowRect.top &&
|
|
screenPosition.y <= flowRect.bottom;
|
|
|
|
if (isInFlow) {
|
|
const position = screenToFlowPosition(screenPosition);
|
|
addNodeToFlow(nodeType, position);
|
|
}
|
|
},
|
|
[screenToFlowPosition],
|
|
);
|
|
|
|
|
|
// Map over the default nodes to get all nodes that can be dropped from the toolbar.
|
|
const droppableNodes = Object.entries(NodeDefaults)
|
|
.filter(([, data]) => data.droppable)
|
|
.map(([type, data]) => ({
|
|
type: type as DraggableNodeProps['nodeType'],
|
|
data
|
|
}));
|
|
|
|
return (
|
|
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`}>
|
|
<div className="description">
|
|
You can drag these nodes to the pane to create new nodes.
|
|
</div>
|
|
<div className={`flex-row gap-lg ${styles.dndNodeContainer}`}>
|
|
{/* Maps over all the nodes that are droppable, and puts them in the panel */}
|
|
{droppableNodes.map(({type, data}) => (
|
|
<DraggableNode
|
|
key={type}
|
|
className={styles[`draggable-node-${type}`]} // Our current style signature for nodes
|
|
nodeType={type}
|
|
onDrop={handleNodeDrop}
|
|
>
|
|
{data.label}
|
|
</DraggableNode>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|