diff --git a/src/backend/shared/transpilers/st-transpiler/__tests__/execute-element.test.ts b/src/backend/shared/transpilers/st-transpiler/__tests__/execute-element.test.ts new file mode 100644 index 000000000..62cf8c6c0 --- /dev/null +++ b/src/backend/shared/transpilers/st-transpiler/__tests__/execute-element.test.ts @@ -0,0 +1,293 @@ +import { emitFbdBody } from '@root/backend/shared/transpilers/st-transpiler/walker/fbd' +import { emitLdBody } from '@root/backend/shared/transpilers/st-transpiler/walker/ld' +import type { RFBody, RFEdge, RFNode, RFRung } from '@root/backend/shared/transpilers/st-transpiler/walker/types' + +// Coverage for the Execute ("ST Block") element — a graphical box holding a +// raw ST snippet, gated by whatever rung condition reaches its EN input and +// passing power straight through on ENO. +// +// Semantics are pinned against a real CODESYS V3.5 SP22 PLCopen export +// (`` + a `.../plcopenxml/stcode` addData). The +// decisive detail there: a coil downstream of an EXECUTE block references the +// block's localId with NO `formalParameter` qualifier — plain rung +// continuation — so `contact -> EXECUTE -> coil` yields `coil := contact`. + +let edgeId = 0 +const e = (source: string, target: string): RFEdge => ({ id: `e${edgeId++}`, source, target }) + +const rail = (id: string, variant: 'left' | 'right', x: number): RFNode => ({ + id, + type: 'powerRail', + position: { x, y: 30 }, + data: { variant }, +}) +const contact = (id: string, name: string, x: number): RFNode => ({ + id, + type: 'contact', + position: { x, y: 38 }, + data: { variant: 'default', variable: { name } }, +}) +const coil = (id: string, name: string, x: number): RFNode => ({ + id, + type: 'coil', + position: { x, y: 38 }, + data: { variant: 'default', variable: { name }, executionOrder: 0 }, +}) +const execute = (id: string, code: string, x: number, executionOrder = 0): RFNode => ({ + id, + type: 'execute', + position: { x, y: 38 }, + data: { code, executionOrder }, +}) +const inVar = (id: string, name: string, x: number): RFNode => ({ + id, + type: 'input-variable', + position: { x, y: 38 }, + data: { variant: 'input-variable', variable: { name } }, +}) +const outVar = (id: string, name: string, x: number): RFNode => ({ + id, + type: 'output-variable', + position: { x, y: 38 }, + data: { variant: 'output-variable', variable: { name }, executionOrder: 0 }, +}) + +const ldBody = (nodes: RFNode[], edges: RFEdge[]): RFBody => ({ + rungs: [{ reactFlowViewport: [800, 200], nodes, edges }], +}) +const fbdRung = (nodes: RFNode[], edges: RFEdge[]): RFRung => ({ nodes, edges }) + +beforeEach(() => { + edgeId = 0 +}) + +describe('Execute element — rung gating', () => { + it('gates the snippet on the rung condition and passes power through to the coil', () => { + // The exact topology from the CODESYS export: + // leftPowerRail -> contact(myContact) -> EXECUTE -> coil(myCoil) + const body = ldBody( + [ + rail('L', 'left', 0), + contact('C', 'myContact', 68), + execute('X', '// Comment in ST Block\nmyNewValue := myValue + 10;\n', 200), + coil('K', 'myCoil', 400), + rail('R', 'right', 600), + ], + [e('L', 'C'), e('C', 'X'), e('X', 'K'), e('K', 'R')], + ) + + const { bodySt, warnings } = emitLdBody(body) + + expect(warnings).toEqual([]) + expect(bodySt).toBe( + '\n' + + ' IF myContact THEN\n' + + ' // Comment in ST Block\n' + + ' myNewValue := myValue + 10;\n' + + ' END_IF;\n' + + ' myCoil := myContact;\n', + ) + }) + + it('emits the snippet bare when the box sits directly on the left rail', () => { + // A trivially-true condition would only produce `IF TRUE THEN`, which is + // noise. The body runs every scan either way. + const body = ldBody( + [rail('L', 'left', 0), execute('X', 'counter := counter + 1;', 200), rail('R', 'right', 400)], + [e('L', 'X'), e('X', 'R')], + ) + + const { bodySt, warnings } = emitLdBody(body) + + expect(warnings).toEqual([]) + expect(bodySt).toBe('\n counter := counter + 1;\n') + }) + + it('emits the snippet bare in FBD when EN is left unwired', () => { + const { bodySt, warnings } = emitFbdBody({ + rung: fbdRung([execute('X', 'myNewVarFBD := myNewVarFBD + 222;', 0)], []), + }) + + expect(warnings).toEqual([]) + expect(bodySt).toBe('\n myNewVarFBD := myNewVarFBD + 222;\n') + }) + + it('combines a multi-contact rung condition into one IF', () => { + const body = ldBody( + [ + rail('L', 'left', 0), + contact('C1', 'a', 68), + contact('C2', 'b', 140), + execute('X', 'total := total + 1;', 260), + rail('R', 'right', 500), + ], + [e('L', 'C1'), e('C1', 'C2'), e('C2', 'X'), e('X', 'R')], + ) + + const { bodySt, warnings } = emitLdBody(body) + + expect(warnings).toEqual([]) + // `b AND a`, not `a AND b` — `visitContact` emits each contact's own + // variable ahead of its upstream chain, so the term nearest the sink leads. + // That is the walker's established convention (it matches the python + // oracle) and is unchanged by the Execute element; asserted here only to + // pin that Execute consumes the condition like any other sink. + expect(bodySt).toBe('\n IF b AND a THEN\n total := total + 1;\n END_IF;\n') + }) +}) + +describe('Execute element — snippet re-indentation', () => { + it('preserves relative nesting and blank lines inside the generated IF', () => { + const code = ['IF a < 10 THEN', ' b := 1;', '', ' c := 2;', 'END_IF;'].join('\n') + const body = ldBody( + [rail('L', 'left', 0), contact('C', 'gate', 68), execute('X', code, 200), rail('R', 'right', 500)], + [e('L', 'C'), e('C', 'X'), e('X', 'R')], + ) + + const { bodySt } = emitLdBody(body) + + expect(bodySt).toBe( + '\n' + + ' IF gate THEN\n' + + ' IF a < 10 THEN\n' + + ' b := 1;\n' + + '\n' + // blank lines stay blank, not trailing whitespace + ' c := 2;\n' + + ' END_IF;\n' + + ' END_IF;\n', + ) + }) + + it('strips a common leading margin so an indented snippet is not double-indented', () => { + const code = [' x := 1;', ' y := 2;'].join('\n') + const body = ldBody( + [rail('L', 'left', 0), execute('X', code, 200), rail('R', 'right', 400)], + [e('L', 'X'), e('X', 'R')], + ) + + expect(emitLdBody(body).bodySt).toBe('\n x := 1;\n y := 2;\n') + }) + + it('normalises CRLF endings and trims surrounding blank lines', () => { + // CODESYS writes a trailing newline for LD payloads but not FBD ones, so + // emission must not depend on either being present. + const body = ldBody( + [rail('L', 'left', 0), execute('X', '\r\n\r\nx := 1;\r\ny := 2;\r\n\r\n', 200), rail('R', 'right', 400)], + [e('L', 'X'), e('X', 'R')], + ) + + expect(emitLdBody(body).bodySt).toBe('\n x := 1;\n y := 2;\n') + }) +}) + +describe('Execute element — ENO passthrough', () => { + it('does not wrap a downstream FBD assignment in an ENO check', () => { + // `getUsedEnoForNode` wraps an outVariable in `IF .ENO THEN` when + // its single upstream is a *block* with EN wired. An Execute node must not + // trigger that: there is no `_TMP_..._ENO` for EXECUTE, so the downstream + // condition has to rebuild from the rung instead. + const { bodySt, warnings } = emitFbdBody({ + rung: fbdRung( + [inVar('IV', 'flag', 0), execute('X', 'side := side + 1;', 200), outVar('OV', 'result', 400)], + [e('IV', 'X'), e('X', 'OV')], + ), + }) + + expect(warnings).toEqual([]) + expect(bodySt).toBe('\n IF flag THEN\n side := side + 1;\n END_IF;\n result := flag;\n') + expect(bodySt).not.toContain('ENO') + }) + + it('feeds a chain of two Execute boxes from the same rung condition', () => { + const body = ldBody( + [ + rail('L', 'left', 0), + contact('C', 'gate', 68), + execute('X1', 'first := 1;', 200), + execute('X2', 'second := 2;', 400), + coil('K', 'done', 600), + rail('R', 'right', 800), + ], + [e('L', 'C'), e('C', 'X1'), e('X1', 'X2'), e('X2', 'K'), e('K', 'R')], + ) + + const { bodySt, warnings } = emitLdBody(body) + + expect(warnings).toEqual([]) + expect(bodySt).toBe( + '\n' + + ' IF gate THEN\n first := 1;\n END_IF;\n' + + ' IF gate THEN\n second := 2;\n END_IF;\n' + + ' done := gate;\n', + ) + }) +}) + +describe('Execute element — ordering', () => { + it('honours an explicit executionOrder ahead of the positional sweep', () => { + // X2 sits to the LEFT of X1 but carries the higher order, so position + // alone would emit it first; the explicit order must win. + const body = ldBody( + [ + rail('L', 'left', 0), + contact('C', 'gate', 68), + execute('X1', 'first := 1;', 400, 1), + execute('X2', 'second := 2;', 200, 2), + rail('R', 'right', 800), + ], + [e('L', 'C'), e('C', 'X1'), e('C', 'X2')], + ) + + const { bodySt } = emitLdBody(body) + + expect(bodySt).toBe('\n IF gate THEN\n first := 1;\n END_IF;\n IF gate THEN\n second := 2;\n END_IF;\n') + }) + + it('falls back to left-to-right position when no order is set', () => { + const body = ldBody( + [ + rail('L', 'left', 0), + contact('C', 'gate', 68), + execute('XB', 'b := 1;', 400), + execute('XA', 'a := 1;', 200), + rail('R', 'right', 800), + ], + [e('L', 'C'), e('C', 'XA'), e('C', 'XB')], + ) + + expect(emitLdBody(body).bodySt).toBe( + '\n IF gate THEN\n a := 1;\n END_IF;\n IF gate THEN\n b := 1;\n END_IF;\n', + ) + }) +}) + +describe('Execute element — malformed input', () => { + it('warns and emits nothing for an empty snippet', () => { + const body = ldBody( + [rail('L', 'left', 0), contact('C', 'gate', 68), execute('X', ' \n\n ', 200), rail('R', 'right', 400)], + [e('L', 'C'), e('C', 'X'), e('X', 'R')], + ) + + const { bodySt, warnings } = emitLdBody(body) + + expect(warnings).toEqual(['Execute block "X" is empty.']) + // No hollow `IF gate THEN END_IF;` + expect(bodySt).toBe('\n') + }) + + it('warns when the node payload has no code field', () => { + const body = ldBody( + [ + rail('L', 'left', 0), + { id: 'X', type: 'execute', position: { x: 200, y: 38 }, data: { executionOrder: 0 } }, + rail('R', 'right', 400), + ], + [e('L', 'X'), e('X', 'R')], + ) + + const { bodySt, warnings } = emitLdBody(body) + + expect(warnings).toEqual(['execute node "X" has unrecognised data shape']) + expect(bodySt).toBe('\n') + }) +}) diff --git a/src/backend/shared/transpilers/st-transpiler/walker/README.md b/src/backend/shared/transpilers/st-transpiler/walker/README.md index d6f932e55..62d853cbc 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/README.md +++ b/src/backend/shared/transpilers/st-transpiler/walker/README.md @@ -42,3 +42,19 @@ and the oracle is a walker bug, never an oracle bug — see the fixture corpus under `xml2st/fixtures/` and the test harness under `xml2st/shared-backend/transpilers/generate-st-from-react-flow/tests/` for the validation loop. + +### Exception: the `execute` node + +The `execute` ("Execute" / "ST Block") element is a **deliberate +superset** of the python generator's vocabulary — it has no oracle +equivalent, so the byte-parity rule above does not apply to it. Its +semantics are instead pinned against a real CODESYS PLCopen export +(`` carrying its source in a +`.../plcopenxml/stcode` `addData`), which establishes that: + +- the snippet runs gated by whatever rung condition reaches `EN`, and +- `ENO` is a plain power passthrough — a coil downstream of an + EXECUTE block references the block with no `formalParameter` + qualifier, so `contact → EXECUTE → coil` yields `coil := contact`. + +See `__tests__/execute-element.test.ts`. diff --git a/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts b/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts index ce67686b7..05e7e1001 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts @@ -120,9 +120,12 @@ export function computeConnectionTypes(body: RFBody, ctx: TypeContext): Map 0 && lines[lines.length - 1].trim() === '') lines.pop() + while (lines.length > 0 && lines[0].trim() === '') lines.shift() + if (lines.length === 0) return [] + + let margin = Infinity + for (const line of lines) { + if (line.trim() === '') continue + const leading = line.length - line.trimStart().length + if (leading < margin) margin = leading + } + if (!Number.isFinite(margin)) margin = 0 + + return lines.map((line) => (line.trim() === '' ? '\n' : `${indent}${line.slice(margin)}\n`)) +} + +/** + * Emit an Execute ("ST Block") element: the user's raw ST snippet, gated by + * the rung condition reaching its `EN` input. + * + * Gating is skipped when that condition is trivially true — no incoming edge + * (FBD with `EN` unwired) or a single path resolving to `TRUE` (an LD box on + * the left rail). Both mean "runs every scan", and emitting the body bare + * keeps the generated ST readable instead of burying it in `IF TRUE THEN`. + * + * The snippet is emitted verbatim apart from re-indentation; strucpp is what + * judges its validity. + */ +function emitExecuteNode(state: WalkerState, node: RFNode): void { + const data = asExecuteData(node.data) + if (data === null) { + state.warnings.push(`execute node "${node.id}" has unrecognised data shape`) + return + } + + // Bail before emitting anything — an empty box would otherwise + // produce a hollow `IF cond THEN END_IF;`, which is legal ST but + // pure noise in the generated output. + if (data.code.trim() === '') { + state.warnings.push(`Execute block "${node.id}" is empty.`) + return + } + + const info: Location = [state.tagName, 'execute', locId(node)] + const paths = pathsFromIncoming(state, node.id, /*order=*/ false) + const gated = paths.length > 0 && !(paths.length === 1 && paths[0].kind === 'true') + + if (gated) { + state.program.push([`${state.currentIndent}IF `, info]) + for (const chunk of pathsToChunks(paths)) state.program.push(chunk) + state.program.push([' THEN\n', []]) + state.currentIndent += ' ' + } + + for (const line of reindentSnippet(data.code, state.currentIndent)) state.program.push([line, info]) + + if (gated) { + state.currentIndent = state.currentIndent.slice(0, -2) + state.program.push([`${state.currentIndent}END_IF;\n`, []]) + } +} + /* ─────────────────────────── standalone block ───────────────────────────── */ function emitStandaloneBlock(state: WalkerState, node: RFNode): void { @@ -611,6 +694,14 @@ function visitUpstream(state: WalkerState, node: RFNode, edge: RFEdge, order: bo // behaviour: emit nothing for the coil, just propagate the // upstream signal. return visitCoilPassthrough(state, node, order) + case 'execute': + // ENO passthrough: an Execute box conducts rung power straight through, + // so `contact -> EXECUTE -> coil` yields `coil := contact` and the + // snippet emits separately as a sink. Confirmed against a CODESYS + // PLCopen export, where a downstream coil references the EXECUTE block + // with no `formalParameter` qualifier — plain rung continuation. Same + // algebra as a coil passthrough. + return visitCoilPassthrough(state, node, order) case 'continuation': return visitContinuation(state, node) case 'connector': diff --git a/src/backend/shared/transpilers/st-transpiler/walker/narrow.ts b/src/backend/shared/transpilers/st-transpiler/walker/narrow.ts index 127740cc7..8e825e2a1 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/narrow.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/narrow.ts @@ -78,6 +78,12 @@ export interface ParallelData { side: 'open' | 'close' } +export interface ExecuteData { + /** The user's raw Structured Text snippet. */ + code: string + executionOrder: number +} + function isObject(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v) } @@ -176,6 +182,21 @@ export function asParallelData(data: Record): ParallelData | nu return { side: t } } +/** + * Execute ("ST Block") payload — a graphical element holding a raw ST + * snippet. `code` is the only required field; a node whose `code` is + * not a string is malformed and the walker warns rather than guessing. + * + * The snippet is stored exactly as the user typed it (indentation and + * blank lines included); re-indentation happens at emission time, not + * here. + */ +export function asExecuteData(data: Record): ExecuteData | null { + const code = asString(data['code']) + if (code === null) return null + return { code, executionOrder: asNumber(data['executionOrder']) ?? 0 } +} + export function asBlockData(data: Record): BlockData | null { const variant = data['variant'] if (!isObject(variant)) return null diff --git a/src/backend/shared/transpilers/st-transpiler/walker/types.ts b/src/backend/shared/transpilers/st-transpiler/walker/types.ts index a2182413f..35bea7710 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/types.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/types.ts @@ -34,7 +34,7 @@ export interface RFNode { * boundary in `from-schema.ts` can pass the editor's `node.type: * string` straight through without a typecast. Known values the * walker dispatches on: `'powerRail' | 'contact' | 'coil' | 'block' - * | 'variable' | 'input-variable' | 'output-variable' | + * | 'execute' | 'variable' | 'input-variable' | 'output-variable' | * 'inout-variable' | 'parallel' | 'connector' | 'continuation'`. * Anything else is treated as a no-op sink. */ type: string diff --git a/src/frontend/assets/icons/project/ladder/Execute.tsx b/src/frontend/assets/icons/project/ladder/Execute.tsx new file mode 100644 index 000000000..110564e04 --- /dev/null +++ b/src/frontend/assets/icons/project/ladder/Execute.tsx @@ -0,0 +1,50 @@ +import { ComponentPropsWithoutRef } from 'react' + +import { cn } from '../../../../utils/cn' + +type IExecuteIconProps = ComponentPropsWithoutRef<'svg'> & { + size?: 'sm' | 'md' | 'lg' +} + +const sizeClasses = { + sm: 'w-5 h-5', + md: 'w-6 h-6', + lg: 'w-12 h-12', +} + +/** + * Execute ("ST Block") toolbox icon — a rung-wired box holding lines of + * text, echoing how the element renders on the canvas. Matches the + * other ladder icons' rounded-square backdrop and `#B4D0FE` palette. + */ +export default function ExecuteIcon(props: IExecuteIconProps) { + const { className, size = 'sm', ...res } = props + + return ( + + + {/* rung wires into EN / out of ENO */} + + + {/* the box */} + + {/* lines of code */} + + + ) +} diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/buildNodes.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/buildNodes.tsx index 5f69f9791..938a49a83 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/buildNodes.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/buildNodes.tsx @@ -11,6 +11,10 @@ import { DEFAULT_BLOCK_TYPE, DEFAULT_CONNECTION_CONNECTOR_X, DEFAULT_CONNECTION_CONNECTOR_Y, + DEFAULT_EXECUTE_CONNECTOR_X, + DEFAULT_EXECUTE_CONNECTOR_Y, + DEFAULT_EXECUTE_HEIGHT, + DEFAULT_EXECUTE_WIDTH, DEFAULT_VARIABLE_CONNECTOR_X, DEFAULT_VARIABLE_CONNECTOR_Y, MINIMUM_ELEMENT_HEIGHT, @@ -24,6 +28,8 @@ import type { CommentNode, ConnectionBuilderProps, ConnectionNode, + ExecuteBuilderProps, + ExecuteNode, VariableBuilderProps, VariableNode, } from './utils/types' @@ -115,6 +121,72 @@ export const buildCommentNode = ({ id, position }: CommentBuilderProps): Comment } } +/** + * Build an FBD Execute ("ST Block") node. + * + * Same electrical shape as the ladder variant — `EN` target left, `ENO` + * source right — but free-positioned and resizable like the comment + * element, since FBD has no rung to lay it out. Leaving `EN` unwired + * means the snippet runs unconditionally; the walker's "no incoming + * paths" case handles that. + */ +export const buildExecuteNode = ({ id, position, code = '' }: ExecuteBuilderProps): ExecuteNode => { + // `style.top` places the handle's DOM element — React Flow draws edges to + // the DOM position, so without it the wire meets the box off the pin row. + const inputHandle = buildHandle({ + id: 'EN', + position: Position.Left, + type: 'target', + glbX: position.x, + glbY: position.y + DEFAULT_EXECUTE_CONNECTOR_Y, + relX: 0, + relY: DEFAULT_EXECUTE_CONNECTOR_Y, + style: { top: DEFAULT_EXECUTE_CONNECTOR_Y, left: 0 }, + }) + const outputHandle = buildHandle({ + id: 'ENO', + position: Position.Right, + type: 'source', + glbX: position.x + DEFAULT_EXECUTE_CONNECTOR_X, + glbY: position.y + DEFAULT_EXECUTE_CONNECTOR_Y, + relX: DEFAULT_EXECUTE_CONNECTOR_X, + relY: DEFAULT_EXECUTE_CONNECTOR_Y, + style: { top: DEFAULT_EXECUTE_CONNECTOR_Y, right: 0 }, + }) + + return { + id, + type: 'execute', + position, + width: DEFAULT_EXECUTE_WIDTH, + height: DEFAULT_EXECUTE_HEIGHT, + measured: { + width: DEFAULT_EXECUTE_WIDTH, + height: DEFAULT_EXECUTE_HEIGHT, + }, + data: { + handles: [inputHandle, outputHandle], + inputHandles: [inputHandle], + outputHandles: [outputHandle], + inputConnector: inputHandle, + outputConnector: outputHandle, + numericId: generateNumericUUID(), + executionOrder: 0, + code, + // Shape-compatibility with `BasicNodeData`; an Execute box binds no + // single variable — it references whatever its code does. + variable: { id: '', name: '' }, + draggable: true, + selectable: true, + deletable: true, + }, + deletable: true, + selectable: true, + draggable: true, + selected: true, + } +} + export const buildConnectionNode = ({ id, position, variant, label }: ConnectionBuilderProps): ConnectionNode => { const inputHandle = variant === 'connector' diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/execute.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/execute.tsx new file mode 100644 index 000000000..7554dfedf --- /dev/null +++ b/src/frontend/components/_atoms/graphical-editor/fbd/execute.tsx @@ -0,0 +1,142 @@ +import { NodeResizer } from '@xyflow/react' +import { memo, useCallback, useEffect, useState } from 'react' + +import { useIsDebuggerVisible } from '../../../../hooks/use-debug-value' +import { useOpenPLCStore } from '../../../../store' +import { cn } from '../../../../utils/cn' +import { executeStDocumentUri } from '../../../../utils/PLC/execute-st-uri' +import { useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' +import { StCodeField } from '../st-code-field' +import { CustomHandle } from './handle' +import { + DEFAULT_EXECUTE_BODY_TOP, + DEFAULT_EXECUTE_CONNECTOR_Y, + DEFAULT_EXECUTE_HEIGHT, + DEFAULT_EXECUTE_WIDTH, +} from './utils/constants' +import type { ExecuteProps } from './utils/types' + +export type { ExecuteNode } from './utils/types' + +/** + * Execute ("ST Block") for FBD — a resizable box holding a raw ST snippet, + * gated by whatever reaches `EN` and passing that signal through on `ENO`. + * + * Free-positioned and user-resizable like the comment element, since FBD has + * no rung to lay it out. An unwired `EN` means the snippet runs every scan. + */ +const ExecuteElement = (block: ExecuteProps) => { + const { id, data, selected, width, height } = block + const pouName = useBoundPou() + const updateNode = useOpenPLCStore((state) => state.fbdFlowActions.updateNode) + const updateModelFBD = useOpenPLCStore((state) => state.editorActions.updateModelFBD) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) + const isDebuggerVisible = useIsDebuggerVisible() + + // Only one Monaco surface may own the shared model URI at a time — see the + // ladder Execute node. + const expandedModal = useOpenPLCStore((state) => state.modals['execute-fbd-element']) + const isExpanded = expandedModal.open && (expandedModal.data as { id?: string } | null)?.id === id + + const [focused, setFocused] = useState(false) + + // Monaco's scroll/zoom gestures fight the canvas', so freeze pan and zoom + // while the field has focus — as the comment element does for its textarea. + useEffect(() => { + updateModelFBD({ canEditorZoom: !focused, canEditorPan: !focused }) + return () => updateModelFBD({ canEditorZoom: true, canEditorPan: true }) + }, [focused, updateModelFBD]) + + const handleCommit = useCallback( + (nextCode: string) => { + const { fbdFlows } = useOpenPLCStore.getState() + const node = fbdFlows.find((flow) => flow.name === pouName)?.rung.nodes.find((n) => n.id === id) + if (!node) return + if ((node.data as { code?: string }).code === nextCode) return + + updateNode({ + editorName: pouName, + nodeId: id, + node: { ...node, data: { ...node.data, code: nextCode } }, + }) + }, + [id, pouName, updateNode], + ) + + return ( + <> + {/* Bare, unstyled root — border and size live on the inner box, mirroring + `Block`. Bordering this element would shift every handle down by the + border width and step the wire into it. */} +
+
+
Execute
+ + + +
+ EN +
+
+ ENO +
+ +
setFocused(true)} + onBlurCapture={() => setFocused(false)} + > + +
+
+ + {data.handles.map((handle, index) => ( + + ))} +
+ + + ) +} + +const exportExecuteElement = memo(ExecuteElement) + +export { exportExecuteElement as ExecuteElement } diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/index.ts b/src/frontend/components/_atoms/graphical-editor/fbd/index.ts index 80007b3ba..ba9448bcd 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/index.ts +++ b/src/frontend/components/_atoms/graphical-editor/fbd/index.ts @@ -2,6 +2,7 @@ import * as blockNode from './block' import * as buildNodes from './buildNodes' import * as commentNode from './comment' import * as connectionNode from './connection' +import * as executeNode from './execute' import * as variableNode from './variable' export const customNodeTypes = { @@ -12,6 +13,7 @@ export const customNodeTypes = { connector: connectionNode.ConnectionElement, continuation: connectionNode.ConnectionElement, comment: commentNode.CommentElement, + execute: executeNode.ExecuteElement, } export type CustomFbdNodeTypes = keyof typeof customNodeTypes @@ -20,4 +22,5 @@ export const nodesBuilder = { variable: buildNodes.buildVariableNode, connection: buildNodes.buildConnectionNode, comment: buildNodes.buildCommentNode, + execute: buildNodes.buildExecuteNode, } diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/utils/constants.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/utils/constants.tsx index 242c69e17..7e9d33dda 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/utils/constants.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/utils/constants.tsx @@ -44,3 +44,14 @@ export const DEFAULT_VARIABLE_CONNECTOR_X = DEFAULT_VARIABLE_WIDTH export const DEFAULT_VARIABLE_CONNECTOR_Y = DEFAULT_VARIABLE_HEIGHT / 2 export { FBD_VARIABLE_NODE_TYPES } from '../../../../../utils/graphical/types' + +// execute ("ST Block") + +// Same connector geometry as an FBD block, so wires meet the box on the +// block pin row rather than at an arbitrary height of its own. +export const DEFAULT_EXECUTE_WIDTH = 260 +export const DEFAULT_EXECUTE_HEIGHT = 120 +export const DEFAULT_EXECUTE_CONNECTOR_Y = DEFAULT_BLOCK_CONNECTOR_Y +export const DEFAULT_EXECUTE_CONNECTOR_X = DEFAULT_EXECUTE_WIDTH +/** Top of the code area — clears the title row and the EN/ENO pin row. */ +export const DEFAULT_EXECUTE_BODY_TOP = DEFAULT_EXECUTE_CONNECTOR_Y + 16 diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/utils/types.ts b/src/frontend/components/_atoms/graphical-editor/fbd/utils/types.ts index d7a899711..69237b427 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/utils/types.ts +++ b/src/frontend/components/_atoms/graphical-editor/fbd/utils/types.ts @@ -68,3 +68,15 @@ export type VariableProps = NodeProps export type VariableBuilderProps = BuilderBasicProps & { variant: 'input-variable' | 'output-variable' | 'inout-variable' } + +// execute ("ST Block") + +/** + * A box holding a raw Structured Text snippet. `EN` gates execution; + * `ENO` passes the same signal through. Unlike the ladder variant this + * one is free-positioned and user-resizable, matching the FBD comment + * element. + */ +export type ExecuteNode = Node +export type ExecuteProps = NodeProps +export type ExecuteBuilderProps = BuilderBasicProps & { code?: string } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/buildNodes.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/buildNodes.tsx index ac79fc34d..fbaf113cf 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/buildNodes.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/buildNodes.tsx @@ -11,6 +11,8 @@ import { DEFAULT_CONTACT_BLOCK_HEIGHT, DEFAULT_CONTACT_BLOCK_WIDTH, DEFAULT_CONTACT_CONNECTOR_Y, + DEFAULT_EXECUTE_CONNECTOR_Y, + DEFAULT_EXECUTE_WIDTH, DEFAULT_PARALLEL_CONNECTOR_Y, DEFAULT_PARALLEL_HEIGHT, DEFAULT_PARALLEL_WIDTH, @@ -24,12 +26,14 @@ import { DEFAULT_VARIABLE_CONNECTOR_Y, DEFAULT_VARIABLE_HEIGHT, DEFAULT_VARIABLE_WIDTH, + executeHeight, } from './utils/constants' import type { BlockBuilderProps, BlockVariant, CoilBuilderProps, ContactBuilderProps, + ExecuteBuilderProps, LadderBlockConnectedVariables, ParallelBuilderProps, ParallelNode, @@ -165,6 +169,78 @@ export const buildCoilNode = ({ id, posX, posY, handleX, handleY, variant }: Coi } } +/** + * Build an Execute ("ST Block") node — a box carrying a raw ST snippet. + * + * Electrically a coil: one `EN` target on the left, one `ENO` source on + * the right, both anchored to the header strip's vertical centre so the + * rung wire enters at a fixed height regardless of how many lines of + * code the box holds. Height is derived from the line count (clamped); + * past the clamp the box scrolls internally rather than growing the rung. + */ +export const buildExecuteNode = ({ id, posX, posY, handleX, handleY, code = '' }: ExecuteBuilderProps) => { + // `style.top` is what actually places the handle's DOM element, and React + // Flow draws every edge to the DOM position — `relY` alone is not enough. + // Omitting it left the handle at the element's default offset while the + // label sat on the pin row, which is what made the rung wire jog into the + // box. Mirrors `getBlockSize`'s handles exactly. + const inputHandle = buildHandle({ + id: 'EN', + position: Position.Left, + isConnectable: false, + type: 'target', + glbX: handleX, + glbY: handleY, + relX: 0, + relY: DEFAULT_EXECUTE_CONNECTOR_Y, + style: { top: DEFAULT_EXECUTE_CONNECTOR_Y, left: 0 }, + }) + const outputHandle = buildHandle({ + id: 'ENO', + position: Position.Right, + isConnectable: false, + type: 'source', + glbX: handleX + DEFAULT_EXECUTE_WIDTH, + glbY: handleY, + relX: DEFAULT_EXECUTE_WIDTH, + relY: DEFAULT_EXECUTE_CONNECTOR_Y, + style: { top: DEFAULT_EXECUTE_CONNECTOR_Y, right: 0 }, + }) + const handles = [inputHandle, outputHandle] + const height = executeHeight(code === '' ? 0 : code.split('\n').length) + + return { + id, + type: 'execute', + position: { x: posX, y: posY }, + data: { + handles, + code, + inputHandles: [inputHandle], + outputHandles: [outputHandle], + inputConnector: inputHandle, + outputConnector: outputHandle, + numericId: generateNumericUUID(), + // Carried for shape-compatibility with `BasicNodeData`; an Execute + // box binds no single variable — it references whatever its code does. + variable: { name: '' }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + }, + width: DEFAULT_EXECUTE_WIDTH, + height, + measured: { + width: DEFAULT_EXECUTE_WIDTH, + height, + }, + draggable: true, + selectable: true, + selected: true, + } +} + export const buildContactNode = ({ id, posX, posY, handleX, handleY, variant }: ContactBuilderProps) => { const inputHandle = buildHandle({ id: 'input', diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/execute.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/execute.tsx new file mode 100644 index 000000000..a33853d36 --- /dev/null +++ b/src/frontend/components/_atoms/graphical-editor/ladder/execute.tsx @@ -0,0 +1,165 @@ +import { memo, useCallback } from 'react' + +import { useIsDebuggerVisible } from '../../../../hooks/use-debug-value' +import { useOpenPLCStore } from '../../../../store' +import { cn } from '../../../../utils/cn' +import { executeStDocumentUri } from '../../../../utils/PLC/execute-st-uri' +import { useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' +import { StCodeField } from '../st-code-field' +import { CustomHandle } from './handle' +import { getLadderPouVariablesRungNodeAndEdges } from './utils' +import { + DEFAULT_EXECUTE_BODY_TOP, + DEFAULT_EXECUTE_CONNECTOR_Y, + DEFAULT_EXECUTE_HEIGHT, + DEFAULT_EXECUTE_WIDTH, + executeHeight, +} from './utils/constants' +import type { ExecuteProps } from './utils/types' + +export type { ExecuteNode } from './utils/types' + +/** + * Execute ("ST Block") — a ladder element holding a raw ST snippet. + * + * Rendered as a standard ladder block: the same chrome as `BlockNodeVisual` on + * the same `DEFAULT_BLOCK_CONNECTOR_Y` geometry, so the layout aligns it like + * any block and the rung wire runs straight through. + * + * `EN` / `ENO` are always shown — execution control is what gates the snippet. + * Electrically the box is a coil: ENO is EN, so power conducts through to + * whatever follows. + */ +const Execute = (block: ExecuteProps) => { + const { id, data, selected, width, height } = block + const pouName = useBoundPou() + const updateNode = useOpenPLCStore((state) => state.ladderFlowActions.updateNode) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) + const isDebuggerVisible = useIsDebuggerVisible() + + // The modal edits the same document URI, so diagnostics attach to whichever + // surface is up. Two Monaco editors on one model path fight over it, so only + // one may be live at a time; the modal wins. + const expandedModal = useOpenPLCStore((state) => state.modals['execute-ladder-element']) + const isExpanded = expandedModal.open && (expandedModal.data as { id?: string } | null)?.id === id + + // Grow / shrink as the user types. The code is only written back on blur, so + // without this the box keeps its committed height while the text runs past + // the bottom. Transient — resizing must not mark the POU dirty on its own. + const handleLineCountChange = useCallback( + (lineCount: number) => { + const nextHeight = executeHeight(lineCount) + const { project, ladderFlows } = useOpenPLCStore.getState() + const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { + nodeId: id, + }) + if (!rung || !node || node.height === nextHeight) return + + updateNode({ + editorName: pouName, + rungId: rung.id, + nodeId: id, + node: { + ...node, + height: nextHeight, + measured: { width: node.width ?? DEFAULT_EXECUTE_WIDTH, height: nextHeight }, + }, + transient: true, + }) + }, + [id, pouName, updateNode], + ) + + const handleCommit = useCallback( + (nextCode: string) => { + // Re-read from the store: the rung id isn't on the node, and it may have + // been re-laid-out since this callback was made. + const { project, ladderFlows } = useOpenPLCStore.getState() + const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { + nodeId: id, + }) + if (!rung || !node) return + if ((node.data as { code?: string }).code === nextCode) return + + const nextHeight = executeHeight(nextCode === '' ? 0 : nextCode.split('\n').length) + updateNode({ + editorName: pouName, + rungId: rung.id, + nodeId: node.id, + node: { + ...node, + height: nextHeight, + measured: { width: node.width ?? DEFAULT_EXECUTE_WIDTH, height: nextHeight }, + data: { ...node.data, code: nextCode }, + }, + }) + }, + [id, pouName, updateNode], + ) + + return ( + // Bare, unstyled root — border and size live on the inner box, mirroring + // `Block`. Load-bearing: handles position with `top: ` against + // the nearest positioned ancestor, so bordering this element would shift + // every handle down by the border width and step the wire into the box. +
+
+ {/* Title, centred like a block's type name. */} +
Execute
+ + + + {/* EN / ENO inset at the connector row, as BlockNodeVisual does it. */} +
+ EN +
+
+ ENO +
+ +
+ +
+
+ + {data.handles.map((handle, index) => ( + + ))} +
+ ) +} + +export default memo(Execute) +export { Execute } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/index.ts b/src/frontend/components/_atoms/graphical-editor/ladder/index.ts index 729c7d2b0..006a99f09 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/index.ts +++ b/src/frontend/components/_atoms/graphical-editor/ladder/index.ts @@ -1,6 +1,7 @@ import * as blockNode from './block' import * as coilNode from './coil' import * as contactNode from './contact' +import * as executeNode from './execute' import * as mockNode from './mock-node' import * as parallelNode from './parallel' import * as placeholderNode from './placeholder' @@ -16,6 +17,7 @@ export const customNodeTypes = { block: blockNode.Block, coil: coilNode.Coil, contact: contactNode.Contact, + execute: executeNode.Execute, parallel: parallelNode.Parallel, parallelPlaceholder: placeholderNode.Placeholder, placeholder: placeholderNode.Placeholder, diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/node-builders.ts b/src/frontend/components/_atoms/graphical-editor/ladder/node-builders.ts index 06f5e0287..c94290b50 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/node-builders.ts +++ b/src/frontend/components/_atoms/graphical-editor/ladder/node-builders.ts @@ -62,6 +62,19 @@ export const defaultCustomNodesStyles: CustomLadderNodeTypes = { offsetY: 0, }, }, + execute: { + width: constants.DEFAULT_EXECUTE_WIDTH, + // Nominal only — an Execute node carries its own `height`, derived + // from its line count, and the layout reads `node.height` first. + height: constants.DEFAULT_EXECUTE_HEIGHT, + gap: 60, + verticalGap: 80, + handle: { + x: constants.DEFAULT_EXECUTE_CONNECTOR_X, + y: constants.DEFAULT_EXECUTE_CONNECTOR_Y, + offsetY: 0, + }, + }, parallel: { width: constants.DEFAULT_PARALLEL_WIDTH, height: constants.DEFAULT_PARALLEL_HEIGHT, @@ -134,6 +147,7 @@ export const nodesBuilder = { block: buildNodes.buildBlockNode, coil: buildNodes.buildCoilNode, contact: buildNodes.buildContactNode, + execute: buildNodes.buildExecuteNode, parallel: buildNodes.buildParallel, parallelPlaceholder: buildNodes.builderPlaceholderNode, placeholder: buildNodes.builderPlaceholderNode, diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/utils/constants.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/utils/constants.tsx index 7499db6ab..d17772b1d 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/utils/constants.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/utils/constants.tsx @@ -186,6 +186,31 @@ export const GAP = 0 export const DEFAULT_PARALLEL_CONNECTOR_Y = DEFAULT_PARALLEL_HEIGHT / 2 +// execute ("ST Block") + +// Geometry is deliberately the SAME as a block's: `EN` / `ENO` sit on the +// first pin row, so the layout aligns this element exactly as it aligns a +// block and the rung wire runs straight into it instead of jogging. +export const DEFAULT_EXECUTE_WIDTH = 240 +export const DEFAULT_EXECUTE_CONNECTOR_Y = DEFAULT_BLOCK_CONNECTOR_Y +export const DEFAULT_EXECUTE_CONNECTOR_X = DEFAULT_EXECUTE_WIDTH + +/** Top of the code area — clears the title row and the EN/ENO pin row. */ +export const DEFAULT_EXECUTE_BODY_TOP = DEFAULT_EXECUTE_CONNECTOR_Y + 16 +export const DEFAULT_EXECUTE_LINE_HEIGHT = 18 +export const DEFAULT_EXECUTE_BODY_PADDING = 10 +/** An empty box still shows the "Enter ST code here" placeholder line. */ +export const DEFAULT_EXECUTE_MIN_LINES = 1 +/** Past this the code area scrolls instead of growing the rung. */ +export const DEFAULT_EXECUTE_MAX_LINES = 12 + +export const executeHeight = (lineCount: number): number => + DEFAULT_EXECUTE_BODY_TOP + + Math.min(Math.max(lineCount, DEFAULT_EXECUTE_MIN_LINES), DEFAULT_EXECUTE_MAX_LINES) * DEFAULT_EXECUTE_LINE_HEIGHT + + DEFAULT_EXECUTE_BODY_PADDING + +export const DEFAULT_EXECUTE_HEIGHT = executeHeight(DEFAULT_EXECUTE_MIN_LINES) + // placeholder export const DEFAULT_PLACEHOLDER_WIDTH = 10 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 6a4567114..6cc13a10f 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts +++ b/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts @@ -105,6 +105,18 @@ export type ContactType = { } } +// execute ("ST Block") + +/** + * A box holding a raw Structured Text snippet, gated by the rung + * condition reaching its `EN` handle and passing power through on + * `ENO`. `code` is stored exactly as the user typed it — indentation + * and blank lines included; the transpiler re-indents at emission. + */ +export type ExecuteNode = Node +export type ExecuteProps = NodeProps +export type ExecuteBuilderProps = BuilderBasicProps & { code?: string } + // mock export type MockNode = Node<{ label: string; handles: CustomHandleProps[] }, 'text'> diff --git a/src/frontend/components/_atoms/graphical-editor/st-code-field/index.tsx b/src/frontend/components/_atoms/graphical-editor/st-code-field/index.tsx new file mode 100644 index 000000000..c5065a895 --- /dev/null +++ b/src/frontend/components/_atoms/graphical-editor/st-code-field/index.tsx @@ -0,0 +1,298 @@ +import { Editor as PrimitiveEditor } from '@monaco-editor/react' +import * as monaco from 'monaco-editor' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { useIsDebuggerVisible } from '../../../../hooks/use-debug-value' +import { useStDebugDecorations } from '../../../../hooks/use-st-debug-decorations' +import { getExecuteDraftApi } from '../../../../services/st-lsp/execute-sync' +import { useOpenPLCStore } from '../../../../store' +import { cn } from '../../../../utils/cn' +import { applyThemeNow, ensureOpenplcThemes } from '../../../_features/[workspace]/editor/monaco/theme-utils' + +/** + * Structured Text editing surface for the Execute ("ST Block") element in LD + * and FBD, and for its expand modal. + * + * Separate from the POU-level `MonacoEditor`, which is bound to a POU (store + * lookups, project sync, file watchers, AI hooks). A rung can hold several + * Execute boxes, so Monaco mounts lazily — the field renders plain text until + * activated. The model is created under `uri`, the same URI the ST LSP holds + * the snippet's document on, which is what makes diagnostics attach. + */ + +const PLACEHOLDER = 'Enter ST code here' + +export type StCodeFieldProps = { + /** Current snippet. Treated as the source of truth while unfocused. */ + value: string + /** Called on blur (and on unmount while dirty) with the edited text. */ + onCommit: (next: string) => void + /** + * LSP document URI for this snippet — also the Monaco model URI, which + * is what makes diagnostics attach. Must be unique per node. + */ + uri: string + /** + * Composite-key prefix for debug value badges, e.g. `MyProgram:`. + * Omit to disable badges (the modal in a non-debug session). + */ + debugPrefix?: string + /** + * When false the field stays in its cheap read-only presentation and + * never mounts Monaco. The node passes `selected`; the modal passes + * `true`. + */ + active?: boolean + /** + * `compact` — the in-rung box: no line numbers or gutter, small type, every + * pixel spent on code because the element is only ~200px wide. + * `full` — the expand modal: a proper editor, matching the POU-level ST + * editor's font size, gutter and padding. That is the whole point of + * expanding. + */ + variant?: 'compact' | 'full' + className?: string + /** + * Fires as the user types, with the snippet's current line count. The + * owning node uses it to grow/shrink itself live — without it the box + * keeps its committed height while the text runs past the bottom edge. + */ + onLineCountChange?: (lineCount: number) => void +} + +export const StCodeField = ({ + value, + onCommit, + uri, + debugPrefix, + active = false, + variant = 'compact', + className, + onLineCountChange, +}: StCodeFieldProps) => { + const containerRef = useRef(null) + const editorRef = useRef(null) + const monacoRef = useRef(null) + const shouldUseDarkMode = useOpenPLCStore((state) => state.workspace.systemConfigs.shouldUseDarkMode) + const isDebuggerVisible = useIsDebuggerVisible() + + // The debugger makes every code surface read-only — the running program is + // what it is; editing it here would be a lie. + const effectiveReadOnly = isDebuggerVisible + + // Monaco mounting is invisible to React — `onMount` only fills refs, so + // nothing re-renders. Without this flag the decoration scan below runs once + // against a null editor and never again. `MonacoEditor` guards the same trap. + const [editorMounted, setEditorMounted] = useState(false) + + // Local buffer so typing doesn't round-trip through the store on + // every keystroke. Re-synced from `value` whenever the field is not + // the thing driving the change. + const [draft, setDraft] = useState(value) + const draftRef = useRef(value) + const dirtyRef = useRef(false) + const lspTimerRef = useRef | null>(null) + + // Push the draft to the LSP as the user types. The store only sees the + // snippet on blur, so a store-driven sync would deliver diagnostics a commit + // late — by which point the field may be deselected and its model disposed, + // leaving markers nowhere to land. Debounced into one `didChange`. + const scheduleLspSync = useCallback( + (text: string) => { + if (lspTimerRef.current !== null) clearTimeout(lspTimerRef.current) + lspTimerRef.current = setTimeout(() => { + lspTimerRef.current = null + getExecuteDraftApi()?.syncDraft(uri, text) + }, 200) + }, + [uri], + ) + + useEffect( + () => () => { + if (lspTimerRef.current !== null) clearTimeout(lspTimerRef.current) + }, + [], + ) + + // Publish on mount so an already-broken snippet is underlined before a key + // is pressed. `force` is required: diagnostics are a one-shot notification, + // and mounting creates a NEW model (the expand modal builds a second one at + // the same URI) with usually-unchanged text, so without a forced re-analyse + // the worker stays silent. Keyed on `editorMounted` — the model does not + // exist until Monaco has mounted. + useEffect(() => { + if (!active || !editorMounted) return + getExecuteDraftApi()?.syncDraft(uri, draftRef.current, true) + }, [active, editorMounted, uri]) + + useEffect(() => { + if (dirtyRef.current) return + setDraft(value) + draftRef.current = value + }, [value]) + + const commit = useCallback(() => { + if (!dirtyRef.current) return + dirtyRef.current = false + if (draftRef.current === value) return + onCommit(draftRef.current) + }, [onCommit, value]) + + // Latest `commit` for callbacks registered once at mount, so the blur + // handler below never runs against a stale `value`. + const commitRef = useRef(commit) + commitRef.current = commit + + // Commit anything still buffered when the field goes away — a node + // deleted or a modal closed mid-edit must not silently lose the text. + // `commit` changes identity with `value`, so this cleanup also runs on an + // ordinary re-render; that is harmless because `commit` no-ops unless the + // buffer is dirty. + useEffect(() => commit, [commit]) + + useStDebugDecorations({ + editorRef, + monacoRef, + prefix: debugPrefix, + enabled: active && editorMounted && isDebuggerVisible && debugPrefix !== undefined, + modelVersion: draft, + }) + + const handleMount = useCallback( + (editor: monaco.editor.IStandaloneCodeEditor, monacoInstance: typeof monaco) => { + editorRef.current = editor + monacoRef.current = monacoInstance + setEditorMounted(true) + ensureOpenplcThemes(monacoInstance) + applyThemeNow(monacoInstance, shouldUseDarkMode) + editor.onDidBlurEditorText(() => commitRef.current()) + }, + [shouldUseDarkMode], + ) + + useEffect(() => { + if (monacoRef.current) applyThemeNow(monacoRef.current, shouldUseDarkMode) + }, [shouldUseDarkMode]) + + // Blur (and commit) on a click anywhere outside the field. The rung's React + // Flow pane covers only the rung, so a click elsewhere on the page never + // reaches it and the editor would otherwise keep focus indefinitely. + useEffect(() => { + if (!active) return + const onPointerDown = (event: PointerEvent) => { + const container = containerRef.current + if (!container) return + const target = event.target + if (target instanceof Node && container.contains(target)) return + // Monaco's overlays (suggest widget, hovers) portal outside the + // container; blurring while one is open would fight the user. + if (target instanceof Element && target.closest('.monaco-editor')) return + editorRef.current?.getDomNode()?.blur() + commit() + } + document.addEventListener('pointerdown', onPointerDown, true) + return () => document.removeEventListener('pointerdown', onPointerDown, true) + }, [active, commit]) + + // Going inactive unmounts Monaco, leaving the refs on a disposed editor. + // Cleared in an effect rather than during render. + useEffect(() => { + if (active) return + editorRef.current = null + monacoRef.current = null + setEditorMounted(false) + }, [active]) + + if (!active) { + // Cheap presentation: no Monaco, no LSP document, no decorations. + return ( +
+        {draft === '' ? PLACEHOLDER : draft}
+      
+ ) + } + + return ( + // `nokey` opts these keystrokes out of @xyflow/react's window-level keydown + // listener, which treats Space as a canvas pan-modifier and preventDefaults + // it. xyflow exempts input/textarea/contenteditable, but Monaco's + // EditContext surface is a plain div — so without this, Space never reaches + // the editor. The POU-level editor carries the same marker. +
+ { + const text = next ?? '' + dirtyRef.current = true + draftRef.current = text + setDraft(text) + scheduleLspSync(text) + onLineCountChange?.(text === '' ? 0 : text.split('\n').length) + }} + onMount={handleMount} + options={{ + readOnly: effectiveReadOnly, + domReadOnly: effectiveReadOnly, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + // Classic hidden-