From 1c131f4aea54e560076b5cd88ac20f90969faafa Mon Sep 17 00:00:00 2001 From: Jeykco Date: Mon, 6 Jul 2026 14:34:56 -0500 Subject: [PATCH 01/10] docs(jwplc): start remote io proof of concept --- docs/jwplc/JWPLC_REMOTE_IO_POC.md | 170 ++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/jwplc/JWPLC_REMOTE_IO_POC.md diff --git a/docs/jwplc/JWPLC_REMOTE_IO_POC.md b/docs/jwplc/JWPLC_REMOTE_IO_POC.md new file mode 100644 index 000000000..32df418c7 --- /dev/null +++ b/docs/jwplc/JWPLC_REMOTE_IO_POC.md @@ -0,0 +1,170 @@ +# JWPLC Remote I/O PoC + +## Estado + +Etapa inicial de desarrollo. + +## Objetivo + +Validar el uso de un JWPLC Basic como controlador maestro y un segundo JWPLC Basic como dispositivo remoto/esclavo, simulando el comportamiento futuro de módulos de expansión JWPLC. + +## Topología inicial + +PC / OpenPLC Editor - JWPLC Edition + | + | USB para programación/debug + | +JWPLC Basic Maestro + | + | Ethernet / Modbus TCP + | +JWPLC Basic Esclavo + +## Roles + +### JWPLC Basic Maestro + +- Ejecuta el programa principal OpenPLC. +- Actúa como Modbus Master/Client. +- Lee entradas remotas del JWPLC esclavo. +- Escribe salidas remotas del JWPLC esclavo. + +### JWPLC Basic Esclavo + +- Ejecuta un programa OpenPLC mínimo o runtime Modbus Server. +- Expone entradas y salidas mediante Modbus TCP. +- Representa una futura expansión JWPLC. + +## Primera prueba + +### Maestro lee entradas del esclavo + +El maestro debe leer: + +- I0_0..I0_7 del esclavo. + +Estas señales serán mapeadas como variables remotas internas del programa del maestro. + +### Maestro escribe salidas del esclavo + +El maestro debe escribir: + +- Q0_0..Q0_7 del esclavo. + +Estas salidas serán controladas desde variables internas del programa del maestro. + +## Transporte inicial + +Primera fase: + +- Modbus TCP sobre Ethernet W5500. + +Motivo: + +- Discovery nativo JWPLC ya validado. +- DHCP/IP estática ya validado. +- Menor fricción que RS-485 para la primera prueba. +- Permite enfocarse en Remote Devices y mapeo I/O. + +Segunda fase: + +- Modbus RTU sobre RS-485. + +Motivo: + +- Será el escenario más cercano a módulos de expansión físicos. + +## Hipótesis de mapeo inicial + +| Función | Modbus | Dirección | Longitud | Uso | +|---|---:|---:|---:|---| +| Leer entradas digitales del esclavo | FC2 | 0 | 8 | I0_0..I0_7 | +| Escribir salidas digitales del esclavo | FC15 | 0 | 8 | Q0_0..Q0_7 | +| Leer estado de salidas del esclavo | FC1 | 0 | 8 | Estado Q0_0..Q0_7 | + +## Configuración inicial del JWPLC Basic Esclavo + +- Board: JWPLC BASIC [2.0.0]. +- Modbus TCP: habilitado. +- Interface: Ethernet W5500. +- DHCP: habilitado inicialmente. +- Puerto Modbus TCP: 502. +- Slave ID: 1. + +## Configuración inicial del JWPLC Basic Maestro + +- Board: JWPLC BASIC [2.0.0]. +- Programa principal OpenPLC. +- Remote Device configurado como Modbus TCP. +- Host: IP detectada del JWPLC esclavo. +- Puerto: 502. +- Slave ID: 1. + +## IO Groups propuestos para el maestro + +### Grupo 1: Remote Digital Inputs + +- Function Code: FC2 - Read Discrete Inputs. +- Offset: 0. +- Length: 8. +- Cycle Time: 50 ms o 100 ms. +- Uso: leer entradas digitales remotas del esclavo. + +### Grupo 2: Remote Digital Outputs + +- Function Code: FC15 - Write Multiple Coils. +- Offset: 0. +- Length: 8. +- Cycle Time: 50 ms o 100 ms. +- Uso: escribir salidas digitales remotas del esclavo. + +### Grupo 3: Remote Output Feedback + +- Function Code: FC1 - Read Coils. +- Offset: 0. +- Length: 8. +- Cycle Time: 100 ms. +- Uso: leer estado actual de salidas remotas. + +## Criterio de éxito + +- [ ] El maestro configura al esclavo como Remote Device. +- [ ] El maestro compila con configuración Modbus Master. +- [ ] El esclavo responde por Modbus TCP. +- [ ] El maestro lee 8 entradas digitales remotas. +- [ ] El maestro escribe 8 salidas digitales remotas. +- [ ] El debugger permite observar variables locales y remotas. +- [ ] El flujo puede documentarse para futuras expansiones JWPLC. + +## No incluido en esta fase + +- No se implementa discovery RS-485. +- No se cambia la IP del esclavo desde el editor. +- No se crea todavía catálogo final de módulos. +- No se modifica el package Arduino base. +- No se asume OTA. +- No se define aún el protocolo final de módulos de expansión. + +## Evolución esperada + +Esta prueba debe servir como base para una futura función del editor: + +- Add JWPLC Remote I/O Device. +- Add JWPLC Expansion Module. +- Generación automática de Remote Devices. +- Generación automática de IO Groups. +- Mapeo automático de variables IEC. +- Diagnóstico de módulos remotos. + +## Resultado esperado de producto + +El usuario no debería configurar manualmente cada función Modbus. + +El flujo ideal futuro sería: + +1. Seleccionar JWPLC Basic como controlador principal. +2. Agregar un módulo remoto JWPLC. +3. Elegir transporte: Modbus TCP o Modbus RTU. +4. Asignar IP o Slave ID. +5. Generar automáticamente el mapa de entradas/salidas. +6. Programar en Ladder usando nombres claros. From 4de570780cc11d2bd462a0f51194958478e5c902 Mon Sep 17 00:00:00 2001 From: Jeykco Date: Mon, 6 Jul 2026 14:47:29 -0500 Subject: [PATCH 02/10] feat(jwplc): add remote io preset for basic devices --- .../editor/device/remote-device/index.tsx | 116 +++++++++++++++--- 1 file changed, 102 insertions(+), 14 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx index 3000d5585..4718e4ef7 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx @@ -74,6 +74,42 @@ const DATA_BITS_OPTIONS = [ { value: '8', label: '8' }, ] +type RemoteIOPresetGroup = { + name: string + functionCode: '1' | '2' | '3' | '4' | '5' | '6' | '15' | '16' + cycleTime: number + offset: string + length: number + errorHandling: 'keep-last-value' | 'set-to-zero' +} + +const JWPLC_BASIC_REMOTE_IO_PRESET_GROUPS: RemoteIOPresetGroup[] = [ + { + name: 'JWPLC Remote Digital Inputs', + functionCode: '2', + cycleTime: 100, + offset: '0', + length: 8, + errorHandling: 'keep-last-value', + }, + { + name: 'JWPLC Remote Digital Outputs', + functionCode: '15', + cycleTime: 100, + offset: '0', + length: 8, + errorHandling: 'keep-last-value', + }, + { + name: 'JWPLC Remote Output Feedback', + functionCode: '1', + cycleTime: 250, + offset: '0', + length: 8, + errorHandling: 'keep-last-value', + }, +] + // Slave ID validation ranges per transport type const SLAVE_ID_TCP_MIN = 0 const SLAVE_ID_TCP_MAX = 255 @@ -699,6 +735,42 @@ const RemoteDeviceEditor = () => { setIsModalOpen(true) }, []) + const hasJwplcBasicRemoteIoPreset = useMemo( + () => JWPLC_BASIC_REMOTE_IO_PRESET_GROUPS.some((preset) => ioGroups.some((group) => group.name === preset.name)), + [ioGroups], + ) + + const handleAddJwplcBasicRemoteIoPreset = useCallback(() => { + const existingPresetGroups = JWPLC_BASIC_REMOTE_IO_PRESET_GROUPS.filter((preset) => + ioGroups.some((group) => group.name === preset.name), + ) + + if (existingPresetGroups.length > 0) { + consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: `JWPLC Basic Remote I/O preset was not added because these groups already exist: ${existingPresetGroups + .map((group) => group.name) + .join(', ')}.`, + }) + return + } + + for (const group of JWPLC_BASIC_REMOTE_IO_PRESET_GROUPS) { + projectActions.addIOGroup(deviceName, { + id: uuidv4(), + ...group, + }) + } + + sharedWorkspaceActions.handleFileAndWorkspaceSavedState(deviceName) + consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'info', + message: 'JWPLC Basic Remote I/O preset added: 8 remote inputs, 8 remote outputs and output feedback.', + }) + }, [consoleActions, deviceName, ioGroups, projectActions, sharedWorkspaceActions]) + const handleOpenEditModal = useCallback( (groupId: string) => { const group = ioGroups.find((g) => g.id === groupId) @@ -943,20 +1015,36 @@ const RemoteDeviceEditor = () => {

IO Tag Mapping

- , - id: 'add-io-group-button', - }, - ]} - buttonProps={{ - className: - 'rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed', - }} - /> +
+ + + , + id: 'add-io-group-button', + }, + ]} + buttonProps={{ + className: + 'rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed', + }} + /> +
From 550e8df993e2940400f9176061b648f0d66d53be Mon Sep 17 00:00:00 2001 From: Jeykco Date: Mon, 6 Jul 2026 17:11:42 -0500 Subject: [PATCH 03/10] fix(jwplc): enable remote devices for jwplc targets --- .../[workspace]/create-element/element-card/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx index b09eff4d9..b6fa7ae32 100644 --- a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx +++ b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx @@ -136,6 +136,9 @@ const ElementCard = (props: ElementCardProps): ReactNode => { const isArduinoTarget = checkIsArduinoTarget(currentBoardInfo) const isSimulator = isSimulatorTarget(currentBoardInfo) const isRuntimeV4 = isOpenPLCRuntimeV4Target(deviceBoard, currentBoardInfo) + const boardInfoText = currentBoardInfo ? JSON.stringify(currentBoardInfo).toLowerCase() : '' + const isJwplcTarget = deviceBoard.toLowerCase().includes('jwplc') || boardInfoText.includes('jwplc') + const supportsRemoteDevices = isRuntimeV4 || isSimulator || isJwplcTarget const handleCreatePou: SubmitHandler = (data) => { const pouWasCreated = create(data) @@ -511,13 +514,13 @@ const ElementCard = (props: ElementCardProps): ReactNode => {
- {!(isRuntimeV4 || isSimulator) ? ( + {!supportsRemoteDevices ? (

- Remote device configuration is only available for OpenPLC Runtime v4 targets. + Remote device configuration is available for OpenPLC Runtime v4, Simulator and JWPLC targets.

- Please select OpenPLC Runtime v4 in the Device Configuration to enable this feature. + Please select a compatible target in the Device Configuration to enable this feature.

) : ( From 93848a5fe31c8ecaaf120014e8a621362f6778a0 Mon Sep 17 00:00:00 2001 From: Jeykco Date: Mon, 6 Jul 2026 18:42:11 -0500 Subject: [PATCH 04/10] docs(jwplc): define expansion backplane architecture --- .../JWPLC_EXPANSION_BACKPLANE_ARCHITECTURE.md | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 docs/jwplc/JWPLC_EXPANSION_BACKPLANE_ARCHITECTURE.md diff --git a/docs/jwplc/JWPLC_EXPANSION_BACKPLANE_ARCHITECTURE.md b/docs/jwplc/JWPLC_EXPANSION_BACKPLANE_ARCHITECTURE.md new file mode 100644 index 000000000..b348fae94 --- /dev/null +++ b/docs/jwplc/JWPLC_EXPANSION_BACKPLANE_ARCHITECTURE.md @@ -0,0 +1,257 @@ +# JWPLC Expansion Backplane Architecture + +## Estado + +Diseño inicial. + +Este documento registra la decisión de usar una interfaz tipo Backplane Configuration para la futura configuración de módulos de expansión JWPLC dentro de OpenPLC Editor - JWPLC Edition. + +## Decisión principal + +La configuración final de módulos JWPLC no debe quedar expuesta únicamente como Remote Devices genéricos. + +Remote Devices seguirá siendo la capa técnica interna para generar configuración Modbus Master, pero la experiencia de usuario final debe ser una pantalla tipo backplane o rack de módulos. + +## Enfoque visual deseado + +El usuario debería ver una configuración similar a: + +Slot 1: +JWPLC Basic v2.0.0 +Controlador principal + +Slot 2: +JWPLC Expansion DI16 +16 entradas digitales +Slave ID 2 + +Slot 3: +JWPLC Expansion DO16 +16 salidas digitales +Slave ID 3 + +Slot 4: +JWPLC Expansion AI8 +8 entradas analógicas +Slave ID 4 + +## Justificación + +La configuración manual mediante Remote Devices es técnicamente correcta, pero poco amigable para usuarios finales. + +Remote Devices requiere conocer detalles como: + +- Function Code. +- Offset. +- Length. +- Cycle Time. +- Slave ID. +- Transporte Modbus. +- Direcciones IEC. + +Para un ecosistema JWPLC, el flujo debe ser más cercano a fabricantes industriales: + +1. Seleccionar controlador principal. +2. Agregar módulos de expansión. +3. Configurar dirección o Slave ID. +4. Ver canales disponibles. +5. Asignar alias. +6. Programar Ladder usando nombres claros. + +## Relación con Remote Devices + +Remote Devices queda como capa técnica. + +La pantalla JWPLC Backplane debe generar o sincronizar internamente: + +- Remote Devices. +- Configuración Modbus TCP o RTU. +- IO Groups. +- IEC addresses. +- Aliases. +- Cycle time. +- Slave ID. + +El usuario avanzado podrá seguir usando Remote Devices directamente, pero el flujo recomendado para módulos JWPLC será Backplane Configuration. + +## Primera prueba PoC + +Antes de crear módulos JWPLC dedicados, se usará un segundo JWPLC Basic como módulo remoto. + +Topología inicial: + +PC con OpenPLC Editor - JWPLC Edition +| +USB para programación/debug +| +JWPLC Basic Maestro +| +Ethernet o RS-485 usando Modbus +| +JWPLC Basic Esclavo + +## Roles + +### JWPLC Basic Maestro + +- Ejecuta el programa principal. +- Actúa como Modbus Master/Client. +- Lee entradas remotas. +- Escribe salidas remotas. + +### JWPLC Basic Esclavo + +- Actúa como dispositivo remoto. +- Expone entradas y salidas. +- Simula el comportamiento de una futura expansión JWPLC. + +## Fase 1: Remote I/O manual + +Objetivo: + +Validar que el flujo técnico funcione usando Remote Devices. + +Acciones: + +- Habilitar Remote Devices para targets JWPLC. +- Crear un Remote Device Modbus. +- Agregar preset JWPLC Basic Remote I/O. +- Compilar. +- Subir al JWPLC maestro. +- Probar comunicación contra el JWPLC esclavo. + +Criterio de éxito: + +- El maestro compila con configuración Modbus Master. +- El maestro puede leer entradas remotas. +- El maestro puede escribir salidas remotas. +- El debugger permite observar variables asociadas. + +## Fase 2: Backplane visual JWPLC + +Objetivo: + +Crear una pantalla propia para configurar módulos JWPLC usando el layout module-slots existente. + +Pantalla propuesta: + +JWPLC Backplane Configuration + +Slots iniciales: + +- Slot 1: JWPLC Basic v2.0.0 +- Slot 2: JWPLC Basic Remote I/O +- Slot 3+: Reservado para futuras expansiones + +Módulos iniciales: + +- JWPLC Basic Remote I/O +- JWPLC Expansion DI16 +- JWPLC Expansion DO16 +- JWPLC Expansion 8DI/8DO +- JWPLC Expansion AI8 +- JWPLC Expansion AO4 +- JWPLC Expansion Relay8 + +## Fase 3: Descriptor de módulos JWPLC + +Cada módulo debe tener un descriptor declarativo. + +Campos propuestos: + +- id +- name +- model +- description +- image +- transport +- defaultSlaveId +- defaultBaudRate +- channels +- modbusGroups +- defaultCycleTime +- allowedSlotRange + +Ejemplo conceptual: + +id: jwplc-expansion-di16 +name: JWPLC Expansion DI16 +transport: modbus-rtu +defaultSlaveId: 2 +channels: + - I1_0 digitalInput + - I1_1 digitalInput + - ... +modbusGroups: + - FC2 offset 0 length 16 + +## Fase 4: Generación automática + +Desde la pantalla Backplane se debe generar automáticamente: + +- Remote Device por módulo. +- IO Groups por función. +- Direcciones IEC. +- Alias iniciales opcionales. +- Configuración Modbus Master. + +Ejemplo: + +Módulo: JWPLC Expansion DI16 +Slave ID: 2 + +Genera: + +Remote Device: +JWPLC_EXP_DI16_01 + +Transport: +Modbus RTU + +IO Group: +Read Discrete Inputs +FC2 +Offset 0 +Length 16 +Cycle Time 100 ms + +## Fase 5: Diagnóstico + +La pantalla debe mostrar estado de módulos: + +- Configurado. +- Sin respuesta. +- Respondiendo. +- Error de timeout. +- Slave ID duplicado. +- Módulo detectado diferente al configurado. + +## Decisiones pendientes + +- Definir si el bus principal de expansión será Modbus RTU, Modbus TCP o ambos. +- Definir mapa Modbus final por familia de módulos. +- Definir si cada módulo tendrá firmware propio con discovery. +- Definir método de asignación de Slave ID. +- Definir si el Backplane podrá modificar parámetros del módulo. +- Definir si habrá modo avanzado para editar Remote Devices generados. + +## No incluido todavía + +- No se implementa discovery RS-485. +- No se modifica IP o Slave ID desde el editor. +- No se define todavía protocolo final de módulos. +- No se elimina Remote Devices. +- No se asume OTA. +- No se modifica el package Arduino base. + +## Conclusión + +El flujo recomendado para JWPLC Expansion será: + +Backplane Configuration como interfaz de producto. + +Remote Devices como backend técnico. + +Modbus Master como mecanismo inicial de comunicación. + +Esta arquitectura permite empezar con un JWPLC Basic como esclavo remoto y evolucionar luego hacia módulos JWPLC Expansion dedicados. From f98c71a26aa90a97336ad5aa2bbe6710f4a80026 Mon Sep 17 00:00:00 2001 From: Jeykco Date: Tue, 7 Jul 2026 00:03:40 -0500 Subject: [PATCH 05/10] fix(jwplc): improve ladder variable autocomplete --- .../ladder/autocomplete/index.tsx | 43 +++++++++++++++++-- .../_atoms/graphical-editor/ladder/coil.tsx | 14 +++++- .../graphical-editor/ladder/contact.tsx | 14 +++++- 3 files changed, 66 insertions(+), 5 deletions(-) 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 64dedd360..2df64e5a3 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx @@ -1,5 +1,5 @@ import { Node } from '@xyflow/react' -import { ComponentPropsWithRef, forwardRef, useEffect, useState } from 'react' +import { ComponentPropsWithRef, forwardRef, useEffect, useMemo, useState } from 'react' import { v4 as uuidv4 } from 'uuid' import { PLCVariable } from '../../../../../../middleware/shared/ports' @@ -12,6 +12,7 @@ import { import { useOpenPLCStore } from '../../../../../store' import { cn } from '../../../../../utils/cn' import { getLiteralType } from '../../../../../utils/keywords' +import { validateVariableType } from '../../../../../utils/PLC/validate-variable-type' import { toast } from '../../../../_features/[app]/toast/use-toast' import { useBoundPou } from '../../../../_features/[workspace]/editor/graphical/active-context' import { GraphicalEditorAutocomplete } from '../../autocomplete' @@ -83,6 +84,42 @@ const VariablesBlockAutoComplete = forwardRef(() => { + if (blockType === 'block') return [] + + const needle = valueToSearch.toLowerCase() + const variables = pous.find((pou) => pou.name === pouName)?.interface?.variables ?? [] + + return variables + .filter((variable) => variable.name.toLowerCase().includes(needle)) + .filter((variable) => { + if (!expectedType) return true + const variableType = variable.type?.value + if (!variableType) return false + return validateVariableType(variableType, expectedType).isValid + }) + .map((variable) => ({ + label: variable.name, + insertText: variable.name, + type: variable.type?.value, + })) + }, [blockType, expectedType, pous, pouName, valueToSearch]) + + const mergedCandidates = useMemo(() => { + const byInsertText = new Map() + + for (const candidate of candidates) { + byInsertText.set(candidate.insertText.toLowerCase(), candidate) + } + + for (const candidate of localVariableCandidates) { + const key = candidate.insertText.toLowerCase() + if (!byInsertText.has(key)) byInsertText.set(key, candidate) + } + + return [...byInsertText.values()] + }, [candidates, localVariableCandidates]) + const submitVariableToBlock = (variable: PLCVariable) => { const { rung, node: variableNode } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: (block as Node).id, @@ -237,7 +274,7 @@ const VariablesBlockAutoComplete = forwardRef c.insertText.toLowerCase() === variable.name.toLowerCase()) + const candidate = mergedCandidates.find((c) => c.insertText.toLowerCase() === variable.name.toLowerCase()) if (!candidate) return submitVariableToBlock(scopeCompletionToVariable(candidate)) @@ -251,7 +288,7 @@ const VariablesBlockAutoComplete = forwardRef ({ id: c.insertText, name: c.insertText }))} + variables={mergedCandidates.map((c) => ({ id: c.insertText, name: c.insertText }))} submit={submit} /> ) diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx index 4da8b8cbe..cfbd1cc5c 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx @@ -1,4 +1,4 @@ -import * as Popover from '@radix-ui/react-popover' +import * as Popover from '@radix-ui/react-popover' import { useEffect, useRef, useState } from 'react' import { useDebugger } from '../../../../../middleware/shared/providers' @@ -8,6 +8,7 @@ import { forceDebugVariable, releaseDebugVariable } from '../../../../services/d import { isExpressionValidForType } from '../../../../services/graphical-scope' import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' +import { validateVariableType } from '../../../../utils/PLC/validate-variable-type' import { useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' import { HighlightedTextArea } from '../../highlighted-textarea' import { VariablesBlockAutoComplete } from './autocomplete' @@ -92,6 +93,15 @@ export const Coil = (block: CoilProps) => { */ useEffect(() => { const name = data.variable?.name?.trim() ?? '' + const localVariable = pous + .find((pou) => pou.name === pouName) + ?.interface?.variables?.find((variable) => variable.name.toLowerCase() === name.toLowerCase()) + + if (localVariable?.type?.value && validateVariableType(localVariable.type.value, 'BOOL').isValid) { + setWrongVariable(false) + return + } + let cancelled = false void isExpressionValidForType(pouName, name, 'BOOL').then((valid) => { if (!cancelled) setWrongVariable(!valid) @@ -211,6 +221,8 @@ export const Coil = (block: CoilProps) => { readOnly={isDebuggerVisible} onFocus={(e) => { e.target.select() + setOpenAutocomplete(true) + setKeyPressedAtTextarea('') const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id ?? '', }) diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx index 950502495..178ec793b 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx @@ -1,4 +1,4 @@ -import * as Popover from '@radix-ui/react-popover' +import * as Popover from '@radix-ui/react-popover' import { useEffect, useRef, useState } from 'react' import { useDebugger } from '../../../../../middleware/shared/providers' @@ -8,6 +8,7 @@ import { forceDebugVariable, releaseDebugVariable } from '../../../../services/d import { isExpressionValidForType } from '../../../../services/graphical-scope' import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' +import { validateVariableType } from '../../../../utils/PLC/validate-variable-type' import { useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' import { HighlightedTextArea } from '../../highlighted-textarea' import { VariablesBlockAutoComplete } from './autocomplete' @@ -93,6 +94,15 @@ export const Contact = (block: ContactProps) => { */ useEffect(() => { const name = data.variable?.name?.trim() ?? '' + const localVariable = pous + .find((pou) => pou.name === pouName) + ?.interface?.variables?.find((variable) => variable.name.toLowerCase() === name.toLowerCase()) + + if (localVariable?.type?.value && validateVariableType(localVariable.type.value, 'BOOL').isValid) { + setWrongVariable(false) + return + } + let cancelled = false void isExpressionValidForType(pouName, name, 'BOOL').then((valid) => { if (!cancelled) setWrongVariable(!valid) @@ -212,6 +222,8 @@ export const Contact = (block: ContactProps) => { readOnly={isDebuggerVisible} onFocus={(e) => { e.target.select() + setOpenAutocomplete(true) + setKeyPressedAtTextarea('') const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id ?? '', }) From f639e466ebf5305d828998f4cb7a34e64259a869 Mon Sep 17 00:00:00 2001 From: Jeykco Date: Tue, 7 Jul 2026 00:12:46 -0500 Subject: [PATCH 06/10] fix(jwplc): improve ladder variable autocomplete --- .../components/_atoms/graphical-editor/ladder/coil.tsx | 6 ++++-- .../components/_atoms/graphical-editor/ladder/contact.tsx | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx index cfbd1cc5c..f3091554b 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx @@ -76,13 +76,15 @@ export const Coil = (block: CoilProps) => { }, [data.variable.name]) /** - * useEffect to focus the variable input when the block is selected + * Focus the variable input and open autocomplete when the block is selected. */ useEffect(() => { if (inputVariableRef.current && selected) { inputVariableRef.current.focus() + setOpenAutocomplete(true) + setKeyPressedAtTextarea('') } - }, []) + }, [selected]) /** * Validate the coil's variable against the full project scope via the diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx index 178ec793b..e7a4a8350 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx @@ -77,13 +77,15 @@ export const Contact = (block: ContactProps) => { }, [data.variable.name]) /** - * useEffect to focus the variable input when the block is selected + * Focus the variable input and open autocomplete when the block is selected. */ useEffect(() => { if (inputVariableRef.current && selected) { inputVariableRef.current.focus() + setOpenAutocomplete(true) + setKeyPressedAtTextarea('') } - }, []) + }, [selected]) /** * Validate the contact's variable against the full project scope via the From c28eb0fd6c1dc0a9bf375513e2d77b961a159df4 Mon Sep 17 00:00:00 2001 From: Jeykco Date: Tue, 7 Jul 2026 00:21:07 -0500 Subject: [PATCH 07/10] fix(jwplc): stabilize ladder variable autocomplete --- .../graphical-editor/autocomplete/index.tsx | 23 ++++++++++++++++--- .../ladder/autocomplete/index.tsx | 12 +++++++++- .../_atoms/graphical-editor/ladder/coil.tsx | 10 +++++++- .../graphical-editor/ladder/contact.tsx | 10 +++++++- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx index 5a2400ccc..bb28b6dae 100644 --- a/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx @@ -22,6 +22,7 @@ export type GraphicalEditorAutocompleteProps = ComponentPropsWithRef<'div'> & { } } } + keepOpenForElementId?: string } export const GraphicalEditorAutocomplete = forwardRef( @@ -36,6 +37,7 @@ export const GraphicalEditorAutocomplete = forwardRef { @@ -91,6 +93,21 @@ export const GraphicalEditorAutocomplete = forwardRef { + if (!keepOpenForElementId || !(target instanceof HTMLElement)) return false + return target.id === keepOpenForElementId + } + + const handleOutsideInteraction = (event: { target: EventTarget | null; preventDefault: () => void }) => { + if (shouldKeepOpenForOutsideTarget(event.target)) { + event.preventDefault() + if (setIsOpen) setIsOpen(true) + return + } + + closeModal() + } + const submitAutocompletion = ({ variable }: { variable: { id: string; name: string } }) => { closeModal() submit({ variable }) @@ -237,9 +254,9 @@ export const GraphicalEditorAutocomplete = forwardRef e.preventDefault()} onCloseAutoFocus={closeModal} onEscapeKeyDown={closeModal} - onPointerDownOutside={closeModal} - onFocusOutside={closeModal} - onInteractOutside={closeModal} + onPointerDownOutside={handleOutsideInteraction} + onFocusOutside={handleOutsideInteraction} + onInteractOutside={handleOutsideInteraction} onFocus={(e) => { if (focusEvent) focusEvent(e) setAutocompleteFocus(true) 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 2df64e5a3..6ae40c1b6 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx @@ -26,6 +26,7 @@ type VariablesBlockAutoCompleteProps = ComponentPropsWithRef<'div'> & { setIsOpen?: (isOpen: boolean) => void keyPressed?: string valueToSearch: string + keepOpenForElementId?: string } /** @@ -51,7 +52,15 @@ const expectedTypeForBlock = ( const VariablesBlockAutoComplete = forwardRef( ( - { block, blockType = 'other', isOpen, setIsOpen, keyPressed, valueToSearch }: VariablesBlockAutoCompleteProps, + { + block, + blockType = 'other', + isOpen, + setIsOpen, + keyPressed, + valueToSearch, + keepOpenForElementId, + }: VariablesBlockAutoCompleteProps, ref, ) => { const pouName = useBoundPou() @@ -290,6 +299,7 @@ const VariablesBlockAutoComplete = forwardRef ({ id: c.insertText, name: c.insertText }))} submit={submit} + keepOpenForElementId={keepOpenForElementId} /> ) }, diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx index f3091554b..219348e59 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx @@ -206,7 +206,14 @@ export const Coil = (block: CoilProps) => { onClick={isDebuggerVisible ? handleClick : undefined} > {coil.svg(wrongVariable, debuggerFillColor)} -
+
{ + setOpenAutocomplete(true) + setKeyPressedAtTextarea('') + }} + > { isOpen={openAutocomplete} setIsOpen={(value) => setOpenAutocomplete(value)} keyPressed={keyPressedAtTextarea} + keepOpenForElementId={`coil-variable-input-${id}`} />
diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx index e7a4a8350..d65d0fb75 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx @@ -207,7 +207,14 @@ export const Contact = (block: ContactProps) => { onClick={isDebuggerVisible ? handleClick : undefined} > {contact.svg(wrongVariable, debuggerStrokeColor)} -
+
{ + setOpenAutocomplete(true) + setKeyPressedAtTextarea('') + }} + > { isOpen={openAutocomplete} setIsOpen={(value) => setOpenAutocomplete(value)} keyPressed={keyPressedAtTextarea} + keepOpenForElementId={`contact-variable-input-${id}`} />
From 43eb33fba8fbe582bd47e59914ecd28742cf005d Mon Sep 17 00:00:00 2001 From: Jeykco Date: Tue, 7 Jul 2026 01:15:37 -0500 Subject: [PATCH 08/10] fix --- .../graphical-editor/autocomplete/index.tsx | 8 +- .../ladder/autocomplete/index.tsx | 3 + .../_atoms/graphical-editor/ladder/coil.tsx | 72 +++++++++++------- .../graphical-editor/ladder/contact.tsx | 73 +++++++++++-------- .../graphical-editor/ladder/rung/body.tsx | 39 +++++++--- 5 files changed, 126 insertions(+), 69 deletions(-) diff --git a/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx index bb28b6dae..882ccd72e 100644 --- a/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx @@ -23,6 +23,7 @@ export type GraphicalEditorAutocompleteProps = ComponentPropsWithRef<'div'> & { } } keepOpenForElementId?: string + keepOpenForSelector?: string } export const GraphicalEditorAutocomplete = forwardRef( @@ -38,6 +39,7 @@ export const GraphicalEditorAutocomplete = forwardRef { @@ -94,8 +96,10 @@ export const GraphicalEditorAutocomplete = forwardRef { - if (!keepOpenForElementId || !(target instanceof HTMLElement)) return false - return target.id === keepOpenForElementId + if (!(target instanceof HTMLElement)) return false + if (keepOpenForElementId && target.id === keepOpenForElementId) return true + if (keepOpenForSelector && target.closest(keepOpenForSelector)) return true + return false } const handleOutsideInteraction = (event: { target: EventTarget | null; preventDefault: () => void }) => { 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 6ae40c1b6..02c5fc93c 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx @@ -27,6 +27,7 @@ type VariablesBlockAutoCompleteProps = ComponentPropsWithRef<'div'> & { keyPressed?: string valueToSearch: string keepOpenForElementId?: string + keepOpenForSelector?: string } /** @@ -60,6 +61,7 @@ const VariablesBlockAutoComplete = forwardRef { @@ -300,6 +302,7 @@ const VariablesBlockAutoComplete = forwardRef ({ id: c.insertText, name: c.insertText }))} submit={submit} keepOpenForElementId={keepOpenForElementId} + keepOpenForSelector={keepOpenForSelector} /> ) }, diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx index 219348e59..c173bbd5f 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx @@ -1,4 +1,4 @@ -import * as Popover from '@radix-ui/react-popover' +import * as Popover from '@radix-ui/react-popover' import { useEffect, useRef, useState } from 'react' import { useDebugger } from '../../../../../middleware/shared/providers' @@ -59,32 +59,36 @@ export const Coil = (block: CoilProps) => { const [openAutocomplete, setOpenAutocomplete] = useState(false) const [keyPressedAtTextarea, setKeyPressedAtTextarea] = useState('') - useEffect(() => { - if (inputVariableRef.current && inputWrapperRef.current) { - inputWrapperRef.current.style.top = inputVariableRef.current.scrollHeight >= 24 ? '-24px' : '-20px' + const variableEditorElementId = 'coil-variable-input-' + id + + const openVariableAutocomplete = () => { + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent('openplc:ladder-variable-autocomplete-open', { + detail: { id: variableEditorElementId }, + }), + ) } - }, [coilVariableValue]) - /** - * useEffect to sync coilVariableValue with data.variable.name when it changes externally - * (e.g., from variable rename propagation) - */ + setOpenAutocomplete(true) + setKeyPressedAtTextarea('') + } + useEffect(() => { - if (data.variable && data.variable.name !== '') { - setCoilVariableValue(data.variable.name) + const handleAutocompleteOpen = (event: Event) => { + const openedId = (event as CustomEvent<{ id: string }>).detail?.id + if (openedId !== variableEditorElementId) { + setOpenAutocomplete(false) + setKeyPressedAtTextarea('') + } } - }, [data.variable.name]) - /** - * Focus the variable input and open autocomplete when the block is selected. - */ - useEffect(() => { - if (inputVariableRef.current && selected) { - inputVariableRef.current.focus() - setOpenAutocomplete(true) - setKeyPressedAtTextarea('') + window.addEventListener('openplc:ladder-variable-autocomplete-open', handleAutocompleteOpen) + + return () => { + window.removeEventListener('openplc:ladder-variable-autocomplete-open', handleAutocompleteOpen) } - }, [selected]) + }, [variableEditorElementId]) /** * Validate the coil's variable against the full project scope via the @@ -185,7 +189,7 @@ export const Coil = (block: CoilProps) => { const onChangeHandler = () => { if (!openAutocomplete) { - setOpenAutocomplete(true) + openVariableAutocomplete() } } @@ -207,11 +211,23 @@ export const Coil = (block: CoilProps) => { > {coil.svg(wrongVariable, debuggerFillColor)}
{ - setOpenAutocomplete(true) - setKeyPressedAtTextarea('') + onPointerDownCapture={(event) => { + event.preventDefault() + event.stopPropagation() + openVariableAutocomplete() + }} + onClickCapture={(event) => { + event.preventDefault() + event.stopPropagation() + openVariableAutocomplete() + }} + onDoubleClickCapture={(event) => { + event.preventDefault() + event.stopPropagation() + openVariableAutocomplete() }} > { readOnly={isDebuggerVisible} onFocus={(e) => { e.target.select() - setOpenAutocomplete(true) - setKeyPressedAtTextarea('') + openVariableAutocomplete() const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id ?? '', }) @@ -287,6 +302,7 @@ export const Coil = (block: CoilProps) => { isOpen={openAutocomplete} setIsOpen={(value) => setOpenAutocomplete(value)} keyPressed={keyPressedAtTextarea} + keepOpenForSelector="[data-ladder-variable-editor='true']" keepOpenForElementId={`coil-variable-input-${id}`} />
diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx index d65d0fb75..bb6efa8cf 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx @@ -1,4 +1,4 @@ -import * as Popover from '@radix-ui/react-popover' +import * as Popover from '@radix-ui/react-popover' import { useEffect, useRef, useState } from 'react' import { useDebugger } from '../../../../../middleware/shared/providers' @@ -59,33 +59,36 @@ export const Contact = (block: ContactProps) => { const [openAutocomplete, setOpenAutocomplete] = useState(false) const [keyPressedAtTextarea, setKeyPressedAtTextarea] = useState('') - useEffect(() => { - if (inputVariableRef.current && inputWrapperRef.current) { - // top - inputWrapperRef.current.style.top = inputVariableRef.current.scrollHeight >= 24 ? '-24px' : '-20px' + const variableEditorElementId = 'contact-variable-input-' + id + + const openVariableAutocomplete = () => { + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent('openplc:ladder-variable-autocomplete-open', { + detail: { id: variableEditorElementId }, + }), + ) } - }, [contactVariableValue]) - /** - * useEffect to sync contactVariableValue with data.variable.name when it changes externally - * (e.g., from variable rename propagation) - */ + setOpenAutocomplete(true) + setKeyPressedAtTextarea('') + } + useEffect(() => { - if (data.variable && data.variable.name !== '') { - setContactVariableValue(data.variable.name) + const handleAutocompleteOpen = (event: Event) => { + const openedId = (event as CustomEvent<{ id: string }>).detail?.id + if (openedId !== variableEditorElementId) { + setOpenAutocomplete(false) + setKeyPressedAtTextarea('') + } } - }, [data.variable.name]) - /** - * Focus the variable input and open autocomplete when the block is selected. - */ - useEffect(() => { - if (inputVariableRef.current && selected) { - inputVariableRef.current.focus() - setOpenAutocomplete(true) - setKeyPressedAtTextarea('') + window.addEventListener('openplc:ladder-variable-autocomplete-open', handleAutocompleteOpen) + + return () => { + window.removeEventListener('openplc:ladder-variable-autocomplete-open', handleAutocompleteOpen) } - }, [selected]) + }, [variableEditorElementId]) /** * Validate the contact's variable against the full project scope via the @@ -186,7 +189,7 @@ export const Contact = (block: ContactProps) => { const onChangeHandler = () => { if (!openAutocomplete) { - setOpenAutocomplete(true) + openVariableAutocomplete() } } @@ -208,11 +211,23 @@ export const Contact = (block: ContactProps) => { > {contact.svg(wrongVariable, debuggerStrokeColor)}
{ - setOpenAutocomplete(true) - setKeyPressedAtTextarea('') + onPointerDownCapture={(event) => { + event.preventDefault() + event.stopPropagation() + openVariableAutocomplete() + }} + onClickCapture={(event) => { + event.preventDefault() + event.stopPropagation() + openVariableAutocomplete() + }} + onDoubleClickCapture={(event) => { + event.preventDefault() + event.stopPropagation() + openVariableAutocomplete() }} > { readOnly={isDebuggerVisible} onFocus={(e) => { e.target.select() - setOpenAutocomplete(true) - setKeyPressedAtTextarea('') + openVariableAutocomplete() const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id ?? '', }) @@ -288,6 +302,7 @@ export const Contact = (block: ContactProps) => { isOpen={openAutocomplete} setIsOpen={(value) => setOpenAutocomplete(value)} keyPressed={keyPressedAtTextarea} + keepOpenForSelector="[data-ladder-variable-editor='true']" keepOpenForElementId={`contact-variable-input-${id}`} />
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 3fc082af2..7caa3fc4a 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx @@ -98,6 +98,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi const [reactFlowInstance, setReactFlowInstance] = useState(null) const reactFlowViewportRef = useRef(null) + const lastNodeClickRef = useRef<{ nodeId: string; clickedAt: number } | null>(null) /** * -- Which means, by default, the flow panel extent is: @@ -662,10 +663,11 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi } } - /** - * Handle the double click of a node - */ // - const handleNodeDoubleClick = (node: FlowNode) => { + const isLadderVariableEditorTarget = (target: EventTarget | null): boolean => { + return target instanceof HTMLElement && Boolean(target.closest("[data-ladder-variable-editor='true']")) + } + + const handleNodeEditRequest = (node: FlowNode) => { const modalToOpen = node.type === 'block' ? 'block-ladder-element' @@ -679,6 +681,23 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi openModal(modalToOpen, node) } + const handleNodeClick = (event: MouseEvent, node: FlowNode) => { + if (isLadderVariableEditorTarget(event.target)) return + + const clickedAt = window.performance.now() + const previousClick = lastNodeClickRef.current + + lastNodeClickRef.current = { + nodeId: node.id, + clickedAt, + } + + if (previousClick?.nodeId === node.id && clickedAt - previousClick.clickedAt <= 750) { + lastNodeClickRef.current = null + handleNodeEditRequest(node) + } + } + /** * Handle the change of the nodes * This function is called every time the nodes change @@ -913,7 +932,11 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi onInit: setReactFlowInstance, onNodesChange: onNodesChange, - onNodeClick: isDebuggerActive ? () => {} : undefined, + onNodeClick: isDebuggerActive + ? () => {} + : (event, node) => { + handleNodeClick(event, node) + }, onNodesDelete: isDebuggerActive ? undefined : (nodes) => { @@ -934,11 +957,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi : (_event, node) => { handleNodeDragStop(node) }, - onNodeDoubleClick: isDebuggerActive - ? undefined - : (_event, node) => { - handleNodeDoubleClick(node) - }, + onNodeDoubleClick: undefined, onDragEnter: onDragEnterViewport, onDragLeave: onDragLeaveViewport, From a467ecbcf8ef56f9091e1066fb727e1bc43f4d34 Mon Sep 17 00:00:00 2001 From: Jeykco Date: Tue, 7 Jul 2026 01:31:52 -0500 Subject: [PATCH 09/10] fix(jwplc): restore typing in ladder variable editor --- .../components/_atoms/graphical-editor/autocomplete/index.tsx | 1 - .../components/_atoms/graphical-editor/ladder/coil.tsx | 3 --- .../components/_atoms/graphical-editor/ladder/contact.tsx | 3 --- 3 files changed, 7 deletions(-) diff --git a/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx index 882ccd72e..f51d14d24 100644 --- a/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx @@ -104,7 +104,6 @@ export const GraphicalEditorAutocomplete = forwardRef void }) => { if (shouldKeepOpenForOutsideTarget(event.target)) { - event.preventDefault() if (setIsOpen) setIsOpen(true) return } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx index c173bbd5f..2b4213390 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx @@ -215,17 +215,14 @@ export const Coil = (block: CoilProps) => { data-ladder-variable-editor='true' ref={inputWrapperRef} onPointerDownCapture={(event) => { - event.preventDefault() event.stopPropagation() openVariableAutocomplete() }} onClickCapture={(event) => { - event.preventDefault() event.stopPropagation() openVariableAutocomplete() }} onDoubleClickCapture={(event) => { - event.preventDefault() event.stopPropagation() openVariableAutocomplete() }} diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx index bb6efa8cf..4567b4d54 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx @@ -215,17 +215,14 @@ export const Contact = (block: ContactProps) => { data-ladder-variable-editor='true' ref={inputWrapperRef} onPointerDownCapture={(event) => { - event.preventDefault() event.stopPropagation() openVariableAutocomplete() }} onClickCapture={(event) => { - event.preventDefault() event.stopPropagation() openVariableAutocomplete() }} onDoubleClickCapture={(event) => { - event.preventDefault() event.stopPropagation() openVariableAutocomplete() }} From ec9b639cb7c46f279df1cc68e05a25494ec35d20 Mon Sep 17 00:00:00 2001 From: Jeykco Date: Tue, 7 Jul 2026 01:59:40 -0500 Subject: [PATCH 10/10] fix(jwplc): finalize ladder variable autocomplete interactions --- .../graphical-editor/autocomplete/index.tsx | 26 ++++++++++--------- .../ladder/autocomplete/index.tsx | 3 +++ .../_atoms/graphical-editor/ladder/coil.tsx | 11 ++++++++ .../graphical-editor/ladder/contact.tsx | 11 ++++++++ 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx index f51d14d24..6ed706e78 100644 --- a/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx @@ -24,6 +24,7 @@ export type GraphicalEditorAutocompleteProps = ComponentPropsWithRef<'div'> & { } keepOpenForElementId?: string keepOpenForSelector?: string + onBeforeSubmit?: ({ variable }: { variable: { id: string; name: string } }) => void } export const GraphicalEditorAutocomplete = forwardRef( @@ -40,6 +41,7 @@ export const GraphicalEditorAutocomplete = forwardRef { @@ -112,8 +114,9 @@ export const GraphicalEditorAutocomplete = forwardRef { - closeModal() + onBeforeSubmit?.({ variable }) submit({ variable }) + closeModal() } /** @@ -280,12 +283,9 @@ export const GraphicalEditorAutocomplete = forwardRef e.preventDefault()} - onClick={() => { + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() submitAutocompletion({ variable: { id: variable.id ?? '', @@ -317,14 +317,15 @@ export const GraphicalEditorAutocomplete = forwardRef e.preventDefault()} - onClick={() => + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() submitAutocompletion({ variable: newBlock.canCreate ? selectableValues[selectableValues.length - 2].variable : selectableValues[selectableValues.length - 1].variable, }) - } + }} >
Add variable
@@ -343,8 +344,9 @@ export const GraphicalEditorAutocomplete = forwardRef e.preventDefault()} - onClick={() => { + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() if (newBlock.options) { submitAutocompletion({ variable: selectableValues[selectableValues.length - 1].variable }) } 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 02c5fc93c..3d6a08b1a 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx @@ -28,6 +28,7 @@ type VariablesBlockAutoCompleteProps = ComponentPropsWithRef<'div'> & { valueToSearch: string keepOpenForElementId?: string keepOpenForSelector?: string + onBeforeSubmit?: (variableName: string) => void } /** @@ -62,6 +63,7 @@ const VariablesBlockAutoComplete = forwardRef { @@ -303,6 +305,7 @@ const VariablesBlockAutoComplete = forwardRef onBeforeSubmit?.(variable.name)} /> ) }, diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx index 2b4213390..5020cc302 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx @@ -56,6 +56,8 @@ export const Coil = (block: CoilProps) => { } >(null) + const skipNextVariableBlurSubmitRef = useRef(false) + const [openAutocomplete, setOpenAutocomplete] = useState(false) const [keyPressedAtTextarea, setKeyPressedAtTextarea] = useState('') @@ -164,6 +166,11 @@ export const Coil = (block: CoilProps) => { * Handle with the variable input onBlur event */ const handleSubmitCoilVariableOnTextareaBlur = (variableName?: string) => { + if (skipNextVariableBlurSubmitRef.current) { + skipNextVariableBlurSubmitRef.current = false + return + } + const variableNameToSubmit = variableName || coilVariableValue const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id, @@ -300,6 +307,10 @@ export const Coil = (block: CoilProps) => { setIsOpen={(value) => setOpenAutocomplete(value)} keyPressed={keyPressedAtTextarea} keepOpenForSelector="[data-ladder-variable-editor='true']" + onBeforeSubmit={(variableName) => { + skipNextVariableBlurSubmitRef.current = true + setCoilVariableValue(variableName) + }} keepOpenForElementId={`coil-variable-input-${id}`} />
diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx index 4567b4d54..6c3c04f97 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx @@ -56,6 +56,8 @@ export const Contact = (block: ContactProps) => { } >(null) + const skipNextVariableBlurSubmitRef = useRef(false) + const [openAutocomplete, setOpenAutocomplete] = useState(false) const [keyPressedAtTextarea, setKeyPressedAtTextarea] = useState('') @@ -164,6 +166,11 @@ export const Contact = (block: ContactProps) => { * Handle with the variable input onBlur event */ const handleSubmitContactVariableOnTextareaBlur = (variableName?: string) => { + if (skipNextVariableBlurSubmitRef.current) { + skipNextVariableBlurSubmitRef.current = false + return + } + const variableNameToSubmit = variableName || contactVariableValue const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id, @@ -300,6 +307,10 @@ export const Contact = (block: ContactProps) => { setIsOpen={(value) => setOpenAutocomplete(value)} keyPressed={keyPressedAtTextarea} keepOpenForSelector="[data-ladder-variable-editor='true']" + onBeforeSubmit={(variableName) => { + skipNextVariableBlurSubmitRef.current = true + setContactVariableValue(variableName) + }} keepOpenForElementId={`contact-variable-input-${id}`} />