fix(canvas): board-less SPICE switches toggle on click instead of opening property dialog

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) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-12 23:34:58 -03:00
parent 55581690f9
commit 083e0df732
2 changed files with 39 additions and 10 deletions

View File

@ -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<DynamicComponentProps> = ({
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<DynamicComponentProps> = ({
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',

View File

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