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);