From 083e0df732b3c39d2de8ba444211b9921de78c4d Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Tue, 12 May 2026 23:34:58 -0300 Subject: [PATCH] fix(canvas): board-less SPICE switches toggle on click instead of opening property dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In digital / analog board-less examples the user clicks a slide-switch or pushbutton expecting it to flip its state. Until this commit the component property dialog opened instead and the click never reached the wokwi-element underneath, so: - The user couldn't change switch state through the canvas at all. - With no state change the SPICE solver kept the old netlist, and every downstream LED stayed dark — the symptom that read as "voltages change but no LED lights". Root cause was the gating: SimulatorCanvas only suppressed the property dialog when `useSimulatorStore.running` was true, but that flag is bound to an MCU's start/stop. Board-less circuits have no MCU to start so `running` is permanently false, even when the SPICE engine has been live since the example loaded. New derived flag `interactionRunning = running || (boards.length === 0 && !electricalPaused)` — true whenever the user is in an "interactive" session, MCU or SPICE-only. Used in three click-handling paths: - SimulatorCanvas mouse-up handler: dialog is suppressed and the click falls through to the wokwi-element (line 1395). - SimulatorCanvas touch-start passthrough: same for touch (line 474). - SimulatorCanvas touch-end short-tap: same for tap (line 774). Also propagated to DynamicComponent so the cursor becomes pointer (not move) for interactive parts in board-less mode — visual cue that the user can click instead of just drag. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/DynamicComponent.tsx | 11 +++++- .../components/simulator/SimulatorCanvas.tsx | 38 ++++++++++++++----- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index e23aec2e..a20fb6e8 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -14,6 +14,7 @@ import React, { useRef, useEffect, useCallback } from 'react'; import type { ComponentMetadata } from '../types/component-metadata'; import { useSimulatorStore } from '../store/useSimulatorStore'; +import { useElectricalStore } from '../store/useElectricalStore'; import { PartSimulationRegistry } from '../simulation/parts'; import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping'; @@ -58,6 +59,14 @@ export const DynamicComponent: React.FC = ({ const handleComponentEvent = useSimulatorStore((s) => s.handleComponentEvent); const running = useSimulatorStore((s) => s.running); const simulator = useSimulatorStore((s) => s.simulator); + // Board-less SPICE circuits (digital / analog gallery) have no MCU to + // run, so `running` is always false — but interactive parts like + // slide-switches and pushbuttons should still show a pointer cursor + // and let the user click them. We treat board-less + un-paused as + // "interactive" so the cursor + dialog gating mirror the MCU mode. + const boardCount = useSimulatorStore((s) => s.boards.length); + const electricalPaused = useElectricalStore((s) => s.paused); + const interactionRunning = running || (boardCount === 0 && !electricalPaused); // hexEpoch increments each time a new hex is loaded, triggering a fresh // attachEvents call (and re-registration of I2C devices on the new bus). // We intentionally do NOT depend on `running` so that I2C displays and @@ -350,7 +359,7 @@ export const DynamicComponent: React.FC = ({ position: 'absolute', left: `${x}px`, top: `${y}px`, - cursor: running && isInteractive ? 'pointer' : 'move', + cursor: interactionRunning && isInteractive ? 'pointer' : 'move', border: isSelected ? '2px dashed #007acc' : '2px solid transparent', borderRadius: '4px', padding: '4px', diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 3cc89a3f..700f596b 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -1,4 +1,5 @@ import { useSimulatorStore, getEsp32Bridge } from '../../store/useSimulatorStore'; +import { useElectricalStore } from '../../store/useElectricalStore'; import React, { useEffect, useState, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { Undo2, Redo2 } from 'lucide-react'; @@ -238,10 +239,23 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const panRef = useRef({ x: 0, y: 0 }); const zoomRef = useRef(1); + // Board-less SPICE circuits (analog / digital examples with no MCU on + // the canvas) have no concept of a board to "start", so `running` is + // always false. But the simulation IS effectively live the moment the + // engine has solved at least once — switches and buttons should toggle + // their own state on click instead of opening the property dialog. + // We treat board-less + un-paused as "interaction-running" so the same + // gating logic that suppresses the dialog for MCU-running mode also + // covers board-less circuits. + const electricalPaused = useElectricalStore((s) => s.paused); + const interactionRunning = running || (boards.length === 0 && !electricalPaused); + // Refs that mirror state/props for use inside touch event closures // (touch listeners are added imperatively and can't access current React state) const runningRef = useRef(running); runningRef.current = running; + const interactionRunningRef = useRef(interactionRunning); + interactionRunningRef.current = interactionRunning; const componentsRef = useRef(components); componentsRef.current = components; const boardPositionRef = useRef(boardPosition); @@ -471,7 +485,9 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // ── 2. Interactive web component during simulation → let browser synthesize mouse events ── // (potentiometer knobs, button presses, etc. need mousedown/mouseup synthesis) // touch-action:none on .canvas-content already prevents browser scroll/zoom. - if (runningRef.current) { + // Board-less SPICE circuits piggy-back on the same path so a tap on + // a slide-switch reaches the wokwi element and triggers its toggle. + if (interactionRunningRef.current) { const webComp = target?.closest('.web-component-container'); if (webComp) { touchPassthroughRef.current = true; @@ -767,11 +783,13 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { } } else if (touchId !== '__board__') { // Short tap on component → open property dialog or sensor panel. - // While the simulator is running, components stay interactive — - // only sensor panels may open; property editing is disabled. + // While the simulator is running (MCU or board-less SPICE), + // components stay interactive — only sensor panels may open; + // property editing is disabled so the tap reaches the + // wokwi-element underneath and toggles it. const component = componentsRef.current.find((c) => c.id === touchId); if (component) { - if (runningRef.current) { + if (interactionRunningRef.current) { if (SENSOR_CONTROLS[component.metadataId] !== undefined) { setSensorControlComponentId(touchId); setSensorControlMetadataId(component.metadataId); @@ -1392,11 +1410,13 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { } else if (draggedComponentId !== '__board__') { const component = components.find((c) => c.id === draggedComponentId); if (component) { - if (running) { - // During simulation only sensor panels open on click — every - // other component is interactive (pushbutton, switch, pot, …) - // and must handle its own clicks, so we suppress the property - // dialog entirely. + if (interactionRunning) { + // During simulation (MCU running OR board-less SPICE active) + // only sensor panels open on click — every other component + // is interactive (pushbutton, switch, pot, …) and must + // handle its own clicks, so we suppress the property + // dialog entirely. This is the path that also unblocks + // wokwi-slide-switch toggling in the digital examples. if (SENSOR_CONTROLS[component.metadataId] !== undefined) { setSensorControlComponentId(draggedComponentId); setSensorControlMetadataId(component.metadataId);