From d249df4ac4abf1e87229c21b4a1eb09640ef69b2 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 13 May 2026 13:32:54 -0400 Subject: [PATCH] sync(ladder): mirror handle-branches-on-ladder feature from openplc-web Pairs with openplc-web PR #395 (multi-branch-ladder). Brings handle branches: contacts/coils/parallels can be wired to a function block's secondary boolean input/output handles (e.g. CTU `R`) via a compact branch with its own local rail. Includes layout (placement, spacing, vertical clearance, parallel paths inside branches with nested-parallel support), routing (placeholders, drops, splices), drag-drop dispatch (classify-then-dispatch), persistence (rung state index, Zod load paths), and XML serializer wiring for FB input handles. Shared surface verified byte-identical against openplc-web at d79f2a1a (plus the type-cast/lint/prettier follow-ups 3f1acfe1, cb923351). --- .../ladder/autocomplete/index.tsx | 23 + .../_atoms/graphical-editor/ladder/block.tsx | 85 +- .../graphical-editor/ladder/power-rail.tsx | 35 +- .../graphical-editor/ladder/utils/types.ts | 21 + .../graphical-editor/ladder/variable.tsx | 58 +- .../_atoms/highlighted-textarea/index.tsx | 4 +- .../graphical/elements/ladder/block/index.tsx | 30 +- .../graphical-editor/ladder/rung/body.tsx | 17 +- .../ladder/rung/ladder-utils/edges.ts | 2 +- .../rung/ladder-utils/elements/core/index.ts | 169 +++ .../ladder-utils/elements/diagram/index.ts | 358 ++++- .../elements/drag-n-drop/handlers.ts | 390 +++++ .../elements/drag-n-drop/index.ts | 377 +++-- .../elements/handle-branch/index.ts | 1328 +++++++++++++++++ .../rung/ladder-utils/elements/index.ts | 284 +++- .../ladder-utils/elements/parallel/index.ts | 214 +-- .../elements/placeholder/index.ts | 262 +++- .../rung/ladder-utils/elements/utils/index.ts | 10 +- .../elements/variable-block/index.ts | 9 +- src/frontend/store/slices/ladder/slice.ts | 18 +- src/frontend/store/slices/ladder/types.ts | 14 +- .../store/slices/ladder/utils/index.ts | 55 +- .../codesys/language/ladder-xml.ts | 78 +- .../old-editor/language/ladder-xml.ts | 87 +- src/middleware/shared/ports/types.ts | 24 + 25 files changed, 3495 insertions(+), 457 deletions(-) create mode 100644 src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/core/index.ts create mode 100644 src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/handlers.ts create mode 100644 src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx index 1804ac44d..ea1332d33 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx @@ -163,6 +163,29 @@ const VariablesBlockAutoComplete = forwardRef { if (!variableName.trim()) { + // For variable nodes on block handles, clearing the name resets the variable + // so that a branch (contacts/coils) can be placed on the handle instead. + if (blockType === 'variable') { + const { rung, node: variableNode } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, { + nodeId: (block as Node).id, + }) + if (rung && variableNode) { + updateNode({ + editorName: editor.meta.name, + rungId: rung.id, + nodeId: variableNode.id, + node: { + ...variableNode, + data: { + ...variableNode.data, + variable: { id: '', name: '' }, + }, + }, + }) + } + return + } + toast({ title: 'Invalid variable name', description: 'Variable name cannot be empty', diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx index 9790f1139..8842c9b01 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx @@ -10,6 +10,7 @@ import { checkVariableName } from '../../../../store/slices/project/validation/v import { cn } from '../../../../utils/cn' import { toast } from '../../../_features/[app]/toast/use-toast' import { updateDiagramElementsPosition } from '../../../_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram' +import { reconcileBranchesIfNeeded } from '../../../_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch' import { HighlightedTextArea } from '../../highlighted-textarea' import { InputWithRef } from '../../input' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../tooltip' @@ -60,7 +61,7 @@ export const BlockNodeElement = ({ editorActions: { updateModelVariables }, libraries, ladderFlows, - ladderFlowActions: { setNodes, setEdges }, + ladderFlowActions: { setNodes, setEdges, setHandleBranches }, project: { data: { pous }, }, @@ -265,6 +266,19 @@ export const BlockNodeElement = ({ newNodes = newNodes.map((n) => (n.id === node.id ? newBlockNode : n)) + // Reconcile branches before main edge remapping so branch edges get new IDs + const reconciled = reconcileBranchesIfNeeded( + { ...rung, nodes: newNodes, edges: newEdges }, + node.id, + newBlockNode.id, + (libraryBlock as { variables?: BlockVariant['variables'] })?.variables ?? [], + ) + if (reconciled) { + newNodes = reconciled.nodes + newEdges = reconciled.edges + } + const reconciledHandleBranches = reconciled?.handleBranches + edges.source?.forEach((edge) => { const newEdge = { ...edge, @@ -289,6 +303,7 @@ export const BlockNodeElement = ({ ...rung, nodes: newNodes, edges: newEdges, + ...(reconciledHandleBranches && { handleBranches: reconciledHandleBranches }), }, [rung.defaultBounds[0], rung.defaultBounds[1]], ) @@ -310,6 +325,13 @@ export const BlockNodeElement = ({ rungId: rung.id, edges: variableEdges, }) + if (reconciledHandleBranches) { + setHandleBranches({ + editorName: editor.meta.name, + rungId: rung.id, + handleBranches: reconciledHandleBranches, + }) + } setWrongName(false) } @@ -349,24 +371,26 @@ export const BlockNodeElement = ({ onKeyDown={(e) => e.key === 'Enter' && inputNameRef.current?.blur()} ref={inputNameRef} /> - {inputConnectors.map((connector, index) => ( -
- {connector} -
- ))} - {outputConnectors.map((connector, index) => ( -
- {connector} -
- ))} + {inputConnectors.map((connector, index) => { + const handle = (data as BasicNodeData).inputHandles?.[index] + const top = + (handle?.relPosition?.y ?? DEFAULT_BLOCK_CONNECTOR_Y + index * DEFAULT_BLOCK_CONNECTOR_Y_OFFSET) - 10 + return ( +
+ {connector} +
+ ) + })} + {outputConnectors.map((connector, index) => { + const handle = (data as BasicNodeData).outputHandles?.[index] + const top = + (handle?.relPosition?.y ?? DEFAULT_BLOCK_CONNECTOR_Y + index * DEFAULT_BLOCK_CONNECTOR_Y_OFFSET) - 10 + return ( +
+ {connector} +
+ ) + })} ) } @@ -383,7 +407,7 @@ export const Block = (block: BlockProps) => { snapshotActions: { pushToHistory }, libraries: { user: userLibraries }, ladderFlows, - ladderFlowActions: { updateNode, setNodes, setEdges }, + ladderFlowActions: { updateNode, setNodes, setEdges, setHandleBranches: setHandleBranchesBlock }, } = useOpenPLCStore() const { type: blockType } = (data.variant as BlockVariant) ?? DEFAULT_BLOCK_TYPE const documentation = getBlockDocumentation(data.variant as newBlockVariant) @@ -722,6 +746,19 @@ export const Block = (block: BlockProps) => { newNodes = newNodes.map((n) => (n.id === node.id ? newBlockNode : n)) + // Reconcile branches before main edge remapping so branch edges get new IDs + const reconciled2 = reconcileBranchesIfNeeded( + { ...rung, nodes: newNodes, edges: newEdges }, + node.id, + newBlockNode.id, + newNodeVariables as BlockVariant['variables'], + ) + if (reconciled2) { + newNodes = reconciled2.nodes + newEdges = reconciled2.edges + } + const reconciledHandleBranches = reconciled2?.handleBranches + edges.source?.forEach((edge) => { const newEdge = { ...edge, @@ -746,6 +783,7 @@ export const Block = (block: BlockProps) => { ...rung, nodes: newNodes, edges: newEdges, + ...(reconciledHandleBranches && { handleBranches: reconciledHandleBranches }), }, [rung.defaultBounds[0], rung.defaultBounds[1]], ) @@ -760,6 +798,13 @@ export const Block = (block: BlockProps) => { rungId: rung.id, edges: variableEdges, }) + if (reconciledHandleBranches) { + setHandleBranchesBlock({ + editorName: editor.meta.name, + rungId: rung.id, + handleBranches: reconciledHandleBranches, + }) + } } return ( diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/power-rail.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/power-rail.tsx index eaee9906d..52edab3a8 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/power-rail.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/power-rail.tsx @@ -1,12 +1,41 @@ +import { useUpdateNodeInternals } from '@xyflow/react' +import { useEffect, useMemo } from 'react' + import { CustomHandle } from './handle' import { DEFAULT_POWER_RAIL_HEIGHT, DEFAULT_POWER_RAIL_WIDTH } from './utils/constants' import { PowerRailProps } from './utils/types' -export const PowerRail = ({ data }: PowerRailProps) => { +export const PowerRail = ({ id, data }: PowerRailProps) => { + const updateNodeInternals = useUpdateNodeInternals() + + // Calculate dynamic height to cover all handles (including branch handles) + const railHeight = useMemo(() => { + if (data.handles.length <= 1) return DEFAULT_POWER_RAIL_HEIGHT + + let maxRelY = 0 + for (const handle of data.handles) { + const relY = handle.relPosition?.y ?? 0 + if (relY > maxRelY) maxRelY = relY + } + + // Add padding below the lowest handle + const dynamicHeight = maxRelY + DEFAULT_POWER_RAIL_HEIGHT / 2 + return Math.max(DEFAULT_POWER_RAIL_HEIGHT, dynamicHeight) + }, [data.handles]) + + // Derive a stable key from handle IDs so we re-scan when handles are added, + // removed, or replaced (e.g. reconcileBranches swaps the handle ID). + const handleIds = useMemo(() => data.handles.map((h) => h.id).join(','), [data.handles]) + + // Force ReactFlow to re-scan handle bounds when handles change count, IDs, or position + useEffect(() => { + updateNodeInternals(id) + }, [handleIds, railHeight, id, updateNodeInternals]) + return ( <> - - + + {data.handles.map((handle, index) => ( diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts b/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts index 2aeeb04e9..6a4567114 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts +++ b/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts @@ -2,8 +2,14 @@ import type { Node, NodeProps } from '@xyflow/react' import { ReactNode } from 'react' import { PLCVariable } from '../../../../../../middleware/shared/ports' +import type { HandleBranch } from '../../../../../../middleware/shared/ports/types' import { CustomHandleProps } from '../handle' +// HandleBranch is defined in the ports layer (where RungLadderState lives) so +// the rung type can reference it without violating layer rules. Re-export it +// here so component code can import it from a single nearby location. +export type { HandleBranch } + export type BuilderBasicProps = { id: string posX: number @@ -24,6 +30,21 @@ export type BasicNodeData = { draggable: boolean selectable: boolean deletable: boolean + /** Marks this node as part of a handle branch (contact/coil on a block input/output) */ + branchContext?: { + blockId: string + handleId: string + direction: 'input' | 'output' + } + /** Set on placeholders to indicate dropping here creates a handle branch */ + handleBranchTarget?: { + blockId: string + handleId: string + direction: 'input' | 'output' + handlePosition: { x: number; y: number } + /** When set, insert into existing branch at this position in nodeIds. When undefined, create new branch. */ + insertIndex?: number + } } // block diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx index b1f58741c..17d7c7a10 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx @@ -128,29 +128,36 @@ const VariableElement = (block: VariableProps) => { * Update inputError state when the table of variables is updated */ useEffect(() => { - const { - node: variableNode, - rung, - variables, - } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, { + const { node: variableNode, rung } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, { nodeId: id, variableName: data.variable?.name, }) if (!rung || !variableNode) return - // Use the selected variable from getLadderPouVariablesRungNodeAndEdges which properly - // handles derived types for 'variable' node types (block pin variables) - const variable = variables.selected + // Use the node's current variable name (from the store) as the source of truth. + // The prop `data.variable?.name` may be stale during React's render cycle, so + // looking up the POU variable by the node's actual name avoids overwriting + // user-initiated changes (selection, clearing) with stale prop values. + const nodeVariableName = (variableNode as VariableNode).data.variable.name + const nodeVarRef = (variableNode as VariableNode).data.variable + + if (!nodeVariableName) { + setIsAVariable(false) + return + } + + // Find the POU variable that matches the node's current variable name + const pouVariables = pous.find((p) => p.name === editor.meta.name)?.interface?.variables ?? [] + const variable = pouVariables.find((v) => v.name.toLowerCase() === nodeVariableName.toLowerCase()) if (!variable || !inputVariableRef) { setIsAVariable(false) } else { - const nodeVariableName = (variableNode as VariableNode).data.variable.name - const namesMatchCI = variable.name.toLowerCase() === nodeVariableName.toLowerCase() const caseDiffers = variable.name !== nodeVariableName + const refStale = nodeVarRef !== variable - if (!namesMatchCI || caseDiffers) { + if (!namesMatchCI || caseDiffers || refStale) { updateNode({ editorName: editor.meta.name, rungId: rung.id, @@ -180,8 +187,6 @@ const VariableElement = (block: VariableProps) => { setIsAVariable(true) } - if (!rung) return - const relatedBlock = rung.nodes.find((node) => node.id === data.block.id) if (!relatedBlock) { setInputError(true) @@ -192,8 +197,8 @@ const VariableElement = (block: VariableProps) => { /** * Handle with the variable input onBlur event */ - const handleSubmitVariableValueOnTextareaBlur = (variableName?: string) => { - const variableNameToSubmit = variableName || variableValue + const handleSubmitVariableValueOnTextareaBlur = (currentValue?: string) => { + const variableNameToSubmit = currentValue ?? variableValue const { pou, rung, node } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, { nodeId: id, @@ -201,6 +206,29 @@ const VariableElement = (block: VariableProps) => { if (!pou || !rung || !node) return const variableNode = node as VariableNode + // Allow clearing a variable from a block handle by submitting an empty name. + // This resets the variable node so a branch (contacts/coils) can be placed instead. + if (!variableNameToSubmit.trim()) { + const emptyVariable = { id: '', name: '' } + setVariableValue('') + setIsAVariable(false) + setInputError(false) + updateNode({ + editorName: editor.meta.name, + rungId: rung.id, + nodeId: variableNode.id, + node: { + ...variableNode, + data: { + ...variableNode.data, + variable: emptyVariable, + }, + }, + }) + updateRelatedNode(rung, variableNode, emptyVariable as PLCVariable) + return + } + // For variable nodes (block pins), allow all types including derived (user-defined types) // Don't use getVariableByName here as it filters out derived types let variable: PLCVariable | { name: string } | undefined = (pou.interface?.variables ?? []).find( diff --git a/src/frontend/components/_atoms/highlighted-textarea/index.tsx b/src/frontend/components/_atoms/highlighted-textarea/index.tsx index 43ee3ffac..538728de7 100644 --- a/src/frontend/components/_atoms/highlighted-textarea/index.tsx +++ b/src/frontend/components/_atoms/highlighted-textarea/index.tsx @@ -8,7 +8,7 @@ import { cn } from '../../../utils/cn' type HighlightedTextAreaProps = ComponentPropsWithRef<'textarea'> & { textAreaValue: string setTextAreaValue: (value: string) => void - handleSubmit?: () => void + handleSubmit?: (currentValue?: string) => void submitWith?: { enter: boolean } @@ -147,7 +147,7 @@ const HighlightedTextArea = forwardRef onScrollHandler(e)} diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/index.tsx index 865634d76..f5879f7df 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/index.tsx @@ -29,6 +29,7 @@ import { BlockVariant } from '../../../../../../../_atoms/graphical-editor/types import { getBlockDocumentation, getVariableRestrictionType } from '../../../../../../../_atoms/graphical-editor/utils' import { InputWithRef } from '../../../../../../../_atoms/input' import { updateDiagramElementsPosition } from '../../../../../../../_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram' +import { reconcileBranchesIfNeeded } from '../../../../../../../_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch' import { Modal, ModalContent, ModalTitle } from '../../../../../../../_molecules/modal' import ArrowButtonGroup from '../../arrow-button-group' import { ModalBlockLibrary } from './library' @@ -69,7 +70,7 @@ const BlockElement = ({ isOpen, onClose, selectedNode }: Block editor, editorActions: { updateModelVariables }, ladderFlows, - ladderFlowActions: { setNodes, setEdges }, + ladderFlowActions: { setNodes, setEdges, setHandleBranches }, project: { data: { pous }, }, @@ -416,6 +417,25 @@ const BlockElement = ({ isOpen, onClose, selectedNode }: Block newNodes = newNodes.map((n) => (n.id === node.id ? newNode : n)) + // Reconcile branches when the block changes: remap surviving branches + // to the new block ID and remove branches for deleted/incompatible handles. + // Must run BEFORE main connector edge remapping so branch edges get new IDs + // and the main remapping loop below won't overwrite them. + const reconciled = reconcileBranchesIfNeeded( + { ...rung, nodes: newNodes, edges: newEdges }, + node.id, + newNode.id, + LadderBlockVariant?.variables ?? [], + ) + if (reconciled) { + newNodes = reconciled.nodes + newEdges = reconciled.edges + } + const reconciledHandleBranches = reconciled?.handleBranches + + // Remap main connector edges (inputConnector/outputConnector) to new block ID. + // Branch edges are already remapped by reconcileBranches (their IDs changed, + // so the old edge.id won't match in newEdges). edges.source?.forEach((edge) => { const newEdge = { ...edge, @@ -440,6 +460,7 @@ const BlockElement = ({ isOpen, onClose, selectedNode }: Block ...rung, nodes: newNodes, edges: newEdges, + ...(reconciledHandleBranches && { handleBranches: reconciledHandleBranches }), }, [rung.defaultBounds[0], rung.defaultBounds[1]], ) @@ -454,6 +475,13 @@ const BlockElement = ({ isOpen, onClose, selectedNode }: Block rungId: rung.id, edges: variableEdges, }) + if (reconciledHandleBranches) { + setHandleBranches({ + editorName: editor.meta.name, + rungId: rung.id, + handleBranches: reconciledHandleBranches, + }) + } handleCloseModal() } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx index ba0577350..4f62c8fdb 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx @@ -510,7 +510,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi } } - const { nodes, edges, newNode } = addNewElement(rungLocal, { + const { nodes, edges, newNode, handleBranches } = addNewElement(rungLocal, { elementType: newNodeType, blockVariant: pouLibrary, }) @@ -519,6 +519,9 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi ladderFlowActions.setNodes({ editorName: editor.meta.name, rungId: rungLocal.id, nodes }) ladderFlowActions.setEdges({ editorName: editor.meta.name, rungId: rungLocal.id, edges }) + if (handleBranches) { + ladderFlowActions.setHandleBranches({ editorName: editor.meta.name, rungId: rungLocal.id, handleBranches }) + } if (newNode) ladderFlowActions.setSelectedNodes({ @@ -541,12 +544,15 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi * Remove some nodes from the rung */ const handleRemoveNode = (nodes: FlowNode[]) => { - const { nodes: newNodes, edges: newEdges } = removeElements({ ...rungLocal }, nodes) + const { nodes: newNodes, edges: newEdges, handleBranches } = removeElements({ ...rungLocal }, nodes) captureAndPush(editor.meta.name) ladderFlowActions.setNodes({ editorName: editor.meta.name, rungId: rungLocal.id, nodes: newNodes }) ladderFlowActions.setEdges({ editorName: editor.meta.name, rungId: rungLocal.id, edges: newEdges }) + if (handleBranches) { + ladderFlowActions.setHandleBranches({ editorName: editor.meta.name, rungId: rungLocal.id, handleBranches }) + } ladderFlowActions.setSelectedNodes({ editorName: editor.meta.name, rungId: rungLocal.id, @@ -659,6 +665,13 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi setDragging(false) ladderFlowActions.setNodes({ editorName: editor.meta.name, rungId: rungLocal.id, nodes: result.nodes }) ladderFlowActions.setEdges({ editorName: editor.meta.name, rungId: rungLocal.id, edges: result.edges }) + if (result.handleBranches) { + ladderFlowActions.setHandleBranches({ + editorName: editor.meta.name, + rungId: rungLocal.id, + handleBranches: result.handleBranches, + }) + } if (pouRef) { syncNodesWithVariables( diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/edges.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/edges.ts index 177873a38..f38ad5976 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/edges.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/edges.ts @@ -48,7 +48,7 @@ export const connectNodes = ( ? edge.sourceHandle === (sourceNode as ParallelNode).data.parallelOutputConnector?.id : isNodeOfType(sourceNode, 'parallel') && sourceNode.data.type === 'close' ? edge.sourceHandle === (sourceNode as ParallelNode).data.parallelOutputConnector?.id || - (sourceNode as ParallelNode).data.outputConnector?.id + edge.sourceHandle === (sourceNode as ParallelNode).data.outputConnector?.id : edge.sourceHandle === (sourceNode.data as BasicNodeData).outputConnector?.id) ) }) diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/core/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/core/index.ts new file mode 100644 index 000000000..e232c14ad --- /dev/null +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/core/index.ts @@ -0,0 +1,169 @@ +import type { Edge, Node } from '@xyflow/react' + +import type { BasicNodeData, ParallelNode } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' +import { buildEdge } from '../../edges' + +/** + * Splice an existing edge and insert a new node in its place. + * Removes `oldEdge` and creates two new edges: + * oldEdge.source[sourceHandle] → newNode[inputHandle] + * newNode[outputHandle] → oldEdge.target[targetHandle] + */ +export function spliceEdgeAndInsertNode( + edges: Edge[], + oldEdge: Edge, + newNodeId: string, + newNodeInputHandle: string, + newNodeOutputHandle: string, +): Edge[] { + const filtered = edges.filter((e) => e.id !== oldEdge.id) + + filtered.push( + buildEdge(oldEdge.source, newNodeId, { + sourceHandle: oldEdge.sourceHandle ?? undefined, + targetHandle: newNodeInputHandle, + }), + ) + filtered.push( + buildEdge(newNodeId, oldEdge.target, { + sourceHandle: newNodeOutputHandle, + targetHandle: oldEdge.targetHandle ?? undefined, + }), + ) + + return filtered +} + +/** + * Find an edge connected to an anchor node and splice a new node into the chain. + * Handles both left (insert before anchor) and right (insert after anchor) positions. + * + * - `left`: finds the edge targeting the anchor, inserts newNode before anchor + * - `right`: finds the edge sourced from anchor's outputConnector, inserts newNode after anchor + * + * Returns the updated edges array, or `null` if no matching edge was found. + */ +export function spliceNodeIntoEdgeChain( + edges: Edge[], + anchorNodeId: string, + anchorNode: Node, + position: 'left' | 'right', + newNode: Node, +): Edge[] | null { + const newData = newNode.data as BasicNodeData + const inputHandle = newData.inputConnector?.id ?? 'input' + const outputHandle = newData.outputConnector?.id ?? 'output' + + if (position === 'left') { + const edgeToSplit = edges.find((e) => e.target === anchorNodeId) + if (!edgeToSplit) return null + return spliceEdgeAndInsertNode(edges, edgeToSplit, newNode.id, inputHandle, outputHandle) + } + + // position === 'right' + const anchorData = anchorNode.data as BasicNodeData + const edgeToSplit = edges.find((e) => e.source === anchorNodeId && e.sourceHandle === anchorData.outputConnector?.id) + if (!edgeToSplit) return null + + const filtered = edges.filter((e) => e.id !== edgeToSplit.id) + filtered.push( + buildEdge(anchorNodeId, newNode.id, { + sourceHandle: edgeToSplit.sourceHandle ?? undefined, + targetHandle: inputHandle, + }), + ) + filtered.push( + buildEdge(newNode.id, edgeToSplit.target, { + sourceHandle: outputHandle, + targetHandle: edgeToSplit.targetHandle ?? undefined, + }), + ) + + return filtered +} + +/** + * Wire a parallel (OPEN/CLOSE) structure around an existing element. + * + * Creates the 6-edge pattern: + * predecessor → OPEN → aboveElement → CLOSE → successor (serial path) + * OPEN → newElement → CLOSE (parallel path) + * + * Returns the IDs of edges to remove and the new edges to add. + * + * Optional `openTargetHandle` / `closeSourceHandle` override the OPEN's receiving + * handle and CLOSE's sending handle for nested parallel cases. + */ +export function wireParallelAroundElement(params: { + incomingEdge: Edge + outgoingEdge: Edge + openParallel: Node + closeParallel: Node + aboveElement: Node + newElement: Node + openTargetHandle?: string + closeSourceHandle?: string +}): { + edgesToRemove: string[] + edgesToAdd: Edge[] +} { + const { incomingEdge, outgoingEdge, openParallel, closeParallel, aboveElement, newElement } = params + + const openData = openParallel.data as ParallelNode['data'] + const closeData = closeParallel.data as ParallelNode['data'] + const aboveData = aboveElement.data as BasicNodeData + const newData = newElement.data as BasicNodeData + + const edgesToRemove = [incomingEdge.id, outgoingEdge.id] + const edgesToAdd: Edge[] = [] + + // 1. Predecessor → OPEN (preserves boundary sourceHandle from incoming edge) + edgesToAdd.push( + buildEdge(incomingEdge.source, openParallel.id, { + sourceHandle: incomingEdge.sourceHandle ?? undefined, + targetHandle: params.openTargetHandle ?? openData.inputConnector?.id, + }), + ) + + // 2. Serial: OPEN → aboveElement + edgesToAdd.push( + buildEdge(openParallel.id, aboveElement.id, { + sourceHandle: openData.outputConnector?.id, + targetHandle: aboveData.inputConnector?.id, + }), + ) + + // 3. Serial: aboveElement → CLOSE + edgesToAdd.push( + buildEdge(aboveElement.id, closeParallel.id, { + sourceHandle: aboveData.outputConnector?.id, + targetHandle: closeData.inputConnector?.id, + }), + ) + + // 4. Parallel: OPEN → newElement + edgesToAdd.push( + buildEdge(openParallel.id, newElement.id, { + sourceHandle: openData.parallelOutputConnector?.id, + targetHandle: newData.inputConnector?.id, + }), + ) + + // 5. Parallel: newElement → CLOSE + edgesToAdd.push( + buildEdge(newElement.id, closeParallel.id, { + sourceHandle: newData.outputConnector?.id, + targetHandle: closeData.parallelInputConnector?.id, + }), + ) + + // 6. CLOSE → successor (preserves boundary targetHandle from outgoing edge) + edgesToAdd.push( + buildEdge(closeParallel.id, outgoingEdge.target, { + sourceHandle: params.closeSourceHandle ?? closeData.outputConnector?.id, + targetHandle: outgoingEdge.targetHandle ?? undefined, + }), + ) + + return { edgesToRemove, edgesToAdd } +} diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts index b8f959463..96a0b97a7 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts @@ -7,6 +7,12 @@ import type { CustomHandleProps } from '../../../../../../../_atoms/graphical-ed import { defaultCustomNodesStyles } from '../../../../../../../_atoms/graphical-editor/ladder/node-builders' import type { BasicNodeData, ParallelNode } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' import { getDefaultNodeStyle, isNodeOfType } from '../../nodes' +import { + calculateBranchElementPositions, + calculateDynamicHandleOffsets, + getBranch, + updateRailForBranches, +} from '../handle-branch' import { findAllParallelsDepthAndNodes, findParallelsInRung, @@ -75,6 +81,300 @@ export const changeRailBounds = (rung: RungLadderState, defaultBounds: [number, return { nodes: newNodes } } +/** + * Calculate the extra horizontal space a block needs for its input branches. + * + * Reads the leftmost X reached by calculateBranchElementPositions and subtracts it + * from the block's input-handle X. Because all positions in that result shift + * uniformly with the block, `handleX - min(posX)` is invariant under where the + * block is currently placed — making it safe to call mid-layout, before the block + * has been moved to its final spot. + * + * Naive width-summation over the branch's nodeIds would double-count nested + * parallels: they overlap at the same X span rather than chaining horizontally, + * so summing their widths grossly overestimates the space needed. + */ +const calculateInputBranchSpace = (rung: RungLadderState, blockId: string): number => { + if (!rung.handleBranches?.length) return 0 + + const blockNode = rung.nodes.find((n) => n.id === blockId) + if (!blockNode) return 0 + const blockData = blockNode.data as BasicNodeData + + let maxSpace = 0 + for (const branch of rung.handleBranches) { + if (branch.blockId !== blockId || branch.direction !== 'input') continue + + const targetHandle = blockData.inputHandles.find((h) => h.id === branch.handleId) + if (!targetHandle) continue + const handleX = targetHandle.glbPosition.x + + const { positions } = calculateBranchElementPositions({ ...rung }, branch) + if (positions.length === 0) continue + + let minX = Infinity + for (const p of positions) if (p.posX < minX) minX = p.posX + if (minX === Infinity) continue + + maxSpace = Math.max(maxSpace, handleX - minX) + } + + return maxSpace +} + +/** + * Position branch elements based on their target block handle positions. + * Runs as a post-pass after the main layout loop finalizes block positions. + * Reuses calculateBranchElementPositions from handle-branch module. + */ +const positionBranchElements = (rung: RungLadderState): Node[] => { + if (!rung.handleBranches?.length) return rung.nodes + + const nodes = [...rung.nodes] + + for (const branch of rung.handleBranches) { + const { positions } = calculateBranchElementPositions({ ...rung, nodes }, branch) + + for (const pos of positions) { + const nodeIdx = nodes.findIndex((n) => n.id === pos.nodeId) + if (nodeIdx === -1) continue + + const node = nodes[nodeIdx] + const nodeData = node.data as BasicNodeData + const style = defaultCustomNodesStyles[node.type ?? 'contact'] ?? defaultCustomNodesStyles.contact + + const newInputHandles = nodeData.inputHandles.map((h) => ({ + ...h, + glbPosition: { x: pos.handleX, y: pos.handleY }, + })) + const newOutputHandles = nodeData.outputHandles.map((h) => ({ + ...h, + glbPosition: { x: pos.handleX + style.width, y: pos.handleY }, + })) + const newHandles = [...newInputHandles, ...newOutputHandles] + + if (node.type === 'parallel') { + // Parallel OPEN/CLOSE nodes need parallelInputConnector/parallelOutputConnector updated + const parallelData = nodeData as ParallelNode['data'] + nodes[nodeIdx] = { + ...node, + position: { x: pos.posX, y: pos.posY }, + data: { + ...parallelData, + handles: newHandles, + inputHandles: newInputHandles, + outputHandles: newOutputHandles, + inputConnector: newHandles.find((h) => h.id === parallelData.inputConnector?.id), + outputConnector: newHandles.find((h) => h.id === parallelData.outputConnector?.id), + parallelInputConnector: newHandles.find((h) => h.id === parallelData.parallelInputConnector?.id), + parallelOutputConnector: newHandles.find((h) => h.id === parallelData.parallelOutputConnector?.id), + }, + } + } else { + nodes[nodeIdx] = { + ...node, + position: { x: pos.posX, y: pos.posY }, + data: { + ...nodeData, + handles: newHandles, + inputHandles: newInputHandles, + outputHandles: newOutputHandles, + inputConnector: newHandles.find((h) => h.id === nodeData.inputConnector?.id), + outputConnector: newHandles.find((h) => h.id === nodeData.outputConnector?.id), + }, + } + } + } + } + + return nodes +} + +/** + * Expand block heights and shift handle positions when branches contain parallels. + * Must run BEFORE positionBranchElements because branch positioning reads handle.glbPosition.y. + */ +const applyDynamicBlockHandleOffsets = (rung: RungLadderState): Node[] => { + const nodes = [...rung.nodes] + + const blockStyle = defaultCustomNodesStyles.block + const DEFAULT_OFFSET = blockStyle.handle.offsetY // 40 + const FIRST_HANDLE_Y = blockStyle.handle.y // 36 + const contactHandleY = defaultCustomNodesStyles.contact.handle.y // 12 + // Vertical clearance between the bottom of main-line nodes (e.g. a parallel-path + // contact at ~y+24) and the top of branch elements on the next handle below. + // Must be large enough to avoid overlapping variable labels (~16px each) plus + // visual breathing room. Derived empirically: contactHeight(24) + labelHeight(16) ≈ 40, + // rounded up for comfortable spacing. + const OVERLAP_PADDING = 50 + + for (let blockIdx = 0; blockIdx < nodes.length; blockIdx++) { + const blockNode = nodes[blockIdx] + if (blockNode.type !== 'block') continue + + const blockData = blockNode.data as BasicNodeData + const maxHandles = Math.max(blockData.inputHandles.length, blockData.outputHandles.length) + if (maxHandles === 0) continue + + const result = calculateDynamicHandleOffsets({ ...rung, nodes }, blockNode) + + if (!result) { + // No expansion needed — reset handle relPosition/style to defaults and clear + // stale height overrides so blocks shrink back after branch removal. + const defaultCumulativeY: number[] = [FIRST_HANDLE_Y] + for (let i = 1; i < maxHandles; i++) { + defaultCumulativeY[i] = FIRST_HANDLE_Y + i * DEFAULT_OFFSET + } + + const newInputHandles = blockData.inputHandles.map((h, i) => ({ + ...h, + glbPosition: { ...h.glbPosition, y: blockNode.position.y + defaultCumulativeY[i] }, + relPosition: { ...h.relPosition, y: defaultCumulativeY[i] }, + style: { ...h.style, top: defaultCumulativeY[i] }, + })) + const newOutputHandles = blockData.outputHandles.map((h, i) => ({ + ...h, + glbPosition: { ...h.glbPosition, y: blockNode.position.y + defaultCumulativeY[i] }, + relPosition: { ...h.relPosition, y: defaultCumulativeY[i] }, + style: { ...h.style, top: defaultCumulativeY[i] }, + })) + const newHandles = [...newInputHandles, ...newOutputHandles] + + nodes[blockIdx] = { + ...blockNode, + height: blockStyle.height, + measured: { width: blockNode.measured?.width ?? blockNode.width ?? 0, height: blockStyle.height }, + data: { + ...blockData, + handles: newHandles, + inputHandles: newInputHandles, + outputHandles: newOutputHandles, + inputConnector: newHandles.find((h) => h.id === blockData.inputConnector?.id), + outputConnector: newHandles.find((h) => h.id === blockData.outputConnector?.id), + }, + } + continue + } + + // Mutable copies of offsets for overlap resolution + const inputOffsets = [...result.inputOffsets] + const outputOffsets = [...result.outputOffsets] + + // Compute cumulative Y for each handle index + const cumulativeY: number[] = [FIRST_HANDLE_Y] // index 0 = 36 + for (let i = 1; i < maxHandles; i++) { + const prevInputOffset = inputOffsets[i - 1] ?? DEFAULT_OFFSET + const prevOutputOffset = outputOffsets[i - 1] ?? DEFAULT_OFFSET + cumulativeY.push(cumulativeY[i - 1] + Math.max(prevInputOffset, prevOutputOffset)) + } + + // Overlap resolution: when handle[i+1] has a branch, ensure handle[i+1] sits + // BELOW the bottom of every main-rail element that would otherwise sit at the + // same Y as the branch contact area. + // + // The resolution is iterative — each handle expansion pushes the contact area + // down, which can now intersect main-rail content that was previously "below" + // the contact area. Loop until stable. Bounded because offsets only grow. + const recomputeCumulativeYFrom = (startIdx: number) => { + for (let j = startIdx; j < maxHandles; j++) { + cumulativeY[j] = + cumulativeY[j - 1] + Math.max(inputOffsets[j - 1] ?? DEFAULT_OFFSET, outputOffsets[j - 1] ?? DEFAULT_OFFSET) + } + } + + let stable = false + let iterations = 0 + const maxIterations = nodes.length * maxHandles + 4 // generous bound; offsets only grow + while (!stable && iterations++ < maxIterations) { + stable = true + for (let i = 0; i < maxHandles - 1; i++) { + const inputH = blockData.inputHandles[i + 1] + const outputH = blockData.outputHandles[i + 1] + const nextHasBranch = + (inputH && getBranch({ ...rung, nodes }, blockNode.id, inputH.id as string)) || + (outputH && getBranch({ ...rung, nodes }, blockNode.id, outputH.id as string)) + if (!nextHasBranch) continue + + const handleIGlbY = blockNode.position.y + cumulativeY[i] + const handleIPlus1GlbY = blockNode.position.y + cumulativeY[i + 1] + // The branch contact at handle[i+1] occupies a Y band centered on its handle. + // Anything outside this band (and below handle[i]) won't visually conflict. + const branchAreaTop = handleIPlus1GlbY - contactHandleY + const branchAreaBottom = handleIPlus1GlbY + contactHandleY + + let maxBottom = 0 + for (const n of nodes) { + if (n.id === blockNode.id) continue + if ((n.data as BasicNodeData).branchContext) continue + if ( + n.type === 'powerRail' || + n.type === 'variable' || + n.type === 'placeholder' || + n.type === 'parallelPlaceholder' + ) + continue + + const nodeHeight = n.height ?? n.measured?.height ?? getDefaultNodeStyle({ node: n }).height + const nodeTop = n.position.y + const nodeBottom = nodeTop + nodeHeight + if (nodeBottom <= handleIGlbY) continue + if (nodeTop > branchAreaBottom) continue + if (nodeBottom < branchAreaTop) continue + maxBottom = Math.max(maxBottom, nodeBottom) + } + + if (maxBottom === 0) continue + + const requiredOffset = maxBottom + OVERLAP_PADDING + contactHandleY - blockNode.position.y - cumulativeY[i] + const currentOffset = Math.max(inputOffsets[i] ?? DEFAULT_OFFSET, outputOffsets[i] ?? DEFAULT_OFFSET) + + if (requiredOffset > currentOffset) { + if (i < inputOffsets.length) inputOffsets[i] = requiredOffset + if (i < outputOffsets.length) outputOffsets[i] = requiredOffset + recomputeCumulativeYFrom(i + 1) + stable = false + } + } + } + + const totalHeight = cumulativeY[maxHandles - 1] + 24 + + // Update input handles + const newInputHandles = blockData.inputHandles.map((h, i) => ({ + ...h, + glbPosition: { ...h.glbPosition, y: blockNode.position.y + cumulativeY[i] }, + relPosition: { ...h.relPosition, y: cumulativeY[i] }, + style: { ...h.style, top: cumulativeY[i] }, + })) + + // Update output handles + const newOutputHandles = blockData.outputHandles.map((h, i) => ({ + ...h, + glbPosition: { ...h.glbPosition, y: blockNode.position.y + cumulativeY[i] }, + relPosition: { ...h.relPosition, y: cumulativeY[i] }, + style: { ...h.style, top: cumulativeY[i] }, + })) + + const newHandles = [...newInputHandles, ...newOutputHandles] + + nodes[blockIdx] = { + ...blockNode, + height: totalHeight, + measured: { width: blockNode.measured?.width ?? blockNode.width ?? 0, height: totalHeight }, + data: { + ...blockData, + handles: newHandles, + inputHandles: newInputHandles, + outputHandles: newOutputHandles, + inputConnector: newHandles.find((h) => h.id === blockData.inputConnector?.id), + outputConnector: newHandles.find((h) => h.id === blockData.outputConnector?.id), + }, + } + } + + return nodes +} + /** * Update the position of the diagram elements * @@ -87,14 +387,25 @@ export const updateDiagramElementsPosition = ( rung: RungLadderState, defaultBounds: [number, number], ): { nodes: Node[]; edges: Edge[] } => { - const { nodes } = rung + // Pre-expand block dimensions BEFORE the main layout loop. findAllParallelsDepthAndNodes + // and the parallel-path Y formulas both read block.height; if blocks haven't been + // sized to fit their branches and the adjacent main-rail content, parallel-path + // contacts end up positioned for a smaller block and overlap once the post-pass + // grows the block. + // + // Using applyDynamicBlockHandleOffsets here (same function the post-pass uses) gives + // a single source of truth for block sizing: the iterative overlap resolution inside + // it pushes branch handles past every main-rail level they'd visually intersect. + const heightExpandedNodes = applyDynamicBlockHandleOffsets(rung) + const expandedRung: RungLadderState = { ...rung, nodes: heightExpandedNodes } + const nodes = heightExpandedNodes const newNodes: Node[] = [] /** * Find the parallels in the rung */ - const parallels = findParallelsInRung(rung) - const parallelsDepth = parallels.map((parallel) => findAllParallelsDepthAndNodes(rung, parallel)) + const parallels = findParallelsInRung(expandedRung) + const parallelsDepth = parallels.map((parallel) => findAllParallelsDepthAndNodes(expandedRung, parallel)) /** * Iterate over the nodes and update their position @@ -113,6 +424,12 @@ export const updateDiagramElementsPosition = ( if (node.type === 'variable') continue + // Branch elements are positioned in a post-pass after block positions are finalized + if ((node.data as BasicNodeData).branchContext) { + newNodes.push(node) + continue + } + let newNodePosition: { posX: number; posY: number; handleX: number; handleY: number } = { posX: 0, posY: 0, @@ -173,6 +490,30 @@ export const updateDiagramElementsPosition = ( } } + // If this block has input branches, ensure there is enough horizontal room + // between the left rail and the block for branch elements. Only shift the block + // further right when the main-line serial chain has not already provided enough space. + if (node.type === 'block') { + const branchSpace = calculateInputBranchSpace(rung, node.id) + if (branchSpace > 0) { + const leftRail = newNodes.find((n) => n.id.startsWith('left-rail')) + const railStyle = defaultCustomNodesStyles.powerRail + const leftRailRight = leftRail ? leftRail.position.x + (leftRail.width ?? railStyle.width) : 0 + // The branch needs branchSpace from the block handle to the leftmost element, + // PLUS enough gap between the rail and the leftmost element for edge rendering. + // Use the same spacing as the main line (railGap + contactGap) so smoothstep + // edges have room for their routing offsets without backtracking through elements. + const railToElementGap = railStyle.gap + defaultCustomNodesStyles.contact.gap + const totalNeeded = branchSpace + railToElementGap + const availableSpace = newNodePosition.posX - leftRailRight + const deficit = totalNeeded - availableSpace + if (deficit > 0) { + newNodePosition.posX += deficit + newNodePosition.handleX += deficit + } + } + } + /** * Find the parallel that * the node is in and update the position @@ -321,10 +662,19 @@ export const updateDiagramElementsPosition = ( } } + // Post-pass: dynamic block handle spacing (must run BEFORE positionBranchElements) + const blockExpandedNodes = applyDynamicBlockHandleOffsets({ ...rung, nodes: newNodes }) + + // Post-pass: position branch elements based on finalized block handle positions + const branchPositionedNodes = positionBranchElements({ ...rung, nodes: blockExpandedNodes }) + + // Post-pass: sync rail branch handle Y positions with block handle Y positions + const railSyncedNodes = updateRailForBranches(branchPositionedNodes, rung.handleBranches) + const { nodes: changedRailNodes } = changeRailBounds( { ...rung, - nodes: newNodes, + nodes: railSyncedNodes, }, defaultBounds, ) diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/handlers.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/handlers.ts new file mode 100644 index 000000000..2c3186cee --- /dev/null +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/handlers.ts @@ -0,0 +1,390 @@ +import type { RungLadderState } from '@root/frontend/store/slices' +import type { Edge, Node } from '@xyflow/react' + +import type { BasicNodeData, HandleBranch } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' +import { PlaceholderNode } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' +import { disconnectNodes } from '../../edges' +import { removeNode } from '../../nodes' +import { removeElement } from '..' +import { spliceNodeIntoEdgeChain } from '../core' +import { updateDiagramElementsPosition } from '../diagram' +import { + getBranch, + insertIntoBranch, + reconcileBranchNodeIds, + removeBranchElement, + removeRailBranchHandle, + replaceVariableWithBranch, + startParallelInBranch, +} from '../handle-branch' +import { removeEmptyParallelConnections, startParallelConnection } from '../parallel' +import { removePlaceholderElements } from '../placeholder' +import { appendSerialConnection } from '../serial' + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +export type DropResult = { nodes: Node[]; edges: Edge[]; handleBranches?: HandleBranch[] } + +/** + * Shared context built once in onElementDrop and passed to handlers that need + * both the current rung state (with copycat/placeholders) and prepared state. + */ +export type DropContext = { + rung: RungLadderState + oldStateRung: RungLadderState + node: Node + copycatNode: Node + selectedPlaceholder: PlaceholderNode + oldNodeIndex: number + selectedPlaceholderIndex: number + preparedNodes: Node[] + preparedEdges: Edge[] +} + +// --------------------------------------------------------------------------- +// Shared private helpers +// --------------------------------------------------------------------------- + +/** + * Insert a node into the nodes array at the correct position for the layout algorithm. + * `updateDiagramElementsPosition` processes nodes in array order and looks up predecessors + * from already-processed nodes. Appending at the end would cause successors to be processed + * before this node, making them "invisible" to the layout and getting skipped. + * + * Strategy: insert the node just before its edge-chain successor (the target of its outgoing edge). + * If no successor is found, append at the end (safe for branch-context nodes which skip layout). + */ +function insertNodeInOrder(nodes: Node[], newNode: Node, edges: Edge[]): Node[] { + const outHandle = (newNode.data as BasicNodeData).outputConnector?.id + const outEdge = edges.find((e) => e.source === newNode.id && e.sourceHandle === outHandle) + if (outEdge) { + const successorIdx = nodes.findIndex((n) => n.id === outEdge.target) + if (successorIdx !== -1) { + const result = [...nodes] + result.splice(successorIdx, 0, newNode) + return result + } + } + return [...nodes, newNode] +} + +/** + * Remove a branch element and collapse any resulting empty parallels. + * Wraps removeBranchElement with removeEmptyParallelConnections + reconcileBranchNodeIds. + */ +function removeBranchElementAndCollapse( + rung: RungLadderState, + nodeId: string, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } { + const result = removeBranchElement(rung, nodeId) + let { nodes: branchNodes, edges: branchEdges } = result + let handleBranches = result.handleBranches + + // Collapse empty parallels within the branch (e.g. after removing a parallel-path element) + const cleaned = removeEmptyParallelConnections({ ...rung, nodes: branchNodes, edges: branchEdges }) + branchNodes = cleaned.nodes + branchEdges = cleaned.edges + + // Reconcile branch nodeIds after parallel collapse may have removed OPEN/CLOSE nodes + handleBranches = handleBranches + .map((branch) => ({ + ...branch, + nodeIds: reconcileBranchNodeIds({ ...rung, nodes: branchNodes, edges: branchEdges, handleBranches }, branch), + })) + .filter((branch) => branch.nodeIds.length > 0) + + // Clean up rail handles for branches that were removed (empty after reconciliation) + const removedBranches = (rung.handleBranches ?? []).filter( + (b) => !handleBranches.some((nb) => nb.blockId === b.blockId && nb.handleId === b.handleId), + ) + for (const removed of removedBranches) { + branchNodes = removeRailBranchHandle(branchNodes, removed.blockId, removed.handleId, removed.direction) + } + + return { nodes: branchNodes, edges: branchEdges, handleBranches } +} + +/** + * Remove a node from its source context (main-rail or branch). + * For branch nodes, delegates to removeBranchElementAndCollapse. + * For main-rail nodes, uses removeNode + disconnectNodes + removeEmptyParallelConnections. + */ +function removeFromSourceContext( + rung: RungLadderState, + nodeId: string, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } { + const element = rung.nodes.find((n) => n.id === nodeId) + if (!element) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches ?? [] } + + const branchCtx = (element.data as BasicNodeData).branchContext + if (branchCtx) { + return removeBranchElementAndCollapse(rung, nodeId) + } + + // Main-rail: removeNode + disconnectNodes + removeEmptyParallelConnections + const newNodes = removeNode(rung, nodeId) + const outEdge = rung.edges.find( + (e) => e.source === nodeId && e.sourceHandle === (element.data as BasicNodeData).outputConnector?.id, + ) + if (!outEdge) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches ?? [] } + + const newEdges = disconnectNodes(rung, outEdge.source, outEdge.target) + + const { nodes: cleanedNodes, edges: cleanedEdges } = removeEmptyParallelConnections({ + ...rung, + nodes: newNodes, + edges: newEdges, + }) + + return { nodes: cleanedNodes, edges: cleanedEdges, handleBranches: rung.handleBranches ?? [] } +} + +/** + * Remove a node from its source context and build a new RungLadderState. + * Used by all cross-context handlers. + */ +function removeAndBuildRung( + oldStateRung: RungLadderState, + nodeId: string, +): { rungAfterRemove: RungLadderState; handleBranches: HandleBranch[] } { + const removed = removeFromSourceContext(oldStateRung, nodeId) + return { + rungAfterRemove: { + ...oldStateRung, + nodes: removed.nodes, + edges: removed.edges, + handleBranches: removed.handleBranches, + }, + handleBranches: removed.handleBranches, + } +} + +/** + * Run layout and return a DropResult. + */ +function layoutAndReturn( + oldStateRung: RungLadderState, + nodes: Node[], + edges: Edge[], + handleBranches?: HandleBranch[], +): DropResult { + const { nodes: diagramNodes, edges: diagramEdges } = updateDiagramElementsPosition( + { + ...oldStateRung, + nodes, + edges, + ...(handleBranches && { handleBranches }), + }, + oldStateRung.defaultBounds as [number, number], + ) + return { nodes: diagramNodes, edges: diagramEdges, handleBranches } +} + +// --------------------------------------------------------------------------- +// Handler functions — one per drop action type +// --------------------------------------------------------------------------- + +/** + * Path 1: Drop back on original position (restore). + */ +export function handleRestore(ctx: DropContext): DropResult { + const resultNodes = [...ctx.preparedNodes] + const resultEdges = [...ctx.preparedEdges] + + resultNodes[resultNodes.indexOf(ctx.copycatNode)] = { + ...ctx.node, + id: ctx.node.id, + dragging: false, + } + resultEdges.forEach((edge, index) => { + if (edge.source === ctx.copycatNode.id) { + resultEdges[index] = { ...edge, source: ctx.node.id, id: edge.id.replace('copycat_', '') } + } + if (edge.target === ctx.copycatNode.id) { + resultEdges[index] = { ...edge, target: ctx.node.id, id: edge.id.replace('copycat_', '') } + } + }) + + const cleanedNodes = removePlaceholderElements(resultNodes) + + return layoutAndReturn(ctx.rung, cleanedNodes, resultEdges) +} + +/** + * Path 2: any → branch parallel. + */ +export function handleBranchParallel(oldStateRung: RungLadderState, node: Node, aboveElement: Node): DropResult { + const { rungAfterRemove } = removeAndBuildRung(oldStateRung, node.id) + const parallel = startParallelInBranch(rungAfterRemove, aboveElement, node) + return layoutAndReturn(oldStateRung, parallel.nodes, parallel.edges, parallel.handleBranches) +} + +/** + * Paths 3 & 4: main parallel (branch source or main source). + */ +export function handleMainParallel(ctx: DropContext, sourceIsBranch: boolean): DropResult { + if (sourceIsBranch) { + // Path 3: branch → main-rail parallel + const { rungAfterRemove, handleBranches } = removeAndBuildRung(ctx.oldStateRung, ctx.node.id) + const movedNode = { ...ctx.node, data: { ...ctx.node.data, branchContext: undefined } } + + const { nodes: parallelNodes, edges: parallelEdges } = startParallelConnection( + rungAfterRemove, + { selected: ctx.selectedPlaceholder, index: 0 }, + movedNode, + ) + + return layoutAndReturn(ctx.oldStateRung, parallelNodes, parallelEdges, handleBranches) + } + + // Path 4: main → main parallel (copycat-aware) + const { nodes: parallelNodes, edges: parallelEdges } = startParallelConnection( + { ...ctx.rung, nodes: ctx.preparedNodes, edges: ctx.preparedEdges }, + { + selected: ctx.selectedPlaceholder, + index: + ctx.oldNodeIndex < ctx.selectedPlaceholderIndex + ? ctx.selectedPlaceholderIndex - 1 + : ctx.selectedPlaceholderIndex, + }, + ctx.node, + ) + + // Remove the copycat node + const { nodes: cleanedNodes, edges: cleanedEdges } = removeElement( + { ...ctx.rung, nodes: parallelNodes, edges: parallelEdges }, + ctx.copycatNode, + ) + + return { nodes: cleanedNodes, edges: cleanedEdges } +} + +/** + * Path 5: any → branch serial. + */ +export function handleBranchSerial( + oldStateRung: RungLadderState, + node: Node, + target: { blockId: string; handleId: string; direction: 'input' | 'output'; insertIndex: number }, +): DropResult { + const { rungAfterRemove } = removeAndBuildRung(oldStateRung, node.id) + + // Adjust insertIndex if the removed node was before the insertion point in nodeIds + const branch = getBranch(oldStateRung, target.blockId, target.handleId) + const removedNodeIndex = branch?.nodeIds.indexOf(node.id) ?? -1 + let adjustedInsertIndex = + removedNodeIndex !== -1 && removedNodeIndex < target.insertIndex ? target.insertIndex - 1 : target.insertIndex + + // Clamp to valid range: parallel collapse may have removed OPEN/CLOSE nodes, + // shrinking nodeIds below the original insertIndex. + const branchAfterRemove = getBranch(rungAfterRemove, target.blockId, target.handleId) + if (branchAfterRemove) { + adjustedInsertIndex = Math.min(adjustedInsertIndex, branchAfterRemove.nodeIds.length) + } + + const inserted = insertIntoBranch( + rungAfterRemove, + { + blockId: target.blockId, + handleId: target.handleId, + direction: target.direction, + insertIndex: adjustedInsertIndex, + }, + node, + ) + + return layoutAndReturn(oldStateRung, inserted.nodes, inserted.edges, inserted.handleBranches) +} + +/** + * Path 6: any → branch parallel-path (splice into edge chain within branch). + */ +export function handleBranchParallelPath( + oldStateRung: RungLadderState, + node: Node, + targetNode: Node, + branchContext: NonNullable, + position: 'left' | 'right', +): DropResult { + const { rungAfterRemove, handleBranches } = removeAndBuildRung(oldStateRung, node.id) + const movedNode = { ...node, data: { ...node.data, branchContext } } + + const splicedEdges = spliceNodeIntoEdgeChain(rungAfterRemove.edges, targetNode.id, targetNode, position, movedNode) + + if (!splicedEdges) { + return { nodes: oldStateRung.nodes, edges: oldStateRung.edges } + } + + const newNodes = insertNodeInOrder(rungAfterRemove.nodes, movedNode, splicedEdges) + return layoutAndReturn(oldStateRung, newNodes, splicedEdges, handleBranches) +} + +/** + * Path 7: any → empty block handle (create new branch). + */ +export function handleBranchCreate( + oldStateRung: RungLadderState, + node: Node, + target: { + blockId: string + handleId: string + direction: 'input' | 'output' + handlePosition: { x: number; y: number } + }, +): DropResult { + const { rungAfterRemove } = removeAndBuildRung(oldStateRung, node.id) + const result = replaceVariableWithBranch(rungAfterRemove, target, node) + return layoutAndReturn(oldStateRung, result.nodes, result.edges, result.handleBranches) +} + +/** + * Paths 8 & 9: main serial (branch source or main source). + */ +export function handleMainSerial(ctx: DropContext, sourceIsBranch: boolean): DropResult { + if (sourceIsBranch) { + // Path 8: branch → main-rail serial + const { rungAfterRemove, handleBranches } = removeAndBuildRung(ctx.oldStateRung, ctx.node.id) + const movedNode = { ...ctx.node, data: { ...ctx.node.data, branchContext: undefined } } + + const mainRelated = ctx.selectedPlaceholder.data.relatedNode + if (!mainRelated) return { nodes: ctx.oldStateRung.nodes, edges: ctx.oldStateRung.edges } + + const position = ctx.selectedPlaceholder.data.position as 'left' | 'right' + + const splicedEdges = spliceNodeIntoEdgeChain( + rungAfterRemove.edges, + mainRelated.id, + mainRelated, + position, + movedNode, + ) + + if (!splicedEdges) return { nodes: ctx.oldStateRung.nodes, edges: ctx.oldStateRung.edges } + + const newNodes = insertNodeInOrder(rungAfterRemove.nodes, movedNode, splicedEdges) + return layoutAndReturn(ctx.oldStateRung, newNodes, splicedEdges, handleBranches) + } + + // Path 9: main → main serial (copycat-aware) + const { nodes: serialNodes, edges: serialEdges } = appendSerialConnection( + { ...ctx.rung, nodes: ctx.preparedNodes, edges: ctx.preparedEdges }, + { + selected: ctx.selectedPlaceholder, + index: + ctx.oldNodeIndex < ctx.selectedPlaceholderIndex + ? ctx.selectedPlaceholderIndex - 1 + : ctx.selectedPlaceholderIndex, + }, + ctx.node, + ) + + // Remove the copycat node + const { nodes: cleanedNodes, edges: cleanedEdges } = removeElement( + { ...ctx.rung, nodes: serialNodes, edges: serialEdges }, + ctx.copycatNode, + ) + + return { nodes: cleanedNodes, edges: cleanedEdges } +} diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/index.ts index 46dbfcba4..40bd85cf6 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/index.ts @@ -1,16 +1,214 @@ import type { RungLadderState } from '@root/frontend/store/slices' +import { toast } from '@root/frontend/utils/toast' import type { Edge, Node, ReactFlowInstance } from '@xyflow/react' import { toInteger } from 'lodash' +import type { BasicNodeData } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' import { PlaceholderNode } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' import { isNodeOfType } from '../../nodes' -import { removeElement } from '..' -import { updateDiagramElementsPosition } from '../diagram' -import { startParallelConnection } from '../parallel' -import { removePlaceholderElements } from '../placeholder' +import { blockHasBranches, getBranch, isBlockInsideMainParallel } from '../handle-branch' import { renderPlaceholderElements, searchNearestPlaceholder } from '../placeholder' -import { appendSerialConnection } from '../serial' import { removeVariableBlock } from '../variable-block' +import { + type DropContext, + type DropResult, + handleBranchCreate, + handleBranchParallel, + handleBranchParallelPath, + handleBranchSerial, + handleMainParallel, + handleMainSerial, + handleRestore, +} from './handlers' + +// --------------------------------------------------------------------------- +// Drop classification types & helpers +// --------------------------------------------------------------------------- + +type DropAction = + | { type: 'restore' } + | { type: 'blocked'; reason: string } + | { type: 'branch-parallel'; aboveElement: Node } + | { type: 'main-parallel'; sourceIsBranch: boolean } + | { + type: 'branch-serial' + target: { blockId: string; handleId: string; direction: 'input' | 'output'; insertIndex: number } + } + | { + type: 'branch-parallel-path' + targetNode: Node + branchContext: NonNullable + position: 'left' | 'right' + } + | { + type: 'branch-create' + target: { + blockId: string + handleId: string + direction: 'input' | 'output' + handlePosition: { x: number; y: number } + } + } + | { type: 'main-serial'; sourceIsBranch: boolean } + +// Reasons surfaced via toast when the user attempts an incompatible drop. +// The branch layout engine can't position a block that's also in a parallel chain +// without creating overlap, so we refuse rather than silently producing a broken diagram. +const BRANCH_BLOCKED_IN_PARALLEL = + 'Cannot add to a handle branch on a block that is in parallel with other elements. Remove the parallel siblings first.' +const PARALLEL_BLOCKED_WITH_BRANCH = + 'Cannot put a block with handle branches in parallel with other elements. Remove the handle branch contacts first.' + +/** + * Pure classifier: inspects the placeholder and node to determine which drop + * scenario applies without performing any mutations. + */ +function classifyDrop( + selectedPlaceholder: PlaceholderNode, + copycatNode: Node, + draggedNode: Node, + oldStateRung: RungLadderState, +): DropAction | null { + // Blocks cannot be dropped on branch handles — the branch layout system + // is designed for single-handle elements (contacts/coils) only. + if (draggedNode.type === 'block' && selectedPlaceholder.data.handleBranchTarget) { + return null + } + + // Path 1: drop back on original position + if (selectedPlaceholder.data.relatedNode?.id === copycatNode.id) { + return { type: 'restore' } + } + + const sourceIsBranch = !!(draggedNode.data as BasicNodeData).branchContext + + if (isNodeOfType(selectedPlaceholder as Node, 'parallelPlaceholder')) { + const relatedNode = selectedPlaceholder.data.relatedNode + const branchCtx = relatedNode && (relatedNode.data as BasicNodeData).branchContext + + if (branchCtx) { + if (isBlockInsideMainParallel(oldStateRung, branchCtx.blockId)) { + return { type: 'blocked', reason: BRANCH_BLOCKED_IN_PARALLEL } + } + // Path 2: any → branch parallel (supports nested parallels via findAllParallelsDepthAndNodes) + const branch = getBranch(oldStateRung, branchCtx.blockId, branchCtx.handleId) + const aboveElement = oldStateRung.nodes.find((n) => n.id === relatedNode.id) + if (branch && aboveElement) { + return { type: 'branch-parallel', aboveElement } + } + return null // fallback + } + + // Paths 3 & 4: main parallel (branch or main source). + // If the related node is a block with handle branches, putting it in parallel would + // entangle the branch layout with a parallel chain — refuse. + if (relatedNode && relatedNode.type === 'block' && blockHasBranches(oldStateRung, relatedNode.id)) { + return { type: 'blocked', reason: PARALLEL_BLOCKED_WITH_BRANCH } + } + return { type: 'main-parallel', sourceIsBranch } + } + + // Serial placeholder + const serialRelated = selectedPlaceholder.data.relatedNode + const serialBranchTarget = selectedPlaceholder.data.handleBranchTarget + + if ( + serialRelated && + (serialRelated.data as BasicNodeData).branchContext && + serialBranchTarget?.insertIndex !== undefined + ) { + if (isBlockInsideMainParallel(oldStateRung, serialBranchTarget.blockId)) { + return { type: 'blocked', reason: BRANCH_BLOCKED_IN_PARALLEL } + } + // Path 5: any → branch serial + return { + type: 'branch-serial', + target: { + blockId: serialBranchTarget.blockId, + handleId: serialBranchTarget.handleId, + direction: serialBranchTarget.direction, + insertIndex: serialBranchTarget.insertIndex, + }, + } + } + + if (serialRelated && (serialRelated.data as BasicNodeData).branchContext) { + // Path 6: any → branch parallel-path + const ctx = (serialRelated.data as BasicNodeData).branchContext! + if (isBlockInsideMainParallel(oldStateRung, ctx.blockId)) { + return { type: 'blocked', reason: BRANCH_BLOCKED_IN_PARALLEL } + } + const targetId = serialRelated.id.startsWith('copycat_') ? serialRelated.id.slice(8) : serialRelated.id + const targetNode = oldStateRung.nodes.find((n) => n.id === targetId) + if (targetNode) { + return { + type: 'branch-parallel-path', + targetNode, + branchContext: ctx, + position: (selectedPlaceholder.data.position as 'left' | 'right') ?? 'left', + } + } + return null // fallback + } + + if (serialBranchTarget && serialBranchTarget.insertIndex === undefined) { + if (isBlockInsideMainParallel(oldStateRung, serialBranchTarget.blockId)) { + return { type: 'blocked', reason: BRANCH_BLOCKED_IN_PARALLEL } + } + // Path 7: any → empty block handle (create new branch) + return { + type: 'branch-create', + target: { + blockId: serialBranchTarget.blockId, + handleId: serialBranchTarget.handleId, + direction: serialBranchTarget.direction, + handlePosition: serialBranchTarget.handlePosition, + }, + } + } + + // Paths 8 & 9: main serial (branch or main source) + return { type: 'main-serial', sourceIsBranch } +} + +/** + * Find the currently selected placeholder node in the rung. + */ +function findSelectedPlaceholder(rung: RungLadderState): { + selectedPlaceholder: PlaceholderNode | undefined + selectedPlaceholderIndex: number +} { + const entry = Object.entries(rung.nodes).find( + ([, n]) => (n.type === 'placeholder' || n.type === 'parallelPlaceholder') && n.selected, + ) + if (!entry) return { selectedPlaceholder: undefined, selectedPlaceholderIndex: -1 } + return { + selectedPlaceholder: entry[1] as PlaceholderNode, + selectedPlaceholderIndex: toInteger(entry[0]), + } +} + +/** + * Prepare the rung state for a drop operation: + * removes variable blocks, finds copycat node, removes the dragged node from nodes array. + */ +function prepareDropState( + rung: RungLadderState, + node: Node, +): { + preparedNodes: Node[] + preparedEdges: Edge[] + copycatNode: Node | undefined + oldNodeIndex: number +} { + const { nodes: cleanedNodes, edges: cleanedEdges } = removeVariableBlock(rung) + + const copycatNode = cleanedNodes.filter((n) => n.type !== 'variable').find((n) => n.id === `copycat_${node.id}`) + const oldNodeIndex = cleanedNodes.findIndex((n) => n.id === node.id) + const preparedNodes = oldNodeIndex !== -1 ? cleanedNodes.filter((n) => n.id !== node.id) : cleanedNodes + + return { preparedNodes, preparedEdges: cleanedEdges, copycatNode, oldNodeIndex } +} export const onElementDragStart = (rung: RungLadderState, draggedNode: Node) => { /** @@ -77,133 +275,62 @@ export const onElementDragOver = ( } /** - * Drag and drop function to stop the drag of an element and connect it to the nearest placeholder - * - * @param rung The current rung state - * @param node The node to be connected - * - * @returns The new nodes and edges + * Drag and drop function to stop the drag of an element and connect it to the nearest placeholder. + * Classifies the drop scenario once, then dispatches to a focused handler. */ -export const onElementDrop = ( - rung: RungLadderState, - oldStateRung: RungLadderState, - node: Node, -): { nodes: Node[]; edges: Edge[] } => { - /** - * Find the selected placeholder - * If not found, return the old rung as it is (remove the placeholder nodes) - */ - const [selectedPlaceholderIndex, selectedPlaceholder] = Object.entries(rung.nodes).find( - (node) => (node[1].type === 'placeholder' || node[1].type === 'parallelPlaceholder') && node[1].selected, - ) ?? [undefined, undefined] - if (!selectedPlaceholder || !selectedPlaceholderIndex) return { nodes: oldStateRung.nodes, edges: oldStateRung.edges } +export const onElementDrop = (rung: RungLadderState, oldStateRung: RungLadderState, node: Node): DropResult => { + const fallback = { nodes: oldStateRung.nodes, edges: oldStateRung.edges } - let newNodes = [...rung.nodes] - let newEdges = [...rung.edges] + // 1. Find selected placeholder + const { selectedPlaceholder, selectedPlaceholderIndex } = findSelectedPlaceholder(rung) + if (!selectedPlaceholder) return fallback - const { nodes: removedVariablesNodes, edges: removedVariablesEdges } = removeVariableBlock({ - ...rung, - nodes: newNodes, - edges: newEdges, - }) - newNodes = removedVariablesNodes - newEdges = removedVariablesEdges + // 2. Prepare state: remove variable blocks, find copycat, remove dragged node + const { preparedNodes, preparedEdges, copycatNode, oldNodeIndex } = prepareDropState(rung, node) + if (!copycatNode || oldNodeIndex === -1) return fallback - /** - * Find the copycat node - * If not found, return the old rung as it is - */ - const copycatNode = newNodes.filter((n) => n.type !== 'variable').find((n) => n.id === `copycat_${node.id}`) - if (!copycatNode) return { nodes: oldStateRung.nodes, edges: oldStateRung.edges } + // 3. Classify the drop scenario + const action = classifyDrop(selectedPlaceholder, copycatNode, node, oldStateRung) + if (!action) return fallback - /** - * Remove the old node and the copycat node - * If the old node is not found, return the old rung as it is - */ - const oldNodeIndex = newNodes.findIndex((n) => n.id === node.id) - if (oldNodeIndex === -1) return { nodes: oldStateRung.nodes, edges: oldStateRung.edges } - newNodes = newNodes.filter((n) => n.id !== node.id) - - // Check if the selected placeholder is the same as the copycat node - if ((selectedPlaceholder as PlaceholderNode).data.relatedNode?.id === copycatNode.id) { - newNodes[newNodes.indexOf(copycatNode)] = { - ...node, - id: node.id, - dragging: false, - } - newEdges.forEach((edge, index) => { - if (edge.source === copycatNode.id) { - newEdges[index] = { ...edge, source: node.id, id: edge.id.replace('copycat_', '') } - } - if (edge.target === copycatNode.id) { - newEdges[index] = { ...edge, target: node.id, id: edge.id.replace('copycat_', '') } - } - }) - // Remove the placeholder nodes - newNodes = removePlaceholderElements(newNodes) - - /** - * After adding the new element, update the diagram with the new rung - */ - const { nodes: updatedDiagramNodes, edges: updatedDiagramEdges } = updateDiagramElementsPosition( - { ...rung, nodes: newNodes, edges: newEdges }, - rung.defaultBounds as [number, number], - ) - newNodes = updatedDiagramNodes - newEdges = updatedDiagramEdges - return { nodes: newNodes, edges: newEdges } + // 4. Build shared context for handlers + const ctx: DropContext = { + rung, + oldStateRung, + node, + copycatNode, + selectedPlaceholder, + oldNodeIndex, + selectedPlaceholderIndex, + preparedNodes, + preparedEdges, } - /** - * Check if the selected placeholder is a parallel placeholder - * If it is, create a new parallel junction and add the new element to it - * If it is not, add the new element to the selected placeholder - */ - if (isNodeOfType(selectedPlaceholder, 'parallelPlaceholder')) { - const { nodes: parallelNodes, edges: parallelEdges } = startParallelConnection( - { - ...rung, - nodes: newNodes, - edges: newEdges, - }, - { - selected: selectedPlaceholder as PlaceholderNode, - index: - oldNodeIndex < toInteger(selectedPlaceholderIndex) - ? toInteger(selectedPlaceholderIndex) - 1 - : toInteger(selectedPlaceholderIndex), - }, - node, - ) - newEdges = parallelEdges - newNodes = parallelNodes - } else { - const { nodes: serialNodes, edges: serialEdges } = appendSerialConnection( - { - ...rung, - nodes: newNodes, - edges: newEdges, - }, - { - selected: selectedPlaceholder as PlaceholderNode, - index: - oldNodeIndex < toInteger(selectedPlaceholderIndex) - ? toInteger(selectedPlaceholderIndex) - 1 - : toInteger(selectedPlaceholderIndex), - }, - node, - ) - newEdges = serialEdges - newNodes = serialNodes - } + // 5. Dispatch to the appropriate handler + switch (action.type) { + case 'blocked': + toast({ variant: 'warn', title: 'Action not supported', description: action.reason }) + return fallback - // If the selected placeholder is not the same as the copycat node, remove the copycat node and the old one - const { nodes: removedCopycatNodes, edges: removedCopycatEdges } = removeElement( - { ...rung, nodes: newNodes, edges: newEdges }, - copycatNode, - ) - newNodes = removedCopycatNodes - newEdges = removedCopycatEdges + case 'restore': + return handleRestore(ctx) - return { nodes: newNodes, edges: newEdges } + case 'branch-parallel': + return handleBranchParallel(oldStateRung, node, action.aboveElement) + + case 'main-parallel': + return handleMainParallel(ctx, action.sourceIsBranch) + + case 'branch-serial': + return handleBranchSerial(oldStateRung, node, action.target) + + case 'branch-parallel-path': + return handleBranchParallelPath(oldStateRung, node, action.targetNode, action.branchContext, action.position) + + case 'branch-create': + return handleBranchCreate(oldStateRung, node, action.target) + + case 'main-serial': + return handleMainSerial(ctx, action.sourceIsBranch) + } } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts new file mode 100644 index 000000000..c703198ca --- /dev/null +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts @@ -0,0 +1,1328 @@ +import type { RungLadderState } from '@root/frontend/store/slices' +import type { Edge, Node } from '@xyflow/react' +import { Position } from '@xyflow/react' +import { v4 as uuidv4 } from 'uuid' + +import { buildHandle } from '../../../../../../../_atoms/graphical-editor/ladder/handle' +// Import from node-builders directly (not the atoms barrel) — the barrel +// re-exports the LD React components, which import the store, which would +// create a circular load chain back through `removeElements` → `slice.ts`. +import { + checkIfElementIsNode, + defaultCustomNodesStyles, + nodesBuilder, +} from '../../../../../../../_atoms/graphical-editor/ladder/node-builders' +import type { + BasicNodeData, + BlockVariant, + HandleBranch, + ParallelNode, +} from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' +import { buildEdge, disconnectNodes, removeEdge } from '../../edges' +import { buildGenericNode, getDefaultNodeStyle, removeNode } from '../../nodes' +import { spliceEdgeAndInsertNode, wireParallelAroundElement } from '../core' +import { findAllParallelsDepthAndNodes, findParallelsInRung, getNodesInsideParallel } from '../utils' + +/** + * Find the left or right rail node id in a rung. + * Rail IDs in the current architecture are `left-rail-{rungId}` / + * `right-rail-{rungId}`, so callers must look them up by prefix rather than + * hardcoding `'left-rail'`/`'right-rail'`. + */ +function findRailId(nodes: Node[], side: 'left' | 'right'): string | undefined { + const prefix = side === 'left' ? 'left-rail' : 'right-rail' + return nodes.find((n) => n.id.startsWith(prefix))?.id +} + +/** + * Check if a block handle has an active element branch. + */ +export function hasBranchOnHandle(rung: RungLadderState, blockId: string, handleId: string): boolean { + return rung.handleBranches?.some((b) => b.blockId === blockId && b.handleId === handleId) ?? false +} + +/** + * Check if a block has any handle branches. + */ +export function blockHasBranches(rung: RungLadderState, blockId: string): boolean { + return rung.handleBranches?.some((b) => b.blockId === blockId) ?? false +} + +/** + * Walk every branch in the rung and collect the parallel-path nodes that belong to + * the deepest nested parallel region of each branch. + * + * Main-rail's getDeepestNodesInsideParallels uses node array order as a proxy for + * nesting depth, which works because main-rail OPEN/CLOSE pairs are inserted in + * nesting order. Branch parallels don't honor that order — nested OPEN/CLOSE pairs + * are appended at creation time, so the outer CLOSE often appears BEFORE the nested + * OPEN in `rung.nodes`. The depth-aware lookup below uses findAllParallelsDepthAndNodes, + * which derives depth from the parallel structure itself rather than from array order. + */ +export function getDeepestBranchParallelNodes(rung: RungLadderState): Set { + const result = new Set() + if (!rung.handleBranches?.length) return result + for (const branch of rung.handleBranches) { + for (const id of branch.nodeIds) { + const n = rung.nodes.find((nd) => nd.id === id) + if (!n || n.type !== 'parallel' || (n as ParallelNode).data.type !== 'open') continue + const regions = Object.values(findAllParallelsDepthAndNodes(rung, n as ParallelNode)) + if (regions.length === 0) continue + const maxDepth = regions.reduce((m, r) => Math.max(m, r.depth), -1) + for (const r of regions) { + if (r.depth !== maxDepth) continue + for (const node of r.nodes.parallel) result.add(node.id) + } + } + } + return result +} + +/** + * Every parallel-path or serial-chain node inside any branch parallel, at any depth. + * Branch-aware counterpart to getNodesInsideAllParallels. + */ +export function getNodesInsideAllBranchParallels(rung: RungLadderState): Set { + const result = new Set() + if (!rung.handleBranches?.length) return result + for (const branch of rung.handleBranches) { + for (const id of branch.nodeIds) { + const n = rung.nodes.find((nd) => nd.id === id) + if (!n || n.type !== 'parallel' || (n as ParallelNode).data.type !== 'open') continue + const regions = Object.values(findAllParallelsDepthAndNodes(rung, n as ParallelNode)) + for (const r of regions) { + for (const node of r.nodes.parallel) result.add(node.id) + for (const node of r.nodes.serial) result.add(node.id) + } + } + } + return result +} + +/** + * Check whether `blockId` sits inside the serial chain of any parallel OPEN/CLOSE pair. + * + * A block in parallel with other main-rail content has a layout dependency the branch + * machinery can't satisfy cleanly: growing the block to fit branches changes the parallel- + * path Y of its siblings (or vice versa), so the diagram ends up with overlapping content + * regardless of which side we re-solve from. Used as a guard before allowing branch ops. + */ +export function isBlockInsideMainParallel(rung: RungLadderState, blockId: string): boolean { + const parallels = findParallelsInRung(rung) + for (const openNode of parallels) { + const closeNode = rung.nodes.find((n) => n.id === openNode.data.parallelCloseReference) + if (!closeNode) continue + const { serial } = getNodesInsideParallel(rung, closeNode) + if (serial.some((n) => n.id === blockId)) return true + } + return false +} + +/** + * Get the HandleBranch metadata for a specific block handle, if any. + */ +export function getBranch(rung: RungLadderState, blockId: string, handleId: string): HandleBranch | undefined { + return rung.handleBranches?.find((b) => b.blockId === blockId && b.handleId === handleId) +} + +/** + * Determine if a contact or coil can be placed on a given block handle. + * Only BOOL-compatible handles support element branches. + */ +export function canPlaceElementOnHandle(handleVariableType: BlockVariant['variables'][0]): boolean { + const typeDef = handleVariableType.type + if (typeDef.definition === 'base-type' && typeDef.value.toUpperCase() === 'BOOL') return true + if (typeDef.definition === 'generic-type') { + const v = typeDef.value.toUpperCase() + if (v === 'ANY' || v === 'ANY_BIT') return true + } + return false +} + +type BranchPositionResult = { + positions: Array<{ nodeId: string; posX: number; posY: number; handleX: number; handleY: number }> + totalHeight: number // Total vertical extent when parallels are present (0 if no parallels) +} + +type RegionInfo = ReturnType[string] +type Pos = { nodeId: string; posX: number; posY: number; handleX: number; handleY: number } + +/** + * Calculate positions for branch elements relative to their target block handle. + * + * For input branches: outer-spine elements are arranged right-to-left ending at the block handle. + * For output branches: outer-spine elements are arranged left-to-right starting from the block handle. + * + * Nested parallels (parallels inside a parallel-path) are positioned at deeper Y levels by + * reusing the main-rail's findAllParallelsDepthAndNodes, which builds a flat map of regions + * keyed by OPEN node id with parent links and depth. Each region's spine Y is its parent's + * parallel Y (or handleY for top-level), and its own parallel Y sits below by serialHeight + gap. + * + * Returns positions for each node in the branch plus totalHeight for block expansion. + */ +export function calculateBranchElementPositions(rung: RungLadderState, branch: HandleBranch): BranchPositionResult { + const blockNode = rung.nodes.find((n) => n.id === branch.blockId) + if (!blockNode) return { positions: [], totalHeight: 0 } + + const blockData = blockNode.data as BasicNodeData + const targetHandle = + branch.direction === 'input' + ? blockData.inputHandles.find((h) => h.id === branch.handleId) + : blockData.outputHandles.find((h) => h.id === branch.handleId) + + if (!targetHandle) return { positions: [], totalHeight: 0 } + + const handleY = targetHandle.glbPosition.y + const handleX = targetHandle.glbPosition.x + const blockStyle = defaultCustomNodesStyles.block + const results: Pos[] = [] + const styleOf = (n: Node) => defaultCustomNodesStyles[n.type ?? 'contact'] ?? defaultCustomNodesStyles.contact + + // ---------- 1. Build region map across all parallels in this branch ---------- + // findAllParallelsDepthAndNodes recurses into nested parallels (both serial- and + // parallel-side), so seeding it from the top-level OPEN nodes of branch.nodeIds gives + // us a flat map of every region in the branch keyed by OPEN id. + const allRegions: Record = {} + for (const nodeId of branch.nodeIds) { + const n = rung.nodes.find((nd) => nd.id === nodeId) + if (n && n.type === 'parallel' && (n as ParallelNode).data.type === 'open') { + Object.assign(allRegions, findAllParallelsDepthAndNodes(rung, n as ParallelNode)) + } + } + const regionList = Object.values(allRegions) + + // ---------- 2. Compute spineY and parallelY per region (parent-first) ---------- + const spineYMap: Record = {} + const parallelYMap: Record = {} + const verticalGap = defaultCustomNodesStyles.contact.verticalGap + const placed = new Set() + + // Iterate until every region has been processed (handles arbitrary depth). + // Safety bound prevents infinite loops on malformed parent chains. + let safety = regionList.length * 2 + 1 + while (placed.size < regionList.length && safety-- > 0) { + for (const region of regionList) { + const openId = region.parallels.open.id + if (placed.has(openId)) continue + const parentOpenId = region.parent?.id + if (parentOpenId && !placed.has(parentOpenId)) continue + + let regionSpineY: number + if (!parentOpenId) { + regionSpineY = handleY + } else { + const parent = allRegions[parentOpenId] + const isInParentParallel = parent.nodes.parallel.some((n) => n.id === openId) + regionSpineY = isInParentParallel ? parallelYMap[parentOpenId] : spineYMap[parentOpenId] + } + spineYMap[openId] = regionSpineY + + let height = 0 + for (const sn of region.nodes.serial) { + height = Math.max(height, getDefaultNodeStyle({ node: sn }).height) + } + parallelYMap[openId] = regionSpineY + height + verticalGap + placed.add(openId) + } + } + + // Determine Y for any node in this branch. Outer-spine nodes sit at handleY; nested + // nodes use the deepest region whose serial/parallel chain claims them. + const yOfNode = (nodeId: string): number => { + if (branch.nodeIds.includes(nodeId)) return handleY + let bestY = handleY + let bestDepth = -1 + for (const region of regionList) { + const openId = region.parallels.open.id + if (region.nodes.serial.some((n) => n.id === nodeId)) { + if (region.depth > bestDepth) { + bestDepth = region.depth + bestY = spineYMap[openId] + } + } + if (region.nodes.parallel.some((n) => n.id === nodeId)) { + if (region.depth > bestDepth) { + bestDepth = region.depth + bestY = parallelYMap[openId] + } + } + } + return bestY + } + + // ---------- 3. Position outer spine at handleY ---------- + if (branch.direction === 'input') { + let currentX = handleX + for (let i = branch.nodeIds.length - 1; i >= 0; i--) { + const nodeId = branch.nodeIds[i] + const node = rung.nodes.find((n) => n.id === nodeId) + if (!node) continue + const style = styleOf(node) + let rightNeighborGap: number + if (i === branch.nodeIds.length - 1) { + rightNeighborGap = blockStyle.gap + } else { + const rn = rung.nodes.find((n) => n.id === branch.nodeIds[i + 1]) + rightNeighborGap = rn ? styleOf(rn).gap : 0 + } + currentX -= style.width + style.gap + rightNeighborGap + results.unshift({ + nodeId, + posX: currentX, + posY: handleY - style.handle.y, + handleX: currentX, + handleY, + }) + } + } else { + let currentX = handleX + for (let i = 0; i < branch.nodeIds.length; i++) { + const nodeId = branch.nodeIds[i] + const node = rung.nodes.find((n) => n.id === nodeId) + if (!node) continue + const style = styleOf(node) + let leftNeighborGap: number + if (i === 0) { + leftNeighborGap = blockStyle.gap + } else { + const ln = rung.nodes.find((n) => n.id === branch.nodeIds[i - 1]) + leftNeighborGap = ln ? styleOf(ln).gap : 0 + } + currentX += leftNeighborGap + style.gap + results.push({ + nodeId, + posX: currentX, + posY: handleY - style.handle.y, + handleX: currentX, + handleY, + }) + currentX += style.width + } + } + + // ---------- 4. Walk each region's parallel chain (parent-first) ---------- + // The chain is the sequence returned by getNodesInsideParallel (edge-walk order). It + // includes nested OPEN/CLOSE pairs and the serial chain between them, all at the + // parent's parallelY. Deeper-nested parallel-paths get walked when we visit that + // child region in this loop. + // + // Parallel→parallel transitions overlay at the same X with no width or gap + // contribution — this matches `getNodePositionBasedOnPreviousNode`'s + // parallelNodeCheckingParallelNode rule on the main rail and keeps nested + // OPEN/CLOSE pairs aligned with their parents (no 4-px-per-level diagonal curve). + const sortedRegions = [...regionList].sort((a, b) => a.depth - b.depth) + for (const region of sortedRegions) { + const openId = region.parallels.open.id + const openPos = results.find((r) => r.nodeId === openId) + if (!openPos) continue + const openNode = rung.nodes.find((n) => n.id === openId) + const openStyle = openNode ? styleOf(openNode) : defaultCustomNodesStyles.parallel + + // Track the previous element so we can apply the par→par overlay rule. + // The walk seeds with OPEN_PARENT as the initial "previous" element. + let prev: { x: number; width: number; gap: number; isParallel: boolean } = { + x: openPos.handleX, + width: openStyle.width, + gap: openStyle.gap, + isParallel: true, + } + + for (const inner of region.nodes.parallel) { + const node = rung.nodes.find((n) => n.id === inner.id) + if (!node) continue + const style = styleOf(node) + const isParallel = node.type === 'parallel' + + const newX = + prev.isParallel && isParallel + ? prev.x // par→par: overlay at parent OPEN's X, no advance + : prev.x + prev.width + prev.gap + style.gap + + const y = yOfNode(inner.id) + results.push({ + nodeId: inner.id, + posX: newX, + posY: y - style.handle.y, + handleX: newX, + handleY: y, + }) + prev = { x: newX, width: style.width, gap: style.gap, isParallel } + } + } + + // ---------- 5. Width post-pass: expand OPEN→CLOSE when parallel chain is wider ---------- + // Process deepest first so that inner expansions propagate outward and the outer + // region's rightmost-X reflects the post-expansion layout. + const childrenOf = (openId: string) => regionList.filter((r) => r.parent?.id === openId) + + // Recursively shift a region's parallel-path elements AND all nested descendants by dx. + // Used when a top-level region's OPEN shifts LEFT — every node positioned relative to + // OPEN (its full subtree) must follow. + const shiftSubtree = (region: RegionInfo, dx: number) => { + for (const inner of region.nodes.parallel) { + const pos = results.find((r) => r.nodeId === inner.id) + if (pos) { + pos.posX += dx + pos.handleX += dx + } + } + for (const child of childrenOf(region.parallels.open.id)) { + shiftSubtree(child, dx) + } + } + + const reversedRegions = [...sortedRegions].reverse() + for (const region of reversedRegions) { + const openId = region.parallels.open.id + const closeId = region.parallels.close.id + const closePos = results.find((r) => r.nodeId === closeId) + if (!closePos) continue + + // Compute the X that CLOSE_PARENT must reach to encompass every parallel-chain + // element. For a non-parallel element we add the same gaps the walk would use to + // place CLOSE_PARENT after it; for a nested-parallel element (typically the + // chain's trailing CLOSE) we use the par→par overlay rule — CLOSE_PARENT lands + // at the nested CLOSE's X, not past its right edge. + const closeNode = rung.nodes.find((n) => n.id === closeId) + const closeStyle = closeNode ? styleOf(closeNode) : defaultCustomNodesStyles.parallel + let requiredCloseX = 0 + let sawAny = false + for (const inner of region.nodes.parallel) { + const pos = results.find((r) => r.nodeId === inner.id) + if (!pos) continue + const node = rung.nodes.find((n) => n.id === inner.id) + if (!node) continue + const style = styleOf(node) + sawAny = true + const x = node.type === 'parallel' ? pos.posX : pos.posX + style.width + style.gap + closeStyle.gap + if (x > requiredCloseX) requiredCloseX = x + } + if (!sawAny) continue + if (requiredCloseX <= closePos.posX) continue + const shift = requiredCloseX - closePos.posX + + const isTopLevel = branch.nodeIds.includes(openId) + + if (isTopLevel && branch.direction === 'input') { + // Outer input branch: CLOSE is anchored near the block (right side), so widening + // pushes OPEN and everything before it LEFT. Every descendant of this region must + // shift LEFT by the same amount so relative alignment is preserved. + const openIdx = branch.nodeIds.indexOf(openId) + for (const pos of results) { + const idx = branch.nodeIds.indexOf(pos.nodeId) + if (idx !== -1 && idx <= openIdx) { + pos.posX -= shift + pos.handleX -= shift + } + } + shiftSubtree(region, -shift) + } else if (isTopLevel && branch.direction === 'output') { + // Outer output branch: OPEN is anchored to the block (left side), so widening + // pushes CLOSE and everything after it RIGHT. + const closeIdx = branch.nodeIds.indexOf(closeId) + for (const pos of results) { + const idx = branch.nodeIds.indexOf(pos.nodeId) + if (idx !== -1 && idx >= closeIdx) { + pos.posX += shift + pos.handleX += shift + } + } + } else { + // Nested region: OPEN is anchored to the parent's parallel chain start (we can't + // shift it LEFT without colliding with siblings or the parent OPEN). Instead, + // push CLOSE and everything to its right within the parent chain RIGHT. The + // parent's rightmost-X is now larger, which propagates on the next iteration + // (parents come later in `reversedRegions`). + const parent = allRegions[region.parent!.id] + const closeIdx = parent.nodes.parallel.findIndex((n) => n.id === closeId) + if (closeIdx !== -1) { + for (let i = closeIdx; i < parent.nodes.parallel.length; i++) { + const pos = results.find((r) => r.nodeId === parent.nodes.parallel[i].id) + if (pos) { + pos.posX += shift + pos.handleX += shift + } + } + } + } + } + + // ---------- 6. Compute total vertical extent for block expansion ---------- + let totalHeight = 0 + for (const region of regionList) { + const openId = region.parallels.open.id + let maxElementHeight = 0 + for (const inner of region.nodes.parallel) { + const node = rung.nodes.find((n) => n.id === inner.id) + if (node) maxElementHeight = Math.max(maxElementHeight, getDefaultNodeStyle({ node }).height) + } + const verticalOffset = parallelYMap[openId] - handleY + totalHeight = Math.max(totalHeight, verticalOffset + maxElementHeight) + } + + return { positions: results, totalHeight } +} + +/** + * Calculate dynamic per-handle Y offsets for a block whose branches contain parallels. + * Returns null when no handle needs expansion (callers can skip the update). + * + * Used in the layout post-pass (Phase 4.1c) to expand block height and shift handles + * so that parallel branch elements don't overlap. + */ +export function calculateDynamicHandleOffsets( + rung: RungLadderState, + blockNode: Node, +): { inputOffsets: number[]; outputOffsets: number[]; totalHeight: number } | null { + const blockStyle = defaultCustomNodesStyles.block + const DEFAULT_OFFSET = blockStyle.handle.offsetY // 40 + const FIRST_HANDLE_Y = blockStyle.handle.y // 36 + + const blockData = blockNode.data as BasicNodeData + const inputHandles = blockData.inputHandles + const outputHandles = blockData.outputHandles + + function getOffsetsForHandles(handles: typeof inputHandles): number[] { + const offsets: number[] = [] + for (let i = 0; i < handles.length; i++) { + let offset = DEFAULT_OFFSET + + // Check this handle's own branch for parallel expansion + const handleId = handles[i].id as string + const branch = getBranch(rung, blockNode.id, handleId) + if (branch) { + const hasParallels = branch.nodeIds.some((id) => { + const n = rung.nodes.find((node) => node.id === id) + return n?.type === 'parallel' + }) + if (hasParallels) { + const { totalHeight } = calculateBranchElementPositions(rung, branch) + offset = Math.max(offset, totalHeight + 20) + } + } + + // When the next handle has a branch attached, expand this handle's offset + // to provide visual clearance between this handle's wire area and the branch + // elements at the next handle's level. Uses the element's verticalGap (80px) + // as the minimum — enough for visual separation without the full parallel height. + if (i + 1 < handles.length) { + const nextHandleId = handles[i + 1].id as string + if (getBranch(rung, blockNode.id, nextHandleId)) { + offset = Math.max(offset, defaultCustomNodesStyles.contact.verticalGap) + } + } + + offsets.push(offset) + } + return offsets + } + + const inputOffsets = getOffsetsForHandles(inputHandles) + const outputOffsets = getOffsetsForHandles(outputHandles) + + // Check if any offset differs from default + const hasExpansion = [...inputOffsets, ...outputOffsets].some((o) => o !== DEFAULT_OFFSET) + if (!hasExpansion) return null + + // Calculate total block height + const maxHandles = Math.max(inputHandles.length, outputHandles.length) + let cumulativeY = FIRST_HANDLE_Y + for (let i = 0; i < maxHandles - 1; i++) { + const inputOffset = inputOffsets[i] ?? DEFAULT_OFFSET + const outputOffset = outputOffsets[i] ?? DEFAULT_OFFSET + cumulativeY += Math.max(inputOffset, outputOffset) + } + const totalHeight = cumulativeY + 24 // padding below last handle + + return { inputOffsets, outputOffsets, totalHeight } +} + +/** + * Add a dynamic branch handle to a power rail node. + * For input branches: adds a source handle to the left rail. + * For output branches: adds a target handle to the right rail. + */ +export function addRailBranchHandle( + nodes: Node[], + blockId: string, + handleId: string, + direction: 'input' | 'output', + handleY: number, +): Node[] { + const railId = findRailId(nodes, direction === 'input' ? 'left' : 'right') + if (!railId) return nodes + const rail = nodes.find((n) => n.id === railId) + if (!rail) return nodes + + const railData = rail.data as BasicNodeData + const branchHandleId = `branch_${blockId}_${handleId}` + + // Don't add if handle already exists + if (railData.handles.some((h) => h.id === branchHandleId)) return nodes + + const isLeftRail = direction === 'input' + const newHandle = buildHandle({ + id: branchHandleId, + position: isLeftRail ? Position.Right : Position.Left, + type: isLeftRail ? 'source' : 'target', + isConnectable: false, + glbX: railData.handles[0]?.glbPosition.x ?? 0, + glbY: handleY, + relX: railData.handles[0]?.relPosition.x ?? 0, + relY: handleY - rail.position.y, + style: isLeftRail ? { top: handleY - rail.position.y, right: -3 } : { top: handleY - rail.position.y, left: -3 }, + }) + + return nodes.map((n) => { + if (n.id !== railId) return n + const data = n.data as BasicNodeData + return { + ...n, + data: { + ...data, + handles: [...data.handles, newHandle], + ...(isLeftRail + ? { outputHandles: [...(data.outputHandles ?? []), newHandle] } + : { inputHandles: [...(data.inputHandles ?? []), newHandle] }), + }, + } + }) +} + +/** + * Remove a dynamic branch handle from a power rail node. + * Inverse of addRailBranchHandle. + */ +export function removeRailBranchHandle( + nodes: Node[], + blockId: string, + handleId: string, + direction: 'input' | 'output', +): Node[] { + const railId = findRailId(nodes, direction === 'input' ? 'left' : 'right') + if (!railId) return nodes + const branchHandleId = `branch_${blockId}_${handleId}` + + return nodes.map((n) => { + if (n.id !== railId) return n + const data = n.data as BasicNodeData + const newHandles = data.handles.filter((h) => h.id !== branchHandleId) + return { + ...n, + data: { + ...data, + handles: newHandles, + inputHandles: newHandles.filter((h) => h.type === 'target'), + outputHandles: newHandles.filter((h) => h.type === 'source'), + }, + } + }) +} + +/** + * Register a new handle branch in the rung's handleBranches metadata. + */ +export function addHandleBranch(handleBranches: HandleBranch[] | undefined, branch: HandleBranch): HandleBranch[] { + return [...(handleBranches ?? []), branch] +} + +/** + * Sync rail branch handle positions with their target block handle positions. + * Called after the main layout loop repositions blocks, so rail branch handles + * stay aligned with the block handles they connect to. + */ +export function updateRailForBranches(nodes: Node[], handleBranches: HandleBranch[] | undefined): Node[] { + if (!handleBranches?.length) return nodes + + // Collect rail updates: railId → array of { branchHandleId, newY } + // Rail ids include a rung suffix on the new architecture, so resolve them + // from nodes[] before keying the map (see feedback-port-ladder-rail-ids). + const railUpdates = new Map>() + + for (const branch of handleBranches) { + const blockNode = nodes.find((n) => n.id === branch.blockId) + if (!blockNode) continue + + const blockData = blockNode.data as BasicNodeData + const targetHandle = + branch.direction === 'input' + ? blockData.inputHandles.find((h) => h.id === branch.handleId) + : blockData.outputHandles.find((h) => h.id === branch.handleId) + if (!targetHandle) continue + + const railId = findRailId(nodes, branch.direction === 'input' ? 'left' : 'right') + if (!railId) continue + const branchHandleId = `branch_${branch.blockId}_${branch.handleId}` + + if (!railUpdates.has(railId)) railUpdates.set(railId, []) + railUpdates.get(railId)!.push({ branchHandleId, newY: targetHandle.glbPosition.y }) + } + + if (railUpdates.size === 0) return nodes + + return nodes.map((n) => { + const updates = railUpdates.get(n.id) + if (!updates) return n + + const railData = n.data as BasicNodeData + const newHandles = railData.handles.map((h) => { + const update = updates.find((u) => u.branchHandleId === h.id) + if (!update) return h + return { + ...h, + glbPosition: { ...h.glbPosition, y: update.newY }, + relPosition: { ...h.relPosition, y: update.newY - n.position.y }, + style: { ...h.style, top: update.newY - n.position.y }, + } + }) + + return { + ...n, + data: { + ...railData, + handles: newHandles, + inputHandles: newHandles.filter((h) => h.type === 'target'), + outputHandles: newHandles.filter((h) => h.type === 'source'), + }, + } + }) +} + +/** + * Insert a new element at a specified position within an existing branch. + * Composes shared primitives: buildGenericNode, buildEdge. + * + * For a branch with nodeIds = [A, B]: + * insertIndex=0 → splits edge from rail → A, inserts before A + * insertIndex=1 → splits edge from A → B, inserts between A and B + * insertIndex=2 → splits edge from B → block, inserts after B + */ +export function insertIntoBranch( + rung: RungLadderState, + target: { + blockId: string + handleId: string + direction: 'input' | 'output' + insertIndex: number + }, + nodeOrType: string | Node, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[]; newNode: Node } { + const branch = getBranch(rung, target.blockId, target.handleId) + if (!branch) { + throw new Error(`No branch found for block ${target.blockId} handle ${target.handleId}`) + } + + // Resolve actual rail node id (suffixed with rung id on this architecture). + const isInput = target.direction === 'input' + const railId = findRailId(rung.nodes, isInput ? 'left' : 'right') + if (!railId) { + throw new Error(`Could not find ${isInput ? 'left' : 'right'} rail in rung`) + } + + // Step 1: Build or use existing element node + const newElement: Node = + typeof nodeOrType === 'string' + ? (buildGenericNode({ + nodeType: nodeOrType, + id: `${nodeOrType.toUpperCase()}_${uuidv4()}`, + posX: 0, + posY: 0, + handleX: 0, + handleY: 0, + }) as Node) + : { ...nodeOrType, data: { ...nodeOrType.data } } + + // Set branch context marker + ;(newElement.data as BasicNodeData).branchContext = { + blockId: target.blockId, + handleId: target.handleId, + direction: target.direction, + } + + // Step 2: Find the edge to split based on insertIndex + const branchHandleId = `branch_${target.blockId}_${target.handleId}` + const { nodeIds } = branch + const idx = target.insertIndex + + // Helper to resolve the actual output handle ID for a node in the branch spine. + // Parallel OPEN/CLOSE nodes use 'output-right', regular nodes use 'output'. + const resolveOutputHandle = (nodeId: string): string => { + const n = rung.nodes.find((nd) => nd.id === nodeId) + return (n?.data as BasicNodeData | undefined)?.outputConnector?.id ?? 'output' + } + // Helper to resolve the actual input handle ID for a node in the branch spine. + const resolveInputHandle = (nodeId: string): string => { + const n = rung.nodes.find((nd) => nd.id === nodeId) + return (n?.data as BasicNodeData | undefined)?.inputConnector?.id ?? 'input' + } + + let sourceId: string + let sourceHandle: string | undefined + let targetId: string + let targetHandle: string | undefined + + if (isInput) { + // Input branch: rail → nodeIds[0] → ... → nodeIds[n-1] → block + sourceId = idx === 0 ? railId : nodeIds[idx - 1] + sourceHandle = idx === 0 ? branchHandleId : resolveOutputHandle(nodeIds[idx - 1]) + targetId = idx === nodeIds.length ? target.blockId : nodeIds[idx] + targetHandle = idx === nodeIds.length ? target.handleId : resolveInputHandle(nodeIds[idx]) + } else { + // Output branch: block → nodeIds[0] → ... → nodeIds[n-1] → rail + sourceId = idx === 0 ? target.blockId : nodeIds[idx - 1] + sourceHandle = idx === 0 ? target.handleId : resolveOutputHandle(nodeIds[idx - 1]) + targetId = idx === nodeIds.length ? railId : nodeIds[idx] + targetHandle = idx === nodeIds.length ? branchHandleId : resolveInputHandle(nodeIds[idx]) + } + + // Find the existing edge between sourceId/sourceHandle → targetId/targetHandle + const oldEdge = rung.edges.find( + (e) => + e.source === sourceId && + e.target === targetId && + e.sourceHandle === sourceHandle && + e.targetHandle === targetHandle, + ) + + // Step 3: Remove old edge, create two new edges via shared core utility + if (!oldEdge) { + console.warn( + `insertIntoBranch: expected edge ${sourceId}[${sourceHandle}] → ${targetId}[${targetHandle}] not found — branch metadata may be stale`, + ) + } + const newEdges = oldEdge + ? spliceEdgeAndInsertNode(rung.edges, oldEdge, newElement.id, 'input', 'output') + : [...rung.edges] + + // Step 4: Splice new element ID into branch's nodeIds + const newNodeIds = [...nodeIds] + newNodeIds.splice(idx, 0, newElement.id) + + const handleBranches = (rung.handleBranches ?? []).map((b) => + b.blockId === target.blockId && b.handleId === target.handleId ? { ...b, nodeIds: newNodeIds } : b, + ) + + // Step 5: Add element to nodes array + const newNodes = [...rung.nodes, newElement] + + return { nodes: newNodes, edges: newEdges, handleBranches, newNode: newElement } +} + +/** + * Remove a branch element from its branch. + * Handles edge reconnection, metadata cleanup, and rail handle removal. + * + * - Multi-element branch: splices nodeId out, uses disconnectNodes to bridge edges. + * - Last element: removes both edges without bridging, removes rail handle and branch entry. + * The Variable node is restored automatically by updateVariableBlockPosition in the layout pass. + */ +export function removeBranchElement( + rung: RungLadderState, + elementId: string, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } { + const element = rung.nodes.find((n) => n.id === elementId) + if (!element) { + return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches ?? [] } + } + + const ctx = (element.data as BasicNodeData).branchContext + if (!ctx) { + return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches ?? [] } + } + + const branch = getBranch(rung, ctx.blockId, ctx.handleId) + if (!branch) { + return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches ?? [] } + } + + let newNodes = removeNode(rung, elementId) + let newEdges: Edge[] + let newHandleBranches: HandleBranch[] + + if (branch.nodeIds.length === 1) { + // Last element in branch: remove both edges without bridging + const incomingEdge = rung.edges.find((e) => e.target === elementId) + const outgoingEdge = rung.edges.find( + (e) => e.source === elementId && e.sourceHandle === (element.data as BasicNodeData).outputConnector?.id, + ) + + newEdges = [...rung.edges] + if (incomingEdge) newEdges = removeEdge(newEdges, incomingEdge.id) + if (outgoingEdge) newEdges = removeEdge(newEdges, outgoingEdge.id) + + // Remove rail branch handle + newNodes = removeRailBranchHandle(newNodes, ctx.blockId, ctx.handleId, ctx.direction) + + // Remove the HandleBranch entry entirely + newHandleBranches = (rung.handleBranches ?? []).filter( + (b) => !(b.blockId === ctx.blockId && b.handleId === ctx.handleId), + ) + } else { + // Multi-element branch: bridge edges around the removed element via disconnectNodes + const outgoingEdge = rung.edges.find( + (e) => e.source === elementId && e.sourceHandle === (element.data as BasicNodeData).outputConnector?.id, + ) + if (outgoingEdge) { + newEdges = disconnectNodes(rung, elementId, outgoingEdge.target) + } else { + newEdges = [...rung.edges] + } + + // Splice nodeId out of the branch + newHandleBranches = (rung.handleBranches ?? []).map((b) => + b.blockId === ctx.blockId && b.handleId === ctx.handleId + ? { ...b, nodeIds: b.nodeIds.filter((id) => id !== elementId) } + : b, + ) + } + + return { nodes: newNodes, edges: newEdges, handleBranches: newHandleBranches } +} + +/** + * Replace a Variable node on a block handle with a branch element (contact or coil). + * Composes shared primitives: buildGenericNode, buildEdge. + */ +export function replaceVariableWithBranch( + rung: RungLadderState, + target: { + blockId: string + handleId: string + direction: 'input' | 'output' + handlePosition: { x: number; y: number } + }, + nodeOrType: string | Node, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[]; newNode: Node } { + // Step 1: Build or use existing element node + const newElement: Node = + typeof nodeOrType === 'string' + ? (buildGenericNode({ + nodeType: nodeOrType, + id: `${nodeOrType.toUpperCase()}_${uuidv4()}`, + posX: 0, + posY: 0, + handleX: 0, + handleY: 0, + }) as Node) + : { ...nodeOrType, data: { ...nodeOrType.data } } + + // Set branch context marker + ;(newElement.data as BasicNodeData).branchContext = { + blockId: target.blockId, + handleId: target.handleId, + direction: target.direction, + } + + // Step 2: Remove existing Variable node and its edge + const isInput = target.direction === 'input' + const varEdge = isInput + ? rung.edges.find((e) => e.target === target.blockId && e.targetHandle === target.handleId) + : rung.edges.find((e) => e.source === target.blockId && e.sourceHandle === target.handleId) + const varNode = varEdge + ? rung.nodes.find((n) => n.id === (isInput ? varEdge.source : varEdge.target) && n.type === 'variable') + : undefined + + let newNodes = rung.nodes.filter((n) => n.id !== varNode?.id) + let newEdges = rung.edges.filter((e) => e.id !== varEdge?.id) + + // Step 3: Add branch handle to rail — addRailBranchHandle (branch-specific) + newNodes = addRailBranchHandle(newNodes, target.blockId, target.handleId, target.direction, target.handlePosition.y) + + // Step 4: Create edges — REUSE buildEdge + // Resolve the actual rail node id (the prefix `left-rail` / `right-rail` + // is followed by `-{rungId}` in the current architecture). + const railId = findRailId(newNodes, isInput ? 'left' : 'right') + if (!railId) { + return { nodes: newNodes, edges: newEdges, handleBranches: rung.handleBranches ?? [], newNode: newElement } + } + const branchHandleId = `branch_${target.blockId}_${target.handleId}` + + if (isInput) { + // Input branch: rail → element → block handle + const edge1 = buildEdge(railId, newElement.id, { + sourceHandle: branchHandleId, + targetHandle: 'input', + }) + const edge2 = buildEdge(newElement.id, target.blockId, { + sourceHandle: 'output', + targetHandle: target.handleId, + }) + newEdges = [...newEdges, edge1, edge2] + } else { + // Output branch: block handle → element → rail + const edge1 = buildEdge(target.blockId, newElement.id, { + sourceHandle: target.handleId, + targetHandle: 'input', + }) + const edge2 = buildEdge(newElement.id, railId, { + sourceHandle: 'output', + targetHandle: branchHandleId, + }) + newEdges = [...newEdges, edge1, edge2] + } + + // Step 5: Register branch metadata — addHandleBranch (branch-specific) + const handleBranches = addHandleBranch(rung.handleBranches, { + blockId: target.blockId, + handleId: target.handleId, + direction: target.direction, + nodeIds: [newElement.id], + }) + + // Step 6: Add element to nodes array + newNodes = [...newNodes, newElement] + + return { nodes: newNodes, edges: newEdges, handleBranches, newNode: newElement } +} + +/** + * Create a parallel (OR-branch) within an existing handle branch. + * + * Wraps the `aboveElement` in an OPEN/CLOSE parallel pair and places the new + * element on the parallel path. The serial spine (nodeIds) gains OPEN and CLOSE; + * the new parallel-path element is intentionally NOT added to nodeIds. + * + * Composes shared primitives: nodesBuilder.parallel, buildGenericNode, buildEdge. + */ +export function startParallelInBranch( + rung: RungLadderState, + aboveElement: Node, + newNode: { elementType: string; blockVariant?: unknown } | Node, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[]; newNode?: Node } { + const ctx = (aboveElement.data as BasicNodeData).branchContext + if (!ctx) { + return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches ?? [] } + } + + const branch = getBranch(rung, ctx.blockId, ctx.handleId) + if (!branch) { + return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches ?? [] } + } + + const aboveIndex = branch.nodeIds.indexOf(aboveElement.id) + + // Step 1: Find predecessor and successor edges of the above element + const incomingEdge = rung.edges.find((e) => e.target === aboveElement.id) + const outgoingEdge = rung.edges.find( + (e) => e.source === aboveElement.id && e.sourceHandle === (aboveElement.data as BasicNodeData).outputConnector?.id, + ) + + if (!incomingEdge || !outgoingEdge) { + console.warn('startParallelInBranch: could not find edges for aboveElement', aboveElement.id) + return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches ?? [] } + } + + // Step 2: Build OPEN parallel — REUSE nodesBuilder.parallel + const openParallel = nodesBuilder.parallel({ + id: `PARALLEL_OPEN_${uuidv4()}`, + type: 'open', + posX: 0, + posY: 0, + handleX: 0, + handleY: 0, + }) as Node + ;(openParallel.data as BasicNodeData).branchContext = { + blockId: ctx.blockId, + handleId: ctx.handleId, + direction: ctx.direction, + } + + // Step 3: Build new element — REUSE buildGenericNode or accept pre-built node + let newElement: Node + if (!checkIfElementIsNode(newNode)) { + newElement = buildGenericNode({ + nodeType: (newNode as { elementType: string }).elementType, + id: `${(newNode as { elementType: string }).elementType.toUpperCase()}_${uuidv4()}`, + posX: 0, + posY: 0, + handleX: 0, + handleY: 0, + }) as Node + } else { + newElement = newNode + } + ;(newElement.data as BasicNodeData).branchContext = { + blockId: ctx.blockId, + handleId: ctx.handleId, + direction: ctx.direction, + } + + // Step 4: Build CLOSE parallel — REUSE nodesBuilder.parallel + const closeParallel = nodesBuilder.parallel({ + id: `PARALLEL_CLOSE_${uuidv4()}`, + type: 'close', + posX: 0, + posY: 0, + handleX: 0, + handleY: 0, + }) as Node + ;(closeParallel.data as BasicNodeData).branchContext = { + blockId: ctx.blockId, + handleId: ctx.handleId, + direction: ctx.direction, + } + openParallel.data.parallelCloseReference = closeParallel.id + closeParallel.data.parallelOpenReference = openParallel.id + + // Step 5: Rewire edges via shared core utility. + // For nested parallels (predecessor is a parent OPEN's parallel-down output, or successor + // is a parent CLOSE's parallel-up input), route through the top handles so the edges run + // as straight verticals. Matches startParallelConnection's handling on the main rail. + const predecessorNode = rung.nodes.find((n) => n.id === incomingEdge.source) + const openTargetHandle = + predecessorNode && + predecessorNode.type === 'parallel' && + (predecessorNode as ParallelNode).data.type === 'open' && + incomingEdge.sourceHandle === (predecessorNode as ParallelNode).data.parallelOutputConnector?.id + ? (openParallel.data as ParallelNode['data']).parallelInputConnector?.id + : undefined + + const successorNode = rung.nodes.find((n) => n.id === outgoingEdge.target) + const closeSourceHandle = + successorNode && + successorNode.type === 'parallel' && + (successorNode as ParallelNode).data.type === 'close' && + outgoingEdge.targetHandle === (successorNode as ParallelNode).data.parallelInputConnector?.id + ? (closeParallel.data as ParallelNode['data']).parallelOutputConnector?.id + : undefined + + const { edgesToRemove, edgesToAdd } = wireParallelAroundElement({ + incomingEdge, + outgoingEdge, + openParallel: openParallel, + closeParallel: closeParallel, + aboveElement, + newElement, + openTargetHandle, + closeSourceHandle, + }) + const newEdges = [...rung.edges.filter((e) => !edgesToRemove.includes(e.id)), ...edgesToAdd] + + // Step 6: Update HandleBranch.nodeIds (outer serial spine only). + // - If aboveElement is on the outer spine, insert OPEN/CLOSE around it there. + // - If aboveElement is a parallel-path element (aboveIndex === -1), the new + // OPEN/CLOSE belong to a nested region inside some parent OPEN's parallel chain. + // They are discovered automatically by findAllParallelsDepthAndNodes via the + // rewired edges, so branch.nodeIds (which only tracks the outer spine) must NOT + // change. + let newNodeIds = branch.nodeIds + if (aboveIndex !== -1) { + newNodeIds = [...branch.nodeIds] + newNodeIds.splice(aboveIndex, 0, openParallel.id) + // aboveElement is now at aboveIndex+1, insert CLOSE after it + newNodeIds.splice(aboveIndex + 2, 0, closeParallel.id) + } + + // Step 7: Build return value + const newNodes = [...rung.nodes, openParallel, newElement, closeParallel] + const newHandleBranches = (rung.handleBranches ?? []).map((b) => + b.blockId === branch.blockId && b.handleId === branch.handleId ? { ...b, nodeIds: newNodeIds } : b, + ) + + return { nodes: newNodes, edges: newEdges, handleBranches: newHandleBranches, newNode: newElement } +} + +/** + * Reconcile handle branches when a block's type/variant changes. + * + * For each existing branch on the old block: + * - If the handle no longer exists or is no longer BOOL-compatible → remove all branch elements + * - If the handle is preserved and still BOOL-compatible → remap IDs from old block to new block + * + * Must be called BEFORE main connector edge remapping in handleBlockSubmit, + * so that branch edges get correct IDs and the main remapping loop won't find them. + */ +export function reconcileBranches( + rung: RungLadderState, + oldBlockId: string, + newBlockId: string, + newVariables: BlockVariant['variables'], +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } { + let newNodes = [...rung.nodes] + let newEdges = [...rung.edges] + const handleBranches = [...(rung.handleBranches ?? [])] + + const blockBranches = handleBranches.filter((b) => b.blockId === oldBlockId) + if (blockBranches.length === 0) { + return { nodes: newNodes, edges: newEdges, handleBranches } + } + + const branchesToRemove: HandleBranch[] = [] + const branchesToKeep: HandleBranch[] = [] + + for (const branch of blockBranches) { + const newVariable = newVariables.find((v) => v.name === branch.handleId) + if (newVariable && canPlaceElementOnHandle(newVariable)) { + branchesToKeep.push(branch) + } else { + branchesToRemove.push(branch) + } + } + + // --- Remove dead branches --- + for (const branch of branchesToRemove) { + // Collect all nodes for this branch (serial spine + parallel-path) + const branchNodeIds = new Set(branch.nodeIds) + for (const node of newNodes) { + const ctx = (node.data as BasicNodeData).branchContext + if (ctx && ctx.blockId === oldBlockId && ctx.handleId === branch.handleId) { + branchNodeIds.add(node.id) + } + } + + // Remove nodes and connected edges + newNodes = newNodes.filter((n) => !branchNodeIds.has(n.id)) + newEdges = newEdges.filter((e) => !branchNodeIds.has(e.source) && !branchNodeIds.has(e.target)) + + // Remove rail branch handle + newNodes = removeRailBranchHandle(newNodes, oldBlockId, branch.handleId, branch.direction) + } + + // Filter out removed branch metadata + const removedKeys = new Set(branchesToRemove.map((b) => `${b.blockId}_${b.handleId}`)) + let updatedHandleBranches = handleBranches.filter((b) => !removedKeys.has(`${b.blockId}_${b.handleId}`)) + + // --- Remap surviving branches --- + for (const branch of branchesToKeep) { + const oldBranchHandleId = `branch_${oldBlockId}_${branch.handleId}` + const newBranchHandleId = `branch_${newBlockId}_${branch.handleId}` + + // Update branchContext.blockId on all branch nodes + newNodes = newNodes.map((n) => { + const ctx = (n.data as BasicNodeData).branchContext + if (ctx && ctx.blockId === oldBlockId && ctx.handleId === branch.handleId) { + return { + ...n, + data: { + ...n.data, + branchContext: { ...ctx, blockId: newBlockId }, + }, + } + } + return n + }) + + // Remap rail branch handle: remove old ID, add new ID at same Y position. + // Resolve the actual rail node id by prefix on the new architecture. + const railId = findRailId(newNodes, branch.direction === 'input' ? 'left' : 'right') + const rail = railId ? newNodes.find((n) => n.id === railId) : undefined + const railData = rail?.data as BasicNodeData | undefined + const oldRailHandle = railData?.handles.find((h) => h.id === oldBranchHandleId) + const handleY = oldRailHandle?.glbPosition.y ?? 0 + + newNodes = removeRailBranchHandle(newNodes, oldBlockId, branch.handleId, branch.direction) + newNodes = addRailBranchHandle(newNodes, newBlockId, branch.handleId, branch.direction, handleY) + + // Remap edges that reference oldBlockId with branch handles. + // Also update edge IDs so the main connector remapping loop won't find them. + newEdges = newEdges.map((e) => { + let updated = e + let changed = false + + // Edge from element → old block (input branch) + if (e.target === oldBlockId && e.targetHandle === branch.handleId) { + updated = { ...updated, target: newBlockId } + changed = true + } + // Edge from old block → element (output branch) + if (e.source === oldBlockId && e.sourceHandle === branch.handleId) { + updated = { ...updated, source: newBlockId } + changed = true + } + // Edge from rail with old branch handle ID + if (e.sourceHandle === oldBranchHandleId) { + updated = { ...updated, sourceHandle: newBranchHandleId } + changed = true + } + // Edge to rail with old branch handle ID + if (e.targetHandle === oldBranchHandleId) { + updated = { ...updated, targetHandle: newBranchHandleId } + changed = true + } + + if (changed) { + // Rebuild ID deterministically from source/target/handles instead of string + // replacement, which could match unrelated substrings in UUID-based IDs. + updated = { + ...updated, + id: `reactflow__edge-${updated.source}${updated.sourceHandle ?? ''}-${updated.target}${updated.targetHandle ?? ''}`, + } + } + return updated + }) + + // Update HandleBranch.blockId + updatedHandleBranches = updatedHandleBranches.map((b) => + b.blockId === oldBlockId && b.handleId === branch.handleId ? { ...b, blockId: newBlockId } : b, + ) + } + + return { nodes: newNodes, edges: newEdges, handleBranches: updatedHandleBranches } +} + +/** + * Convenience wrapper around reconcileBranches for block-change handlers. + * Reconciles branches if any exist on the old block, otherwise returns undefined. + * Callers can use the result to update nodes/edges/handleBranches in one step. + */ +export function reconcileBranchesIfNeeded( + rung: RungLadderState, + oldBlockId: string, + newBlockId: string, + newVariables: BlockVariant['variables'], +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } | undefined { + if (!rung.handleBranches?.some((b) => b.blockId === oldBlockId)) return undefined + return reconcileBranches(rung, oldBlockId, newBlockId, newVariables) +} + +/** + * Rebuild a branch's nodeIds by traversing the serial spine from rail to block + * (or block to rail for output branches). Called after removeEmptyParallelConnections + * may have removed OPEN/CLOSE nodes, promoting parallel-path elements to the serial spine. + * + * Traversal is preferred over simple filtering because: + * - Filtering wouldn't add parallel-path elements promoted to the serial spine + * - Traversal discovers the actual edge-connected chain + */ +export function reconcileBranchNodeIds(rung: RungLadderState, branch: HandleBranch): string[] { + const branchHandleId = `branch_${branch.blockId}_${branch.handleId}` + const isInput = branch.direction === 'input' + + // Resolve actual rail node id by prefix (suffixed with rung id on this arch). + const leftRailId = findRailId(rung.nodes, 'left') + const rightRailId = findRailId(rung.nodes, 'right') + let currentId = isInput ? (leftRailId ?? '') : branch.blockId + let currentHandle = isInput ? branchHandleId : branch.handleId + const nodeIds: string[] = [] + const visited = new Set() + + // If a rail id couldn't be resolved (defensive), return the existing nodeIds unchanged + if (isInput && !leftRailId) return branch.nodeIds + if (!isInput && !rightRailId) return branch.nodeIds + + while (true) { + const edge = rung.edges.find((e) => e.source === currentId && e.sourceHandle === currentHandle) + if (!edge) break + + const targetNode = rung.nodes.find((n) => n.id === edge.target) + if (!targetNode) break + + // Guard against cycles in the edge graph to prevent infinite loops + if (visited.has(targetNode.id)) break + visited.add(targetNode.id) + + // Stop at the endpoint (block for input, rail for output) + const endpointId = isInput ? branch.blockId : rightRailId + if (targetNode.id === endpointId) break + + nodeIds.push(targetNode.id) + + // Follow the serial spine: use outputConnector for regular nodes, + // outputConnector (output-right) for parallel nodes + const nodeData = targetNode.data as BasicNodeData + currentId = targetNode.id + currentHandle = nodeData.outputConnector?.id ?? 'output' + } + + return nodeIds +} diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts index ce0dece9d..5fa9cdffe 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts @@ -1,15 +1,39 @@ import type { RungLadderState } from '@root/frontend/store/slices' +import { toast } from '@root/frontend/utils/toast' import type { Edge, Node } from '@xyflow/react' -import type { BasicNodeData, PlaceholderNode } from '../../../../../../_atoms/graphical-editor/ladder/utils/types' +import type { + BasicNodeData, + HandleBranch, + PlaceholderNode, +} from '../../../../../../_atoms/graphical-editor/ladder/utils/types' import { disconnectNodes } from '../edges' import { isNodeOfType, removeNode } from '../nodes' import { updateDiagramElementsPosition } from './diagram' +import { + blockHasBranches, + getBranch, + insertIntoBranch, + isBlockInsideMainParallel, + reconcileBranchNodeIds, + removeBranchElement, + removeRailBranchHandle, + replaceVariableWithBranch, + startParallelInBranch, +} from './handle-branch' import { removeEmptyParallelConnections } from './parallel' import { startParallelConnection } from './parallel' import { removePlaceholderElements } from './placeholder' import { appendSerialConnection } from './serial' +// Same guard messages used by drag-n-drop's classifyDrop. Kept here as constants so the +// click-to-add path surfaces identical wording. +const BRANCH_BLOCKED_IN_PARALLEL = + 'Cannot add to a handle branch on a block that is in parallel with other elements. Remove the parallel siblings first.' +const PARALLEL_BLOCKED_WITH_BRANCH = + 'Cannot put a block with handle branches in parallel with other elements. Remove the handle branch contacts first.' +const blockedToast = (reason: string) => toast({ variant: 'warn', title: 'Action not supported', description: reason }) + export const addNewElement = ( rung: RungLadderState, newNode: @@ -18,10 +42,11 @@ export const addNewElement = ( blockVariant?: T } | Node, -): { nodes: Node[]; edges: Edge[]; newNode?: Node } => { +): { nodes: Node[]; edges: Edge[]; newNode?: Node; handleBranches?: HandleBranch[] } => { let newNodeData: Node | undefined let newNodes = [...rung.nodes] let newEdges = [...rung.edges] + let handleBranches: HandleBranch[] | undefined /** * Search for the selected placeholder in the rung @@ -39,21 +64,89 @@ export const addNewElement = ( * If it is not, add the new element to the selected placeholder */ if (isNodeOfType(selectedPlaceholder, 'parallelPlaceholder')) { - const { - nodes: parallelNodes, - edges: parallelEdges, - newNode: parellelNewNode, - } = startParallelConnection( - rung, - { - index: parseInt(selectedPlaceholderIndex), - selected: selectedPlaceholder as PlaceholderNode, - }, - newNode, - ) - newNodes = parallelNodes - newEdges = parallelEdges - newNodeData = parellelNewNode + const relatedNode = (selectedPlaceholder as PlaceholderNode).data.relatedNode + + if (relatedNode && (relatedNode.data as BasicNodeData).branchContext) { + // Branch parallel: route to startParallelInBranch. + // Supports nested parallels — calculateBranchElementPositions uses + // findAllParallelsDepthAndNodes to recursively position deeper levels. + const ctx = (relatedNode.data as BasicNodeData).branchContext! + if (isBlockInsideMainParallel(rung, ctx.blockId)) { + blockedToast(BRANCH_BLOCKED_IN_PARALLEL) + return { nodes: removePlaceholderElements(rung.nodes), edges: rung.edges } + } + const branch = getBranch(rung, ctx.blockId, ctx.handleId) + if (!branch) { + return { nodes: removePlaceholderElements(rung.nodes), edges: rung.edges } + } + + const result = startParallelInBranch(rung, relatedNode, newNode) + newNodes = result.nodes + newEdges = result.edges + newNodeData = result.newNode + handleBranches = result.handleBranches + } else { + // Main-line parallel. If the related node is a block with handle branches, refuse — + // wrapping it in OPEN/CLOSE would entangle the branch layout with a parallel chain. + if (relatedNode && relatedNode.type === 'block' && blockHasBranches(rung, relatedNode.id)) { + blockedToast(PARALLEL_BLOCKED_WITH_BRANCH) + return { nodes: removePlaceholderElements(rung.nodes), edges: rung.edges } + } + const { + nodes: parallelNodes, + edges: parallelEdges, + newNode: parellelNewNode, + } = startParallelConnection( + rung, + { + index: parseInt(selectedPlaceholderIndex), + selected: selectedPlaceholder as PlaceholderNode, + }, + newNode, + ) + newNodes = parallelNodes + newEdges = parallelEdges + newNodeData = parellelNewNode + } + } else if ((selectedPlaceholder.data as BasicNodeData).handleBranchTarget) { + const target = (selectedPlaceholder.data as BasicNodeData).handleBranchTarget! + if ('elementType' in newNode) { + const elementType = (newNode as { elementType: string }).elementType + + // Blocks cannot be placed on branch handles — the branch layout system + // is designed for single-handle elements (contacts/coils) only. + if (elementType === 'block') { + return { nodes: removePlaceholderElements(rung.nodes), edges: rung.edges } + } + if (isBlockInsideMainParallel(rung, target.blockId)) { + blockedToast(BRANCH_BLOCKED_IN_PARALLEL) + return { nodes: removePlaceholderElements(rung.nodes), edges: rung.edges } + } + if (target.insertIndex !== undefined) { + // Insert into existing branch at specified position + const result = insertIntoBranch( + rung, + { + blockId: target.blockId, + handleId: target.handleId, + direction: target.direction, + insertIndex: target.insertIndex, + }, + elementType, + ) + newNodes = result.nodes + newEdges = result.edges + newNodeData = result.newNode + handleBranches = result.handleBranches + } else { + // Create new branch (replace variable node) + const result = replaceVariableWithBranch(rung, target, elementType) + newNodes = result.nodes + newEdges = result.edges + newNodeData = result.newNode + handleBranches = result.handleBranches + } + } } else { const { nodes: serialNodes, @@ -67,9 +160,27 @@ export const addNewElement = ( }, newNode, ) - newNodes = serialNodes + + // When the placeholder is anchored to a parallel-path branch element (a branch + // contact NOT in branch.nodeIds), appendSerialConnection inserts the new node + // into the nested edge chain but doesn't know to tag it as a branch element. + // Without branchContext, the layout treats the new node as main-rail content, + // and getPreviousElementsByEdge skips its (branch) predecessor — leaving it + // without a valid position and dropped from the final node list. Propagate + // branchContext from the related node so the layout recognises it as part of + // the branch. The drag-drop equivalent (handleBranchParallelPath) does the same. + const relatedNode = (selectedPlaceholder as PlaceholderNode).data.relatedNode + const relatedBranchCtx = relatedNode && (relatedNode.data as BasicNodeData).branchContext + if (relatedBranchCtx && serialNewNode) { + newNodes = serialNodes.map((n) => + n.id === serialNewNode.id ? { ...n, data: { ...n.data, branchContext: relatedBranchCtx } } : n, + ) + newNodeData = newNodes.find((n) => n.id === serialNewNode.id) + } else { + newNodes = serialNodes + newNodeData = serialNewNode + } newEdges = serialEdges - newNodeData = serialNewNode } /** @@ -80,6 +191,7 @@ export const addNewElement = ( ...rung, nodes: newNodes, edges: newEdges, + ...(handleBranches && { handleBranches }), }, rung.defaultBounds as [number, number], ) @@ -90,23 +202,76 @@ export const addNewElement = ( /** * Return the updated rung */ - return { nodes: newNodes, edges: newEdges, newNode: newNodeData } + return { nodes: newNodes, edges: newEdges, newNode: newNodeData, handleBranches } } -export const removeElement = (rung: RungLadderState, element: Node): { nodes: Node[]; edges: Edge[] } => { - /** - * Remove the selected element from the rung - */ - let newNodes = removeNode(rung, element.id) +export const removeElement = ( + rung: RungLadderState, + element: Node, +): { nodes: Node[]; edges: Edge[]; handleBranches?: HandleBranch[] } => { + let newNodes: Node[] + let newEdges: Edge[] + let handleBranches: HandleBranch[] | undefined - /** - * Disconnect the element from the rung - */ - const edgeToRemove = rung.edges.find( - (e) => e.source === element.id && e.sourceHandle === (element.data as BasicNodeData).outputConnector?.id, - ) - if (!edgeToRemove) return { nodes: rung.nodes, edges: rung.edges } - let newEdges = disconnectNodes(rung, edgeToRemove.source, edgeToRemove.target) + const branchContext = (element.data as BasicNodeData).branchContext + + if (branchContext) { + /** + * Branch element: delegate node/edge cleanup to removeBranchElement + */ + const result = removeBranchElement(rung, element.id) + newNodes = result.nodes + newEdges = result.edges + handleBranches = result.handleBranches + } else { + /** + * Regular element: remove node and disconnect edges + */ + newNodes = removeNode(rung, element.id) + + const edgeToRemove = rung.edges.find( + (e) => e.source === element.id && e.sourceHandle === (element.data as BasicNodeData).outputConnector?.id, + ) + if (!edgeToRemove) return { nodes: rung.nodes, edges: rung.edges } + newEdges = disconnectNodes(rung, edgeToRemove.source, edgeToRemove.target) + + /** + * When removing a block, also clean up all associated branch elements. + * Branch elements (contacts/coils on secondary handles) and their edges + * would be left floating since disconnectNodes only bridges the main chain. + */ + if (element.type === 'block' && rung.handleBranches?.length) { + const blockBranches = rung.handleBranches.filter((b) => b.blockId === element.id) + if (blockBranches.length > 0) { + // Collect all branch node IDs: serial-spine nodes from nodeIds + // plus parallel-path nodes identified by branchContext + const branchNodeIds = new Set() + for (const branch of blockBranches) { + for (const nodeId of branch.nodeIds) { + branchNodeIds.add(nodeId) + } + } + for (const node of newNodes) { + const ctx = (node.data as BasicNodeData).branchContext + if (ctx && ctx.blockId === element.id) { + branchNodeIds.add(node.id) + } + } + + // Remove branch nodes and their connected edges + newNodes = newNodes.filter((n) => !branchNodeIds.has(n.id)) + newEdges = newEdges.filter((e) => !branchNodeIds.has(e.source) && !branchNodeIds.has(e.target)) + + // Remove rail branch handles + for (const branch of blockBranches) { + newNodes = removeRailBranchHandle(newNodes, branch.blockId, branch.handleId, branch.direction) + } + + // Remove HandleBranch entries for this block + handleBranches = (rung.handleBranches ?? []).filter((b) => b.blockId !== element.id) + } + } + } /** * Check if there is empty parallel connections @@ -121,13 +286,35 @@ export const removeElement = (rung: RungLadderState, element: Node): { nodes: No newEdges = checkedParallelEdges /** - * After adding the new element, update the diagram with the new rung + * Reconcile branch nodeIds after parallel collapse may have removed OPEN/CLOSE nodes + * or promoted parallel-path elements to the serial spine. + */ + if (handleBranches) { + handleBranches = handleBranches + .map((branch) => ({ + ...branch, + nodeIds: reconcileBranchNodeIds({ ...rung, nodes: newNodes, edges: newEdges, handleBranches }, branch), + })) + .filter((branch) => branch.nodeIds.length > 0) + + // If a branch was removed (empty after reconciliation), clean up its rail handle + const removedBranches = (rung.handleBranches ?? []).filter( + (b) => !handleBranches!.some((nb) => nb.blockId === b.blockId && nb.handleId === b.handleId), + ) + for (const removed of removedBranches) { + newNodes = removeRailBranchHandle(newNodes, removed.blockId, removed.handleId, removed.direction) + } + } + + /** + * After removing the element, update the diagram with the new rung */ const { nodes: updatedDiagramNodes, edges: updatedDiagramEdges } = updateDiagramElementsPosition( { ...rung, nodes: newNodes, edges: newEdges, + ...(handleBranches && { handleBranches }), }, rung.defaultBounds as [number, number], ) @@ -137,18 +324,33 @@ export const removeElement = (rung: RungLadderState, element: Node): { nodes: No /** * Return the updated rung */ - return { nodes: newNodes, edges: newEdges } + return { nodes: newNodes, edges: newEdges, handleBranches } } -export const removeElements = (rung: RungLadderState, nodesToRemove: Node[]): { nodes: Node[]; edges: Edge[] } => { +export const removeElements = ( + rung: RungLadderState, + nodesToRemove: Node[], +): { nodes: Node[]; edges: Edge[]; handleBranches?: HandleBranch[] } => { if (!nodesToRemove || nodesToRemove.length === 0) return { nodes: rung.nodes, edges: rung.edges } - const RungLadderState = { ...rung } + const state = { ...rung } + let handleBranchesChanged = false for (const node of nodesToRemove) { - const { nodes, edges } = removeElement(RungLadderState, node) - RungLadderState.nodes = nodes - RungLadderState.edges = edges + // Skip if node was already removed (e.g., branch element cleaned up when its parent block was deleted) + if (!state.nodes.find((n) => n.id === node.id)) continue + + const { nodes, edges, handleBranches } = removeElement(state, node) + state.nodes = nodes + state.edges = edges + if (handleBranches) { + state.handleBranches = handleBranches + handleBranchesChanged = true + } } - return { nodes: RungLadderState.nodes, edges: RungLadderState.edges } + return { + nodes: state.nodes, + edges: state.edges, + ...(handleBranchesChanged && { handleBranches: state.handleBranches }), + } } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/parallel/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/parallel/index.ts index f71f337da..b1d14a5f3 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/parallel/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/parallel/index.ts @@ -5,18 +5,17 @@ import type { Edge, Node } from '@xyflow/react' import { checkIfElementIsNode, nodesBuilder } from '../../../../../../../_atoms/graphical-editor/ladder/node-builders' import type { BasicNodeData, - BlockNodeData, ParallelNode, PlaceholderNode, } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' -import { buildEdge, connectNodes, removeEdge } from '../../edges' +import { buildEdge, removeEdge } from '../../edges' import { buildGenericNode, isNodeOfType, removeNode } from '../../nodes' +import { wireParallelAroundElement } from '../core' import { removePlaceholderElements } from '../placeholder' import { getElementPositionBasedOnPlaceholderElement, getNodePositionBasedOnPreviousNode, getNodesInsideParallel, - getPreviousElementsByEdge, } from '../utils' /** @@ -38,23 +37,26 @@ export const startParallelConnection = ( node: Node | { elementType: string; blockVariant?: T }, ): { nodes: Node[]; edges: Edge[]; newNode?: Node } => { let newNodes = [...rung.nodes] - let newEdges = [...rung.edges] + const newEdges = [...rung.edges] /** - * Get the element above the selected placeholder and the edges + * Get the element above the selected placeholder and its edges */ const aboveElement = placeholder.selected.data.relatedNode if (!aboveElement) return { nodes: newNodes, edges: newEdges } - const aboveElementTargetEdges = newEdges.filter((edge) => edge.target === aboveElement.id) - const aboveElementSourceEdges = newEdges.filter((edge) => edge.source === aboveElement.id) - if (!aboveElementTargetEdges || !aboveElementSourceEdges) return { nodes: newNodes, edges: newEdges } + + const aboveData = aboveElement.data as BasicNodeData + const incomingEdge = newEdges.find((edge) => edge.target === aboveElement.id) + const outgoingEdge = newEdges.find( + (edge) => edge.source === aboveElement.id && edge.sourceHandle === aboveData.outputConnector?.id, + ) + if (!incomingEdge || !outgoingEdge) return { nodes: newNodes, edges: newEdges } /** - * Build the parallel open node based on the node that antecede the above node - * or the above node itself + * Build the parallel open node */ const openParallelPosition = getNodePositionBasedOnPreviousNode( - newNodes.find((node) => node.id === aboveElementTargetEdges[0].source) ?? aboveElement, + newNodes.find((node) => node.id === incomingEdge.source) ?? aboveElement, 'parallel', 'serial', ) @@ -86,37 +88,6 @@ export const startParallelConnection = ( newElement = node } - /** - * Recreate the above element - * After recreate, set the old data to the new element - */ - const newAboveElementPosition = getNodePositionBasedOnPreviousNode(openParallelElement, aboveElement, 'serial') - const buildedAboveElement = - aboveElement.type !== 'block' - ? buildGenericNode({ - nodeType: aboveElement.type ?? '', - blockType: aboveElement.data.blockType, - id: newGraphicalEditorNodeID((aboveElement.type ?? '').toUpperCase()), - ...newAboveElementPosition, - }) - : nodesBuilder.block({ - id: newGraphicalEditorNodeID((aboveElement.type ?? '').toUpperCase()), - variant: (aboveElement.data as BlockNodeData).variant, - executionControl: (aboveElement.data as BlockNodeData).executionControl, - ...newAboveElementPosition, - }) - const newAboveElement = { - ...buildedAboveElement, - selected: false, - position: { x: newAboveElementPosition.posX, y: newAboveElementPosition.posY }, - data: { - ...aboveElement.data, - handles: buildedAboveElement.data.handles, - inputConnector: buildedAboveElement.data.inputConnector, - outputConnector: buildedAboveElement.data.outputConnector, - }, - } - /** * Build the close parallel node */ @@ -144,136 +115,53 @@ export const startParallelConnection = ( closeParallelElement.data.parallelOpenReference = openParallelElement.id /** - * Get the related node of the placeholder + * Insert nodes: OPEN before aboveElement, newElement+CLOSE after aboveElement + * (no rebuild — original aboveElement is preserved with its ID and data) */ - const relatedNode = placeholder.selected.data.relatedNode as Node - const { nodes: relatedElementPreviousElements, edges: relatedElementPreviousEdges } = getPreviousElementsByEdge( - { ...rung, nodes: newNodes, edges: newEdges }, - relatedNode, - ) - if (!relatedElementPreviousElements || !relatedElementPreviousEdges) return { nodes: newNodes, edges: newEdges } + const aboveIdx = newNodes.findIndex((n) => n.id === aboveElement.id) + newNodes.splice(aboveIdx, 0, openParallelElement) + // aboveElement shifted to aboveIdx+1, insert newElement+closeParallel after it + newNodes.splice(aboveIdx + 2, 0, newElement, closeParallelElement) + newNodes = removePlaceholderElements(newNodes) /** - * Insert the new element node + * Detect nested parallel for handle overrides */ - // first insert the new element - newNodes.splice(placeholder.index, 1, openParallelElement, newAboveElement, newElement, closeParallelElement) - // then remove the old above node - newNodes = removeNode({ ...rung, nodes: newNodes }, aboveElement.id) - // finally remove the placeholder nodes - newNodes = removePlaceholderElements(newNodes) + const predecessorNode = newNodes.find((n) => n.id === incomingEdge.source) + const openTargetHandle = + predecessorNode && + isNodeOfType(predecessorNode, 'parallel') && + (predecessorNode as ParallelNode).data?.type === 'open' && + incomingEdge.sourceHandle === (predecessorNode as ParallelNode).data?.parallelOutputConnector?.id + ? openParallelElement.data.parallelInputConnector?.id + : undefined + + const successorNode = newNodes.find((n) => n.id === outgoingEdge.target) + const closeSourceHandle = + successorNode && + isNodeOfType(successorNode, 'parallel') && + (successorNode as ParallelNode).data?.type === 'close' && + outgoingEdge.targetHandle === (successorNode as ParallelNode).data?.parallelInputConnector?.id + ? closeParallelElement.data.parallelOutputConnector?.id + : undefined /** - * Create the new edges + * Wire parallel edges via shared core utility */ - // clear old edges of the above node - newEdges = newEdges.filter((edge) => edge.source !== aboveElement.id && edge.target !== aboveElement.id) - - // serial connections - const isPreviousConnectionParallel = (() => { - const previousElements = relatedElementPreviousElements.serial - const previousEdges = relatedElementPreviousEdges - - if (previousElements.length === 0 || previousEdges.length === 0) { - return false - } - - const firstElement = previousElements[0] - const firstEdge = previousEdges[0] - - return ( - isNodeOfType(firstElement, 'parallel') && - (firstElement as ParallelNode).data?.type === 'open' && - firstEdge.sourceHandle === (firstElement as ParallelNode).data?.parallelOutputConnector?.id - ) - })() - - newEdges = connectNodes( - { ...rung, nodes: newNodes, edges: newEdges }, - aboveElementTargetEdges[0].source, - openParallelElement.id, - isPreviousConnectionParallel ? 'parallel' : 'serial', - { - sourceHandle: aboveElementTargetEdges[0].sourceHandle ?? undefined, - targetHandle: isPreviousConnectionParallel - ? openParallelElement.data.parallelInputConnector?.id - : openParallelElement.data.inputConnector?.id, - }, - ) - newEdges = connectNodes( - { ...rung, nodes: newNodes, edges: newEdges }, - openParallelElement.id, - newAboveElement.id, - 'serial', - { - sourceHandle: openParallelElement.data.outputConnector?.id, - targetHandle: newAboveElement.data.inputConnector?.id, - }, - ) - newEdges = connectNodes( - { ...rung, nodes: newNodes, edges: newEdges }, - newAboveElement.id, - closeParallelElement.id, - 'serial', - { - sourceHandle: newAboveElement.data.outputConnector?.id, - targetHandle: closeParallelElement.data.inputConnector?.id, - }, - ) - - // Validacao para verificar se o bloco de destino e um paralelo - const isTargetConnectionParallel = (() => { - const targetNode = newNodes.find((node) => node.id === aboveElementSourceEdges[0]?.target) - - if (!targetNode || !aboveElementSourceEdges[0]) { - return false - } - - const targetEdge = aboveElementSourceEdges[0] - - return ( - isNodeOfType(targetNode, 'parallel') && - (targetNode as ParallelNode).data?.type === 'close' && - targetEdge.targetHandle === (targetNode as ParallelNode).data?.parallelInputConnector?.id - ) - })() - - newEdges = connectNodes( - { ...rung, nodes: newNodes, edges: newEdges }, - closeParallelElement.id, - aboveElementSourceEdges[0].target, - 'serial', - { - sourceHandle: isTargetConnectionParallel - ? closeParallelElement.data.parallelOutputConnector?.id - : closeParallelElement.data.outputConnector?.id, - targetHandle: aboveElementSourceEdges[0].targetHandle ?? undefined, - }, - ) + const { edgesToRemove, edgesToAdd } = wireParallelAroundElement({ + incomingEdge, + outgoingEdge, + openParallel: openParallelElement as Node, + closeParallel: closeParallelElement as Node, + aboveElement, + newElement, + openTargetHandle, + closeSourceHandle, + }) - // parallel connections - newEdges = connectNodes( - { ...rung, nodes: newNodes, edges: newEdges }, - openParallelElement.id, - newElement.id, - 'parallel', - { - sourceHandle: openParallelElement.data.parallelOutputConnector?.id, - targetHandle: (newElement.data as BasicNodeData).inputConnector?.id, - }, - ) - newEdges = connectNodes( - { ...rung, nodes: newNodes, edges: newEdges }, - newElement.id, - closeParallelElement.id, - 'parallel', - { - sourceHandle: (newElement.data as BasicNodeData).outputConnector?.id, - targetHandle: closeParallelElement.data.parallelInputConnector?.id, - }, - ) + const resultEdges = [...newEdges.filter((e) => !edgesToRemove.includes(e.id)), ...edgesToAdd] - return { nodes: newNodes, edges: newEdges, newNode: newElement } + return { nodes: newNodes, edges: resultEdges, newNode: newElement } } /** diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts index 344a2950d..362fe9f95 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts @@ -1,8 +1,18 @@ import type { RungLadderState } from '@root/frontend/store/slices' -import { newGraphicalEditorNodeID } from '@root/frontend/utils/new-graphical-editor-node-id' import type { Node, ReactFlowInstance } from '@xyflow/react' +import { v4 as uuidv4 } from 'uuid' -import { nodesBuilder } from '../../../../../../../_atoms/graphical-editor/ladder/node-builders' +import { + defaultCustomNodesStyles, + nodesBuilder, +} from '../../../../../../../_atoms/graphical-editor/ladder/node-builders' +import type { BasicNodeData, BlockVariant } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' +import { + canPlaceElementOnHandle, + getDeepestBranchParallelNodes, + getNodesInsideAllBranchParallels, + hasBranchOnHandle, +} from '../handle-branch' import { getDeepestNodesInsideParallels, getNodesInsideAllParallels, getPlaceholderPositionBasedOnNode } from '../utils' export const removePlaceholderElements = (nodes: Node[]) => { @@ -23,6 +33,17 @@ export const renderPlaceholderElements = (rung: RungLadderState) => { const placeholderNodes: Node[] = [] const nodesInsideParallels = getNodesInsideAllParallels(rung) const deepestNodesParallels = getDeepestNodesInsideParallels(rung) + // Branch-aware sets: main-rail's array-order heuristic doesn't survive in branches + // because nested OPEN/CLOSE pairs are appended on creation rather than interleaved + // in nesting order. Use depth-derived sets so the "parallel placeholder only on the + // deepest level" rule applies to branch parallel-path elements too. + const insideBranchParallels = getNodesInsideAllBranchParallels(rung) + const deepestBranchParallels = getDeepestBranchParallelNodes(rung) + const allowsBottomPlaceholder = (node: Node): boolean => { + const inBranchParallel = insideBranchParallels.has(node.id) + if (inBranchParallel) return deepestBranchParallels.has(node.id) + return !nodesInsideParallels.includes(node) || deepestNodesParallels.includes(node) + } nodes.forEach((node) => { let placeholders: Node[] = [] @@ -36,10 +57,145 @@ export const renderPlaceholderElements = (rung: RungLadderState) => { return } + // Branch elements: generate left/right placeholders with handleBranchTarget data + // so drops trigger branch insertion, plus bottom (parallel) placeholder. + if ((node.data as BasicNodeData).branchContext) { + // OPEN/CLOSE junction nodes in branches: generate one serial placeholder each + // so users can insert elements before/after the parallel group. + if (node.type === 'parallel') { + const ctx = (node.data as BasicNodeData).branchContext! + const branch = rung.handleBranches?.find((b) => b.blockId === ctx.blockId && b.handleId === ctx.handleId) + const nodeIndex = branch?.nodeIds.indexOf(node.id) ?? -1 + + if (nodeIndex !== -1) { + const branchTarget = (insertIdx: number) => ({ + blockId: ctx.blockId, + handleId: ctx.handleId, + direction: ctx.direction, + handlePosition: { x: 0, y: 0 }, + insertIndex: insertIdx, + }) + + const parallelData = node.data as { type?: string } + if (parallelData.type === 'open') { + const ph = nodesBuilder.placeholder({ + id: `placeholder_${node.id}_${uuidv4()}`, + type: 'default', + relatedNode: node, + position: 'left', + ...getPlaceholderPositionBasedOnNode(node, 'left'), + }) + ph.data = { ...ph.data, handleBranchTarget: branchTarget(nodeIndex) } + placeholderNodes.push(ph, node) + } else { + const ph = nodesBuilder.placeholder({ + id: `placeholder_${node.id}_${uuidv4()}`, + type: 'default', + relatedNode: node, + position: 'right', + ...getPlaceholderPositionBasedOnNode(node, 'right'), + }) + ph.data = { ...ph.data, handleBranchTarget: branchTarget(nodeIndex + 1) } + placeholderNodes.push(node, ph) + } + } else { + placeholderNodes.push(node) + } + return + } + + const ctx = (node.data as BasicNodeData).branchContext! + const branch = rung.handleBranches?.find((b) => b.blockId === ctx.blockId && b.handleId === ctx.handleId) + // For copycat nodes, look up the original ID in nodeIds so the copycat + // gets proper serial-spine placeholders during drag operations. + const lookupId = node.id.startsWith('copycat_') ? node.id.slice(8) : node.id + const nodeIndex = branch?.nodeIds.indexOf(lookupId) ?? -1 + + // Parallel-path elements are not in nodeIds (indexOf returns -1). + // Generate left/right placeholders (without handleBranchTarget) so users can + // drag-drop in series with them. Also allow bottom placeholder for depth logic. + if (nodeIndex === -1) { + const ppPlaceholders = [ + nodesBuilder.placeholder({ + id: `placeholder_${node.id}_${uuidv4()}`, + type: 'default', + relatedNode: node, + position: 'left', + ...getPlaceholderPositionBasedOnNode(node, 'left'), + }), + nodesBuilder.placeholder({ + id: `placeholder_${node.id}_${uuidv4()}`, + type: 'default', + relatedNode: node, + position: 'right', + ...getPlaceholderPositionBasedOnNode(node, 'right'), + }), + ] + + if (allowsBottomPlaceholder(node)) { + const bottomPlaceholder = nodesBuilder.placeholder({ + id: `parallelPlaceholder_${node.id}_${uuidv4()}`, + type: 'parallel', + relatedNode: node, + position: 'bottom', + ...getPlaceholderPositionBasedOnNode(node, 'bottom'), + }) + placeholderNodes.push(ppPlaceholders[0], node, bottomPlaceholder, ppPlaceholders[1]) + } else { + placeholderNodes.push(ppPlaceholders[0], node, ppPlaceholders[1]) + } + return + } + + const branchTarget = (insertIndex: number) => ({ + blockId: ctx.blockId, + handleId: ctx.handleId, + direction: ctx.direction, + handlePosition: { x: 0, y: 0 }, + insertIndex, + }) + + placeholders = [ + nodesBuilder.placeholder({ + id: `placeholder_${node.id}_${uuidv4()}`, + type: 'default', + relatedNode: node, + position: 'left', + ...getPlaceholderPositionBasedOnNode(node, 'left'), + }), + nodesBuilder.placeholder({ + id: `placeholder_${node.id}_${uuidv4()}`, + type: 'default', + relatedNode: node, + position: 'right', + ...getPlaceholderPositionBasedOnNode(node, 'right'), + }), + ] + + placeholders[0].data = { ...placeholders[0].data, handleBranchTarget: branchTarget(nodeIndex) } + placeholders[1].data = { ...placeholders[1].data, handleBranchTarget: branchTarget(nodeIndex + 1) } + + if (allowsBottomPlaceholder(node)) { + placeholders.push( + nodesBuilder.placeholder({ + id: `parallelPlaceholder_${node.id}_${uuidv4()}`, + type: 'parallel', + relatedNode: node, + position: 'bottom', + ...getPlaceholderPositionBasedOnNode(node, 'bottom'), + }), + ) + placeholderNodes.push(placeholders[0], node, placeholders[2], placeholders[1]) + } else { + placeholderNodes.push(placeholders[0], node, placeholders[1]) + } + return + } + if (node.id.startsWith('left-rail')) { placeholders = [ nodesBuilder.placeholder({ - id: newGraphicalEditorNodeID(`placeholder_${node.id}`), + id: `placeholder_${node.id}_${uuidv4()}`, type: 'default', relatedNode: node, position: 'right', @@ -53,7 +209,7 @@ export const renderPlaceholderElements = (rung: RungLadderState) => { if (node.id.startsWith('right-rail')) { placeholders = [ nodesBuilder.placeholder({ - id: newGraphicalEditorNodeID(`placeholder_${node.id}`), + id: `placeholder_${node.id}_${uuidv4()}`, type: 'default', relatedNode: node, position: 'left', @@ -68,7 +224,7 @@ export const renderPlaceholderElements = (rung: RungLadderState) => { if (node.data.type === 'open') { placeholders = [ nodesBuilder.placeholder({ - id: newGraphicalEditorNodeID(`placeholder_${node.id}`), + id: `placeholder_${node.id}_${uuidv4()}`, type: 'default', relatedNode: node, position: 'left', @@ -80,7 +236,7 @@ export const renderPlaceholderElements = (rung: RungLadderState) => { } placeholders = [ nodesBuilder.placeholder({ - id: newGraphicalEditorNodeID(`placeholder_${node.id}`), + id: `placeholder_${node.id}_${uuidv4()}`, type: 'default', relatedNode: node, position: 'right', @@ -94,14 +250,14 @@ export const renderPlaceholderElements = (rung: RungLadderState) => { placeholders = [ nodesBuilder.placeholder({ - id: newGraphicalEditorNodeID(`placeholder_${node.id}`), + id: `placeholder_${node.id}_${uuidv4()}`, type: 'default', relatedNode: node, position: 'left', ...getPlaceholderPositionBasedOnNode(node, 'left'), }), nodesBuilder.placeholder({ - id: newGraphicalEditorNodeID(`placeholder_${node.id}`), + id: `placeholder_${node.id}_${uuidv4()}`, type: 'default', relatedNode: node, position: 'right', @@ -109,10 +265,10 @@ export const renderPlaceholderElements = (rung: RungLadderState) => { }), ] - if (!nodesInsideParallels.includes(node) || deepestNodesParallels.includes(node)) { + if (allowsBottomPlaceholder(node)) { placeholders.push( nodesBuilder.placeholder({ - id: newGraphicalEditorNodeID(`parallelPlaceholder_${node.id}`), + id: `parallelPlaceholder_${node.id}_${uuidv4()}`, type: 'parallel', relatedNode: node, position: 'bottom', @@ -125,6 +281,92 @@ export const renderPlaceholderElements = (rung: RungLadderState) => { placeholderNodes.push(placeholders[0], node, placeholders[1]) }) + + // Generate handle placeholders for block input handles (BOOL-type, index > 0, no existing branch). + // Note: this runs during onDragEnterViewport, after updateDiagramElementsPosition has positioned + // all nodes, so handle.glbPosition values are current. + const pStyle = defaultCustomNodesStyles.placeholder + nodes.forEach((node) => { + if (node.type !== 'block') return + + const blockData = node.data as BasicNodeData & { variant: BlockVariant } + const inputHandles = blockData.inputHandles + + // Skip index 0 (rail connector), iterate remaining input handles + for (let i = 1; i < inputHandles.length; i++) { + const handle = inputHandles[i] + const handleId = handle.id as string + + // Find the variable type for this handle from the block variant + const variableType = blockData.variant?.variables?.find((v) => v.name === handleId) + if (!variableType) continue + + // Only generate for BOOL-compatible handles without existing branches + if (!canPlaceElementOnHandle(variableType)) continue + if (hasBranchOnHandle(rung, node.id, handleId)) continue + + const placeholder = nodesBuilder.placeholder({ + id: `placeholder_handle_${node.id}_${handleId}`, + type: 'default', + relatedNode: node, + position: 'left', + posX: node.position.x - pStyle.gap - pStyle.width / 2, + posY: handle.glbPosition.y - pStyle.handle.y, + handleX: node.position.x - pStyle.gap - pStyle.width / 2, + handleY: handle.glbPosition.y, + }) + + // Add branch target data to distinguish from regular placeholders + placeholder.data = { + ...placeholder.data, + handleBranchTarget: { + blockId: node.id, + handleId, + direction: 'input' as const, + handlePosition: { x: handle.glbPosition.x, y: handle.glbPosition.y }, + }, + } + + placeholderNodes.push(placeholder) + } + + // Generate handle placeholders for block output handles (BOOL-type, index > 0, no existing branch). + const outputHandles = blockData.outputHandles + for (let i = 1; i < outputHandles.length; i++) { + const handle = outputHandles[i] + const handleId = handle.id as string + + const variableType = blockData.variant?.variables?.find((v) => v.name === handleId) + if (!variableType) continue + + if (!canPlaceElementOnHandle(variableType)) continue + if (hasBranchOnHandle(rung, node.id, handleId)) continue + + const placeholder = nodesBuilder.placeholder({ + id: `placeholder_handle_${node.id}_${handleId}`, + type: 'default', + relatedNode: node, + position: 'right', + posX: node.position.x + (defaultCustomNodesStyles.block?.width ?? 80) + pStyle.gap + pStyle.width / 2, + posY: handle.glbPosition.y - pStyle.handle.y, + handleX: node.position.x + (defaultCustomNodesStyles.block?.width ?? 80) + pStyle.gap + pStyle.width / 2, + handleY: handle.glbPosition.y, + }) + + placeholder.data = { + ...placeholder.data, + handleBranchTarget: { + blockId: node.id, + handleId, + direction: 'output' as const, + handlePosition: { x: handle.glbPosition.x, y: handle.glbPosition.y }, + }, + } + + placeholderNodes.push(placeholder) + } + }) + return placeholderNodes } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts index 2ab82738c..ad78836e6 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts @@ -35,9 +35,12 @@ export const getPreviousElementsByEdge = ( const n = rung.nodes.find((n) => n.id === e.source) /** - * If the node is undefined or an variable, skip it + * If the node is undefined, a variable, or a branch element, skip it. + * Branch elements connect to block handles but are not serial predecessors + * — they are positioned in a post-pass and must not affect the main layout. */ if (!n || n.type === 'variable') return + if ((n.data as BasicNodeData).branchContext) return /** * If there is a parallel node, check if it is an open or close parallel @@ -54,8 +57,9 @@ export const getPreviousElementsByEdge = ( lastNodes.nodes.serial.push({ ...n }) }) - lastNodes.edges = connectedEdges lastNodes.nodes.all = [...lastNodes.nodes.serial, ...lastNodes.nodes.parallel] + const allNodeIds = new Set(lastNodes.nodes.all.map((n) => n.id)) + lastNodes.edges = connectedEdges.filter((e) => allNodeIds.has(e.source)) return lastNodes } @@ -377,7 +381,7 @@ export const getNodesInsideParallel = ( let nextEdge = parallelEdge while (nextEdge && nextEdge.target !== closeParallelNode.id) { const node = rung.nodes.find((n) => n.id === nextEdge.target) - if (!node) continue + if (!node) break // Broken chain — bail out (continue would infinite-loop without advancing) nextEdge = rung.edges.find( (edge) => edge.source === nextEdge.target && edge.sourceHandle === (node.data as BasicNodeData).outputConnector?.id, diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts index c3626ef08..81929ceb2 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts @@ -8,6 +8,7 @@ import { } from '../../../../../../../_atoms/graphical-editor/ladder/node-builders' import { BlockNode, BlockVariant } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' import { buildEdge } from '../../edges' +import { hasBranchOnHandle } from '../handle-branch' export const renderVariableBlock = (rung: RungLadderState, block: Node) => { const variableElements: Node[] = [] @@ -19,11 +20,15 @@ export const renderVariableBlock = (rung: RungLadderStat const inputHandles = blockElement.data.inputHandles.length > 1 - ? blockElement.data.inputHandles.slice(1, blockElement.data.inputHandles.length) + ? blockElement.data.inputHandles + .slice(1, blockElement.data.inputHandles.length) + .filter((handle) => !hasBranchOnHandle(rung, blockElement.id, handle.id as string)) : [] const outputHandles = blockElement.data.outputHandles.length > 1 - ? blockElement.data.outputHandles.slice(1, blockElement.data.outputHandles.length) + ? blockElement.data.outputHandles + .slice(1, blockElement.data.outputHandles.length) + .filter((handle) => !hasBranchOnHandle(rung, blockElement.id, handle.id as string)) : [] inputHandles.forEach((inputHandle) => { diff --git a/src/frontend/store/slices/ladder/slice.ts b/src/frontend/store/slices/ladder/slice.ts index 041a97e16..e9085818e 100644 --- a/src/frontend/store/slices/ladder/slice.ts +++ b/src/frontend/store/slices/ladder/slice.ts @@ -316,9 +316,10 @@ export const createLadderFlowSlice: StateCreator rung.id === rungId) if (!rung) return - const { nodes: newNodes, edges: newEdges } = removeElements(rung, nodes) + const { nodes: newNodes, edges: newEdges, handleBranches } = removeElements(rung, nodes) rung.nodes = newNodes rung.edges = newEdges + if (handleBranches) rung.handleBranches = handleBranches flow.updated = true }), ) @@ -437,6 +438,21 @@ export const createLadderFlowSlice: StateCreator { + const flow = ladderFlows.find((flow) => flow.name === editorName) + if (!flow) return + + const rung = flow.rungs.find((rung) => rung.id === rungId) + if (!rung) return + + rung.handleBranches = handleBranches + flow.updated = true + }), + ) + }, + /** * Control the flow viewport of the rung */ diff --git a/src/frontend/store/slices/ladder/types.ts b/src/frontend/store/slices/ladder/types.ts index e39def26c..95f7672ec 100644 --- a/src/frontend/store/slices/ladder/types.ts +++ b/src/frontend/store/slices/ladder/types.ts @@ -14,9 +14,9 @@ type ZodLadderFlowState = z.infer const zodLadderNodeTypesSchema = z.enum(['block', 'contact', 'coil', 'parallel', 'powerRail', 'variable']) type ZodLadderNodeType = z.infer -import type { RungLadderState } from '../../../../middleware/shared/ports/types' +import type { HandleBranch, RungLadderState } from '../../../../middleware/shared/ports/types' -export type { RungLadderState } +export type { HandleBranch, RungLadderState } /** * Types used at the slice @@ -109,6 +109,16 @@ type LadderFlowActions = { }) => void addEdge: ({ edge, rungId, editorName }: { edge: Edge; rungId: string; editorName: string }) => void + setHandleBranches: ({ + handleBranches, + rungId, + editorName, + }: { + handleBranches: HandleBranch[] + rungId: string + editorName: string + }) => void + /** * Control the flow viewport of the rung */ diff --git a/src/frontend/store/slices/ladder/utils/index.ts b/src/frontend/store/slices/ladder/utils/index.ts index 4b5248543..6e5bbdda0 100644 --- a/src/frontend/store/slices/ladder/utils/index.ts +++ b/src/frontend/store/slices/ladder/utils/index.ts @@ -2,7 +2,10 @@ import { Edge, Node } from '@xyflow/react' import type { PLCVariable } from '../../../../../middleware/shared/ports/types' import { nodesBuilder } from '../../../../components/_atoms/graphical-editor/ladder/node-builders' -import type { LadderBlockConnectedVariables } from '../../../../components/_atoms/graphical-editor/ladder/utils/types' +import type { + BasicNodeData, + LadderBlockConnectedVariables, +} from '../../../../components/_atoms/graphical-editor/ladder/utils/types' import type { BlockNode, BlockVariant, @@ -28,14 +31,25 @@ export const duplicateLadderRung = (editorName: string, rung: RungLadderState): {} as { [key: string]: Node }, ) + // Build mapping for branch handle IDs (old → new) based on block ID remapping + const branchHandleMap: Record = {} + if (rung.handleBranches) { + for (const branch of rung.handleBranches) { + const newBlockId = nodeMaps[branch.blockId]?.id ?? branch.blockId + branchHandleMap[`branch_${branch.blockId}_${branch.handleId}`] = `branch_${newBlockId}_${branch.handleId}` + } + } + const edgeMaps: { [key: string]: Edge } = rung.edges.reduce( (acc, edge) => { + const newSourceHandle = branchHandleMap[edge.sourceHandle ?? ''] ?? edge.sourceHandle + const newTargetHandle = branchHandleMap[edge.targetHandle ?? ''] ?? edge.targetHandle acc[edge.id] = { - id: `e_${nodeMaps[edge.source].id}_${nodeMaps[edge.target].id}__${edge.sourceHandle}_${edge.targetHandle}`, + id: `e_${nodeMaps[edge.source].id}_${nodeMaps[edge.target].id}__${newSourceHandle}_${newTargetHandle}`, source: nodeMaps[edge.source].id, target: nodeMaps[edge.target].id, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, + sourceHandle: newSourceHandle, + targetHandle: newTargetHandle, } return acc }, @@ -75,11 +89,18 @@ export const duplicateLadderRung = (editorName: string, rung: RungLadderState): handleY: (node as CoilNode).data.inputConnector?.glbPosition.y ?? 0, variant: (node as CoilNode).data.variant, }) + const coilData = node.data as BasicNodeData return { ...newCoil, data: { ...newCoil.data, variable: (node as CoilNode).data.variable, + ...(coilData.branchContext && { + branchContext: { + ...coilData.branchContext, + blockId: nodeMaps[coilData.branchContext.blockId]?.id ?? coilData.branchContext.blockId, + }, + }), }, } as CoilNode } @@ -92,11 +113,18 @@ export const duplicateLadderRung = (editorName: string, rung: RungLadderState): handleY: (node as ContactNode).data.inputConnector?.glbPosition.y ?? 0, variant: (node as ContactNode).data.variant, }) + const contactData = node.data as BasicNodeData return { ...newContact, data: { ...newContact.data, variable: (node as ContactNode).data.variable, + ...(contactData.branchContext && { + branchContext: { + ...contactData.branchContext, + blockId: nodeMaps[contactData.branchContext.blockId]?.id ?? contactData.branchContext.blockId, + }, + }), }, } as ContactNode } @@ -117,12 +145,20 @@ export const duplicateLadderRung = (editorName: string, rung: RungLadderState): } as ParallelNode } case 'powerRail': { + const railData = node.data as BasicNodeData + const remappedHandles = railData.handles.map((h) => { + const newId = branchHandleMap[h.id as string] ?? h.id + return { ...h, id: newId } + }) return { ...node, id: nodeMaps[node.id].id, data: { - ...node.data, + ...railData, numericId: generateNumericUUID(), + handles: remappedHandles, + inputHandles: remappedHandles.filter((h) => h.type === 'target'), + outputHandles: remappedHandles.filter((h) => h.type === 'source'), }, } as PowerRailNode } @@ -151,6 +187,8 @@ export const duplicateLadderRung = (editorName: string, rung: RungLadderState): id: edgeMaps[edge.id].id, source: edgeMaps[edge.id].source, target: edgeMaps[edge.id].target, + sourceHandle: edgeMaps[edge.id].sourceHandle, + targetHandle: edgeMaps[edge.id].targetHandle, })) const newRung = { @@ -161,6 +199,13 @@ export const duplicateLadderRung = (editorName: string, rung: RungLadderState): selectedNodes: [], nodes: newNodes, edges: newEdges, + ...(rung.handleBranches && { + handleBranches: rung.handleBranches.map((branch) => ({ + ...branch, + blockId: nodeMaps[branch.blockId]?.id ?? branch.blockId, + nodeIds: branch.nodeIds.map((id) => nodeMaps[id]?.id ?? id), + })), + }), } return newRung diff --git a/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts b/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts index f021205a6..457bc0bde 100644 --- a/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts @@ -82,10 +82,17 @@ const findNodesBasedOnParallelClose = ( return findNodesBasedOnParallelClose(bottomNode as ParallelNode, rung, path) } -const findConnections = (node: Node, rung: RungLadderState, offsetY: number = 0) => { +const findConnections = ( + node: Node, + rung: RungLadderState, + offsetY: number = 0, + targetHandle?: string, +) => { const { nodes: rungNodes, edges: rungEdges } = rung - const connectedEdges = rungEdges.filter((edge) => edge.target === node.id) + const connectedEdges = rungEdges.filter( + (edge) => edge.target === node.id && (targetHandle === undefined || edge.targetHandle === targetHandle), + ) if (!connectedEdges.length) return [] const connections = connectedEdges.map((edge) => { @@ -95,10 +102,10 @@ const findConnections = (node: Node, rung: RungLadderState, offse // Node is not a parallel node if (sourceNode.type !== 'parallel') { + const sourceHandle = edge.sourceHandle ?? sourceNode.data.outputConnector?.id ?? '' return { '@refLocalId': sourceNode.data.numericId, - '@formalParameter': - sourceNode.data.outputConnector?.id === 'OUT' ? '' : sourceNode.data.outputConnector?.id || '', + '@formalParameter': sourceHandle === 'OUT' ? '' : sourceHandle, position: [ // Final edge destination { @@ -434,21 +441,22 @@ const blockToXml = ( offsetY: number = 0, leftRailId: string, ): BlockLadderXML => { - const connections = findConnections(block, rung, offsetY) - - // If the block is connected to a power rail, replace the refLocalId with the left rail id at connections - const railConnection = connections.find((connection) => { - const rail = rung.nodes.find((node) => node.type === 'powerRail' && (node as PowerRailNode).data.variant === 'left') - if (rail?.data.numericId === connection['@refLocalId']) { - return true - } - return false - }) - const inputVariables = block.data.inputHandles.map((handle) => { - // Only the input of the block contains connections from other blocks - // The other handles are connected to variables + // Main input connector: connections from other elements on the main rail if (handle.id === block.data.inputConnector?.id) { + const connections = findConnections(block, rung, offsetY, handle.id) + + // If the block is connected to a power rail, replace the refLocalId with the left rail id at connections + const railConnection = connections.find((connection) => { + const rail = rung.nodes.find( + (node) => node.type === 'powerRail' && (node as PowerRailNode).data.variant === 'left', + ) + if (rail?.data.numericId === connection['@refLocalId']) { + return true + } + return false + }) + return { '@formalParameter': handle.id || '', connectionPointIn: { @@ -472,18 +480,34 @@ const blockToXml = ( (node as VariableNode).data.block.id === block.id && (node as VariableNode).data.block.handleId === handle.id, ) as Node - if (!variableNode) return undefined - return { - '@formalParameter': handle.id || '', - connectionPointIn: { - connection: [ - { - '@refLocalId': variableNode.data.numericId, - }, - ], - }, + if (variableNode) { + return { + '@formalParameter': handle.id || '', + connectionPointIn: { + connection: [ + { + '@refLocalId': variableNode.data.numericId, + }, + ], + }, + } } + + // No variable node — check if there's a branch (contacts/coils) connected to this handle. + // Reuse findConnections with a handle filter to trace the branch elements, + // including any parallels within the branch. + const handleConnections = findConnections(block, rung, offsetY, handle.id) + if (handleConnections.length > 0) { + return { + '@formalParameter': handle.id || '', + connectionPointIn: { + connection: handleConnections, + }, + } + } + + return undefined }) const outputVariable = block.data.outputHandles.map((handle, handleIndex) => { diff --git a/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts b/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts index f7a0b2eb5..84a02c090 100644 --- a/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts @@ -83,10 +83,17 @@ const findNodesBasedOnParallelClose = ( return findNodesBasedOnParallelClose(bottomNode as ParallelNode, rung, path) } -const findConnections = (node: Node, rung: RungLadderState, offsetY: number = 0) => { +const findConnections = ( + node: Node, + rung: RungLadderState, + offsetY: number = 0, + targetHandle?: string, +) => { const { nodes: rungNodes, edges: rungEdges } = rung - const connectedEdges = rungEdges.filter((edge) => edge.target === node.id) + const connectedEdges = rungEdges.filter( + (edge) => edge.target === node.id && (targetHandle === undefined || edge.targetHandle === targetHandle), + ) if (!connectedEdges.length) return [] const connections = connectedEdges.map((edge) => { @@ -96,9 +103,10 @@ const findConnections = (node: Node, rung: RungLadderState, offse // Node is not a parallel node if (sourceNode.type !== 'parallel') { + const sourceHandle = edge.sourceHandle ?? sourceNode.data.outputConnector?.id ?? '' return { '@refLocalId': sourceNode.data.numericId, - '@formalParameter': sourceNode.data.outputConnector?.id, + '@formalParameter': sourceHandle === 'OUT' ? '' : sourceHandle, position: [ // Final edge destination { @@ -383,11 +391,10 @@ const coilToXml = (coil: CoilNode, rung: RungLadderState, offsetY: number = 0): } const blockToXml = (block: BlockNode, rung: RungLadderState, offsetY: number = 0): BlockLadderXML => { - const connections = findConnections(block, rung, offsetY) const inputVariables = block.data.inputHandles.map((handle) => { - // Only the input of the block contains connections from other blocks - // The other handles are connected to variables + // Main input connector: connections from other elements on the main rail if (handle.id === block.data.inputConnector?.id) { + const connections = findConnections(block, rung, offsetY, handle.id) return { '@formalParameter': handle.id || '', connectionPointIn: { @@ -407,34 +414,54 @@ const blockToXml = (block: BlockNode, rung: RungLadderState, offse (node as VariableNode).data.block.id === block.id && (node as VariableNode).data.block.handleId === handle.id, ) as Node - if (!variableNode) return undefined - return { - '@formalParameter': handle.id || '', - connectionPointIn: { - relPosition: { - '@x': handle.relPosition.x || 0, - '@y': handle.relPosition.y || 0, + if (variableNode) { + return { + '@formalParameter': handle.id || '', + connectionPointIn: { + relPosition: { + '@x': handle.relPosition.x || 0, + '@y': handle.relPosition.y || 0, + }, + connection: [ + { + '@refLocalId': variableNode.data.numericId, + position: [ + // Connection at the block + { + '@x': handle.glbPosition.x || 0, + '@y': (handle.glbPosition.y || 0) + offsetY, + }, + // Start the edge connecting the variable + { + '@x': variableNode.data.outputConnector?.glbPosition.x || 0, + '@y': (variableNode.data.outputConnector?.glbPosition.y || 0) + offsetY, + }, + ], + }, + ], }, - connection: [ - { - '@refLocalId': variableNode.data.numericId, - position: [ - // Connection at the block - { - '@x': handle.glbPosition.x || 0, - '@y': (handle.glbPosition.y || 0) + offsetY, - }, - // Start the edge connecting the variable - { - '@x': variableNode.data.outputConnector?.glbPosition.x || 0, - '@y': (variableNode.data.outputConnector?.glbPosition.y || 0) + offsetY, - }, - ], + } + } + + // No variable node — check if there's a branch (contacts/coils) connected to this handle. + // Reuse findConnections with a handle filter to trace the branch elements, + // including any parallels within the branch. + const handleConnections = findConnections(block, rung, offsetY, handle.id) + if (handleConnections.length > 0) { + return { + '@formalParameter': handle.id || '', + connectionPointIn: { + relPosition: { + '@x': handle.relPosition.x || 0, + '@y': handle.relPosition.y || 0, }, - ], - }, + connection: handleConnections, + }, + } } + + return undefined }) const outputVariable = block.data.outputHandles.map((handle) => { diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index e9c7745c4..ba2786263 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -776,6 +776,28 @@ export type FBDRungState = { edges: import('@xyflow/react').Edge[] } +/** + * Represents a branch of elements connected to a specific block handle. + * Input branches connect from the left rail to a block input handle. + * Output branches connect from a block output handle to the right rail. + * + * Defined here (ports layer) rather than in the components layer so the + * `RungLadderState.handleBranches` field below can reference it without + * violating the layer rule that forbids ports from depending on components. + * The components/_atoms ladder types module re-exports this name so + * component code can keep importing it from a single nearby location. + */ +export type HandleBranch = { + /** The block node ID this branch connects to */ + blockId: string + /** The handle ID on the block (e.g., "R", "PV", "CV") */ + handleId: string + /** Direction: 'input' means elements feed INTO the block, 'output' means elements come OUT */ + direction: 'input' | 'output' + /** Ordered list of node IDs in this branch (left-to-right for input, block-to-right for output) */ + nodeIds: string[] +} + /** * Ladder rung data — nodes + edges + layout for one Ladder rung. * Used by both the store slice and the compiler adapter. @@ -788,6 +810,8 @@ export type RungLadderState = { selectedNodes: import('@xyflow/react').Node[] nodes: import('@xyflow/react').Node[] edges: import('@xyflow/react').Edge[] + /** Index of active handle branches in this rung (undefined for backward compatibility) */ + handleBranches?: HandleBranch[] } // ---------------------------------------------------------------------------