refactor: Initial working framework of node encapsulation works- polymorphic implementation of nodes in creating and connecting calls correct functions

ref: N25B-294
This commit is contained in:
Björn Otgaar
2025-11-17 14:25:01 +01:00
parent b7eb0cb5ec
commit c5dc825ca3
13 changed files with 605 additions and 662 deletions

View File

@@ -1,19 +1,10 @@
import {useDraggable} from '@neodrag/react';
import {
useReactFlow,
type XYPosition
} from '@xyflow/react';
import {
type ReactNode,
useCallback,
useRef,
useState
} from 'react';
import useFlowStore from "../VisProgStores.tsx";
import styles from "../../VisProg.module.css"
import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx";
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 type { AppNode } from '../VisProgTypes';
import { NodeDefaults, type NodeTypes } from '../NodeRegistry'
/**
* DraggableNodeProps dictates the type properties of a DraggableNode
@@ -21,41 +12,28 @@ import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx";
interface DraggableNodeProps {
className?: string;
children: ReactNode;
nodeType: string;
onDrop: (nodeType: string, position: XYPosition) => void;
nodeType: keyof typeof NodeTypes;
onDrop: (nodeType: keyof typeof NodeTypes, position: XYPosition) => void;
}
/**
* Definition of a node inside the drag and drop toolbar,
* these nodes require an onDrop function to be supplied
* that dictates how the node is created in the graph.
*
* @param className
* @param children
* @param nodeType
* @param onDrop
* @constructor
* Definition of a node inside the drag and drop toolbar.
* These nodes require an onDrop function that dictates
* how the node is created in the graph.
*/
function DraggableNode({className, children, nodeType, onDrop}: DraggableNodeProps) {
function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) {
const draggableRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<XYPosition>({x: 0, y: 0});
const [position, setPosition] = useState<XYPosition>({ x: 0, y: 0 });
// @ts-expect-error comes from a package and doesn't appear to play nicely with strict typescript typing
// @ts-expect-error from the neodrag package — safe to ignore
useDraggable(draggableRef, {
position: position,
onDrag: ({offsetX, offsetY}) => {
// Calculate position relative to the viewport
setPosition({
x: offsetX,
y: offsetY,
});
position,
onDrag: ({ offsetX, offsetY }) => {
setPosition({ x: offsetX, y: offsetY });
},
onDragEnd: ({event}) => {
setPosition({x: 0, y: 0});
onDrop(nodeType, {
x: event.clientX,
y: event.clientY,
});
onDragEnd: ({ event }) => {
setPosition({ x: 0, y: 0 });
onDrop(nodeType, { x: event.clientX, y: event.clientY });
},
});
@@ -66,71 +44,49 @@ function DraggableNode({className, children, nodeType, onDrop}: DraggableNodePro
);
}
/**
* addNode — adds a new node to the flow using the unified class-based system.
* Keeps numbering logic for phase/norm nodes.
*/
export function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) {
const { nodes, setNodes } = useFlowStore.getState();
const defaultData = NodeDefaults[nodeType]
// eslint-disable-next-line react-refresh/only-export-components
export function addNode(nodeType: string, position: XYPosition) {
const {setNodes} = useFlowStore.getState();
const nds : AppNode[] = useFlowStore.getState().nodes;
const newNode = () => {
switch (nodeType) {
case "phase":
{
const phaseNodes= nds.filter((node) => node.type === 'phase');
let phaseNumber;
if (phaseNodes.length > 0) {
const finalPhaseId : number = +(phaseNodes[phaseNodes.length - 1].id.split('-')[1]);
phaseNumber = finalPhaseId + 1;
} else {
phaseNumber = 1;
}
const phaseNode : PhaseNode = {
id: `phase-${phaseNumber}`,
type: nodeType,
position,
data: {label: 'new', number: phaseNumber},
}
return phaseNode;
}
case "norm":
{
const normNodes= nds.filter((node) => node.type === 'norm');
let normNumber
if (normNodes.length > 0) {
const finalNormId : number = +(normNodes[normNodes.length - 1].id.split('-')[1]);
normNumber = finalNormId + 1;
} else {
normNumber = 1;
}
if (!defaultData) throw new Error(`Node type '${nodeType}' not found in registry`);
const normNode : NormNode = {
id: `norm-${normNumber}`,
type: nodeType,
position,
data: {label: `new norm node`, value: "Pepper should be formal"},
}
return normNode;
}
default: {
throw new Error(`Node ${nodeType} not found`);
}
}
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}`;
let newNode = {
id: id,
type: nodeType,
position,
data: {...defaultData}
}
setNodes(nds.concat(newNode()));
console.log("Tried to add node");
setNodes([...nodes, newNode]);
}
/**
* the DndToolbar defines how the drag and drop toolbar component works
* and includes the default onDrop behavior through handleNodeDrop
* @constructor
* DndToolbar defines how the drag and drop toolbar component works
* and includes the default onDrop behavior.
*/
export function DndToolbar() {
const {screenToFlowPosition} = useReactFlow();
/**
* handleNodeDrop implements the default onDrop behavior
*/
const { screenToFlowPosition } = useReactFlow();
const handleNodeDrop = useCallback(
(nodeType: string, screenPosition: XYPosition) => {
(nodeType: keyof typeof NodeTypes, screenPosition: XYPosition) => {
const flow = document.querySelector('.react-flow');
const flowRect = flow?.getBoundingClientRect();
const isInFlow =
@@ -140,7 +96,6 @@ export function DndToolbar() {
screenPosition.y >= flowRect.top &&
screenPosition.y <= flowRect.bottom;
// Create a new node and add it to the flow
if (isInFlow) {
const position = screenToFlowPosition(screenPosition);
addNode(nodeType, position);
@@ -149,19 +104,35 @@ export function DndToolbar() {
[screenToFlowPosition],
);
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}`}>
<DraggableNode className={styles.draggableNodePhase} nodeType="phase" onDrop={handleNodeDrop}>
phase Node
</DraggableNode>
<DraggableNode className={styles.draggableNodeNorm} nodeType="norm" onDrop={handleNodeDrop}>
norm Node
</DraggableNode>
{
// Maps over all the nodes that are droppable, and puts them in the panel
}
{droppableNodes.map(({type, data}) => (
<DraggableNode
className={styles.nodeType}
nodeType={type}
onDrop={handleNodeDrop}
>
{data.label}
</DraggableNode>
))}
</div>
</div>
);
}
}

View File

@@ -1,183 +0,0 @@
import {
Handle,
type NodeProps,
NodeToolbar,
Position
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import styles from '../../VisProg.module.css';
import useFlowStore from "../VisProgStores.tsx";
import type {
StartNode,
EndNode,
PhaseNode,
NormNode
} from "../VisProgTypes.tsx";
//Toolbar definitions
type ToolbarProps = {
nodeId: string;
allowDelete: boolean;
};
/**
* Node Toolbar definition:
* handles: node deleting functionality
* can be added to any custom node component as a React component
*
* @param {string} nodeId
* @param {boolean} allowDelete
* @returns {React.JSX.Element}
* @constructor
*/
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
const {deleteNode} = useFlowStore();
const deleteParentNode = ()=> {
deleteNode(nodeId);
}
return (
<NodeToolbar>
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
</NodeToolbar>);
}
// Renaming component
/**
* Adds a component that can be used to edit a node's label entry inside its Data
* can be added to any custom node that has a label inside its Data
*
* @param {string} nodeLabel
* @param {string} nodeId
* @returns {React.JSX.Element}
* @constructor
*/
export function EditableName({nodeLabel = "new node", nodeId} : { nodeLabel : string, nodeId: string}) {
const {updateNodeData} = useFlowStore();
const updateData = (event: React.FocusEvent<HTMLInputElement>) => {
const input = event.target.value;
updateNodeData(nodeId, {label: input});
event.currentTarget.setAttribute("readOnly", "true");
window.getSelection()?.empty();
event.currentTarget.classList.replace("nodrag", "drag"); // enable dragging of the node with cursor on the input box
};
const updateOnEnter = (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); };
const enableEditing = (event: React.MouseEvent<HTMLInputElement>) => {
if(event.currentTarget.hasAttribute("readOnly")) {
event.currentTarget.removeAttribute("readOnly"); // enable editing
event.currentTarget.select(); // select the text input
window.getSelection()?.collapseToEnd(); // move the caret to the end of the current value
event.currentTarget.classList.replace("drag", "nodrag"); // disable dragging using input box
}
}
return (
<div className={styles.NodeTextBar }>
<label>name: </label>
<input
className={`drag ${styles.nodeTextInput}`} // prevents dragging the component when user has focused the text input
type={"text"}
defaultValue={nodeLabel}
onKeyDown={updateOnEnter}
onBlur={updateData}
onClick={enableEditing}
maxLength={25}
readOnly
/>
</div>
)
}
// Definitions of Nodes
/**
* Start Node definition:
*
* @param {string} id
* @param {defaultNodeData} data
* @returns {React.JSX.Element}
* @constructor
*/
export const StartNodeComponent = ({id, data}: NodeProps<StartNode>) => {
return (
<>
<Toolbar nodeId={id} allowDelete={false}/>
<div className={`${styles.defaultNode} ${styles.nodeStart}`}>
<div> data test {data.label} </div>
<Handle type="source" position={Position.Right} id="start"/>
</div>
</>
);
};
/**
* End node definition:
*
* @param {string} id
* @param {defaultNodeData} data
* @returns {React.JSX.Element}
* @constructor
*/
export const EndNodeComponent = ({id, data}: NodeProps<EndNode>) => {
return (
<>
<Toolbar nodeId={id} allowDelete={false}/>
<div className={`${styles.defaultNode} ${styles.nodeEnd}`}>
<div> {data.label} </div>
<Handle type="target" position={Position.Left} id="end"/>
</div>
</>
);
};
/**
* Phase node definition:
*
* @param {string} id
* @param {defaultNodeData & {number: number}} data
* @returns {React.JSX.Element}
* @constructor
*/
export const PhaseNodeComponent = ({id, data}: NodeProps<PhaseNode>) => {
return (
<>
<Toolbar nodeId={id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
<EditableName nodeLabel={data.label} nodeId={id}/>
<Handle type="target" position={Position.Left} id="target"/>
<Handle type="target" position={Position.Bottom} id="norms"/>
<Handle type="source" position={Position.Right} id="source"/>
</div>
</>
);
};
/**
* Norm node definition:
*
* @param {string} id
* @param {defaultNodeData & {value: string}} data
* @returns {React.JSX.Element}
* @constructor
*/
export const NormNodeComponent = ({id, data}: NodeProps<NormNode>) => {
return (
<>
<Toolbar nodeId={id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
<EditableName nodeLabel={data.label} nodeId={id}/>
<Handle type="source" position={Position.Right} id="NormSource"/>
</div>
</>
);
};