Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 101 additions & 32 deletions src/frontend/components/_atoms/graphical-editor/fbd/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { Position } from '@xyflow/react'

import type { PLCPou } from '../../../../../../middleware/shared/ports/types'
Expand All @@ -11,6 +11,102 @@
import { DEFAULT_BLOCK_CONNECTOR_Y, DEFAULT_BLOCK_CONNECTOR_Y_OFFSET, DEFAULT_BLOCK_WIDTH } from './constants'
import type { BasicNodeData } from './types'

type FBDRung = FBDFlowType['rung']
type FBDRungNode = FBDRung['nodes'][0]
type FBDRungEdge = FBDRung['edges'][0]

type RungLookups = {
nodeById: Map<string, FBDRungNode>
edgesBySource: Map<string, FBDRungEdge[]>
edgesByTarget: Map<string, FBDRungEdge[]>
}

// Per-rung lookup tables, cached on the rung's (immutable) identity. This
// util runs during every FBD node render; the previous linear scans made a
// render pass O(nodes × (nodes + edges)). Immer replaces the rung object on
// any change, so a stale entry can never be served.
const rungLookupsCache = new WeakMap<FBDRung, RungLookups>()

const getRungLookups = (rung: FBDRung): RungLookups => {
let lookups = rungLookupsCache.get(rung)
if (!lookups) {
lookups = {
nodeById: new Map(),
edgesBySource: new Map(),
edgesByTarget: new Map(),
}
for (const node of rung.nodes) {
if (!lookups.nodeById.has(node.id)) lookups.nodeById.set(node.id, node)
}
for (const edge of rung.edges) {
const bySource = lookups.edgesBySource.get(edge.source)
if (bySource) bySource.push(edge)
else lookups.edgesBySource.set(edge.source, [edge])
const byTarget = lookups.edgesByTarget.get(edge.target)
if (byTarget) byTarget.push(edge)
else lookups.edgesByTarget.set(edge.target, [edge])
}
rungLookupsCache.set(rung, lookups)
}
return lookups
}

// Variable names are unique per POU (case-insensitive, enforced by the
// variables table), so a first-wins lowercase index matches `find` exactly.
const variablesByNameCache = new WeakMap<PLCVariable[], Map<string, PLCVariable>>()

const getVariablesByName = (variables: PLCVariable[]): Map<string, PLCVariable> => {
let byName = variablesByNameCache.get(variables)
if (!byName) {
byName = new Map()
for (const variable of variables) {
const key = variable.name.toLowerCase()
if (!byName.has(key)) byName.set(key, variable)
}
variablesByNameCache.set(variables, byName)
}
return byName
}

const EMPTY_EDGES: FBDRungEdge[] = []

const selectNodeVariable = (
node: FBDRungNode,
variables: PLCVariable[],
variableName: string | undefined,
): PLCVariable | undefined => {
const byName = getVariablesByName(variables)

const findByNodeVarOrFallback = (): PLCVariable | undefined => {
const nodeVarName = (node.data as BasicNodeData).variable.name
if (nodeVarName !== undefined) return byName.get(nodeVarName.toLowerCase())
if (variableName === undefined) return undefined
const candidate = byName.get(variableName.toLowerCase())
return candidate?.name === variableName ? candidate : undefined
}

switch (node.type as keyof typeof customNodeTypes) {
case 'block': {
const nodeVarName = (node.data as BasicNodeData).variable.name
return nodeVarName !== undefined ? byName.get(nodeVarName.toLowerCase()) : undefined
}
case 'connector':
case 'continuation':
case 'comment':
return undefined
case 'input-variable':
case 'output-variable':
case 'inout-variable':
// Variable nodes - allow all types including derived (user-defined types)
return findByNodeVarOrFallback()
default: {
// Other node types - only allow base types (not derived/user-defined)
const candidate = findByNodeVarOrFallback()
return candidate && candidate.type.definition !== 'derived' ? candidate : undefined
}
}
}

// `pouName` is the bound POU for the caller's editor instance (from
// `useBoundPou()` under multi-mount, or the active editor's name for
// legacy single-mount call sites). Taking it as a string instead of
Expand All @@ -34,38 +130,11 @@
} => {
const pou = pous.find((pou) => pou.name === pouName)
const rung = fbdFlows.find((flow) => flow.name === pouName)?.rung
const node = rung?.nodes.find((node) => node.id === data.nodeId)
const lookups = rung ? getRungLookups(rung) : undefined
const node = lookups?.nodeById.get(data.nodeId)

const variables: PLCVariable[] = pou?.interface?.variables ?? []
let variable = variables.find((variable) => {
if (!node) return undefined
switch (node.type as keyof typeof customNodeTypes) {
case 'block':
return (
(node.data as BasicNodeData).variable.name !== undefined &&
(node.data as BasicNodeData).variable.name.toLowerCase() === variable.name.toLowerCase()
)
case 'connector':
case 'continuation':
return undefined
case 'comment':
return undefined
case 'input-variable':
case 'output-variable':
case 'inout-variable':
// Variable nodes - allow all types including derived (user-defined types)
return (node.data as BasicNodeData).variable.name !== undefined
? variable.name.toLowerCase() === (node.data as BasicNodeData).variable.name.toLowerCase()
: variable.name === data.variableName
default:
// Other node types - only allow base types (not derived/user-defined)
return (
((node.data as BasicNodeData).variable.name !== undefined
? variable.name.toLowerCase() === (node.data as BasicNodeData).variable.name.toLowerCase()
: variable.name === data.variableName) && variable.type.definition !== 'derived'
)
}
})
let variable = node && variables.length > 0 ? selectNodeVariable(node, variables, data.variableName) : undefined

// Fallback: try to resolve as array element access (e.g. "Sensor[0]")
if (!variable && node) {
Expand All @@ -88,8 +157,8 @@
}
}

const edgesThatNodeIsSource = rung?.edges.filter((edge) => edge.source === data.nodeId)
const edgesThatNodeIsTarget = rung?.edges.filter((edge) => edge.target === data.nodeId)
const edgesThatNodeIsSource = lookups ? (lookups.edgesBySource.get(data.nodeId) ?? EMPTY_EDGES) : undefined
const edgesThatNodeIsTarget = lookups ? (lookups.edgesByTarget.get(data.nodeId) ?? EMPTY_EDGES) : undefined

return {
pou,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { Edge, Node, ReactFlowInstance, XYPosition } from '@xyflow/react'
import { RefObject, useCallback, useEffect } from 'react'

Expand All @@ -13,15 +13,20 @@
} from '../../../../_features/[workspace]/editor/graphical/active-context'

export const useFBDClipboard = ({
mousePosition,
insideViewport,
mousePositionRef,
insideViewportRef,
reactFlowInstance,
rung,
viewportRef,
handleDeleteNodes,
}: {
mousePosition: { x: number; y: number }
insideViewport: boolean
/**
* Refs, not values: mouse position and hover state change on every pointer
* move, and they're only read at paste time — passing them as refs keeps
* the FBD container from re-rendering while the mouse travels the canvas.
*/
mousePositionRef: RefObject<{ x: number; y: number }>
insideViewportRef: RefObject<boolean>
reactFlowInstance: ReactFlowInstance | null
rung: FBDRungState
/**
Expand Down Expand Up @@ -172,8 +177,9 @@
return
}

const mousePosition = mousePositionRef.current ?? { x: 0, y: 0 }
const nodePosition: XYPosition = reactFlowInstance
? insideViewport
? insideViewportRef.current
? reactFlowInstance.screenToFlowPosition({
x: mousePosition.x,
y: mousePosition.y,
Expand Down Expand Up @@ -214,7 +220,7 @@
})
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isActive, insideViewport, mousePosition, reactFlowInstance, fbdFlowActions, rung],
[isActive, reactFlowInstance, fbdFlowActions, rung],
)

useEffect(() => {
Expand Down
Loading
Loading