fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog

The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.

Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.

Documented the model in docs/wiki/component-interaction.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-19 15:16:47 -03:00
parent dd22bcfe50
commit a9285c6698
2 changed files with 159 additions and 20 deletions

View File

@ -0,0 +1,126 @@
# Component Interaction Model
> How the canvas decides whether a mouse click on a component opens its
> property dialog, starts a drag-to-rearrange, or is forwarded to the
> wokwi web component's own pointer logic (knob rotation, button press,
> joystick stick drag, etc.).
## TL;DR
When a user clicks on a component on the simulator canvas, exactly one
of three things happens:
| Surface clicked | Result |
|-----------------|--------|
| Wrapper border / padding / non-interactive area of any component | Canvas owns the click. Single click = open property dialog; mousedown + threshold movement = drag the component to reposition it. |
| A wokwi web component whose tag is in the **pointer-owning whitelist** below | Wokwi component owns the click. Rotates the knob, presses the button, slides the switch, etc. |
| A wokwi web component NOT in the whitelist (sensor, display, LED, resistor, etc.) | Same as the wrapper case — canvas owns the click. |
## The whitelist
The exhaustive list lives in
[`frontend/src/components/DynamicComponent.tsx`](../../frontend/src/components/DynamicComponent.tsx)
inside `handleMouseDown`. As of this writing it covers:
```ts
const ownsPointer =
interactionRunning &&
(tag === 'wokwi-pushbutton'
|| tag === 'wokwi-pushbutton-6mm'
|| tag === 'wokwi-potentiometer'
|| tag === 'wokwi-slide-potentiometer'
|| tag === 'wokwi-slide-switch'
|| tag === 'wokwi-dip-switch-8'
|| tag === 'wokwi-analog-joystick'
|| tag === 'wokwi-ky-040'
|| tag === 'wokwi-membrane-keypad'
|| tag === 'wokwi-rotary-dialer');
```
If you wire a NEW wokwi component that depends on its own internal
`pointerdown` / `pointermove` / `pointerup` handlers, append its custom
element tag here. Otherwise the canvas's `e.stopPropagation()` in the
capture phase will swallow the pointer event before the wokwi component
ever sees it — the knob will not rotate, the button won't report a
press, the joystick stick won't move.
## Why the gate is opt-in (whitelist) rather than opt-out
The gate used to be heuristic: *"if the component has a
PartSimulationRegistry handler (`isInteractive`), let the wokwi
component own the pointerdown."* That heuristic was too broad — DHT22,
HC-SR04, NTC, photoresistor, LED all register `attachEvents` to bridge
the SPICE / sensor-update layers, but visually they have NO internal
pointer logic. The heuristic caused clicks on those components to be
silently swallowed by the wokwi shadow DOM (which did nothing with
them) → property dialog never opened → users couldn't change sensor
values.
A tight whitelist is verifiable in one read and trivial to extend.
## Property dialog flow
For non-pointer-owning components, the mousedown bubbles to
`SimulatorCanvas` which:
1. Tracks the down position and starts a movement-threshold timer.
2. If the user releases the button without moving past ~3 px ⇒ treat as
click ⇒ open `<ComponentPropertyDialog>` for that component.
3. If the user moves past the threshold ⇒ treat as drag ⇒ reposition the
component on the canvas grid.
The property dialog renders a `SensorControlPanel` (for sensors that
have `SENSOR_CONTROL_DEFS`) or a generic property editor. The slider
dispatches `dispatchSensorUpdate(componentId, { … })` which routes via
`SensorUpdateRegistry` to the part's `attachEvents` callback. That
callback updates the AVR ADC (`setAdcVoltage`) AND emits a
`PROPERTY_CHANGE_EVENT` so the SPICE netlist gets rebuilt with the new
value (e.g. NTC resistance recalculated from the new temperature).
## Live-simulation vs. edit-mode
The whitelist is only consulted when `interactionRunning` is true —
either the MCU sim is running or this is a board-less SPICE-only
circuit with the electrical simulation un-paused. While the user is in
"edit mode" (sim stopped) the canvas always owns clicks so the user can
arrange components without accidentally rotating knobs.
## Test infrastructure note
CDP-synthesized `PointerEvent` carries `isTrusted = false`. Lit's
gesture machinery (used by wokwi-elements) requires real OS-issued
trusted events. Automated tests that need to rotate a potentiometer or
press a button cannot do it via `dispatchEvent`. Two workarounds:
- For pushbuttons: dispatch the custom `button-press` /
`button-release` events directly on the wokwi-pushbutton DOM
element. The Velxio simulation layer reacts to those.
- For rotary / slider components: reach into the wokwi shadow root,
find the hidden `<input type="range">`, set its `value` via the
native HTMLInputElement setter, then dispatch a bubbling `input`
event. Example:
```js
const slider = document.querySelector('wokwi-potentiometer')
.shadowRoot.querySelector('input[type="range"]');
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
set.call(slider, '512');
slider.dispatchEvent(new Event('input', { bubbles: true }));
```
Real user mouse drag works correctly without any of this — the
whitelist fix is all that was needed for the runtime path.
## Related files
- `frontend/src/components/DynamicComponent.tsx` — the per-component
wrapper, `handleMouseDown` lives here.
- `frontend/src/components/simulator/SimulatorCanvas.tsx` — owns the
canvas drag-threshold logic and dialog open dispatch.
- `frontend/src/components/simulator/SensorControlPanel.tsx` — renders
the slider/button UI for sensor property editing.
- `frontend/src/simulation/SensorUpdateRegistry.ts` — module-level
singleton bridging the dialog → part `attachEvents` callbacks.
- `frontend/src/simulation/parts/SensorParts.ts`,
`BasicParts.ts`, `ComplexParts.ts` — the actual per-part handlers
that consume slider updates.

View File

@ -277,36 +277,49 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (!onMouseDown) return; if (!onMouseDown) return;
// Don't swallow the pointerdown when the user is interacting with a // Don't swallow the pointerdown for wokwi components that own their
// live wokwi component (rotating a potentiometer knob, pressing a // own pointer interaction (rotary knobs, pushbuttons, slide-switches,
// pushbutton, toggling a slide-switch, dragging the joystick stick, // joysticks, keypads, encoders). For those the wokwi element binds
// etc.). The wokwi-elements internally use pointerdown/move/up on // pointerdown/move/up on its shadow-DOM SVG; if we call
// their shadow-DOM SVGs; if we call stopPropagation() in the capture // stopPropagation() in the capture phase here the internal logic
// phase here, that internal logic NEVER receives the event and the // never sees the event and the knob can't rotate, the button never
// knob can't rotate, the button never reports pressed, etc. // reports pressed, etc.
// //
// The canvas's drag-to-rearrange flow still works: the user can grab // EVERY OTHER component (sensors, displays, LEDs, resistors, even
// the component WRAPPER (its padding/border) or any non-interactive // ones with attachEvents for the sensor-update / SPICE-prop bridge)
// part of the body. For interactive parts the wokwi component owns // expects clicks to bubble up to the canvas → open the property
// the click — same UX as Wokwi/Tinkercad. // dialog or grab for drag-to-rearrange. The previous "swallow only
// when isInteractive" heuristic was too broad: it included DHT22,
// HC-SR04, NTC, photoresistor, LED, etc. — all of which have
// attachEvents but no internal pointer handler, so clicks on them
// SHOULD bubble. With the broad guard, those dialogs never opened.
// //
// We additionally allow the canvas drag when the simulation is NOT // The whitelist below is tight on purpose: only add a tag name when
// running (interactionRunning=false): the user is in "edit mode" // the wokwi element actually has its own pointerdown handler that
// arranging the board, so capturing the click into a canvas drag // the user needs to reach. If a new interactive part is added,
// is the correct behaviour even on interactive components. // append its tag here.
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
const targetIsInteractive = const tag = target.tagName?.toLowerCase() ?? '';
isInteractive && const ownsPointer =
interactionRunning && interactionRunning &&
target.tagName.toLowerCase().startsWith('wokwi-'); (tag === 'wokwi-pushbutton' ||
if (targetIsInteractive) { tag === 'wokwi-pushbutton-6mm' ||
tag === 'wokwi-potentiometer' ||
tag === 'wokwi-slide-potentiometer' ||
tag === 'wokwi-slide-switch' ||
tag === 'wokwi-dip-switch-8' ||
tag === 'wokwi-analog-joystick' ||
tag === 'wokwi-ky-040' ||
tag === 'wokwi-membrane-keypad' ||
tag === 'wokwi-rotary-dialer');
if (ownsPointer) {
// Let the wokwi component own this pointerdown. // Let the wokwi component own this pointerdown.
return; return;
} }
e.stopPropagation(); e.stopPropagation();
onMouseDown(e); onMouseDown(e);
}, },
[onMouseDown, isInteractive, interactionRunning], [onMouseDown, interactionRunning],
); );
const handleDoubleClick = useCallback( const handleDoubleClick = useCallback(